fix(converters): OpenCode subagent model and FQ agent name resolution (#483)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-01 16:45:07 -07:00
committed by GitHub
parent 428f4fd548
commit 577db53a2d
5 changed files with 298 additions and 11 deletions

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode"
import { convertClaudeToOpenCode, transformSkillContentForOpenCode } from "../src/converters/claude-to-opencode"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { ClaudePlugin } from "../src/types/claude"
@@ -61,7 +61,7 @@ describe("convertClaudeToOpenCode", () => {
test("normalizes models and infers temperature", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
agentMode: "primary",
inferTemperature: true,
permissions: "none",
})
@@ -78,7 +78,36 @@ describe("convertClaudeToOpenCode", () => {
expect(commandParsed.data.model).toBe("openai/gpt-4o")
})
test("resolves bare Claude model aliases to full IDs", () => {
test("resolves bare Claude model aliases for primary agents", () => {
const plugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "cheap-agent",
description: "Agent using bare alias",
body: "Test agent.",
sourcePath: "/tmp/plugin/agents/cheap-agent.md",
model: "haiku",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "primary",
inferTemperature: false,
permissions: "none",
})
const agent = bundle.agents.find((a) => a.name === "cheap-agent")
expect(agent).toBeDefined()
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.model).toBe("anthropic/claude-haiku-4-5")
})
test("omits model for subagents to allow provider inheritance (#477)", () => {
const plugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
@@ -104,7 +133,63 @@ describe("convertClaudeToOpenCode", () => {
const agent = bundle.agents.find((a) => a.name === "cheap-agent")
expect(agent).toBeDefined()
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.model).toBe("anthropic/claude-haiku-4-5")
expect(parsed.data.model).toBeUndefined()
})
test("omits model when agent has no model field regardless of mode", () => {
const plugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "no-model-agent",
description: "Agent without model",
body: "Test agent.",
sourcePath: "/tmp/plugin/agents/no-model-agent.md",
},
],
commands: [],
skills: [],
}
for (const mode of ["primary", "subagent"] as const) {
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: mode,
inferTemperature: false,
permissions: "none",
})
const agent = bundle.agents.find((a) => a.name === "no-model-agent")
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.model).toBeUndefined()
}
})
test("omits model: inherit even in primary mode", () => {
const plugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "inherit-agent",
description: "Agent with inherit model",
body: "Test agent.",
sourcePath: "/tmp/plugin/agents/inherit-agent.md",
model: "inherit",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "primary",
inferTemperature: false,
permissions: "none",
})
const agent = bundle.agents.find((a) => a.name === "inherit-agent")
const parsed = parseFrontmatter(agent!.content)
expect(parsed.data.model).toBeUndefined()
})
test("converts hooks into plugin file", async () => {
@@ -319,3 +404,91 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
expect(parsed.body).toContain("Do the thing")
})
})
describe("transformSkillContentForOpenCode", () => {
test("rewrites 3-segment FQ agent names to flat names", () => {
const input = "- `compound-engineering:document-review:coherence-reviewer`"
expect(transformSkillContentForOpenCode(input)).toBe("- `coherence-reviewer`")
})
test("rewrites multiple FQ agent refs in one block", () => {
const input = [
"- `compound-engineering:document-review:coherence-reviewer`",
"- `compound-engineering:document-review:feasibility-reviewer`",
"- `compound-engineering:review:security-sentinel`",
].join("\n")
const result = transformSkillContentForOpenCode(input)
expect(result).toContain("- `coherence-reviewer`")
expect(result).toContain("- `feasibility-reviewer`")
expect(result).toContain("- `security-sentinel`")
expect(result).not.toContain("compound-engineering:")
})
test("preserves 2-segment skill references", () => {
const input = 'load the `compound-engineering:document-review` skill'
// 2-segment refs are skill names, not agent names — left unchanged
expect(transformSkillContentForOpenCode(input)).toBe(input)
})
test("rewrites .claude/ paths to .opencode/", () => {
const input = "Read `.claude/config.json`"
expect(transformSkillContentForOpenCode(input)).toBe("Read `.opencode/config.json`")
})
test("rewrites ~/. claude/ paths to ~/.config/opencode/", () => {
const input = "Look in `~/.claude/plugins/`"
expect(transformSkillContentForOpenCode(input)).toBe("Look in `~/.config/opencode/plugins/`")
})
test("handles FQ names in JSON-like contexts", () => {
const input = ' subagent_type: "compound-engineering:review:security-sentinel",'
expect(transformSkillContentForOpenCode(input)).toBe(
' subagent_type: "security-sentinel",'
)
})
test("does not match URLs or non-agent colon patterns", () => {
const cases = [
"Visit https://example.com/path",
"Use http://localhost:8080/api",
"Set font-size: 12px; color: red;",
"Time is 10:30:45 UTC",
'key: "value"',
]
for (const input of cases) {
expect(transformSkillContentForOpenCode(input)).toBe(input)
}
})
test("rewrites FQ names from any plugin namespace", () => {
const input = "- `other-plugin:category:my-agent`"
expect(transformSkillContentForOpenCode(input)).toBe("- `my-agent`")
})
test("preserves bare agent names (no namespace)", () => {
const input = "Use `coherence-reviewer` for review."
expect(transformSkillContentForOpenCode(input)).toBe(input)
})
test("preserves 2-segment plugin:agent names (no category)", () => {
const input = "Spawn `compound-engineering:coherence-reviewer` as subagent."
// 2-segment names could be skill refs or flat agent refs — not rewritten
expect(transformSkillContentForOpenCode(input)).toBe(input)
})
test("does not partially rewrite 4-segment colon patterns", () => {
const input = "`a:b:c:d`"
// Without the lookahead, this would become `c:d` — a broken partial rewrite
expect(transformSkillContentForOpenCode(input)).toBe(input)
})
test("preserves 3-segment slash commands", () => {
const cases = [
"Run `/team:ops:deploy` to deploy.",
"Use /compound-engineering:review:check after changes.",
]
for (const input of cases) {
expect(transformSkillContentForOpenCode(input)).toBe(input)
}
})
})

