mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff736fbbc8 |
@@ -10,6 +10,7 @@ const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
needs_client_registration: "mcp.status.needs_client_registration",
|
||||
disabled: "mcp.status.disabled",
|
||||
} as const
|
||||
|
||||
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed") return s.error
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
|
||||
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base": status() === "needs_auth",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
test("detects MCP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
describe("hasServiceNeedingAttention", () => {
|
||||
test("detects MCP states that need user attention", () => {
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores states that do not need user attention", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||
return input.mcp.some((status) => status === "needs_auth")
|
||||
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
needs_client_registration: input.connect,
|
||||
}[input.status]()
|
||||
await input.refresh()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ function icon(status: McpServer["status"]) {
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
@@ -44,6 +45,8 @@ function describe(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return "needs authentication"
|
||||
case "needs_client_registration":
|
||||
return `needs client registration: ${status.error}`
|
||||
case "failed":
|
||||
return `failed: ${status.error}`
|
||||
default:
|
||||
|
||||
@@ -259,6 +259,8 @@ export type McpStatusFailed = { status: "failed"; error: string }
|
||||
|
||||
export type McpStatusNeedsAuth = { status: "needs_auth" }
|
||||
|
||||
export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string }
|
||||
|
||||
export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string }
|
||||
|
||||
export type McpResourceTemplate = {
|
||||
@@ -1259,7 +1261,13 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
|
||||
status:
|
||||
| McpStatusConnected
|
||||
| McpStatusPending
|
||||
| McpStatusDisabled
|
||||
| McpStatusFailed
|
||||
| McpStatusNeedsAuth
|
||||
| McpStatusNeedsClientRegistration
|
||||
integrationID?: string
|
||||
}
|
||||
|
||||
@@ -1693,43 +1701,44 @@ export type AgentInfo = {
|
||||
export type ConfigEntry =
|
||||
| {
|
||||
type: "document"
|
||||
path?: string
|
||||
path?: string | null
|
||||
info: {
|
||||
$schema?: string
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
autoupdate?: boolean | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
permissions?: PermissionRuleset
|
||||
$schema?: string | null
|
||||
shell?: string | null
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
default_agent?: string | null
|
||||
autoupdate?: boolean | "notify" | null
|
||||
share?: "manual" | "auto" | "disabled" | null
|
||||
enterprise?: { url?: string | null } | null
|
||||
username?: string | null
|
||||
permissions?: PermissionRuleset | null
|
||||
agents?: {
|
||||
[x: string]: {
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
request?: { headers?: { [x: string]: string }; body?: { [x: string]: JsonValue } }
|
||||
system?: string
|
||||
description?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
hidden?: boolean
|
||||
color?: string
|
||||
steps?: number
|
||||
disabled?: boolean
|
||||
permissions?: PermissionRuleset
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
request?: { headers?: { [x: string]: string } | null; body?: { [x: string]: JsonValue } | null } | null
|
||||
system?: string | null
|
||||
description?: string | null
|
||||
mode?: "subagent" | "primary" | "all" | null
|
||||
hidden?: boolean | null
|
||||
color?: string | null
|
||||
steps?: number | null
|
||||
disabled?: boolean | null
|
||||
permissions?: PermissionRuleset | null
|
||||
}
|
||||
}
|
||||
snapshots?: boolean
|
||||
watcher?: { ignore?: Array<string> }
|
||||
} | null
|
||||
snapshots?: boolean | null
|
||||
watcher?: { ignore?: Array<string> | null } | null
|
||||
formatter?:
|
||||
| boolean
|
||||
| {
|
||||
[x: string]: {
|
||||
disabled?: boolean
|
||||
command?: Array<string>
|
||||
environment?: { [x: string]: string }
|
||||
extensions?: Array<string>
|
||||
disabled?: boolean | null
|
||||
command?: Array<string> | null
|
||||
environment?: { [x: string]: string } | null
|
||||
extensions?: Array<string> | null
|
||||
}
|
||||
}
|
||||
| null
|
||||
lsp?:
|
||||
| boolean
|
||||
| {
|
||||
@@ -1737,117 +1746,125 @@ export type ConfigEntry =
|
||||
| { disabled: true }
|
||||
| {
|
||||
command: Array<string>
|
||||
extensions?: Array<string>
|
||||
disabled?: boolean
|
||||
env?: { [x: string]: string }
|
||||
initialization?: { [x: string]: JsonValue }
|
||||
extensions?: Array<string> | null
|
||||
disabled?: boolean | null
|
||||
env?: { [x: string]: string } | null
|
||||
initialization?: { [x: string]: JsonValue } | null
|
||||
}
|
||||
}
|
||||
| null
|
||||
media?: {
|
||||
image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number }
|
||||
}
|
||||
tool_output?: { max_lines?: number; max_bytes?: number }
|
||||
image?: {
|
||||
auto_resize?: boolean | null
|
||||
max_width?: number | null
|
||||
max_height?: number | null
|
||||
max_base64_bytes?: number | null
|
||||
} | null
|
||||
} | null
|
||||
tool_output?: { max_lines?: number | null; max_bytes?: number | null } | null
|
||||
mcp?: {
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
servers?: {
|
||||
[x: string]:
|
||||
| {
|
||||
type: "local"
|
||||
command: Array<string>
|
||||
cwd?: string
|
||||
environment?: { [x: string]: string }
|
||||
disabled?: boolean
|
||||
codemode?: boolean
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
cwd?: string | null
|
||||
environment?: { [x: string]: string } | null
|
||||
disabled?: boolean | null
|
||||
codemode?: boolean | null
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
}
|
||||
| {
|
||||
type: "remote"
|
||||
url: string
|
||||
headers?: { [x: string]: string }
|
||||
headers?: { [x: string]: string } | null
|
||||
oauth?:
|
||||
| {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
scope?: string
|
||||
callback_port?: number
|
||||
redirect_uri?: string
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
scope?: string | null
|
||||
callback_port?: number | null
|
||||
redirect_uri?: string | null
|
||||
}
|
||||
| false
|
||||
disabled?: boolean
|
||||
codemode?: boolean
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
| null
|
||||
disabled?: boolean | null
|
||||
codemode?: boolean | null
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
}
|
||||
}
|
||||
}
|
||||
compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: number }
|
||||
skills?: Array<string>
|
||||
} | null
|
||||
} | null
|
||||
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
|
||||
skills?: Array<string> | null
|
||||
commands?: {
|
||||
[x: string]: {
|
||||
template: string
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
subtask?: boolean
|
||||
description?: string | null
|
||||
agent?: string | null
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
subtask?: boolean | null
|
||||
}
|
||||
}
|
||||
instructions?: Array<string>
|
||||
} | null
|
||||
instructions?: Array<string> | null
|
||||
references?: {
|
||||
[x: string]:
|
||||
| string
|
||||
| { repository: string; branch?: string; description?: string; hidden?: boolean }
|
||||
| { path: string; description?: string; hidden?: boolean }
|
||||
}
|
||||
websearch?: { provider: string }
|
||||
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
| { repository: string; branch?: string | null; description?: string | null; hidden?: boolean | null }
|
||||
| { path: string; description?: string | null; hidden?: boolean | null }
|
||||
} | null
|
||||
websearch?: { provider: string } | null
|
||||
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } | null }> | null
|
||||
warming?: boolean | { prompt?: string | null; interval?: string | null; duration?: string | null } | null
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
name?: string | null
|
||||
env?: Array<string> | null
|
||||
package?: string | null
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
models?: {
|
||||
[x: string]: {
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
modelID?: string | null
|
||||
family?: string | null
|
||||
name?: string | null
|
||||
compatibility?: ModelCompatibility | null
|
||||
package?: string | null
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
capabilities?: ModelCapabilities | null
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
}> | null
|
||||
cost?:
|
||||
| {
|
||||
tier?: { type: "context"; size: number }
|
||||
tier?: { type: "context"; size: number } | null
|
||||
input: MoneyUSDPerMillionTokens
|
||||
output: MoneyUSDPerMillionTokens
|
||||
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
|
||||
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
|
||||
}
|
||||
| Array<{
|
||||
tier?: { type: "context"; size: number }
|
||||
tier?: { type: "context"; size: number } | null
|
||||
input: MoneyUSDPerMillionTokens
|
||||
output: MoneyUSDPerMillionTokens
|
||||
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
|
||||
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
|
||||
}>
|
||||
disabled?: boolean
|
||||
limit?: { context?: number; input?: number; output?: number }
|
||||
| null
|
||||
disabled?: boolean | null
|
||||
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}
|
||||
}
|
||||
} | null
|
||||
experimental?: {
|
||||
subagent_depth?: number
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
subagent_depth?: number | null
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
| { type: "directory"; path: string }
|
||||
@@ -1920,9 +1937,23 @@ export type IntegrationInfo = {
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
export type FormInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
coalesce?: string
|
||||
metadata?: FormMetadata
|
||||
fields: FormFields
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
export type FormInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
coalesce?: string
|
||||
metadata?: FormMetadata1
|
||||
fields: FormFields1
|
||||
}
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
id: string
|
||||
@@ -3233,28 +3264,41 @@ export type McpAddInput = {
|
||||
| {
|
||||
readonly type: "local"
|
||||
readonly command: ReadonlyArray<string>
|
||||
readonly cwd?: string
|
||||
readonly environment?: { readonly [x: string]: string }
|
||||
readonly disabled?: boolean
|
||||
readonly codemode?: boolean
|
||||
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
|
||||
readonly cwd?: string | undefined
|
||||
readonly environment?: { readonly [x: string]: string } | undefined
|
||||
readonly disabled?: boolean | undefined
|
||||
readonly codemode?: boolean | undefined
|
||||
readonly timeout?:
|
||||
| {
|
||||
readonly startup?: number | undefined
|
||||
readonly catalog?: number | undefined
|
||||
readonly execution?: number | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
| {
|
||||
readonly type: "remote"
|
||||
readonly url: string
|
||||
readonly headers?: { readonly [x: string]: string }
|
||||
readonly headers?: { readonly [x: string]: string } | undefined
|
||||
readonly oauth?:
|
||||
| {
|
||||
readonly client_id?: string
|
||||
readonly client_secret?: string
|
||||
readonly scope?: string
|
||||
readonly callback_port?: number
|
||||
readonly redirect_uri?: string
|
||||
readonly client_id?: string | undefined
|
||||
readonly client_secret?: string | undefined
|
||||
readonly scope?: string | undefined
|
||||
readonly callback_port?: number | undefined
|
||||
readonly redirect_uri?: string | undefined
|
||||
}
|
||||
| false
|
||||
readonly disabled?: boolean
|
||||
readonly codemode?: boolean
|
||||
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
|
||||
| undefined
|
||||
readonly disabled?: boolean | undefined
|
||||
readonly codemode?: boolean | undefined
|
||||
readonly timeout?:
|
||||
| {
|
||||
readonly startup?: number | undefined
|
||||
readonly catalog?: number | undefined
|
||||
readonly execution?: number | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}["config"]
|
||||
}
|
||||
|
||||
+17
-35
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigNormalize } from "./config/normalize"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
@@ -92,43 +93,24 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
||||
const parseInfo = (text: string) => {
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: "$",
|
||||
kind: "invalid",
|
||||
action: "rejected malformed JSON or JSONC document",
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
|
||||
Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
|
||||
kind: diagnostic.kind,
|
||||
action: diagnostic.message,
|
||||
}),
|
||||
if (errors.length) return
|
||||
return Option.getOrUndefined(
|
||||
ConfigMigrateV1.isV1(input)
|
||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||
: decodeInfo(input),
|
||||
)
|
||||
if (result.type === "rejected") return
|
||||
const info = Option.getOrUndefined(decodeInfo(result.encoded))
|
||||
if (info) return info
|
||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: "$",
|
||||
kind: "invalid",
|
||||
action: "rejected canonical configuration after final validation",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
const text = yield* fs.readFileStringSafe(filepath)
|
||||
if (text === undefined) return
|
||||
if (!text) return
|
||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||
const info = yield* parseInfo(substituted, filepath)
|
||||
const info = parseInfo(substituted)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
})
|
||||
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
text: JSON.stringify(config),
|
||||
env: variables,
|
||||
}).pipe(
|
||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||
Effect.map(parseInfo),
|
||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
Effect.orDie,
|
||||
)
|
||||
: []
|
||||
const content = options?.content !== undefined
|
||||
const content = options?.content
|
||||
? yield* ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
dir: location.directory,
|
||||
text: options.content,
|
||||
}).pipe(
|
||||
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
||||
Effect.map(parseInfo),
|
||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||
Effect.orDie,
|
||||
)
|
||||
@@ -251,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...(supplementary[0] ?? []),
|
||||
...explicit,
|
||||
...direct,
|
||||
...supplementary.slice(1).flat(),
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...content,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,796 +0,0 @@
|
||||
export * as ConfigNormalize from "./normalize"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
|
||||
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
|
||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigAgentV1 } from "../v1/config/agent"
|
||||
import { ConfigAttachmentV1 } from "../v1/config/attachment"
|
||||
import { ConfigCommandV1 } from "../v1/config/command"
|
||||
import { ConfigMCPV1 } from "../v1/config/mcp"
|
||||
import { ConfigPermissionV1 } from "../v1/config/permission"
|
||||
import { ConfigPluginV1 } from "../v1/config/plugin"
|
||||
import { ConfigProviderV1 } from "../v1/config/provider"
|
||||
import { ConfigMigrateV1 } from "../v1/config/migrate"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly kind: "conflict" | "invalid" | "unsupported"
|
||||
readonly path: readonly string[]
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export type Result =
|
||||
| {
|
||||
readonly type: "normalized"
|
||||
readonly encoded: Readonly<Record<string, unknown>>
|
||||
readonly diagnostics: readonly Diagnostic[]
|
||||
}
|
||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
||||
const unsupportedExperimental = [
|
||||
"disable_paste_summary",
|
||||
"batch_tool",
|
||||
"openTelemetry",
|
||||
"primary_tools",
|
||||
"continue_loop_on_deny",
|
||||
] as const
|
||||
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
||||
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
||||
|
||||
export function normalize(input: unknown): Result {
|
||||
if (!isRecord(input))
|
||||
return {
|
||||
type: "rejected",
|
||||
diagnostics: [
|
||||
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
|
||||
],
|
||||
}
|
||||
|
||||
const diagnostics: Diagnostic[] = []
|
||||
const encoded: Record<string, unknown> = {}
|
||||
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
|
||||
|
||||
const legacySnapshots = own(input, "snapshot")
|
||||
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
: undefined
|
||||
: undefined
|
||||
const legacyMedia = own(input, "attachment")
|
||||
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
||||
: undefined
|
||||
if (legacyMedia !== undefined) {
|
||||
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"references",
|
||||
legacyReferences,
|
||||
nativeReferences,
|
||||
isRecord(input.reference) || isRecord(input.references),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||
})
|
||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"commands",
|
||||
migratedCommands,
|
||||
nativeCommands,
|
||||
isRecord(input.command) || isRecord(input.commands),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"agents",
|
||||
migratedAgents,
|
||||
nativeAgents,
|
||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"providers",
|
||||
legacyProviders,
|
||||
nativeProviders,
|
||||
isRecord(input.provider) || isRecord(input.providers),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const toolRules = migrateTools(input.tools, diagnostics)
|
||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
)
|
||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||
|
||||
normalizeSkills(input, encoded, diagnostics)
|
||||
normalizeMcp(input, encoded, diagnostics)
|
||||
normalizeCompaction(input, encoded, diagnostics)
|
||||
normalizeExperimental(input, encoded, diagnostics)
|
||||
normalizeWatcher(input, encoded, diagnostics)
|
||||
normalizeFormatter(input, encoded, diagnostics)
|
||||
normalizeLsp(input, encoded, diagnostics)
|
||||
|
||||
const nativeAtomic = {
|
||||
$schema: Info.fields.$schema,
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
autoupdate: Info.fields.autoupdate,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
snapshots: Info.fields.snapshots,
|
||||
media: Info.fields.media,
|
||||
tool_output: Info.fields.tool_output,
|
||||
websearch: Info.fields.websearch,
|
||||
warming: Info.fields.warming,
|
||||
}
|
||||
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
||||
if (!own(input, key)) return
|
||||
const value = decodeEncoded(schema, input[key], [key], diagnostics)
|
||||
if (value === undefined) return
|
||||
overlay(encoded, key, value, [key], diagnostics)
|
||||
})
|
||||
|
||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||
|
||||
return { type: "normalized", encoded, diagnostics }
|
||||
}
|
||||
|
||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "skills")) return
|
||||
if (Array.isArray(input.skills)) {
|
||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||
return
|
||||
}
|
||||
if (!isRecord(input.skills)) {
|
||||
invalid(["skills"], diagnostics)
|
||||
return
|
||||
}
|
||||
encoded.skills = [
|
||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
const legacyServers: Record<string, unknown> = {}
|
||||
const nativeServers: Record<string, unknown> = {}
|
||||
const timeout: Record<string, unknown> = {}
|
||||
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
|
||||
const value = decodeEncoded(
|
||||
PositiveInt,
|
||||
input.experimental.mcp_timeout,
|
||||
["experimental", "mcp_timeout"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) {
|
||||
timeout.catalog = value
|
||||
timeout.execution = value
|
||||
}
|
||||
}
|
||||
if (own(input, "mcp")) {
|
||||
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
|
||||
if (isRecord(input.mcp)) {
|
||||
Object.entries(input.mcp).forEach(([name, value]) => {
|
||||
const path = ["mcp", name]
|
||||
if (isEnabledOnlyMcp(value)) {
|
||||
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
|
||||
return
|
||||
}
|
||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||
setOwn(nativeServers, key, server),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (name === "timeout" && !isDirectLegacyMcp(value)) {
|
||||
normalizeMcpTimeout(value, timeout, path, diagnostics)
|
||||
return
|
||||
}
|
||||
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
|
||||
if (server !== undefined)
|
||||
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
|
||||
})
|
||||
}
|
||||
}
|
||||
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
|
||||
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
|
||||
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
|
||||
return
|
||||
}
|
||||
encoded.mcp = {
|
||||
...(Object.keys(timeout).length ? { timeout } : {}),
|
||||
...(Object.keys(servers).length ? { servers } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMcpTimeout(
|
||||
value: unknown,
|
||||
timeout: Record<string, unknown>,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
||||
if (Object.keys(value).length && !recognized.length) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
recognized.forEach((key) => {
|
||||
const leaf = decodeEncoded(
|
||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
||||
value[key],
|
||||
[...path, key],
|
||||
diagnostics,
|
||||
)
|
||||
if (leaf === undefined) return
|
||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeCompaction(
|
||||
input: Record<string, unknown>,
|
||||
encoded: Record<string, unknown>,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!own(input, "compaction")) return
|
||||
if (!isRecord(input.compaction)) {
|
||||
invalid(["compaction"], diagnostics)
|
||||
return
|
||||
}
|
||||
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
|
||||
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
|
||||
const result: Record<string, unknown> = {}
|
||||
if (own(input.compaction, "auto")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigCompaction.Info.fields.auto,
|
||||
input.compaction.auto,
|
||||
["compaction", "auto"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.auto = value
|
||||
}
|
||||
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
|
||||
? decodeEncoded(
|
||||
ConfigCompaction.Keep.fields.tokens,
|
||||
input.compaction.preserve_recent_tokens,
|
||||
["compaction", "preserve_recent_tokens"],
|
||||
diagnostics,
|
||||
)
|
||||
: undefined
|
||||
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
|
||||
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
|
||||
const nativeTokens =
|
||||
nativeKeep && own(nativeKeep, "tokens")
|
||||
? decodeEncoded(
|
||||
ConfigCompaction.Keep.fields.tokens,
|
||||
nativeKeep.tokens,
|
||||
["compaction", "keep", "tokens"],
|
||||
diagnostics,
|
||||
)
|
||||
: undefined
|
||||
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
|
||||
if (tokens !== undefined) result.keep = { tokens }
|
||||
const legacyBuffer = own(input.compaction, "reserved")
|
||||
? decodeEncoded(
|
||||
ConfigCompaction.Info.fields.buffer,
|
||||
input.compaction.reserved,
|
||||
["compaction", "reserved"],
|
||||
diagnostics,
|
||||
)
|
||||
: undefined
|
||||
const nativeBuffer = own(input.compaction, "buffer")
|
||||
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
|
||||
: undefined
|
||||
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
|
||||
if (buffer !== undefined) result.buffer = buffer
|
||||
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
|
||||
}
|
||||
|
||||
function normalizeExperimental(
|
||||
input: Record<string, unknown>,
|
||||
encoded: Record<string, unknown>,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const result: Record<string, unknown> = {}
|
||||
const generated: unknown[] = []
|
||||
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
|
||||
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
|
||||
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
|
||||
generated.push(
|
||||
...enabled.values.map((resource) => ({
|
||||
action: "provider.use",
|
||||
resource: ConfigMigrateV1.providerID(resource),
|
||||
effect: "allow",
|
||||
})),
|
||||
)
|
||||
}
|
||||
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
|
||||
generated.push(
|
||||
...disabled.values.map((resource) => ({
|
||||
action: "provider.use",
|
||||
resource: ConfigMigrateV1.providerID(resource),
|
||||
effect: "deny",
|
||||
})),
|
||||
)
|
||||
const native: unknown[] = []
|
||||
if (own(input, "experimental")) {
|
||||
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
|
||||
if (isRecord(input.experimental)) {
|
||||
const experimental = input.experimental
|
||||
unsupportedExperimental.forEach((key) =>
|
||||
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
||||
)
|
||||
if (own(experimental, "subagent_depth")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_depth,
|
||||
experimental.subagent_depth,
|
||||
["experimental", "subagent_depth"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
native.push(
|
||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
|
||||
result.policies = [...generated, ...native]
|
||||
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
|
||||
encoded.experimental = result
|
||||
}
|
||||
|
||||
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "watcher")) return
|
||||
if (!isRecord(input.watcher)) {
|
||||
invalid(["watcher"], diagnostics)
|
||||
return
|
||||
}
|
||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||
}
|
||||
|
||||
function normalizeFormatter(
|
||||
input: Record<string, unknown>,
|
||||
encoded: Record<string, unknown>,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!own(input, "formatter")) return
|
||||
if (typeof input.formatter === "boolean") {
|
||||
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
|
||||
if (value !== undefined) encoded.formatter = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||
encoded.formatter = entries
|
||||
}
|
||||
|
||||
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "lsp")) return
|
||||
if (typeof input.lsp === "boolean") {
|
||||
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
|
||||
if (value !== undefined) encoded.lsp = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||
}
|
||||
|
||||
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
|
||||
if (value === undefined) return []
|
||||
if (!isRecord(value)) {
|
||||
invalid(["tools"], diagnostics)
|
||||
return []
|
||||
}
|
||||
return Object.entries(value).flatMap(([action, raw]) => {
|
||||
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
|
||||
if (enabled === undefined) return []
|
||||
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
|
||||
})
|
||||
}
|
||||
|
||||
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
|
||||
if (value === undefined) return []
|
||||
if (typeof value === "string") {
|
||||
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
|
||||
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
invalid(["permission"], diagnostics)
|
||||
return []
|
||||
}
|
||||
return Object.entries(value).flatMap(([action, raw]) => {
|
||||
if (typeof raw === "string") {
|
||||
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
|
||||
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
invalid(["permission", action], diagnostics)
|
||||
return []
|
||||
}
|
||||
return Object.entries(raw).flatMap(([resource, effect], index) => {
|
||||
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
|
||||
return decoded === undefined
|
||||
? []
|
||||
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(["provider"], diagnostics)
|
||||
return {}
|
||||
}
|
||||
const candidates = Object.entries(value).flatMap(([name, raw]) => {
|
||||
const path = ["provider", name]
|
||||
diagnoseProviderUnsupported(raw, path, diagnostics)
|
||||
if (invalidProviderOverlays(raw, path, diagnostics)) return []
|
||||
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
|
||||
if (provider === undefined) return []
|
||||
const destination = ConfigMigrateV1.providerID(name)
|
||||
return [
|
||||
{
|
||||
name,
|
||||
destination,
|
||||
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
|
||||
},
|
||||
]
|
||||
})
|
||||
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
|
||||
const result: Record<string, unknown> = {}
|
||||
candidates.forEach((item) => {
|
||||
if (item.name !== item.destination && current.has(item.destination)) return
|
||||
setOwn(result, item.destination, item.provider)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value) || !isRecord(value.options)) return false
|
||||
const headersInvalid =
|
||||
own(value.options, "headers") &&
|
||||
(!isPlainRecord(value.options.headers) ||
|
||||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
|
||||
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
|
||||
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
|
||||
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
|
||||
return headersInvalid || bodyInvalid
|
||||
}
|
||||
|
||||
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value)) return
|
||||
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
|
||||
if (!isRecord(value.models)) return
|
||||
Object.entries(value.models).forEach(([name, model]) => {
|
||||
if (!isRecord(model)) return
|
||||
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
|
||||
if (own(model, "status") && model.status !== "deprecated")
|
||||
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
|
||||
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
|
||||
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value)) return
|
||||
Object.entries(value).forEach(([name, agent]) => {
|
||||
if (!isRecord(agent)) return
|
||||
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
|
||||
diagnoseSelection(agent, [...path, name], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value)) return
|
||||
Object.entries(value).forEach(([name, entry]) => {
|
||||
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
|
||||
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
|
||||
if (own(value, "model") && typeof value.model === "string" && !modelValid)
|
||||
diagnostics.push({
|
||||
kind: "unsupported",
|
||||
path: [...path, "model"],
|
||||
message: "omitted unsupported legacy model reference",
|
||||
})
|
||||
if (
|
||||
own(value, "variant") &&
|
||||
typeof value.variant === "string" &&
|
||||
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
|
||||
)
|
||||
diagnostics.push({
|
||||
kind: "unsupported",
|
||||
path: [...path, "variant"],
|
||||
message: "omitted unsupported legacy model variant",
|
||||
})
|
||||
}
|
||||
|
||||
function decodeProviderList(
|
||||
input: Record<string, unknown>,
|
||||
key: "enabled_providers" | "disabled_providers",
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
|
||||
if (!Array.isArray(input[key])) {
|
||||
invalid([key], diagnostics)
|
||||
return { present: true, nonEmpty: true, values: [] as string[] }
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
nonEmpty: input[key].length > 0,
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {} as Record<string, S["Type"]>
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
) as Record<string, S["Type"]>
|
||||
}
|
||||
|
||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Encoded"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Encoded"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Type"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Type"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
||||
if (Option.isSome(decoded)) return decoded.value
|
||||
invalid(path, diagnostics)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
||||
if (Option.isNone(decoded)) {
|
||||
invalid(path, diagnostics)
|
||||
return undefined
|
||||
}
|
||||
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
|
||||
if (Option.isSome(encoded)) return plain(encoded.value)
|
||||
invalid(path, diagnostics)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
|
||||
return plain(
|
||||
Option.getOrThrow(
|
||||
Schema.decodeUnknownOption(
|
||||
schema,
|
||||
options,
|
||||
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function plain(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(plain)
|
||||
if (!isRecord(value)) return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
|
||||
)
|
||||
}
|
||||
|
||||
function mergeMap(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
legacy: Readonly<Record<string, unknown>>,
|
||||
native: Readonly<Record<string, unknown>>,
|
||||
present: boolean,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const merged = mergeMaps(legacy, native, [key], diagnostics)
|
||||
if (present) target[key] = merged
|
||||
}
|
||||
|
||||
function mergeMaps(
|
||||
legacy: Readonly<Record<string, unknown>>,
|
||||
native: Readonly<Record<string, unknown>>,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const result = Object.fromEntries(Object.entries(legacy))
|
||||
Object.entries(native).forEach(([name, value]) => {
|
||||
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
|
||||
setOwn(result, name, value)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).flatMap(([key, value]) => {
|
||||
const mapped = map(value)
|
||||
return mapped === undefined ? [] : [[key, mapped]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function overlay(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
|
||||
target[key] = value
|
||||
}
|
||||
|
||||
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (native === undefined) return legacy
|
||||
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
|
||||
return native
|
||||
}
|
||||
|
||||
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!own(value, key)) return
|
||||
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
|
||||
}
|
||||
|
||||
function invalid(path: string[], diagnostics: Diagnostic[]) {
|
||||
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
|
||||
}
|
||||
|
||||
function conflict(path: string[], diagnostics: Diagnostic[]) {
|
||||
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
|
||||
}
|
||||
|
||||
function isDirectLegacyMcp(value: unknown) {
|
||||
return isRecord(value) && (value.type === "local" || value.type === "remote")
|
||||
}
|
||||
|
||||
function isEnabledOnlyMcp(value: unknown) {
|
||||
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (!isRecord(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function own(value: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(value, key)
|
||||
}
|
||||
|
||||
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
||||
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
fields: input.fields,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
)
|
||||
|
||||
function sessionHook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
function sessionHook(
|
||||
...registration: {
|
||||
[Name in keyof SessionHooks]: [
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
]
|
||||
}[keyof SessionHooks]
|
||||
) {
|
||||
if (registration[0] !== "http")
|
||||
return register(
|
||||
host.session.hook(registration[0], (event) =>
|
||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const output: SessionHttp = {
|
||||
...event,
|
||||
use: (item) => {
|
||||
middlewares.push(item)
|
||||
},
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.forEach(
|
||||
middlewares,
|
||||
(item) =>
|
||||
event.use((input, next) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => {
|
||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||
return Promise.resolve(
|
||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||
return Effect.runPromiseWith(
|
||||
context,
|
||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
@@ -265,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: sessionHook,
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
evt.use((request, next) => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||
const url = new URL(request.url)
|
||||
request.headers.set("originator", "opencode")
|
||||
request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return next(request)
|
||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface Interface {
|
||||
| "synthetic"
|
||||
| "wait"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "get" | "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
@@ -69,7 +69,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
},
|
||||
job: {
|
||||
get: (id) => require(cell, (runtime) => runtime.job.get(id)),
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
|
||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
const send = (input: Request) =>
|
||||
Effect.gen(function* () {
|
||||
let sent = HttpClientRequest.fromWeb(input)
|
||||
if (input.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||
input.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
latest = sent
|
||||
const response = yield* handler(sent)
|
||||
const body = [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||
origins.set(output, sent)
|
||||
return output
|
||||
})
|
||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
send,
|
||||
)
|
||||
const response = yield* dispatch(web)
|
||||
const origin = origins.get(response) ?? latest
|
||||
return HttpClientResponse.fromWeb(origin, response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
|
||||
@@ -23,10 +23,6 @@ export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
|
||||
description:
|
||||
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
|
||||
}),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||
@@ -40,8 +36,7 @@ export const Output = Schema.Struct({
|
||||
})
|
||||
export const description = [
|
||||
"Spawns an agent in a child session to work on the specified task.",
|
||||
"The output includes a sessionID you can pass back later to continue that specific conversation with the subagent.",
|
||||
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
|
||||
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
@@ -82,7 +77,7 @@ export const Plugin = {
|
||||
) {
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state },
|
||||
})
|
||||
@@ -169,51 +164,22 @@ export const Plugin = {
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||
|
||||
if (input.sessionID !== undefined && input.background === true)
|
||||
return yield* new ToolFailure({
|
||||
message: "Continuing a subagent in the background is not implemented yet",
|
||||
})
|
||||
|
||||
const existing =
|
||||
input.sessionID === undefined
|
||||
? undefined
|
||||
: yield* runtime.session.get(input.sessionID).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({ message: `Subagent session not found: ${input.sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
if (existing !== undefined && existing.parentID !== context.sessionID)
|
||||
return yield* new ToolFailure({
|
||||
message: `Session ${existing.id} is not a child of the current session`,
|
||||
})
|
||||
if (existing !== undefined && existing.agent !== agent.id)
|
||||
return yield* new ToolFailure({
|
||||
message: `Session ${existing.id} belongs to agent ${existing.agent ?? "unknown"}, not ${agent.id}`,
|
||||
})
|
||||
if (existing !== undefined && (yield* runtime.job.get(existing.id))?.status === "running")
|
||||
return yield* new ToolFailure({
|
||||
message: "Continuing a running subagent is not implemented yet",
|
||||
})
|
||||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child =
|
||||
existing ??
|
||||
(yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
))
|
||||
const child = yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
|
||||
const background = input.background === true
|
||||
yield* context.progress({
|
||||
@@ -224,10 +190,7 @@ export const Plugin = {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* runtime.session.prompt({
|
||||
sessionID: child.id,
|
||||
text:
|
||||
existing === undefined
|
||||
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
|
||||
: input.prompt,
|
||||
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
yield* runtime.session.resume(child.id)
|
||||
@@ -275,10 +238,7 @@ export const Plugin = {
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content:
|
||||
output.status === "completed"
|
||||
? `<subagent sessionID="${output.sessionID}" state="completed">\n${output.output}\n</subagent>`
|
||||
: output.output,
|
||||
content: output.output,
|
||||
metadata: { sessionID: output.sessionID, status: output.status },
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -59,6 +59,7 @@ export const Plugin = {
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
coalesce: `${context.messageID}:websearch-consent`,
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
@@ -91,6 +92,7 @@ export const Plugin = {
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
coalesce: `${context.messageID}:websearch-provider`,
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export * as ConfigMigrateV1 from "./migrate"
|
||||
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigV1 } from "./config"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
@@ -13,54 +10,82 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||
import { Provider } from "../../provider"
|
||||
import { Model } from "../../model"
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
||||
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
return encodeInfo(
|
||||
decodeInfo(
|
||||
JSON.stringify({
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
providers: providers(info.provider),
|
||||
}),
|
||||
),
|
||||
const keys = new Set([
|
||||
"logLevel",
|
||||
"server",
|
||||
"command",
|
||||
"reference",
|
||||
"snapshot",
|
||||
"plugin",
|
||||
"autoshare",
|
||||
"disabled_providers",
|
||||
"enabled_providers",
|
||||
"small_model",
|
||||
"mode",
|
||||
"agent",
|
||||
"provider",
|
||||
"permission",
|
||||
"tools",
|
||||
"attachment",
|
||||
"layout",
|
||||
])
|
||||
|
||||
export function isV1(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
|
||||
const record = input as Record<string, unknown>
|
||||
if (Object.keys(record).some((key) => keys.has(key))) return true
|
||||
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
|
||||
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
|
||||
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
|
||||
const mcp = record.mcp
|
||||
return (
|
||||
typeof mcp === "object" &&
|
||||
mcp !== null &&
|
||||
!Array.isArray(mcp) &&
|
||||
!("servers" in mcp) &&
|
||||
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
|
||||
)
|
||||
}
|
||||
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
return {
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
}
|
||||
|
||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||
const policies = [
|
||||
...(info.enabled_providers === undefined
|
||||
@@ -107,7 +132,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
|
||||
}
|
||||
|
||||
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
|
||||
export function normalizeAction(action: string) {
|
||||
function normalizeAction(action: string) {
|
||||
if (action === "write" || action === "patch") return "edit"
|
||||
if (action === "task") return "subagent"
|
||||
if (action === "bash") return "shell"
|
||||
@@ -129,25 +154,21 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
return encodeAgent(
|
||||
decodeAgent(
|
||||
JSON.stringify({
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return {
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}
|
||||
}
|
||||
|
||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([id, command]) => [
|
||||
@@ -184,7 +205,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||
}
|
||||
|
||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||
if (info.type === "local")
|
||||
return {
|
||||
@@ -223,7 +244,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
||||
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
||||
return migrateStandardProvider(info)
|
||||
@@ -235,7 +256,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
models:
|
||||
@@ -279,8 +300,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename these only while migrating unambiguous V1 fields.
|
||||
export function providerID(input: string) {
|
||||
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||
function providerID(input: string) {
|
||||
if (input === "azure-cognitive-services") return "azure"
|
||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||
return input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads authenticated wellknown config below project config", () =>
|
||||
it.live("loads authenticated wellknown config at highest priority", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
@@ -370,7 +370,7 @@ describe("Config", () => {
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||
const updated = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
@@ -378,7 +378,7 @@ describe("Config", () => {
|
||||
key = "next"
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
|
||||
}).pipe(
|
||||
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
||||
)
|
||||
@@ -387,96 +387,27 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("logs redacted source-aware diagnostics for every config source", () => {
|
||||
const output: Array<Record<string, unknown>> = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
|
||||
const details = entry.message[1]
|
||||
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
|
||||
})
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const malformed = path.join(tmp.path, "malformed.json")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(path.join(global, "opencode.json"), "null")
|
||||
await fs.writeFile(path.join(project, "opencode.json"), "")
|
||||
await fs.writeFile(malformed, '{ "credential": "file-secret"')
|
||||
})
|
||||
const integrationID = Integration.ID.make("https://invalid.example.com")
|
||||
const entry: WellKnown.Entry = {
|
||||
origin: "https://invalid.example.com",
|
||||
integrationID,
|
||||
manifest: { auth: { command: ["login"], env: "TOKEN" } },
|
||||
}
|
||||
const credentialNode = makeGlobalNode({
|
||||
service: Credential.Service,
|
||||
layer: Layer.succeed(
|
||||
Credential.Service,
|
||||
Credential.Service.of({
|
||||
all: () => Effect.die("unused Credential.all"),
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
new Credential.Info({
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "default",
|
||||
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
|
||||
}),
|
||||
]),
|
||||
get: () => Effect.die("unused Credential.get"),
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const wellknownNode = makeGlobalNode({
|
||||
service: WellKnown.Service,
|
||||
layer: Layer.succeed(
|
||||
WellKnown.Service,
|
||||
WellKnown.Service.of({
|
||||
entries: () => Effect.succeed([entry]),
|
||||
snapshot: () => [entry],
|
||||
refresh: () => Effect.succeed(false),
|
||||
add: () => Effect.die("unused Wellknown.add"),
|
||||
remove: () => Effect.die("unused Wellknown.remove"),
|
||||
// Exercise the loader boundary against a malformed implementation response.
|
||||
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Config.Service.use((config) => config.entries()).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
|
||||
file: malformed,
|
||||
content: "",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
|
||||
[
|
||||
`${path.join(global, "opencode.json")}:$:invalid`,
|
||||
`${path.join(project, "opencode.json")}:$:invalid`,
|
||||
`${malformed}:$:invalid`,
|
||||
"https://invalid.example.com:$:invalid",
|
||||
"OPENCODE_CONFIG_CONTENT:$:invalid",
|
||||
].toSorted(),
|
||||
)
|
||||
expect(JSON.stringify(output)).not.toContain("secret")
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
|
||||
Effect.sync(() => {
|
||||
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
|
||||
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
|
||||
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
@@ -585,12 +516,12 @@ describe("Config", () => {
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
package: undefined,
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Schema } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
|
||||
function normalized(input: unknown) {
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
expect(result.type).toBe("normalized")
|
||||
if (result.type !== "normalized") throw new Error("expected normalized config")
|
||||
return result
|
||||
}
|
||||
|
||||
function decoded(input: unknown) {
|
||||
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
|
||||
}
|
||||
|
||||
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
|
||||
const result = structuredClone(input)
|
||||
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
|
||||
const mcp = result.mcp as Record<string, unknown>
|
||||
const originallyEmpty = !Object.keys(mcp).length
|
||||
for (const key of ["servers", "timeout"]) {
|
||||
if (
|
||||
typeof mcp[key] === "object" &&
|
||||
mcp[key] !== null &&
|
||||
!Array.isArray(mcp[key]) &&
|
||||
!Object.keys(mcp[key]).length
|
||||
)
|
||||
delete mcp[key]
|
||||
}
|
||||
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
|
||||
}
|
||||
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
|
||||
const compaction = result.compaction as Record<string, unknown>
|
||||
const originallyEmpty = !Object.keys(compaction).length
|
||||
if (
|
||||
typeof compaction.keep === "object" &&
|
||||
compaction.keep !== null &&
|
||||
!Array.isArray(compaction.keep) &&
|
||||
!Object.keys(compaction.keep).length
|
||||
)
|
||||
delete compaction.keep
|
||||
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
describe("ConfigNormalize", () => {
|
||||
test("rejects every non-object root with one root diagnostic", () => {
|
||||
for (const input of [null, [], "config", true, 1]) {
|
||||
expect(ConfigNormalize.normalize(input)).toEqual({
|
||||
type: "rejected",
|
||||
diagnostics: [
|
||||
{
|
||||
kind: "invalid",
|
||||
path: ["$"],
|
||||
message: "rejected configuration because its root is not an object",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps unrelated native fields when a legacy field is present", () => {
|
||||
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
|
||||
expect(result.snapshots).toBe(false)
|
||||
expect(result.agents?.reviewer?.system).toBe("Use V2")
|
||||
})
|
||||
|
||||
test("canonicalizes transformed native values through decode then encode", () => {
|
||||
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
|
||||
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
|
||||
const info = Schema.decodeUnknownSync(Info)(result.encoded)
|
||||
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
|
||||
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
|
||||
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
|
||||
})
|
||||
|
||||
test("preserves arbitrary JSON-round-tripped native configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(Info), (info) => {
|
||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
||||
const result = normalized(source)
|
||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
||||
)
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
test("merges named maps by entry and gives valid native entries precedence", () => {
|
||||
const result = normalized({
|
||||
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
|
||||
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
|
||||
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
|
||||
commands: { native: { template: "native" }, duplicate: { template: "new" } },
|
||||
})
|
||||
expect(result.encoded.references).toEqual({
|
||||
legacy: { path: "../legacy" },
|
||||
native: { path: "../native" },
|
||||
duplicate: { path: "../new" },
|
||||
})
|
||||
expect(result.encoded.commands).toEqual({
|
||||
legacy: { template: "legacy" },
|
||||
native: { template: "native" },
|
||||
duplicate: { template: "new" },
|
||||
})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
|
||||
["references", "duplicate"],
|
||||
["commands", "duplicate"],
|
||||
])
|
||||
})
|
||||
|
||||
test("does not report canonical-equal duplicates as conflicts", () => {
|
||||
const result = normalized({
|
||||
snapshot: false,
|
||||
snapshots: false,
|
||||
reference: { docs: { path: "../docs" } },
|
||||
references: { docs: { path: "../docs" } },
|
||||
agent: { reviewer: { prompt: "same" } },
|
||||
agents: { reviewer: { system: "same" } },
|
||||
provider: { custom: { name: "same" } },
|
||||
providers: { custom: { name: "same" } },
|
||||
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
|
||||
})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
|
||||
})
|
||||
|
||||
test("uses agent then mode then native agent precedence", () => {
|
||||
const result = normalized({
|
||||
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
|
||||
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
|
||||
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
|
||||
})
|
||||
expect(result.encoded.agents).toEqual({
|
||||
reviewer: { system: "native" },
|
||||
agentOnly: { system: "agent-only" },
|
||||
modeOnly: { system: "mode-only", mode: "primary" },
|
||||
nativeOnly: { system: "native-only" },
|
||||
})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
|
||||
["agents", "reviewer"],
|
||||
["agents", "reviewer"],
|
||||
])
|
||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
||||
})
|
||||
|
||||
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
||||
const result = normalized({
|
||||
command: { fallback: { template: "legacy" } },
|
||||
commands: {
|
||||
fallback: { template: 1 },
|
||||
valid: { template: "native" },
|
||||
invalid: { template: false },
|
||||
},
|
||||
providers: {
|
||||
valid: { name: "Valid" },
|
||||
invalid: { env: [1] },
|
||||
},
|
||||
})
|
||||
expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
|
||||
expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
|
||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
||||
["commands", "fallback"],
|
||||
["commands", "invalid"],
|
||||
["providers", "invalid"],
|
||||
])
|
||||
})
|
||||
|
||||
test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
|
||||
const result = normalized({
|
||||
provider: {
|
||||
"azure-cognitive-services": { models: { deployment: {} } },
|
||||
azure: { env: [1] },
|
||||
},
|
||||
})
|
||||
expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
|
||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
|
||||
"provider",
|
||||
"azure",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves permission source order and appends native rules", () => {
|
||||
expect(
|
||||
normalized({
|
||||
tools: { bash: true, write: false },
|
||||
permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
|
||||
permissions: [{ action: "native", resource: "*", effect: "deny" }],
|
||||
}).encoded.permissions,
|
||||
).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "custom", resource: "first", effect: "deny" },
|
||||
{ action: "custom", resource: "second", effect: "ask" },
|
||||
{ action: "subagent", resource: "*", effect: "allow" },
|
||||
{ action: "native", resource: "*", effect: "deny" },
|
||||
])
|
||||
})
|
||||
|
||||
test("redacts permission resource keys from invalid diagnostics", () => {
|
||||
const result = normalized({
|
||||
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
|
||||
})
|
||||
expect(result.diagnostics).toEqual([
|
||||
{
|
||||
kind: "invalid",
|
||||
path: ["permission", "bash", "0"],
|
||||
message: "skipped malformed recognized value",
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
|
||||
})
|
||||
|
||||
test("recovers list items for skills, plugins, instructions, and permissions", () => {
|
||||
const result = normalized({
|
||||
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
|
||||
plugin: ["legacy", ["tuple", {}], [1, {}]],
|
||||
plugins: ["native", { package: "object" }, { package: 1 }],
|
||||
instructions: ["one", 2, "three"],
|
||||
permissions: [
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "invalid" },
|
||||
],
|
||||
})
|
||||
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
|
||||
expect(result.encoded.plugins).toEqual([
|
||||
"legacy",
|
||||
{ package: "tuple", options: {} },
|
||||
"native",
|
||||
{ package: "object" },
|
||||
])
|
||||
expect(result.encoded.instructions).toEqual(["one", "three"])
|
||||
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
|
||||
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
|
||||
})
|
||||
|
||||
test("omits malformed collection roots instead of synthesizing empty values", () => {
|
||||
const result = normalized({
|
||||
commands: [],
|
||||
providers: "invalid",
|
||||
references: false,
|
||||
agents: 1,
|
||||
plugins: {},
|
||||
permissions: {},
|
||||
instructions: {},
|
||||
})
|
||||
expect(result.encoded).toEqual({})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
||||
["references"],
|
||||
["commands"],
|
||||
["agents"],
|
||||
["providers"],
|
||||
["permissions"],
|
||||
["plugins"],
|
||||
["instructions"],
|
||||
])
|
||||
})
|
||||
|
||||
test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
|
||||
const invalid = normalized({
|
||||
formatter: { prettier: { command: [1] } },
|
||||
lsp: { typescript: { command: [1] } },
|
||||
})
|
||||
expect(invalid.encoded).not.toHaveProperty("formatter")
|
||||
expect(invalid.encoded).not.toHaveProperty("lsp")
|
||||
expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
||||
["formatter", "prettier"],
|
||||
["lsp", "typescript"],
|
||||
])
|
||||
|
||||
expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
|
||||
})
|
||||
|
||||
test("combines legacy and native MCP servers and merges timeout leaves", () => {
|
||||
const result = normalized({
|
||||
experimental: { mcp_timeout: 5000 },
|
||||
mcp: {
|
||||
legacy: { type: "local", command: ["legacy"] },
|
||||
duplicate: { type: "remote", url: "https://legacy.example.com" },
|
||||
servers: {
|
||||
native: { type: "local", command: ["native"] },
|
||||
duplicate: { type: "remote", url: "https://native.example.com" },
|
||||
invalid: { type: "local", command: [1] },
|
||||
},
|
||||
timeout: { startup: 1000, catalog: 6000 },
|
||||
},
|
||||
})
|
||||
expect(result.encoded.mcp).toEqual({
|
||||
timeout: { catalog: 6000, execution: 5000, startup: 1000 },
|
||||
servers: {
|
||||
legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
|
||||
duplicate: { type: "remote", url: "https://native.example.com" },
|
||||
native: { type: "local", command: ["native"] },
|
||||
},
|
||||
})
|
||||
expect(
|
||||
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("uses raw MCP discriminators for reserved server names", () => {
|
||||
const result = normalized({
|
||||
mcp: {
|
||||
servers: { type: "local", command: ["reserved-servers"] },
|
||||
timeout: { type: "remote", url: "https://reserved.example.com" },
|
||||
},
|
||||
})
|
||||
expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
|
||||
servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
|
||||
timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
|
||||
})
|
||||
|
||||
const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
|
||||
expect(enabledOnly.encoded.mcp).toBeUndefined()
|
||||
expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
||||
["unsupported", ["mcp", "servers"]],
|
||||
["unsupported", ["mcp", "timeout"]],
|
||||
])
|
||||
})
|
||||
|
||||
test("merges bounded compaction leaves and omits unsupported leaves", () => {
|
||||
const result = normalized({
|
||||
compaction: {
|
||||
auto: false,
|
||||
preserve_recent_tokens: 1000,
|
||||
keep: { tokens: 2000 },
|
||||
reserved: 3000,
|
||||
buffer: 4000,
|
||||
tail_turns: 2,
|
||||
prune: true,
|
||||
},
|
||||
})
|
||||
expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
|
||||
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
|
||||
["unsupported", ["compaction", "tail_turns"]],
|
||||
["unsupported", ["compaction", "prune"]],
|
||||
["conflict", ["compaction", "keep", "tokens"]],
|
||||
["conflict", ["compaction", "buffer"]],
|
||||
])
|
||||
})
|
||||
|
||||
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
|
||||
expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
|
||||
policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
],
|
||||
})
|
||||
expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
|
||||
expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
|
||||
})
|
||||
|
||||
test("appends native policies after migrated provider policies", () => {
|
||||
expect(
|
||||
normalized({
|
||||
enabled_providers: ["anthropic"],
|
||||
disabled_providers: ["openai"],
|
||||
experimental: {
|
||||
subagent_depth: 0,
|
||||
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
|
||||
},
|
||||
}).encoded.experimental,
|
||||
).toEqual({
|
||||
subagent_depth: 0,
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
{ action: "provider.use", resource: "custom", effect: "allow" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("reports unsupported legacy settings without including their values", () => {
|
||||
const secret = "do-not-log-this-value"
|
||||
const result = normalized({
|
||||
logLevel: "DEBUG",
|
||||
small_model: secret,
|
||||
agent: { reviewer: { name: secret, prompt: "review" } },
|
||||
provider: {
|
||||
custom: {
|
||||
id: secret,
|
||||
whitelist: ["model"],
|
||||
models: {
|
||||
model: {
|
||||
release_date: secret,
|
||||
status: "active",
|
||||
interleaved: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: { openTelemetry: true },
|
||||
})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
||||
["logLevel"],
|
||||
["small_model"],
|
||||
["agent", "reviewer", "name"],
|
||||
["provider", "custom", "id"],
|
||||
["provider", "custom", "whitelist"],
|
||||
["provider", "custom", "models", "model", "release_date"],
|
||||
["provider", "custom", "models", "model", "status"],
|
||||
["provider", "custom", "models", "model", "interleaved"],
|
||||
["experimental", "openTelemetry"],
|
||||
])
|
||||
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
|
||||
})
|
||||
|
||||
test("diagnoses unsupported legacy model selections without dropping their entries", () => {
|
||||
const result = normalized({
|
||||
command: {
|
||||
invalidModel: { template: "one", model: "invalid" },
|
||||
invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
|
||||
missingModel: { template: "three", variant: "high" },
|
||||
},
|
||||
agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
|
||||
})
|
||||
expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
|
||||
"invalidModel",
|
||||
"invalidVariant",
|
||||
"missingModel",
|
||||
])
|
||||
expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
|
||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
||||
["command", "invalidModel", "model"],
|
||||
["command", "invalidVariant", "variant"],
|
||||
["command", "missingModel", "variant"],
|
||||
["agent", "invalid", "model"],
|
||||
["agent", "invalid", "variant"],
|
||||
])
|
||||
})
|
||||
|
||||
test("invalid legacy provider overlays skip only that provider", () => {
|
||||
const result = normalized({
|
||||
provider: {
|
||||
headers: { options: { headers: { valid: "yes", invalid: 1 } } },
|
||||
body: { options: { body: "not-an-object" } },
|
||||
valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
|
||||
},
|
||||
})
|
||||
expect(result.encoded.providers).toEqual({
|
||||
valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
|
||||
})
|
||||
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
|
||||
["provider", "headers", "options", "headers"],
|
||||
["provider", "body", "options", "body"],
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves explicit false, zero, empty list, and empty map presence", () => {
|
||||
const result = normalized({
|
||||
snapshot: false,
|
||||
autoshare: false,
|
||||
references: {},
|
||||
commands: {},
|
||||
agents: {},
|
||||
providers: {},
|
||||
plugins: [],
|
||||
instructions: [],
|
||||
experimental: { subagent_depth: 0 },
|
||||
})
|
||||
expect(result.encoded).toMatchObject({
|
||||
snapshots: false,
|
||||
references: {},
|
||||
commands: {},
|
||||
agents: {},
|
||||
providers: {},
|
||||
plugins: [],
|
||||
instructions: [],
|
||||
experimental: { subagent_depth: 0 },
|
||||
})
|
||||
expect(result.encoded.share).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const input = {
|
||||
id: formID,
|
||||
sessionID: SessionSchema.ID.make("ses_test"),
|
||||
title: "Test form",
|
||||
coalesce: "test-form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
@@ -32,6 +33,7 @@ describe("Form", () => {
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
const form = yield* Deferred.await(created)
|
||||
expect(form.coalesce).toBe("test-form")
|
||||
|
||||
yield* service.cancel(form.id)
|
||||
|
||||
|
||||
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP request and response hooks", () =>
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const bodies: string[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
request.headers.set("x-hook", "promise")
|
||||
await next(request)
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-response`)
|
||||
})
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-outer`)
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) =>
|
||||
Effect.promise(() => input.text()).pipe(
|
||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||
),
|
||||
)
|
||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||
|
||||
expect(bodies).toEqual(["payload", "payload"])
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
id: "promise-session-http-interrupt",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
})
|
||||
await ctx.session.hook("http.response", async (event) => {
|
||||
event.response = new Response(`${await event.response.text()}-response`, {
|
||||
status: event.response.status,
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => next(request))
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
const request = yield* hooks.trigger("session", "http.request", {
|
||||
...context,
|
||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||
})
|
||||
const response = yield* hooks.trigger("session", "http.response", {
|
||||
...context,
|
||||
request: request.request,
|
||||
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
||||
})
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
() =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
)
|
||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect(request.request.url).toBe("https://provider.test/changed")
|
||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) => {
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("x-seen-url", input.url)
|
||||
return Effect.succeed(new Response(null, { headers }))
|
||||
},
|
||||
)
|
||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 400_000,
|
||||
context: 272_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
describe("SessionModelRequest HTTP bridge", () => {
|
||||
const bodies: Uint8Array[] = []
|
||||
const methods: string[] = []
|
||||
const headers: Array<string | undefined> = []
|
||||
const response = [
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
methods.push(request.method)
|
||||
bodies.push(request.body.body.slice())
|
||||
headers.push(request.headers["x-hook"])
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const httpIt = testEffect(
|
||||
const retryIt = testEffect(
|
||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||
)
|
||||
|
||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||
Effect.gen(function* () {
|
||||
bodies.length = 0
|
||||
methods.length = 0
|
||||
headers.length = 0
|
||||
const seen: string[] = []
|
||||
const agents = yield* Agent.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
catalog: catalogHost(catalog),
|
||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||
})
|
||||
yield* pluginHost.session.hook("http.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push("request")
|
||||
event.request.headers.set("x-hook", "effect")
|
||||
}),
|
||||
)
|
||||
yield* pluginHost.session.hook("http.response", (event) =>
|
||||
Effect.gen(function* () {
|
||||
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
||||
event.response = new Response(
|
||||
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
||||
event.response,
|
||||
)
|
||||
}),
|
||||
yield* pluginHost.session.hook("http", (event) =>
|
||||
event.use((request, next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||
return yield* next(request)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||
const { db } = yield* Database.Service
|
||||
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
|
||||
yield* session.resume(retrySessionID)
|
||||
|
||||
expect(methods).toEqual(["POST"])
|
||||
expect(headers).toEqual(["effect"])
|
||||
expect(seen).toEqual(["request", "response:200:effect"])
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(methods).toEqual(["POST", "POST"])
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Hooked!" }],
|
||||
})
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -240,7 +240,7 @@ describe("SubagentTool", () => {
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { status: "completed" },
|
||||
content: [{ type: "text", text: expect.stringContaining(childText) }],
|
||||
content: [{ type: "text", text: childText }],
|
||||
})
|
||||
expect(settled.metadata).toEqual({
|
||||
sessionID: outputSessionID(settled.metadata),
|
||||
@@ -283,15 +283,9 @@ describe("SubagentTool", () => {
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { status: "completed" },
|
||||
content: [{ type: "text", text: expect.stringContaining(childText) }],
|
||||
content: [{ type: "text", text: childText }],
|
||||
})
|
||||
const child = yield* sessions.get(outputSessionID(settled.metadata))
|
||||
expect(settled.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: `<subagent sessionID="${child.id}" state="completed">\n${childText}\n</subagent>`,
|
||||
},
|
||||
])
|
||||
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
|
||||
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
|
||||
expect(child).toMatchObject({
|
||||
@@ -321,144 +315,6 @@ describe("SubagentTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("continues an existing child session", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const first = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-first",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this" },
|
||||
},
|
||||
})
|
||||
const childID = outputSessionID(first.metadata)
|
||||
const second = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-second",
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "reviewer",
|
||||
description: "follow up",
|
||||
prompt: "continue this",
|
||||
sessionID: childID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(outputSessionID(second.metadata)).toBe(childID)
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
expect((yield* sessions.get(childID)).title).toBe("review")
|
||||
expect(
|
||||
(yield* sessions.pending(childID)).flatMap((message) =>
|
||||
message.type === "user" ? [message.data.text] : [],
|
||||
),
|
||||
).toEqual(["You are a subagent spawned by another session.\nreview this", "continue this"])
|
||||
expect(second.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: `<subagent sessionID="${childID}" state="completed">\n${childText}\n</subagent>`,
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects background continuation", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location })
|
||||
const child = yield* sessions.create({
|
||||
parentID: parent.id,
|
||||
title: "review",
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-background-continuation",
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "reviewer",
|
||||
description: "follow up",
|
||||
prompt: "continue this",
|
||||
sessionID: child.id,
|
||||
background: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "Continuing a subagent in the background is not implemented yet",
|
||||
},
|
||||
})
|
||||
|
||||
const jobs = yield* Job.Service
|
||||
yield* jobs.start({ id: child.id, type: "subagent", run: Effect.never })
|
||||
yield* jobs.background(child.id)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-running-continuation",
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "reviewer",
|
||||
description: "follow up",
|
||||
prompt: "continue this",
|
||||
sessionID: child.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "Continuing a running subagent is not implemented yet",
|
||||
},
|
||||
})
|
||||
yield* jobs.cancel(child.id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns child runner failures as tool errors", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -537,7 +393,7 @@ describe("SubagentTool", () => {
|
||||
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
|
||||
|
||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||
expect(admission?.data.input.data.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
|
||||
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(admission?.data.input.data).toMatchObject({
|
||||
description: "background review",
|
||||
metadata: {
|
||||
@@ -551,7 +407,7 @@ describe("SubagentTool", () => {
|
||||
yield* SessionPending.promote(database.db, bus, parent.id, "steer")
|
||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(synthetic).toHaveLength(1)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(childText)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => {
|
||||
{
|
||||
sessionID,
|
||||
title: "Web Search",
|
||||
coalesce: "msg_tool_test:websearch-consent",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
@@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => {
|
||||
expect(formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
coalesce: "msg_tool_test:websearch-provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
|
||||
TEffectHKT
|
||||
>
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
||||
export class SQLiteEffectSelectBuilder<
|
||||
out TSelection extends SelectedFields | undefined,
|
||||
out TRunResult,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TBuilderMode extends "db" | "qb" = "db",
|
||||
TSelection extends SelectedFields | undefined,
|
||||
TRunResult,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TBuilderMode extends "db" | "qb" = "db",
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||
|
||||
|
||||
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
||||
export abstract class SQLiteEffectSession<
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TRunResult = unknown,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TRunResult = unknown,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||
|
||||
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
|
||||
}
|
||||
|
||||
export abstract class SQLiteEffectTransaction<
|
||||
out TEffectHKT extends QueryEffectHKTBase,
|
||||
out TRunResult,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
TEffectHKT extends QueryEffectHKTBase,
|
||||
TRunResult,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: Request
|
||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Effect.Effect<Response, Error>
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: Request
|
||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||
}
|
||||
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Promise<Response> | Response
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
+1539
-690
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { AbsolutePath, optional } from "./schema.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { ConfigAgent } from "./config/agent.js"
|
||||
import { ConfigMedia } from "./config/media.js"
|
||||
import { ConfigCompaction } from "./config/compaction.js"
|
||||
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
|
||||
import { ConfigWarming } from "./config/warming.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: optional(Schema.String).annotate({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(optional).annotate({
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: ConfigModel.Selection.pipe(optional).annotate({
|
||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(optional)
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(optional),
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
.pipe(optional)
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(optional).annotate({
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(optional).annotate({
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(optional).annotate({
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(optional).annotate({
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(optional).annotate({
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(optional).annotate({
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(optional).annotate({
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(optional).annotate({
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(optional).annotate({
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(optional).annotate({
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
warming: ConfigWarming.Warming.pipe(optional).annotate({
|
||||
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||
}),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
|
||||
experimental: ConfigExperimental.Info.pipe(optional),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
import { ConfigModel } from "./model.js"
|
||||
import { ConfigProvider } from "./provider.js"
|
||||
|
||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Agent")({
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
request: ConfigProvider.Request.pipe(optional),
|
||||
system: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
color: Color.pipe(optional),
|
||||
steps: PositiveInt.pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
permissions: Permission.Ruleset.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export * as ConfigCommand from "./command.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
import { ConfigModel } from "./model.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as ConfigCompaction from "./compaction.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, optional } from "../schema.js"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(optional),
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||
auto: Schema.Boolean.pipe(optional),
|
||||
keep: Keep.pipe(optional),
|
||||
buffer: NonNegativeInt.pipe(optional),
|
||||
auto: Schema.Boolean.pipe(Schema.optional),
|
||||
keep: Keep.pipe(Schema.optional),
|
||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as ConfigExperimental from "./experimental.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, optional } from "../schema.js"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
import { ConfigPolicy } from "./policy.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(optional).annotate({
|
||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||
}),
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Ordered policies controlling access to configured resources",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export * as ConfigFormatter from "./formatter.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
command: Schema.String.pipe(Schema.Array, optional),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
extensions: Schema.String.pipe(Schema.Array, optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
command: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigLSP from "./lsp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
|
||||
|
||||
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
|
||||
command: Schema.String.pipe(Schema.Array),
|
||||
extensions: Schema.String.pipe(Schema.Array, optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Disabled, Server])
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as ConfigMCP from "./mcp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Mcp } from "../mcp.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export const Timeout = Mcp.TimeoutConfig
|
||||
export type Timeout = Mcp.TimeoutConfig
|
||||
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
|
||||
export const Server = Mcp.ServerConfig
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.MCP")({
|
||||
timeout: Timeout.pipe(optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(optional),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigMedia from "./media.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(optional),
|
||||
max_width: PositiveInt.pipe(optional),
|
||||
max_height: PositiveInt.pipe(optional),
|
||||
max_base64_bytes: PositiveInt.pipe(optional),
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
image: Image.pipe(optional),
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ConfigModel from "./model.js"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
|
||||
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
|
||||
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
|
||||
const Explicit = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
model: ModelID,
|
||||
variant: VariantID.pipe(optional),
|
||||
variant: VariantID.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export * as ConfigPlugin from "./plugin.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
|
||||
package: Schema.String,
|
||||
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Plugin = Schema.Union([Schema.String, Entry])
|
||||
|
||||
@@ -3,14 +3,13 @@ export * as ConfigProvider from "./provider.js"
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "../money.js"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
settings: JsonRecord.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: JsonRecord.pipe(optional),
|
||||
settings: JsonRecord.pipe(Schema.optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: JsonRecord.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
@@ -19,47 +18,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
||||
read: Money.USDPerMillionTokens.pipe(optional),
|
||||
write: Money.USDPerMillionTokens.pipe(optional),
|
||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(optional),
|
||||
}).pipe(Schema.optional),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache: Cache.pipe(optional),
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
context: Schema.Int.pipe(optional),
|
||||
input: Schema.Int.pipe(optional),
|
||||
output: Schema.Int.pipe(optional),
|
||||
context: Schema.Int.pipe(Schema.optional),
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("Config.Model")({
|
||||
modelID: ID.pipe(optional),
|
||||
family: Family.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
modelID: ID.pipe(Schema.optional),
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
compatibility: Compatibility.pipe(Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
capabilities: Capabilities.pipe(optional),
|
||||
capabilities: Capabilities.pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...Overlays,
|
||||
}).pipe(Schema.Array, optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
limit: Limit.pipe(optional),
|
||||
}).pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
limit: Limit.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(optional),
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
export * as ConfigReference from "./reference.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
|
||||
repository: Schema.String,
|
||||
branch: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
|
||||
path: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Schema.String, Git, Local])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as ConfigToolOutput from "./tool-output.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(optional),
|
||||
max_bytes: PositiveInt.pipe(optional),
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
max_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
export * as ConfigWarming from "./warming.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Warming")({
|
||||
prompt: Schema.String.pipe(optional).annotate({
|
||||
prompt: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Prompt sent for keep-alive requests",
|
||||
}),
|
||||
interval: Schema.DurationFromString.pipe(optional).annotate({
|
||||
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||
description: 'Idle time between keep-alive requests (default: "4 minutes")',
|
||||
}),
|
||||
duration: Schema.DurationFromString.pipe(optional).annotate({
|
||||
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
|
||||
}),
|
||||
}) {}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as ConfigWatcher from "./watcher.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Watcher")({
|
||||
ignore: Schema.String.pipe(Schema.Array, optional),
|
||||
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -124,6 +124,9 @@ const InfoBase = {
|
||||
// on non-session owners anywhere else.
|
||||
sessionID: Schema.String,
|
||||
title: Schema.String,
|
||||
coalesce: Schema.String.pipe(optional).annotate({
|
||||
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
|
||||
}),
|
||||
metadata: Metadata.pipe(optional),
|
||||
}
|
||||
|
||||
|
||||
+23
-19
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
|
||||
import { IntegrationID } from "./integration-id.js"
|
||||
|
||||
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
|
||||
startup: PositiveInt.pipe(optional).annotate({
|
||||
startup: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
|
||||
}),
|
||||
catalog: PositiveInt.pipe(optional).annotate({
|
||||
catalog: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
|
||||
}),
|
||||
execution: PositiveInt.pipe(optional).annotate({
|
||||
execution: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
|
||||
}),
|
||||
}) {}
|
||||
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
|
||||
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
|
||||
type: Schema.Literal("local"),
|
||||
command: Schema.String.pipe(Schema.Array),
|
||||
cwd: Schema.String.pipe(optional).annotate({
|
||||
cwd: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
|
||||
}),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
codemode: Schema.Boolean.pipe(optional).annotate({
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||
}),
|
||||
timeout: TimeoutConfig.pipe(optional),
|
||||
timeout: TimeoutConfig.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
|
||||
client_id: Schema.String.pipe(optional),
|
||||
client_secret: Schema.String.pipe(optional),
|
||||
scope: Schema.String.pipe(optional),
|
||||
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
|
||||
redirect_uri: Schema.String.pipe(optional),
|
||||
client_id: Schema.String.pipe(Schema.optional),
|
||||
client_secret: Schema.String.pipe(Schema.optional),
|
||||
scope: Schema.String.pipe(Schema.optional),
|
||||
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
|
||||
redirect_uri: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
|
||||
type: Schema.Literal("remote"),
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
codemode: Schema.Boolean.pipe(optional).annotate({
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||
}),
|
||||
timeout: TimeoutConfig.pipe(optional),
|
||||
timeout: TimeoutConfig.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
|
||||
@@ -68,9 +68,13 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
|
||||
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
|
||||
identifier: "Mcp.Status.NeedsAuth",
|
||||
})
|
||||
const NeedsClientRegistration = Schema.Struct({
|
||||
status: Schema.Literal("needs_client_registration"),
|
||||
error: Schema.String,
|
||||
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
|
||||
|
||||
export type Status = typeof Status.Type
|
||||
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
|
||||
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
|
||||
Schema.toTaggedUnion("status"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Config } from "../src/config.js"
|
||||
import { ConfigAgent } from "../src/config/agent.js"
|
||||
import { ConfigMCP } from "../src/config/mcp.js"
|
||||
import { ConfigProvider } from "../src/config/provider.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { AbsolutePath } from "../src/schema.js"
|
||||
|
||||
describe("Config.Entry", () => {
|
||||
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
|
||||
expect(decoded).toEqual(entries)
|
||||
expect(decoded[0]).toBeInstanceOf(Config.Document)
|
||||
expect(decoded[1]).not.toHaveProperty("path")
|
||||
expect(decoded.map((entry) => entry.type)).toEqual([
|
||||
"document",
|
||||
"document",
|
||||
"directory",
|
||||
"file",
|
||||
"agents",
|
||||
"claude",
|
||||
])
|
||||
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
|
||||
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
|
||||
test("has a stable public identifier", () => {
|
||||
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
|
||||
})
|
||||
|
||||
test("omits undefined optional properties while encoding", () => {
|
||||
const entry = new Config.Document({
|
||||
type: "document",
|
||||
path: undefined,
|
||||
info: new Config.Info({
|
||||
default_agent: undefined,
|
||||
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: undefined,
|
||||
servers: {
|
||||
docs: new Mcp.RemoteConfig({
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: undefined,
|
||||
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
|
||||
}),
|
||||
})
|
||||
const encoded = Schema.encodeSync(Config.Entry)(entry)
|
||||
if (encoded.type !== "document") throw new Error("Expected a config document")
|
||||
|
||||
expect(encoded).not.toHaveProperty("path")
|
||||
expect(encoded.info).not.toHaveProperty("default_agent")
|
||||
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
|
||||
expect(encoded.info.mcp).not.toHaveProperty("timeout")
|
||||
const docs = encoded.info.mcp?.servers?.docs
|
||||
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
|
||||
expect(docs).not.toHaveProperty("headers")
|
||||
expect(docs.oauth).not.toHaveProperty("client_id")
|
||||
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,9 +16,7 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const config = path.join(project, "opencode.json")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
|
||||
)
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
config,
|
||||
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
|
||||
yield* Effect.promise(() => response.json()),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(entries)).toBe(true)
|
||||
@@ -58,21 +56,7 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
|
||||
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
|
||||
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
|
||||
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
|
||||
expect(raw["info"]).not.toHaveProperty("default_agent")
|
||||
expect(raw["info"]).not.toHaveProperty("model")
|
||||
const mcp = raw["info"]["mcp"]
|
||||
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
|
||||
throw new Error("Expected an MCP server config")
|
||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
|
||||
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
IntegrationOauthConnectOutput,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/client"
|
||||
import open from "open"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
@@ -446,19 +445,6 @@ function OAuthAuto(props: {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{
|
||||
bind: "o",
|
||||
title: "Open authorization URL",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
open(props.attempt.url).catch(() =>
|
||||
toast.show({
|
||||
message: "Could not open the browser. Copy the URL and continue manually.",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "c",
|
||||
title: "Copy authorization details",
|
||||
@@ -516,7 +502,6 @@ function OAuthAuto(props: {
|
||||
instructions={props.attempt.instructions}
|
||||
message="Waiting for authorization..."
|
||||
copy
|
||||
open
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -574,14 +559,7 @@ function OAuthCode(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function OAuthView(props: {
|
||||
title: string
|
||||
url?: string
|
||||
instructions?: string
|
||||
message: string
|
||||
copy?: boolean
|
||||
open?: boolean
|
||||
}) {
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
return (
|
||||
@@ -605,18 +583,11 @@ function OAuthView(props: {
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={props.open}>
|
||||
<text fg={theme.text.default}>
|
||||
o <span style={{ fg: theme.text.subdued }}>open</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={props.copy}>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={props.copy}>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,24 +15,17 @@ import { useConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
function statusError(status: McpServer["status"]) {
|
||||
if (status.status === "failed") return status.error
|
||||
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
|
||||
return undefined
|
||||
}
|
||||
|
||||
function Status(props: { status: McpServer["status"]; loading: boolean }) {
|
||||
if (props.loading || props.status.status === "pending") {
|
||||
return <>Connecting …</>
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const theme = useTheme("elevated")
|
||||
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
if (props.status.status === "connected") {
|
||||
return <span style={{ attributes: TextAttributes.BOLD }}>Connected ✓</span>
|
||||
}
|
||||
if (props.status.status === "failed") {
|
||||
return <>Failed !</>
|
||||
}
|
||||
if (props.status.status === "needs_auth") {
|
||||
return <>Sign in required →</>
|
||||
}
|
||||
return <>Disabled ○</>
|
||||
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
@@ -45,13 +38,6 @@ export function DialogMcp() {
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const statusColor = (status: McpServer["status"]) => {
|
||||
if (status.status === "connected") return theme.text.feedback.success.default
|
||||
if (status.status === "failed") return theme.text.feedback.error.default
|
||||
if (status.status === "needs_auth") return theme.text.feedback.warning.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
|
||||
const servers = createMemo(() =>
|
||||
pipe(
|
||||
data.location.mcp.server.list() ?? [],
|
||||
@@ -67,29 +53,17 @@ export function DialogMcp() {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const loadingMcp = loading()
|
||||
return servers().map((server) => {
|
||||
const pending = loadingMcp === server.name || server.status.status === "pending"
|
||||
return {
|
||||
value: server.name,
|
||||
title: server.name,
|
||||
footer: <Status status={server.status} loading={pending} />,
|
||||
footerColor: pending ? theme.text.subdued : statusColor(server.status),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
|
||||
|
||||
const toggleTitle = createMemo(() => {
|
||||
const status = focusedServer()?.status.status
|
||||
if (status === "connected") return "disconnect"
|
||||
if (status === "failed") return "retry"
|
||||
if (status === "needs_auth") return "sign in"
|
||||
return "connect"
|
||||
return servers().map((server) => ({
|
||||
value: server.name,
|
||||
title: server.name,
|
||||
description: server.status.status,
|
||||
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
|
||||
}))
|
||||
})
|
||||
|
||||
const focusedError = createMemo(() => {
|
||||
const server = focusedServer()
|
||||
const name = focused()
|
||||
const server = servers().find((entry) => entry.name === name)
|
||||
return server ? statusError(server.status) : undefined
|
||||
})
|
||||
|
||||
@@ -126,7 +100,7 @@ export function DialogMcp() {
|
||||
onSelect={(option) => open(option.value as string)}
|
||||
actions={[
|
||||
{
|
||||
title: toggleTitle(),
|
||||
title: "toggle",
|
||||
command: "dialog.mcp.toggle",
|
||||
onTrigger: (option) => {
|
||||
setFocused(option.value as string)
|
||||
|
||||
@@ -14,6 +14,7 @@ export function DialogStatus() {
|
||||
if (status === "connected") return theme.text.feedback.success.default
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
return (
|
||||
@@ -45,6 +46,9 @@ export function DialogStatus() {
|
||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
||||
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
|
||||
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
|
||||
<Match when={item.status.status === "needs_client_registration" && item.status}>
|
||||
{(val) => (val() as { error: string }).error}
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
|
||||
@@ -8,7 +8,13 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
|
||||
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
|
||||
const bad = createMemo(
|
||||
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
|
||||
() =>
|
||||
list().filter(
|
||||
(item) =>
|
||||
item.status.status === "failed" ||
|
||||
item.status.status === "needs_auth" ||
|
||||
item.status.status === "needs_client_registration",
|
||||
).length,
|
||||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
@@ -16,6 +22,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
if (status === "failed") return theme.text.feedback.error.default
|
||||
if (status === "disabled") return theme.text.subdued
|
||||
if (status === "needs_auth") return theme.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return theme.text.feedback.error.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
|
||||
@@ -58,6 +65,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
</Match>
|
||||
<Match when={item.status.status === "disabled"}>Disabled</Match>
|
||||
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
|
||||
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
|
||||
@@ -93,13 +93,13 @@ export function Home() {
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? (
|
||||
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
|
||||
<box width="100%">
|
||||
<FormPrompt form={form} />
|
||||
<FormPrompt form={form} forms={forms()} />
|
||||
</box>
|
||||
</box>
|
||||
) : null
|
||||
|
||||
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
|
||||
const client = useClient()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
let textarea: TextareaRenderable | undefined
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
|
||||
const forms = createMemo(() => {
|
||||
if (!props.form.coalesce) return [props.form]
|
||||
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
|
||||
})
|
||||
|
||||
const message = createMemo(() => {
|
||||
const value = props.form.metadata?.["message"]
|
||||
return typeof value === "string" ? value : undefined
|
||||
@@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [field.key]: value },
|
||||
},
|
||||
requestOptions(props.form),
|
||||
function reply(answer: Record<string, FormValue>) {
|
||||
Promise.all(
|
||||
forms().map((form) =>
|
||||
client.api.form.reply(
|
||||
{
|
||||
sessionID: form.sessionID,
|
||||
formID: form.id,
|
||||
answer,
|
||||
},
|
||||
requestOptions(form),
|
||||
),
|
||||
),
|
||||
).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
reply({ [field.key]: value })
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
for (const form of forms())
|
||||
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
reply(
|
||||
Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||
|
||||
@@ -1026,10 +1026,10 @@ export function Session() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
return form ? <FormPrompt form={form} forms={forms()} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -84,8 +84,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
<box gap={1}>
|
||||
{props.description?.()}
|
||||
<textarea
|
||||
height={1}
|
||||
wrapMode="none"
|
||||
height={3}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
setTextareaTarget(val)
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface DialogSelectOption<T = any> {
|
||||
detailsColor?: RGBA
|
||||
detailsWrap?: boolean
|
||||
footer?: JSX.Element | string
|
||||
footerColor?: RGBA
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
category?: string
|
||||
@@ -728,7 +727,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
footer={
|
||||
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
|
||||
}
|
||||
footerColor={option.footerColor}
|
||||
titleWidth={option.titleWidth}
|
||||
truncateTitle={option.truncateTitle}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
@@ -786,7 +784,6 @@ function Option(props: {
|
||||
current?: boolean
|
||||
muted?: boolean
|
||||
footer?: JSX.Element | string
|
||||
footerColor?: RGBA
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
gutter?: () => JSX.Element
|
||||
@@ -835,17 +832,7 @@ function Option(props: {
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text
|
||||
fg={
|
||||
props.active && !props.muted
|
||||
? text()
|
||||
: props.muted && (props.active || props.current)
|
||||
? theme.text.subdued
|
||||
: (props.footerColor ?? theme.text.subdued)
|
||||
}
|
||||
>
|
||||
{props.footer}
|
||||
</text>
|
||||
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
<a href={props.href}>{displayText}</a>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
async function mountForm(root: string, width = 80, coalesce = false) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
|
||||
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
|
||||
const events = createEventStream()
|
||||
const transport = createFetch(
|
||||
(url, request) =>
|
||||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
|
||||
? request.json().then((answer) => {
|
||||
replies.push(answer)
|
||||
return new Response(null, { status: 204 })
|
||||
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
...(coalesce ? { coalesce: "authorization" } : {}),
|
||||
fields: [
|
||||
{
|
||||
key: "authorization",
|
||||
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("replies to every coalesced form", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, true)
|
||||
try {
|
||||
prompt.app.mockInput.pressKey("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
|
||||
prompt.app.mockInput.pressKey("left")
|
||||
prompt.app.mockInput.pressKey("c")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.replies.length === 2)
|
||||
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,8 +81,9 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"prune": false,
|
||||
"keep": {
|
||||
"tokens": 15000
|
||||
"tokens": 8000
|
||||
},
|
||||
"buffer": 20000
|
||||
}
|
||||
@@ -92,7 +93,8 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
||||
| Field | Default | V2 behavior |
|
||||
| --- | ---: | --- |
|
||||
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
|
||||
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
|
||||
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
|
||||
|
||||
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
|
||||
@@ -133,6 +135,8 @@ behavior.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
|
||||
not implemented in V2.
|
||||
- Compaction requires a resolvable model with a positive catalog context limit.
|
||||
There is no separate compaction-model setting or fallback model.
|
||||
- Summary generation can fail if the summary prompt itself cannot fit beside
|
||||
|
||||
+8
-16
@@ -246,27 +246,19 @@ Runtime hooks intercept live operations:
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
||||
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
||||
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
|
||||
HTTP hooks can modify requests and responses. They apply to native models; AI
|
||||
SDK models do not currently pass through these hooks. Request and response
|
||||
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
||||
separate reader, but be aware that its slower branch may buffer data. To inspect
|
||||
or modify chunks while preserving streaming, replace the body with one piped
|
||||
through a `TransformStream`.
|
||||
HTTP hooks can modify requests, inspect responses, retry, or return a
|
||||
response without calling the provider. It applies to native models; AI SDK
|
||||
models do not currently pass through this hook.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request.headers.set("x-session-id", event.sessionID)
|
||||
})
|
||||
|
||||
await ctx.session.hook("http.response", (event) => {
|
||||
event.response = new Response(event.response.body, {
|
||||
status: event.response.status,
|
||||
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => {
|
||||
request.headers.set("x-session-id", event.sessionID)
|
||||
return next(request)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"keep": {
|
||||
"tokens": 15000
|
||||
"tokens": 8000
|
||||
},
|
||||
"buffer": 20000
|
||||
}
|
||||
|
||||
@@ -16,17 +16,15 @@ V2 has three intentional breaking changes:
|
||||
- The [server API and clients](#server-api-and-clients) have new contracts.
|
||||
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
|
||||
|
||||
Supported V1 functionality outside those areas is intended to remain compatible with V1. Some fields accepted by the V1
|
||||
schema never had a V2 equivalent and are intentionally ignored; these are listed under
|
||||
[Accepted but unsupported fields](#accepted-but-unsupported-fields).
|
||||
All other functionality is intended to remain compatible with V1.
|
||||
|
||||
Existing supported server config fields, agent definitions, command definitions, skills, and other files in `.opencode/`
|
||||
should continue to work without changes. If supported behavior described in this guide stops working in V2, treat it as a
|
||||
beta compatibility bug rather than an expected migration requirement.
|
||||
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
|
||||
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
|
||||
an expected migration requirement.
|
||||
|
||||
<Callout type="tip">
|
||||
Run `/report` if supported V1 functionality does not work in V2. The report skill collects diagnostics and helps you
|
||||
file a compatibility issue.
|
||||
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
|
||||
a compatibility issue.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -62,9 +60,8 @@ V2 reads existing global and project configuration from the same locations as V1
|
||||
<project>/.opencode/opencode.json(c)
|
||||
```
|
||||
|
||||
V2 reads these same locations. It normalizes supported V1 and native V2 fields in memory without rewriting the source
|
||||
file. Existing supported V1 configuration is intended to keep working, so you do not need to convert it to try or adopt
|
||||
V2.
|
||||
V2 reads these same locations. It detects V1-shaped configuration and translates it in memory without rewriting the
|
||||
source file. Existing V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2.
|
||||
|
||||
### Ask OpenCode to migrate
|
||||
|
||||
@@ -79,13 +76,7 @@ Preserve its behavior and all unrelated settings.
|
||||
```
|
||||
|
||||
OpenCode can inspect the complete file, apply the relevant changes below, and avoid rewriting settings that do not need to
|
||||
change. Conversion does not need to happen all at once: supported V1 and native V2 fields may coexist at the top level.
|
||||
When both forms set the same canonical value, a valid native V2 value takes precedence regardless of JSON key order.
|
||||
|
||||
Nested mixing is intentionally bounded. OpenCode recognizes mixed V1 and V2 members within `mcp`, `compaction`, and
|
||||
`experimental`, but it does not recursively infer formats inside individual agents, providers, commands, or models. Keep
|
||||
each of those nested entries entirely in one format. Supported V1 syntax remains quiet by itself; malformed values,
|
||||
unsupported legacy fields, and conflicting V1/V2 values produce warnings while unrelated valid settings continue to load.
|
||||
change. Do not mix V1 and V2 field names manually in one file.
|
||||
|
||||
### Sharing
|
||||
|
||||
@@ -259,8 +250,8 @@ V2 groups the retained-context token budget under `keep` and gives the reserve a
|
||||
}
|
||||
```
|
||||
|
||||
`auto` keeps its name. V2 has no native `tail_turns` or `prune` field; both legacy fields are ignored with a warning. Recent
|
||||
context is retained by token budget instead. See [Compaction](/compaction).
|
||||
`auto` and `prune` keep their names. V2 has no native `tail_turns` field; recent context is retained by token budget instead.
|
||||
See [Compaction](/compaction).
|
||||
|
||||
### Skills
|
||||
|
||||
@@ -363,17 +354,6 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
|
||||
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
|
||||
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
|
||||
|
||||
V2 consolidated two legacy provider namespaces:
|
||||
|
||||
| V1 provider ID | Canonical V2 provider ID |
|
||||
| --- | --- |
|
||||
| `azure-cognitive-services` | `azure` |
|
||||
| `google-vertex-anthropic` | `google-vertex` |
|
||||
|
||||
Migration of unambiguous V1 provider, agent, command, and provider-filter fields uses these canonical IDs. The shared
|
||||
top-level `model` field keeps its exact provider ID because the same syntax is valid in native V2 config; update that field
|
||||
to the canonical ID when migrating a legacy built-in provider.
|
||||
|
||||
### Models and variants
|
||||
|
||||
Models remain nested under their provider, but several model fields become more explicit:
|
||||
@@ -410,39 +390,22 @@ Models remain nested under their provider, but several model fields become more
|
||||
|
||||
See [Models](/models) for the complete native model shape.
|
||||
|
||||
### Supported fields without direct native equivalents
|
||||
### Fields without native equivalents
|
||||
|
||||
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
|
||||
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
|
||||
|
||||
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
|
||||
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
|
||||
|
||||
### Accepted but unsupported fields
|
||||
|
||||
The V1 schema also accepted fields that have no supported V2 behavior. V2 ignores these values and emits a warning so
|
||||
they are not mistaken for active configuration:
|
||||
These V1 fields do not have one-to-one native V2 config fields:
|
||||
|
||||
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
|
||||
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
|
||||
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
|
||||
- `enabled_providers` and `disabled_providers`: there is no native provider allowlist or denylist field yet.
|
||||
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
|
||||
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
|
||||
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
|
||||
- Agent `name` inside V1 JSON configuration.
|
||||
- An enabled-only V1 MCP entry without a `type`.
|
||||
- V1 experimental fields `disable_paste_summary`, `batch_tool`, `openTelemetry`, `primary_tools`, and
|
||||
`continue_loop_on_deny`.
|
||||
- V1 provider fields `id`, `whitelist`, and `blacklist`.
|
||||
- V1 provider-model fields `release_date`, `attachment`, `reasoning`, `temperature`, `experimental`, a non-`deprecated`
|
||||
`status`, and boolean `interleaved`.
|
||||
- `compaction.tail_turns`: V2 uses `compaction.keep.tokens` instead.
|
||||
|
||||
Ignoring these fields is intentional and is not a compatibility regression. If V2 does not preserve behavior identified
|
||||
as supported elsewhere in this guide, run `/report`.
|
||||
If your V1 configuration relies on a field without a native equivalent, keep using the supported V1 format rather than
|
||||
forcing a manual conversion. Run `/report` if V2 does not preserve the behavior you rely on.
|
||||
|
||||
### Agent files
|
||||
|
||||
|
||||
+1539
-690
File diff suppressed because it is too large
Load Diff
+1539
-690
File diff suppressed because it is too large
Load Diff
@@ -1,163 +0,0 @@
|
||||
# Mixed V1/V2 Config Normalization Plan
|
||||
|
||||
Status: **Implemented and verified**
|
||||
|
||||
## Goal
|
||||
|
||||
Replace whole-document V1/V2 detection with one config-domain compatibility pipeline. Supported V1 fields, native V2 fields, and practical mixtures of both should load without an unrelated legacy key changing how the rest of the document is decoded.
|
||||
|
||||
## Decision
|
||||
|
||||
Normalize recognized fields independently into the encoded side of the V2 `Config.Info` schema, then perform one final complete-document V2 decode:
|
||||
|
||||
```text
|
||||
JSON/JSONC encoded input
|
||||
-> parse and retain source-property presence
|
||||
-> validate each recognized field or collection entry
|
||||
-> migrate supported V1 candidates to V2 encoded values
|
||||
-> decode and re-encode native V2 candidates
|
||||
-> merge with native V2 precedence
|
||||
-> decode Config.Info once
|
||||
-> log redacted diagnostics
|
||||
```
|
||||
|
||||
There is no whole-document version classification and no independent whole-document V1 and V2 decode.
|
||||
|
||||
The encoded boundary matters because schemas such as warming durations transform strings into runtime values. Decoded values must not be fed back into the encoded side of `Config.Info`.
|
||||
|
||||
## Behavior
|
||||
|
||||
| Situation | Result |
|
||||
| --- | --- |
|
||||
| Supported V1-only field | Migrate it to its canonical V2 destination. |
|
||||
| Native V2 field | Preserve it after schema decode and encode. |
|
||||
| Disjoint V1 and V2 map entries | Preserve both. |
|
||||
| Same canonical scalar, map entry, or nested leaf | Valid native V2 wins regardless of JSON key order. |
|
||||
| Malformed native value with valid legacy fallback | Skip native value, log it, and retain legacy value. |
|
||||
| Malformed collection entry | Skip only the explicitly supported recovery unit. |
|
||||
| Unsupported accepted V1 setting | Omit it and log a redacted warning. |
|
||||
| Unknown field | Continue ignoring it for forward compatibility. |
|
||||
|
||||
Valid supported V1 syntax does not warn merely because it is legacy.
|
||||
|
||||
## Field Precedence
|
||||
|
||||
| Destination | Lowest to highest precedence |
|
||||
| --- | --- |
|
||||
| `snapshots` | `snapshot` < `snapshots` |
|
||||
| `share` | `autoshare` < `share` |
|
||||
| `references[name]` | `reference[name]` < `references[name]` |
|
||||
| `agents[name]` | `agent[name]` < `mode[name]` < `agents[name]` |
|
||||
| `commands[name]` | `command[name]` < `commands[name]` |
|
||||
| `providers[name]` | `provider[name]` < `providers[name]` |
|
||||
| `permissions` | `tools` rules < `permission` rules < native `permissions` |
|
||||
| `plugins` | migrated `plugin` items < native `plugins` items |
|
||||
| `media` | `attachment` < `media` |
|
||||
| `experimental.policies` | enabled-provider policies < disabled-provider policies < native policies |
|
||||
| `mcp.servers[name]` | direct legacy server < native `servers[name]` |
|
||||
| `mcp.timeout.*` | `experimental.mcp_timeout` < native timeout leaf |
|
||||
| `compaction.keep.tokens` | `preserve_recent_tokens` < `keep.tokens` |
|
||||
| `compaction.buffer` | `reserved` < `buffer` |
|
||||
|
||||
Ordered rules and plugin directives retain both forms, with migrated V1 entries first and native V2 entries last.
|
||||
|
||||
## Shared Shapes
|
||||
|
||||
### Skills
|
||||
|
||||
- A V2 array retains each valid string item.
|
||||
- A V1 object combines valid `paths` followed by valid `urls`.
|
||||
- Empty and unknown-only V1 objects normalize to an empty array under permissive excess-property handling.
|
||||
|
||||
### MCP
|
||||
|
||||
- Direct entries under `mcp` are V1 servers.
|
||||
- Entries under `mcp.servers` are native V2 servers.
|
||||
- Both sets are merged by server name, with a complete native server replacing a duplicate legacy server.
|
||||
- A malformed native duplicate is skipped so a valid legacy server remains.
|
||||
- Native global timeout leaves override only matching values migrated from `experimental.mcp_timeout`.
|
||||
- Raw `type` and `enabled` discriminators preserve legacy servers that happen to be named `servers` or `timeout`.
|
||||
|
||||
### Compaction
|
||||
|
||||
- `preserve_recent_tokens` becomes `keep.tokens`.
|
||||
- `reserved` becomes `buffer`.
|
||||
- Native leaves win conflicts.
|
||||
- `tail_turns` and `prune` remain unsupported and produce warnings.
|
||||
|
||||
### Experimental
|
||||
|
||||
- `subagent_depth` is shared.
|
||||
- Legacy provider lists generate ordered canonical policies.
|
||||
- Native policies follow generated policies.
|
||||
- An explicit empty `enabled_providers` keeps deny-all behavior.
|
||||
- A non-empty list with no valid items contributes no policy, avoiding accidental deny-all from malformed input.
|
||||
|
||||
## Recovery Units
|
||||
|
||||
Named commands, agents, providers, MCP servers, formatters, language servers, and references recover independently. Plugin, permission, skill, instruction, provider-ID, and policy arrays recover by item. Top-level legacy permissions recover by action/resource rule. Complex interiors of one agent, provider, command, or MCP server remain atomic rather than being recursively salvaged.
|
||||
|
||||
Every decoder preserves `propertyOrder: "original"` because V1 permission precedence depends on user order. Excess properties remain ignored except for the explicit unsupported inventory.
|
||||
|
||||
## Provider IDs
|
||||
|
||||
Provider ID compatibility remains a config migration concern only. Existing V1 agent, command, provider, and provider-policy adapters continue using the migration helper's retired-ID mapping.
|
||||
|
||||
The shared top-level `model` field remains exact because its string and object forms are valid native V2 syntax and provider declarations may come from a different config layer. It is never reinterpreted based on unrelated legacy fields.
|
||||
|
||||
This change does not add runtime provider aliases or modify provider policy evaluation, catalog state, model resolution, Sessions, plugins, Server behavior, or generation.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Diagnostics contain only source, JSON path, category, and action. They never include raw values because config may contain credentials after substitution.
|
||||
|
||||
Malformed JSON, empty content, and valid non-object roots reject one document with a source-aware warning. Malformed recognized fields and entries are skipped at their recovery boundary while unrelated valid configuration continues loading.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Add a pure `ConfigNormalize.normalize` module under `packages/core/src/config/`.
|
||||
- Reuse field migration primitives from `packages/core/src/v1/config/migrate.ts`.
|
||||
- Replace `ConfigMigrateV1.isV1` in `packages/core/src/config.ts` with normalization and one final V2 decode.
|
||||
- Log diagnostics uniformly for files, `OPENCODE_CONFIG_CONTENT`, and well-known virtual config.
|
||||
- Add property and table-driven config normalization tests.
|
||||
- Update migration and compaction documentation.
|
||||
|
||||
## Verification
|
||||
|
||||
The implementation must establish:
|
||||
|
||||
1. Valid native V2 config preserves decoded meaning after encoded normalization.
|
||||
2. Supported V1 fields preserve existing behavior.
|
||||
3. Adding a legacy field cannot change unrelated native field interpretation.
|
||||
4. Native V2 wins canonical conflicts independent of key order.
|
||||
5. One malformed entry does not remove valid siblings.
|
||||
6. Mixed MCP, compaction, and experimental values normalize deterministically.
|
||||
7. Diagnostics are precise and value-redacted.
|
||||
8. False, zero, empty, and absent values retain distinct presence semantics.
|
||||
|
||||
Run from `packages/core`:
|
||||
|
||||
```sh
|
||||
bun test test/config
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
Run from `packages/www` after documentation changes:
|
||||
|
||||
```sh
|
||||
bun typecheck
|
||||
bun validate
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Runtime provider alias resolution.
|
||||
- Provider policy or catalog changes.
|
||||
- Model resolver or Session changes.
|
||||
- Plugin API changes.
|
||||
- Server or Protocol changes.
|
||||
- Generation lifecycle changes.
|
||||
- Recursive V1/V2 inference inside one agent, provider, command, or model.
|
||||
- Restoring removed V1 functionality.
|
||||
- Rewriting user files on disk.
|
||||
Reference in New Issue
Block a user