From 77f8ad9c712c285426816c12de9dac0b80849ef2 Mon Sep 17 00:00:00 2001 From: Cody Moore Date: Sun, 12 Apr 2026 20:21:57 -0400 Subject: [PATCH] fix: bridge shared tool I/O mappings more completely --- src/claude-tools.ts | 2 +- src/index.test.ts | 253 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 119 +++++++++++++++++++-- 3 files changed, 362 insertions(+), 12 deletions(-) diff --git a/src/claude-tools.ts b/src/claude-tools.ts index 138bf69..61c759c 100644 --- a/src/claude-tools.ts +++ b/src/claude-tools.ts @@ -214,7 +214,7 @@ const SHARED_TOOLS: ToolDefinition[] = [ enum: ["worktree"], }, }, - required: ["description", "prompt"], + required: ["description", "prompt", "subagent_type"], additionalProperties: false, }, }, diff --git a/src/index.test.ts b/src/index.test.ts index b8d6757..3c1e73c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -43,6 +43,160 @@ function transformBody(bodyStr: string): Record { return parsed; } +function normalizeOutboundToolUse(name: string, input: Record) { + const normalized = structuredClone(input); + + if (name === "Agent") { + if (typeof normalized.subagent_type === "string") { + const agentMap: Record = { + build: "general-purpose", + general: "general-purpose", + explore: "Explore", + plan: "Plan", + }; + normalized.subagent_type = agentMap[normalized.subagent_type as string] || normalized.subagent_type; + } + delete normalized.task_id; + delete normalized.command; + } + + if (name === "AskUserQuestion" && Array.isArray(normalized.questions)) { + for (const item of normalized.questions as Array>) { + if (typeof item.multiple === "boolean" && item.multiSelect === undefined) { + item.multiSelect = item.multiple; + delete item.multiple; + } + } + } + + if (name === "Skill" && typeof normalized.name === "string" && normalized.skill === undefined) { + normalized.skill = normalized.name; + delete normalized.name; + } + + if (name === "WebFetch") { + if (typeof normalized.format === "string" && normalized.prompt === undefined) { + const format = normalized.format; + normalized.prompt = format === "text" + ? "Fetch this URL and return the content as plain text." + : format === "html" + ? "Fetch this URL and return the raw HTML." + : "Fetch this URL and return the content as markdown."; + delete normalized.format; + } + delete normalized.timeout; + } + + return normalized; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripScalarJsonField(text: string, field: string): string { + const escapedField = escapeRegExp(field); + const valuePattern = String.raw`(?:"(?:[^"\\]|\\.)*"|true|false|null|-?\d+(?:\.\d+)?)`; + return text + .replace(new RegExp(`"${escapedField}"\\s*:\\s*${valuePattern}\\s*,`, "g"), "") + .replace(new RegExp(`,\\s*"${escapedField}"\\s*:\\s*${valuePattern}`, "g"), ""); +} + +function normalizeInboundStreamChunk(text: string, currentToolName: string): string { + let normalized = text; + + if (currentToolName === "Agent" && normalized.includes('"content_block_start"')) { + if (!normalized.includes('"subagent_type"')) { + if (/"input"\s*:\s*\{\s*\}/.test(normalized)) { + normalized = normalized.replace( + /"input"\s*:\s*\{\s*\}/, + '"input":{"subagent_type":"general"}', + ); + } else { + normalized = normalized.replace( + /"input"\s*:\s*\{/, + '"input":{"subagent_type":"general",', + ); + } + } + } + + if (currentToolName === "WebFetch" && normalized.includes('"content_block_start"') && !normalized.includes('"format"')) { + if (/"input"\s*:\s*\{\s*\}/.test(normalized)) { + normalized = normalized.replace( + /"input"\s*:\s*\{\s*\}/, + '"input":{"format":"markdown"}', + ); + } else { + normalized = normalized.replace( + /"input"\s*:\s*\{/, + '"input":{"format":"markdown",', + ); + } + } + + if (currentToolName === "AskUserQuestion") { + normalized = normalized.replace(/"multiSelect"\s*:/g, '"multiple":'); + } + + if (currentToolName === "Agent") { + normalized = normalized.replace( + /"subagent_type"\s*:\s*"(general-purpose|statusline-setup|Explore|Plan)"/g, + (_m, val: string) => { + const map: Record = { + "general-purpose": "general", + "statusline-setup": "build", + "Explore": "explore", + "Plan": "plan", + }; + return `"subagent_type": "${map[val] || val}"`; + }, + ); + normalized = normalized + .replace(/"model"\s*:\s*"(?:[^"\\]|\\.)*"\s*,/g, "") + .replace(/,\s*"model"\s*:\s*"(?:[^"\\]|\\.)*"/g, "") + .replace(/"run_in_background"\s*:\s*(?:true|false)\s*,/g, "") + .replace(/,\s*"run_in_background"\s*:\s*(?:true|false)/g, "") + .replace(/"isolation"\s*:\s*"(?:[^"\\]|\\.)*"\s*,/g, "") + .replace(/,\s*"isolation"\s*:\s*"(?:[^"\\]|\\.)*"/g, ""); + } + + if (currentToolName === "Bash") { + normalized = normalized + .replace(/"run_in_background"\s*:\s*(?:true|false)\s*,/g, "") + .replace(/,\s*"run_in_background"\s*:\s*(?:true|false)/g, "") + .replace(/"dangerouslyDisableSandbox"\s*:\s*(?:true|false)\s*,/g, "") + .replace(/,\s*"dangerouslyDisableSandbox"\s*:\s*(?:true|false)/g, ""); + } + + if (currentToolName === "Read") { + normalized = normalized + .replace(/"pages"\s*:\s*"(?:[^"\\]|\\.)*"\s*,/g, "") + .replace(/,\s*"pages"\s*:\s*"(?:[^"\\]|\\.)*"/g, ""); + } + + if (currentToolName === "Grep") { + for (const field of ["output_mode", "-B", "-A", "-C", "context", "-n", "-i", "type", "head_limit", "offset", "multiline"]) { + normalized = stripScalarJsonField(normalized, field); + } + } + + if (currentToolName === "Skill") { + normalized = normalized + .replace(/"skill"\s*:/g, '"name":') + .replace(/"args"\s*:\s*"(?:[^"\\]|\\.)*"\s*,/g, "") + .replace(/,\s*"args"\s*:\s*"(?:[^"\\]|\\.)*"/g, ""); + } + + if (currentToolName === "WebFetch") { + normalized = normalized + .replace(/"prompt"\s*:\s*"(?:[^"\\]|\\.)*"\s*,/g, "") + .replace(/,\s*"prompt"\s*:\s*"(?:[^"\\]|\\.)*"/g, ""); + } + + return normalized; +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe("thinking injection", () => { @@ -133,3 +287,102 @@ describe("windows compatibility regressions", () => { assert.equal(args[args.length - 1], payload); }); }); + +describe("tool mapping regressions", () => { + it("maps OpenCode general agent type to Claude general-purpose", () => { + const out = normalizeOutboundToolUse("Agent", { subagent_type: "general" }); + assert.equal(out.subagent_type, "general-purpose"); + }); + + it("maps AskUserQuestion multiple to multiSelect", () => { + const out = normalizeOutboundToolUse("AskUserQuestion", { + questions: [{ question: "Q?", header: "Q", options: [], multiple: true }], + }); + assert.deepEqual(out, { + questions: [{ question: "Q?", header: "Q", options: [], multiSelect: true }], + }); + }); + + it("strips OpenCode-only agent history fields before sending to Claude", () => { + const out = normalizeOutboundToolUse("Agent", { + subagent_type: "general", + task_id: "abc", + command: "do thing", + }); + assert.deepEqual(out, { subagent_type: "general-purpose" }); + }); + + it("maps OpenCode skill name to Claude skill", () => { + const out = normalizeOutboundToolUse("Skill", { name: "commit" }); + assert.deepEqual(out, { skill: "commit" }); + }); + + it("maps OpenCode webfetch format to a best-effort Claude prompt", () => { + const out = normalizeOutboundToolUse("WebFetch", { + url: "https://example.com", + format: "markdown", + timeout: 5, + }); + assert.deepEqual(out, { + url: "https://example.com", + prompt: "Fetch this URL and return the content as markdown.", + }); + }); + + it("seeds missing inbound agent subagent_type with general", () => { + const out = normalizeInboundStreamChunk( + '{"type":"content_block_start","content_block":{"type":"tool_use","id":"x","name":"task","input":{}}}', + "Agent", + ); + assert.match(out, /"subagent_type":"general"/); + }); + + it("seeds missing inbound agent subagent_type even when input has other fields", () => { + const out = normalizeInboundStreamChunk( + '{"type":"content_block_start","content_block":{"type":"tool_use","id":"x","name":"task","input":{"description":"d"}}}', + "Agent", + ); + assert.match(out, /"input":\{"subagent_type":"general","description":"d"\}/); + }); + + it("maps inbound general-purpose agent type to general", () => { + const out = normalizeInboundStreamChunk( + '{"subagent_type":"general-purpose"}', + "Agent", + ); + assert.equal(out, '{"subagent_type": "general"}'); + }); + + it("maps inbound AskUserQuestion multiSelect to multiple", () => { + const out = normalizeInboundStreamChunk( + '{"multiSelect":true}', + "AskUserQuestion", + ); + assert.equal(out, '{"multiple":true}'); + }); + + it("maps inbound Claude skill to OpenCode name and drops args", () => { + const out = normalizeInboundStreamChunk( + '{"skill":"commit","args":"-m hi"}', + "Skill", + ); + assert.equal(out, '{"name":"commit"}'); + }); + + it("maps inbound Claude webfetch to OpenCode format and drops prompt", () => { + const out = normalizeInboundStreamChunk( + '{"type":"content_block_start","content_block":{"type":"tool_use","id":"x","name":"webfetch","input":{"url":"https://example.com","prompt":"Summarize"}}}', + "WebFetch", + ); + assert.match(out, /"format":"markdown"/); + assert.doesNotMatch(out, /"prompt"/); + }); + + it("drops inbound Claude-only grep options unsupported by OpenCode", () => { + const out = normalizeInboundStreamChunk( + '{"glob":"*.ts","output_mode":"content","head_limit":10}', + "Grep", + ); + assert.equal(out, '{"glob":"*.ts"}'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 467737f..697905e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,6 +89,8 @@ const OUTBOUND_TOOL_NAME_MAP: Record = { mcp_webfetch: "WebFetch", mcp_todowrite: "TodoWrite", mcp_skill: "Skill", + question: "AskUserQuestion", + mcp_question: "AskUserQuestion", }; const INBOUND_TOOL_NAME_MAP: Record = { @@ -102,6 +104,7 @@ const INBOUND_TOOL_NAME_MAP: Record = { WebFetch: "webfetch", TodoWrite: "todowrite", Skill: "skill", + AskUserQuestion: "question", }; const ANTHROPIC_MODELS: Record = { @@ -281,6 +284,27 @@ function mapOutboundToolName(name: string | undefined): string | undefined { return OUTBOUND_TOOL_NAME_MAP[name] || name; } +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripScalarJsonField(text: string, field: string): string { + const escapedField = escapeRegExp(field); + const valuePattern = String.raw`(?:"(?:[^"\\]|\\.)*"|true|false|null|-?\d+(?:\.\d+)?)`; + return text + .replace(new RegExp(`"${escapedField}"\\s*:\\s*${valuePattern}\\s*,`, "g"), "") + .replace(new RegExp(`,\\s*"${escapedField}"\\s*:\\s*${valuePattern}`, "g"), ""); +} + +function ensureInputStringField(text: string, field: string, value: string): string { + if (text.includes(`"${field}"`)) return text; + const exactEmpty = /"input"\s*:\s*\{\s*\}/; + if (exactEmpty.test(text)) { + return text.replace(exactEmpty, `"input":{"${field}":"${value}"}`); + } + return text.replace(/"input"\s*:\s*\{/, `"input":{"${field}":"${value}",`); +} + function maybeUnquoteText(text: string): string { const trimmed = text.trim(); if (!trimmed.startsWith("\"")) return text; @@ -618,17 +642,54 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { block.input as Record, ); // Agent: translate OpenCode subagent_type values → Claude values - if (block.name === "Agent" && typeof (block.input as Record).subagent_type === "string") { - const agentMap: Record = { - build: "general-purpose", - explore: "Explore", - plan: "Plan", - }; - const cur = (block.input as Record).subagent_type as string; - if (agentMap[cur]) { - (block.input as Record).subagent_type = agentMap[cur]; + if (block.name === "Agent") { + if (typeof (block.input as Record).subagent_type === "string") { + const agentMap: Record = { + build: "general-purpose", + general: "general-purpose", + explore: "Explore", + plan: "Plan", + }; + const cur = (block.input as Record).subagent_type as string; + if (agentMap[cur]) { + (block.input as Record).subagent_type = agentMap[cur]; + } + } + delete (block.input as Record).task_id; + delete (block.input as Record).command; + } + // AskUserQuestion: OpenCode uses `multiple`, Claude uses `multiSelect`. + if (block.name === "AskUserQuestion" && Array.isArray((block.input as Record).questions)) { + for (const item of (block.input as Record).questions as Array>) { + if (typeof item.multiple === "boolean" && item.multiSelect === undefined) { + item.multiSelect = item.multiple; + delete item.multiple; + } + } + } + // Skill: OpenCode uses `name`, Claude uses `skill`. + if (block.name === "Skill") { + const input = block.input as Record; + if (typeof input.name === "string" && input.skill === undefined) { + input.skill = input.name; + delete input.name; } } + // WebFetch: OpenCode uses `format`, Claude uses a freeform `prompt`. + // Best-effort bridge: synthesize a prompt from the requested format. + if (block.name === "WebFetch") { + const input = block.input as Record; + if (typeof input.format === "string" && input.prompt === undefined) { + const format = input.format; + input.prompt = format === "text" + ? "Fetch this URL and return the content as plain text." + : format === "html" + ? "Fetch this URL and return the raw HTML." + : "Fetch this URL and return the content as markdown."; + delete input.format; + } + delete input.timeout; + } // TodoWrite: translate OpenCode fields → Claude fields // OpenCode: { content, status, priority } with status ∈ {pending, in_progress, completed, cancelled} // Claude: { content, status, activeForm } with status ∈ {pending, in_progress, completed} @@ -736,6 +797,16 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { : `"name": "${name}"`; }); + // OpenCode requires a task subagent_type; Claude may omit it for + // the default general-purpose agent. Seed the default at + // content_block_start so later deltas can override it. + if (currentToolName === "Agent" && text.includes('"content_block_start"')) { + text = ensureInputStringField(text, "subagent_type", "general"); + } + if (currentToolName === "WebFetch" && text.includes('"content_block_start"')) { + text = ensureInputStringField(text, "format", "markdown"); + } + // Translate snake_case argument keys to camelCase in // streamed tool input JSON. The model streams tool arguments // as partial JSON in input_json_delta events. We translate @@ -766,9 +837,13 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { if (currentToolName === "TodoWrite") { text = text.replace(/"activeForm"\s*:/g, '"priority":'); } + // AskUserQuestion: multiSelect → multiple + if (currentToolName === "AskUserQuestion") { + text = text.replace(/"multiSelect"\s*:/g, '"multiple":'); + } // Agent: translate Claude's subagent_type values to OpenCode's. // Claude uses "general-purpose", "Explore", "Plan", "statusline-setup". - // OpenCode has "build" (default) and "plan" as built-in agents. + // OpenCode has "build", "general", "explore", and "plan" agents. // We match the full key:value pair to avoid replacing these // strings inside prompt/description text. if (currentToolName === "Agent") { @@ -776,7 +851,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { /"subagent_type"\s*:\s*"(general-purpose|statusline-setup|Explore|Plan)"/g, (_m, val: string) => { const map: Record = { - "general-purpose": "build", + "general-purpose": "general", "statusline-setup": "build", "Explore": "explore", "Plan": "plan", @@ -784,6 +859,28 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { return `"subagent_type": "${map[val] || val}"`; }, ); + text = stripScalarJsonField(text, "model"); + text = stripScalarJsonField(text, "run_in_background"); + text = stripScalarJsonField(text, "isolation"); + } + if (currentToolName === "Bash") { + text = stripScalarJsonField(text, "run_in_background"); + text = stripScalarJsonField(text, "dangerouslyDisableSandbox"); + } + if (currentToolName === "Read") { + text = stripScalarJsonField(text, "pages"); + } + if (currentToolName === "Grep") { + for (const field of ["output_mode", "-B", "-A", "-C", "context", "-n", "-i", "type", "head_limit", "offset", "multiline"]) { + text = stripScalarJsonField(text, field); + } + } + if (currentToolName === "Skill") { + text = text.replace(/"skill"\s*:/g, '"name":'); + text = stripScalarJsonField(text, "args"); + } + if (currentToolName === "WebFetch") { + text = stripScalarJsonField(text, "prompt"); } }