View File

@@ -223,6 +223,77 @@ describe("writeOpenCodeBundle", () => {
expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n")
})
test("rewrites FQ agent names in copied skill markdown (#477)", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-skill-transform-"))
const skillSrcDir = path.join(tempRoot, "src-skill")
const refsDir = path.join(skillSrcDir, "references")
await fs.mkdir(refsDir, { recursive: true })
await fs.writeFile(
path.join(skillSrcDir, "SKILL.md"),
"---\nname: test-skill\n---\n\n- `compound-engineering:review:coherence-reviewer`\n"
)
await fs.writeFile(
path.join(refsDir, "agents.md"),
"Use `compound-engineering:research:repo-research-analyst` for codebase analysis.\n"
)
const outputRoot = path.join(tempRoot, ".opencode")
const bundle: OpenCodeBundle = {
config: { $schema: "https://opencode.ai/config.json" },
agents: [],
plugins: [],
commandFiles: [],
skillDirs: [{ name: "test-skill", sourceDir: skillSrcDir }],
}
await writeOpenCodeBundle(outputRoot, bundle)
const skillContent = await fs.readFile(
path.join(outputRoot, "skills", "test-skill", "SKILL.md"),
"utf8"
)
expect(skillContent).toContain("`coherence-reviewer`")
expect(skillContent).not.toContain("compound-engineering:review:coherence-reviewer")
const refContent = await fs.readFile(
path.join(outputRoot, "skills", "test-skill", "references", "agents.md"),
"utf8"
)
expect(refContent).toContain("`repo-research-analyst`")
expect(refContent).not.toContain("compound-engineering:research:repo-research-analyst")
})
test("does not transform non-markdown files in skill directories", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-skill-nonmd-"))
const skillSrcDir = path.join(tempRoot, "src-skill")
const scriptsDir = path.join(skillSrcDir, "scripts")
await fs.mkdir(scriptsDir, { recursive: true })
await fs.writeFile(
path.join(skillSrcDir, "SKILL.md"),
"---\nname: test-skill\n---\n\nSkill body.\n"
)
const scriptContent = "#!/bin/bash\n# compound-engineering:review:security-sentinel\necho done\n"
await fs.writeFile(path.join(scriptsDir, "run.sh"), scriptContent)
const outputRoot = path.join(tempRoot, ".opencode")
const bundle: OpenCodeBundle = {
config: { $schema: "https://opencode.ai/config.json" },
agents: [],
plugins: [],
commandFiles: [],
skillDirs: [{ name: "test-skill", sourceDir: skillSrcDir }],
}
await writeOpenCodeBundle(outputRoot, bundle)
const copiedScript = await fs.readFile(
path.join(outputRoot, "skills", "test-skill", "scripts", "run.sh"),
"utf8"
)
// Non-markdown files should be copied verbatim — no FQ rewriting
expect(copiedScript).toBe(scriptContent)
})
test("backs up existing command .md file before overwriting", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
const outputRoot = path.join(tempRoot, ".opencode")