Compare commits

..
Author SHA1 Message Date
Kit Langton 44c09180c4 refactor(core): derive config recognition fields 2026-08-27 15:29:59 -04:00
7 changed files with 57 additions and 77 deletions
+3 -8
View File
@@ -291,18 +291,13 @@ function normalizeMcpTimeout(
invalid(path, diagnostics)
return
}
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
const recognized = Object.entries(ConfigMCP.Timeout.fields).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,
)
recognized.forEach(([key, field]) => {
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
+1 -13
View File
@@ -32,19 +32,7 @@ type PathAction =
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set([
"model",
"variant",
"request",
"system",
"description",
"mode",
"hidden",
"color",
"steps",
"disabled",
"permissions",
])
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
export const Plugin = define({
id: "opencode.config.agent",
+1 -18
View File
@@ -40,24 +40,7 @@ const AgentSchema = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Any)],
)
const KNOWN_KEYS = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
const KNOWN_KEYS = new Set(["name", ...Object.keys(AgentSchema.schema.fields)])
const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {
const options: Record<string, unknown> = { ...agent.options }
+26
View File
@@ -15,6 +15,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
import { advance, drain } from "../lib/clock"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
@@ -34,6 +35,30 @@ test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
})
test("keeps schema fields and name out of legacy agent options", () => {
const agent = Schema.decodeUnknownSync(ConfigAgentV1.Info)({
name: "reviewer",
model: "test/model",
variant: "high",
temperature: 0.5,
top_p: 0.9,
prompt: "Review carefully.",
tools: { edit: false },
disable: false,
description: "Reviews changes",
mode: "subagent",
hidden: true,
options: { existing: true },
color: "#112233",
steps: 10,
maxSteps: 20,
permission: { read: "allow" },
custom: "preserved",
})
expect(agent.options).toEqual({ existing: true, custom: "preserved" })
})
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
@@ -354,6 +379,7 @@ Review carefully.`,
await fs.writeFile(
path.join(tmp.path, "agents", "native.md"),
`---
variant: high
request:
headers:
x-agent: native
@@ -362,6 +362,20 @@ describe("ConfigNormalize", () => {
])
})
test("normalizes MCP timeout fields in schema order with per-leaf recovery", () => {
const result = normalized({ mcp: { timeout: { execution: 3000, startup: "invalid", catalog: 2000 } } })
expect(result.encoded.mcp).toEqual({ timeout: { catalog: 2000, execution: 3000 } })
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["invalid", ["mcp", "timeout", "startup"]],
])
expect(normalized({ mcp: { timeout: {} } }).encoded.mcp).toBeUndefined()
const unknown = normalized({ mcp: { timeout: { unknown: 1000 } } })
expect(unknown.encoded.mcp).toBeUndefined()
expect(unknown.diagnostics.map((item) => [item.kind, item.path])).toEqual([["invalid", ["mcp", "timeout"]]])
})
test("merges bounded compaction leaves and omits unsupported leaves", () => {
const result = normalized({
compaction: {
+7 -21
View File
@@ -5,9 +5,7 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
## Defining A Plugin
@@ -48,15 +46,12 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = description
item.description = "Reviews code for regressions"
item.mode = "subagent"
})
})
@@ -69,12 +64,8 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -90,7 +81,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
event.language = event.sdk.responses(event.model.api.id)
})
```
@@ -103,15 +94,14 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use complete executable tool values with async executors:
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add({
name: "echo",
options: { codemode: false },
tools.add("echo", {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -142,10 +132,6 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+5 -17
View File
@@ -31,9 +31,7 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
Transform hooks contribute to stateful domains:
```ts
yield *
@@ -54,12 +52,8 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -78,12 +72,10 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -125,10 +117,6 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```