Skip to content

Commit f1eed8b

Browse files
authored
fix(core): preserve legacy markdown agent variants
1 parent 4ea368e commit f1eed8b

2 files changed

Lines changed: 102 additions & 4 deletions

File tree

‎packages/core/src/config/plugin/agent.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,23 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
184184
.replace(/\.md$/, "")
185185
const body = markdown.content.trim()
186186
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
187+
// Join legacy model + variant without sending native request/permissions through migration.
188+
// Embedded and structured native selections, and a variant without a model, stay unchanged.
189+
const data =
190+
typeof markdown.data.model === "string" &&
191+
!markdown.data.model.includes("#") &&
192+
typeof markdown.data.variant === "string" &&
193+
/^[^#]+$/.test(markdown.data.variant)
194+
? { ...markdown.data, model: `${markdown.data.model}#${markdown.data.variant}` }
195+
: markdown.data
187196
const agent = legacy
188197
? Option.getOrUndefined(
189198
Option.map(
190199
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
191200
ConfigMigrateV1.migrateAgent,
192201
),
193202
)
194-
: Option.getOrUndefined(
195-
decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
196-
)
203+
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
197204
if (!agent) return
198205
const info = Option.getOrUndefined(
199206
decodeConfig({

‎packages/core/test/config/agent.test.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Agent } from "@opencode/core/agent"
66
import { Bus } from "@opencode/core/bus"
77
import { Config } from "@opencode/core/config"
88
import { Directory, Document, Event, Info } from "@opencode/schema/config"
9+
import { Model } from "@opencode/schema/model"
910
import { ConfigAgentPlugin } from "@opencode/core/config/plugin/agent"
1011
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
1112
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -17,7 +18,7 @@ import { AbsolutePath } from "@opencode/core/schema"
1718
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
1819
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
1920
import { advance, drain } from "../lib/clock"
20-
import { tmpdir } from "../fixture/tmpdir"
21+
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
2122
import { testEffect } from "../lib/effect"
2223
import { agentHost, host } from "../plugin/host"
2324

@@ -60,6 +61,76 @@ test("keeps schema fields and name out of legacy agent options", () => {
6061
})
6162

6263
describe("ConfigAgentPlugin.Plugin", () => {
64+
for (const item of [
65+
{ name: "separate legacy variant", frontmatter: "model: example/chat\nvariant: high", model: "example/chat#high" },
66+
{ name: "unqualified model", frontmatter: "model: example/chat", model: "example/chat" },
67+
{ name: "embedded native variant", frontmatter: "model: example/chat#high", model: "example/chat#high" },
68+
{
69+
name: "structured native variant",
70+
frontmatter: "model:\n providerID: example\n model: chat\n variant: high",
71+
model: "example/chat#high",
72+
},
73+
{
74+
name: "structured unqualified model",
75+
frontmatter: "model:\n providerID: example\n model: chat",
76+
model: "example/chat",
77+
},
78+
{ name: "standalone variant", frontmatter: "variant: high", model: undefined },
79+
{
80+
name: "embedded native variant with an ignored separate variant",
81+
frontmatter: "model: example/chat#high\nvariant: low",
82+
model: "example/chat#high",
83+
},
84+
{
85+
name: "structured native variant with an ignored separate variant",
86+
frontmatter: "model:\n providerID: example\n model: chat\n variant: high\nvariant: low",
87+
model: "example/chat#high",
88+
},
89+
]) {
90+
for (const native of [false, true]) {
91+
it.live(`loads Markdown ${item.name}${native ? " with native request and permissions" : ""}`, () =>
92+
Effect.gen(function* () {
93+
const agent = yield* loadMarkdownAgent(
94+
native
95+
? `${item.frontmatter}
96+
request:
97+
headers:
98+
x-agent: native
99+
body:
100+
effort: high
101+
permissions:
102+
- action: edit
103+
resource: "*"
104+
effect: deny`
105+
: item.frontmatter,
106+
)
107+
expect(agent.model).toEqual(item.model === undefined ? undefined : Model.Ref.parse(item.model))
108+
expect(agent.request).toEqual({
109+
settings: {},
110+
headers: native ? { "x-agent": "native" } : {},
111+
body: native ? { effort: "high" } : {},
112+
})
113+
if (native) {
114+
expect(agent.permissions).toContainEqual({ action: "edit", resource: "*", effect: "deny" })
115+
expect(Permission.evaluate("edit", "example.txt", agent.permissions).effect).toBe("deny")
116+
}
117+
}),
118+
)
119+
}
120+
}
121+
122+
for (const variant of [undefined, "high"]) {
123+
it.live(`loads Markdown legacy temperature ${variant ? "with" : "without"} a separate variant`, () =>
124+
Effect.gen(function* () {
125+
const agent = yield* loadMarkdownAgent(
126+
`model: example/chat\ntemperature: 0.5${variant ? `\nvariant: ${variant}` : ""}`,
127+
)
128+
expect(agent.model).toEqual(Model.Ref.parse(variant ? "example/chat#high" : "example/chat"))
129+
expect(agent.request).toEqual({ settings: {}, headers: {}, body: { temperature: 0.5 } })
130+
}),
131+
)
132+
}
133+
63134
it.effect("matches POSIX paths against home-relative permissions", () =>
64135
Effect.gen(function* () {
65136
const permissions = yield* loadHomePermissions("/home/test")
@@ -560,6 +631,26 @@ Use native v2 fields.`,
560631
)
561632
})
562633

634+
function loadMarkdownAgent(frontmatter: string) {
635+
return Effect.gen(function* () {
636+
const tmp = yield* tmpdirScoped()
637+
const fs = yield* FSUtil.Service
638+
yield* fs.makeDirectory(path.join(tmp.path, "agents"))
639+
yield* fs.writeFileString(
640+
path.join(tmp.path, "agents", "reviewer.md"),
641+
`---\n${frontmatter}\n---\nReview carefully.`,
642+
)
643+
const agents = yield* Agent.Service
644+
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
645+
Effect.provide(Config.testLayer([directoryEntry(tmp.path)])),
646+
)
647+
const agent = yield* agents.get(Agent.ID.make("reviewer"))
648+
if (!agent) throw new Error("expected configured Markdown agent")
649+
expect(agent.system).toBe("Review carefully.")
650+
return agent
651+
})
652+
}
653+
563654
function directoryEntry(directory: string) {
564655
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
565656
}

0 commit comments

Comments
 (0)