diff --git a/src/claude-tools.ts b/src/claude-tools.ts index 61c759c..f1e9227 100644 --- a/src/claude-tools.ts +++ b/src/claude-tools.ts @@ -98,22 +98,21 @@ export const PARAM_SNAKE_TO_CAMEL: Record> = { }, // Agent: Claude has model/run_in_background/isolation that OpenCode doesn't have. // OpenCode has task_id/command that Claude doesn't have. - // The shared params (description, prompt, subagent_type) are the same name. Agent: { - // All shared params have same names, no translation needed + // All shared params have same names, no renaming needed }, // WebFetch: Claude uses "prompt", OpenCode uses "format". - // These are fundamentally different params — special handling needed. + // We strip "prompt" inbound and inject a default "format" so OpenCode's + // webfetch tool receives a valid payload. Outbound does the reverse: + // synthesizes a "prompt" from "format" so Claude's schema is satisfied. WebFetch: { - // prompt has no equivalent in OpenCode; format has no equivalent in Claude - // Handled via special logic in the bridge + // Handled specially in translateToolArgsJsonString / translateArgsOpencodeToClaude }, TodoWrite: { - // Both use "todos" array but the item shape differs: - // Claude: { content, status, activeForm } with status enum [pending, in_progress, completed] - // OpenCode: { content, status, priority } with status enum [pending, in_progress, completed, cancelled] - // The activeForm field is Claude-only; priority is OpenCode-only. - // We strip activeForm inbound since OpenCode doesn't use it. + // Item shape differs — handled specially (activeForm → priority per item) + }, + Skill: { + skill: "name", // Claude sends "skill" (the name), OpenCode expects "name" }, }; @@ -133,44 +132,202 @@ export const PARAM_CAMEL_TO_SNAKE: Record> = { Grep: { include: "glob", }, + Skill: { + name: "skill", + }, }; /** - * Translate tool_use arguments from Claude's snake_case to OpenCode's camelCase. - * Returns a new object with translated keys. + * Single source of truth for Claude ↔ OpenCode subagent_type mappings. + * + * Inbound (Claude → OpenCode) and outbound (OpenCode → Claude) are kept + * explicit rather than derived because they're not perfect inverses: + * both OpenCode "build" and "general" map to Claude "general-purpose". */ -export function translateArgsSnakeToCamel( - toolName: string, - args: Record, -): Record { - const map = PARAM_SNAKE_TO_CAMEL[toolName]; - if (!map || Object.keys(map).length === 0) return args; +export const AGENT_TYPE_CLAUDE_TO_OPENCODE: Record = { + "general-purpose": "general", + "statusline-setup": "build", + "Explore": "explore", + "Plan": "plan", +}; + +export const AGENT_TYPE_OPENCODE_TO_CLAUDE: Record = { + build: "general-purpose", + general: "general-purpose", + explore: "Explore", + plan: "Plan", +}; + +/** + * Fields to strip from Claude's schema that OpenCode's tool doesn't accept. + * Keyed by Claude tool name. These are fields present in Claude's wire + * schema but not in OpenCode's Zod schema — Zod in non-strict mode would + * silently drop them, but we drop them explicitly so behavior is + * independent of OpenCode's validation mode. + */ +const INBOUND_FIELDS_TO_STRIP: Record = { + Agent: ["model", "run_in_background", "isolation"], + Bash: ["run_in_background", "dangerouslyDisableSandbox"], + Read: ["pages"], + Grep: ["output_mode", "-B", "-A", "-C", "context", "-n", "-i", "type", "head_limit", "offset", "multiline"], + Skill: ["args"], + WebFetch: ["prompt"], // stripped because we inject "format" instead +}; + +/** + * Translate tool argument JSON from Claude's schema to OpenCode's schema. + * Used by the SSE stream processor after all partial_json fragments for a + * tool_use block have been buffered. + * + * Parses the JSON and walks the object — do NOT regex-substitute on the + * raw string, because a key name can legitimately appear inside a value + * (e.g. a TodoWrite item whose content literally says 'activeForm', + * or a Bash command with "file_path=..." inside a heredoc). + */ +export function translateToolArgsJsonString(json: string, toolName: string): string { + let obj: unknown; + try { + obj = JSON.parse(json); + } catch { + // Malformed — pass through rather than corrupt. The downstream + // consumer will surface the parse error with a clearer message. + return json; + } + if (obj === null || typeof obj !== "object" || Array.isArray(obj)) { + return json; + } + const record = obj as Record; + + // 1. Top-level key renames from PARAM_SNAKE_TO_CAMEL (Read, Write, Edit, + // Grep, Skill). + const keyMap = PARAM_SNAKE_TO_CAMEL[toolName]; + let out: Record = record; + if (keyMap && Object.keys(keyMap).length > 0) { + out = {}; + for (const [k, v] of Object.entries(record)) { + out[keyMap[k] || k] = v; + } + } + + // 2. Strip Claude-only fields OpenCode doesn't accept. + const stripFields = INBOUND_FIELDS_TO_STRIP[toolName]; + if (stripFields) { + for (const field of stripFields) delete out[field]; + } - const result: Record = {}; - for (const [key, value] of Object.entries(args)) { - const newKey = map[key] || key; - result[newKey] = value; + // 3. Tool-specific deeper translations. + if (toolName === "TodoWrite" && Array.isArray(out.todos)) { + // Claude: { content, status, activeForm }. OpenCode: { content, status, priority }. + // OpenCode's priority is typed as z.string() so the activeForm text works fine. + for (const item of out.todos as Array>) { + if (item && typeof item === "object" && "activeForm" in item) { + item.priority = item.activeForm; + delete item.activeForm; + } + } + } + if (toolName === "Agent" && typeof out.subagent_type === "string") { + const mapped = AGENT_TYPE_CLAUDE_TO_OPENCODE[out.subagent_type]; + if (mapped) out.subagent_type = mapped; + } + if (toolName === "AskUserQuestion" && Array.isArray(out.questions)) { + // Claude uses "multiSelect", OpenCode's question tool uses "multiple". + for (const item of out.questions as Array>) { + if (item && typeof item === "object" && "multiSelect" in item) { + item.multiple = item.multiSelect; + delete item.multiSelect; + } + } + } + if (toolName === "WebFetch") { + // OpenCode's webfetch takes a `format` field (markdown/text/html). + // Default it to markdown if Claude didn't send one (it never does, + // since Claude's WebFetch has no equivalent field). + if (typeof out.format !== "string") { + out.format = "markdown"; + } + } + if (toolName === "Agent" && typeof out.subagent_type !== "string") { + // OpenCode's task tool requires subagent_type. Default to "general" + // (the closest equivalent to Claude's default "general-purpose"). + out.subagent_type = "general"; } - return result; + + return JSON.stringify(out); } /** - * Translate tool_use arguments from OpenCode's camelCase to Claude's snake_case. - * Returns a new object with translated keys. + * Translate tool_use arguments from OpenCode's schema to Claude's schema. + * Used on the outbound path (message history being sent back to the API). + * + * This is the counterpart to translateToolArgsJsonString — they must stay + * in lockstep so round-trips preserve meaning. */ -export function translateArgsCamelToSnake( +export function translateArgsOpencodeToClaude( toolName: string, args: Record, ): Record { + // 1. Top-level key renames (camelCase → snake_case) const map = PARAM_CAMEL_TO_SNAKE[toolName]; - if (!map || Object.keys(map).length === 0) return args; + let out: Record = args; + if (map && Object.keys(map).length > 0) { + out = {}; + for (const [k, v] of Object.entries(args)) { + out[map[k] || k] = v; + } + } - const result: Record = {}; - for (const [key, value] of Object.entries(args)) { - const newKey = map[key] || key; - result[newKey] = value; + // 2. Agent: translate OpenCode subagent_type values → Claude values; + // strip OpenCode-only fields that aren't in Claude's schema. + if (toolName === "Agent") { + if (typeof out.subagent_type === "string") { + const mapped = AGENT_TYPE_OPENCODE_TO_CLAUDE[out.subagent_type]; + if (mapped) out.subagent_type = mapped; + } + delete out.task_id; + delete out.command; + } + + // 3. TodoWrite: OpenCode { priority, status∈{…,cancelled} } → Claude + // { activeForm, status∈{pending,in_progress,completed} }. + if (toolName === "TodoWrite" && Array.isArray(out.todos)) { + for (const item of out.todos as Array>) { + if (item.priority && !item.activeForm) { + item.activeForm = item.priority; + delete item.priority; + } + if (item.status === "cancelled") { + item.status = "completed"; + } + } } - return result; + + // 4. AskUserQuestion: OpenCode uses "multiple", Claude uses "multiSelect". + if (toolName === "AskUserQuestion" && Array.isArray(out.questions)) { + for (const item of out.questions as Array>) { + if (typeof item.multiple === "boolean" && item.multiSelect === undefined) { + item.multiSelect = item.multiple; + delete item.multiple; + } + } + } + + // 5. WebFetch: OpenCode uses "format", Claude uses a freeform "prompt". + // Best-effort bridge: synthesize a prompt from the requested format. + if (toolName === "WebFetch") { + if (typeof out.format === "string" && out.prompt === undefined) { + const format = out.format; + out.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 out.format; + } + delete out.timeout; // OpenCode-only + } + + return out; } // ── Claude Code Tool Definitions (wire-captured) ─────────────────── diff --git a/src/index.test.ts b/src/index.test.ts index 3c1e73c..23073f4 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,14 +1,22 @@ /** * Unit tests for opencode-claude-bridge plugin logic. - * Run with: node --import tsx/esm src/index.test.ts - * Or after build: node dist/index.test.js + * Run with: npm test (builds then runs node --test). * - * Uses node:test — no extra dependencies required. + * These tests exercise the actual production modules — the SSE processor + * is imported from ./stream, the argument translator from ./claude-tools. + * No local re-implementations. */ -import { describe, it, before } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { buildTokenCurlArgs } from "./oauth.js"; + +import { + translateToolArgsJsonString, + translateArgsOpencodeToClaude, + AGENT_TYPE_CLAUDE_TO_OPENCODE, + AGENT_TYPE_OPENCODE_TO_CLAUDE, +} from "./claude-tools.js"; +import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; // ── Helpers (extracted / reimplemented from index.ts for unit testing) ──────── @@ -43,160 +51,6 @@ 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", () => { @@ -275,53 +129,248 @@ describe("temperature coercion", () => { }); }); -describe("windows compatibility regressions", () => { - it("builds curl args without shell escaping requirements", () => { - const payload = JSON.stringify({ note: "O'Reilly" }); - const args = buildTokenCurlArgs(payload); +// ── translateToolArgsJsonString: argument translation on parsed JSON ───────── - assert.equal(args[0], "-s"); - assert.equal(args[1], "-w"); - assert.equal(args[2], "\n__HTTP_STATUS__%{http_code}"); - assert.equal(args[args.length - 2], "-d"); - assert.equal(args[args.length - 1], payload); +describe("translateToolArgsJsonString", () => { + function translated(toolName: string, args: Record): any { + return JSON.parse(translateToolArgsJsonString(JSON.stringify(args), toolName)); + } + + it("renames file_path → filePath for Read", () => { + const out = translated("Read", { file_path: "/tmp/x.txt" }); + assert.deepEqual(out, { filePath: "/tmp/x.txt" }); + }); + + it("renames all Edit params", () => { + const out = translated("Edit", { + file_path: "/f.ts", + old_string: "foo", + new_string: "bar", + replace_all: true, + }); + assert.deepEqual(out, { + filePath: "/f.ts", + oldString: "foo", + newString: "bar", + replaceAll: true, + }); + }); + + it("renames glob → include for Grep and preserves other keys", () => { + const out = translated("Grep", { pattern: "hello", glob: "*.ts", path: "/src" }); + assert.deepEqual(out, { pattern: "hello", include: "*.ts", path: "/src" }); + }); + + it("renames skill → name for Skill and strips args (OpenCode skill has no args param)", () => { + const out = translated("Skill", { skill: "commit", args: "-m fix" }); + assert.deepEqual(out, { name: "commit" }); + }); + + it("translates activeForm → priority INSIDE TodoWrite todos[]", () => { + const out = translated("TodoWrite", { + todos: [ + { content: "fix bug", status: "pending", activeForm: "Running tests" }, + { content: "ship it", status: "completed", activeForm: "Shipping" }, + ], + }); + assert.deepEqual(out, { + todos: [ + { content: "fix bug", status: "pending", priority: "Running tests" }, + { content: "ship it", status: "completed", priority: "Shipping" }, + ], + }); + }); + + it("does NOT rewrite activeForm when it appears only inside a string value (Linus case)", () => { + // A TodoWrite whose content text happens to contain the literal token + // "activeForm" must not have that substring corrupted. + const content = 'Update the activeForm handler in foo.ts'; + const out = translated("TodoWrite", { + todos: [{ content, status: "pending", activeForm: "Editing foo.ts" }], + }); + // content preserved verbatim + assert.equal(out.todos[0].content, content); + // activeForm key renamed to priority + assert.equal(out.todos[0].priority, "Editing foo.ts"); + assert.equal(out.todos[0].activeForm, undefined); + }); + + it("does NOT rewrite file_path when it appears inside a Bash command string", () => { + // Bash commands have no translation — the string value must survive untouched. + const command = 'cat heredoc < { + assert.equal(translated("Agent", { subagent_type: "general-purpose" }).subagent_type, "general"); + assert.equal(translated("Agent", { subagent_type: "Explore" }).subagent_type, "explore"); + assert.equal(translated("Agent", { subagent_type: "Plan" }).subagent_type, "plan"); + assert.equal(translated("Agent", { subagent_type: "statusline-setup" }).subagent_type, "build"); + }); + + it("leaves an unknown Agent subagent_type untouched", () => { + assert.equal(translated("Agent", { subagent_type: "custom-agent" }).subagent_type, "custom-agent"); + }); + + it("strips prompt param for WebFetch and injects default format", () => { + // Claude's WebFetch has no "format" field; OpenCode's webfetch needs one. + // We default to "markdown" so the OpenCode tool receives a valid payload. + const out = translated("WebFetch", { url: "https://example.com", prompt: "Extract title" }); + assert.deepEqual(out, { url: "https://example.com", format: "markdown" }); + }); + + it("respects explicit WebFetch format if already set", () => { + const out = translated("WebFetch", { url: "https://example.com", format: "text" }); + assert.deepEqual(out, { url: "https://example.com", format: "text" }); + }); + + it("returns input unchanged for malformed JSON", () => { + const malformed = "{not valid json"; + assert.equal(translateToolArgsJsonString(malformed, "Read"), malformed); + }); + + it("returns input unchanged for non-object JSON (array or primitive)", () => { + assert.equal(translateToolArgsJsonString("[1,2,3]", "Read"), "[1,2,3]"); + assert.equal(translateToolArgsJsonString('"hello"', "Read"), '"hello"'); + assert.equal(translateToolArgsJsonString("null", "Read"), "null"); + }); + + it("passes through unknown tool names without modification", () => { + const out = translated("TotallyMadeUpTool", { foo: "bar", file_path: "/x" }); + assert.deepEqual(out, { foo: "bar", file_path: "/x" }); + }); + + // Field stripping — Claude sends fields OpenCode's Zod schemas don't accept + it("strips Claude-only Agent fields (model, run_in_background, isolation)", () => { + const out = translated("Agent", { + description: "d", + prompt: "p", + subagent_type: "general-purpose", + model: "opus", + run_in_background: true, + isolation: "worktree", + }); + assert.deepEqual(out, { + description: "d", + prompt: "p", + subagent_type: "general", + }); }); -}); -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("defaults Agent subagent_type to 'general' when missing (OpenCode requires it)", () => { + const out = translated("Agent", { description: "d", prompt: "p" }); + assert.equal(out.subagent_type, "general"); }); - it("maps AskUserQuestion multiple to multiSelect", () => { - const out = normalizeOutboundToolUse("AskUserQuestion", { - questions: [{ question: "Q?", header: "Q", options: [], multiple: true }], + it("strips Claude-only Bash fields (run_in_background, dangerouslyDisableSandbox)", () => { + const out = translated("Bash", { + command: "ls", + run_in_background: true, + dangerouslyDisableSandbox: true, + }); + assert.deepEqual(out, { command: "ls" }); + }); + + it("strips Claude-only Read field (pages)", () => { + const out = translated("Read", { file_path: "/x.pdf", pages: "1-5" }); + assert.deepEqual(out, { filePath: "/x.pdf" }); + }); + + it("strips Claude-only Grep fields", () => { + const out = translated("Grep", { + pattern: "foo", + glob: "*.ts", + output_mode: "content", + "-B": 2, + "-A": 3, + "-C": 5, + context: 1, + "-n": true, + "-i": true, + type: "js", + head_limit: 10, + offset: 5, + multiline: false, + }); + assert.deepEqual(out, { pattern: "foo", include: "*.ts" }); + }); + + it("translates AskUserQuestion multiSelect → multiple per question", () => { + const out = translated("AskUserQuestion", { + questions: [ + { question: "Which?", options: ["a", "b"], multiSelect: true }, + { question: "Either?", options: ["y", "n"], multiSelect: false }, + ], + }); + assert.deepEqual(out, { + questions: [ + { question: "Which?", options: ["a", "b"], multiple: true }, + { question: "Either?", options: ["y", "n"], multiple: false }, + ], + }); + }); +}); + +// ── translateArgsOpencodeToClaude: outbound translation on parsed objects ─── + +describe("translateArgsOpencodeToClaude", () => { + it("renames camelCase keys to snake_case for Edit", () => { + const out = translateArgsOpencodeToClaude("Edit", { + filePath: "/f.ts", + oldString: "a", + newString: "b", + replaceAll: true, }); assert.deepEqual(out, { - questions: [{ question: "Q?", header: "Q", options: [], multiSelect: true }], + file_path: "/f.ts", + old_string: "a", + new_string: "b", + replace_all: true, }); }); - it("strips OpenCode-only agent history fields before sending to Claude", () => { - const out = normalizeOutboundToolUse("Agent", { + it("maps OpenCode subagent_type values back to Claude", () => { + assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "general" }).subagent_type, "general-purpose"); + assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "build" }).subagent_type, "general-purpose"); + assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "explore" }).subagent_type, "Explore"); + assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "plan" }).subagent_type, "Plan"); + }); + + it("strips Agent OpenCode-only fields (task_id, command)", () => { + const out = translateArgsOpencodeToClaude("Agent", { + description: "d", + prompt: "p", subagent_type: "general", - task_id: "abc", - command: "do thing", + task_id: "t_1", + command: "go", + }); + assert.deepEqual(out, { + description: "d", + prompt: "p", + subagent_type: "general-purpose", }); - 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("translates AskUserQuestion multiple → multiSelect per question", () => { + const out = translateArgsOpencodeToClaude("AskUserQuestion", { + questions: [ + { question: "Which?", options: ["a", "b"], multiple: true }, + { question: "Either?", options: ["y", "n"], multiple: false }, + ], + }); + assert.deepEqual(out, { + questions: [ + { question: "Which?", options: ["a", "b"], multiSelect: true }, + { question: "Either?", options: ["y", "n"], multiSelect: false }, + ], + }); }); - it("maps OpenCode webfetch format to a best-effort Claude prompt", () => { - const out = normalizeOutboundToolUse("WebFetch", { + it("synthesizes a WebFetch prompt from format (markdown)", () => { + const out = translateArgsOpencodeToClaude("WebFetch", { url: "https://example.com", format: "markdown", - timeout: 5, }); assert.deepEqual(out, { url: "https://example.com", @@ -329,60 +378,502 @@ describe("tool mapping regressions", () => { }); }); - 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("synthesizes a WebFetch prompt from format (text)", () => { + const out = translateArgsOpencodeToClaude("WebFetch", { + url: "https://example.com", + format: "text", + }); + assert.equal(out.prompt, "Fetch this URL and return the content as plain text."); }); - 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("synthesizes a WebFetch prompt from format (html)", () => { + const out = translateArgsOpencodeToClaude("WebFetch", { + url: "https://example.com", + format: "html", + }); + assert.equal(out.prompt, "Fetch this URL and return the raw HTML."); }); - it("maps inbound general-purpose agent type to general", () => { - const out = normalizeInboundStreamChunk( - '{"subagent_type":"general-purpose"}', - "Agent", - ); - assert.equal(out, '{"subagent_type": "general"}'); + it("strips WebFetch timeout (OpenCode-only)", () => { + const out = translateArgsOpencodeToClaude("WebFetch", { + url: "https://example.com", + format: "markdown", + timeout: 30, + }); + assert.equal(out.timeout, undefined); }); - it("maps inbound AskUserQuestion multiSelect to multiple", () => { - const out = normalizeInboundStreamChunk( - '{"multiSelect":true}', - "AskUserQuestion", - ); - assert.equal(out, '{"multiple":true}'); + it("renames priority → activeForm in TodoWrite and collapses cancelled → completed", () => { + const out = translateArgsOpencodeToClaude("TodoWrite", { + todos: [ + { content: "a", status: "pending", priority: "Doing a" }, + { content: "b", status: "cancelled", priority: "Was doing b" }, + ], + }); + assert.deepEqual(out, { + todos: [ + { content: "a", status: "pending", activeForm: "Doing a" }, + { content: "b", status: "completed", activeForm: "Was doing b" }, + ], + }); }); - 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("is the inverse of translateToolArgsJsonString for the Edit round trip", () => { + // Round-trip: Claude inbound → OpenCode → Claude outbound should be identity + // for keys that have a bidirectional mapping. + const claudeArgs = { file_path: "/f.ts", old_string: "a", new_string: "b" }; + const opencodeArgs = JSON.parse(translateToolArgsJsonString(JSON.stringify(claudeArgs), "Edit")); + const roundTripped = translateArgsOpencodeToClaude("Edit", opencodeArgs); + assert.deepEqual(roundTripped, claudeArgs); }); +}); - 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"/); +// ── Agent type map integrity ───────────────────────────────────────────────── + +describe("Agent type maps", () => { + it("CLAUDE_TO_OPENCODE covers Claude's subagent_type values", () => { + assert.equal(AGENT_TYPE_CLAUDE_TO_OPENCODE["general-purpose"], "general"); + assert.equal(AGENT_TYPE_CLAUDE_TO_OPENCODE["Explore"], "explore"); + assert.equal(AGENT_TYPE_CLAUDE_TO_OPENCODE["Plan"], "plan"); + assert.equal(AGENT_TYPE_CLAUDE_TO_OPENCODE["statusline-setup"], "build"); + }); + + it("OPENCODE_TO_CLAUDE covers OpenCode's built-in agents", () => { + assert.equal(AGENT_TYPE_OPENCODE_TO_CLAUDE["general"], "general-purpose"); + assert.equal(AGENT_TYPE_OPENCODE_TO_CLAUDE["build"], "general-purpose"); + assert.equal(AGENT_TYPE_OPENCODE_TO_CLAUDE["explore"], "Explore"); + assert.equal(AGENT_TYPE_OPENCODE_TO_CLAUDE["plan"], "Plan"); + }); +}); + +// ── SSE processor: test the actual createSseProcessor from ./stream ───────── + +const INBOUND_TOOL_NAME_MAP: Record = { + Bash: "bash", + Read: "read", + Glob: "glob", + Grep: "grep", + Edit: "edit", + Write: "write", + Agent: "task", + WebFetch: "webfetch", + TodoWrite: "todowrite", + Skill: "skill", + AskUserQuestion: "question", +}; + +function makeProcessor(opts: { debug?: (msg: string) => void } = {}) { + return createSseProcessor({ + inboundToolNameMap: INBOUND_TOOL_NAME_MAP, + translateToolArgs: translateToolArgsJsonString, + debug: opts.debug, + }); +} + +function sseEvent(eventName: string, data: object): string { + return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; +} + +/** + * Walk a concatenated SSE stream and return every parsed event, so tests + * can assert on object shape rather than substring matching. + */ +function parseAllEvents(sse: string): Array<{ event: string | null; data: any }> { + const events: Array<{ event: string | null; data: any }> = []; + const frames = sse.split("\n\n").filter((f) => f.length > 0).map((f) => f + "\n\n"); + for (const frame of frames) { + const parsed = parseSseEvent(frame); + if (!parsed) continue; + events.push({ event: parsed.event, data: JSON.parse(parsed.data) }); + } + return events; +} + +/** + * Extract the tool-use input that will reach OpenCode for a given block + * index. Returns the parsed args object the consumer's SDK will see after + * concatenating all partial_json fragments for that block. + */ +function finalToolArgs(sse: string, blockIndex: number): any { + const events = parseAllEvents(sse); + const fragments: string[] = []; + for (const e of events) { + if ( + e.data?.type === "content_block_delta" && + e.data.index === blockIndex && + e.data.delta?.type === "input_json_delta" + ) { + fragments.push(String(e.data.delta.partial_json ?? "")); + } + } + if (fragments.length === 0) return null; + return JSON.parse(fragments.join("")); +} + +function toolUseStartName(sse: string, blockIndex: number): string | null { + const events = parseAllEvents(sse); + for (const e of events) { + if ( + e.data?.type === "content_block_start" && + e.data.index === blockIndex && + e.data.content_block?.type === "tool_use" + ) { + return e.data.content_block.name; + } + } + return null; +} + +// Helper that runs a tool_use through the processor: start, one or more +// delta fragments, stop. Returns the concatenated SSE output. +function runToolUse(toolName: string, fragments: string[], opts: { index?: number } = {}): string { + const idx = opts.index ?? 1; + const proc = makeProcessor(); + let out = ""; + out += proc.feedChunk(sseEvent("content_block_start", { + type: "content_block_start", + index: idx, + content_block: { type: "tool_use", id: `tu_${idx}`, name: toolName, input: {} }, + })); + for (const frag of fragments) { + out += proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: idx, + delta: { type: "input_json_delta", partial_json: frag }, + })); + } + out += proc.feedChunk(sseEvent("content_block_stop", { + type: "content_block_stop", + index: idx, + })); + return out; +} + +describe("SSE processor: tool name mapping", () => { + for (const [claude, opencode] of Object.entries(INBOUND_TOOL_NAME_MAP)) { + it(`maps ${claude} → ${opencode} on content_block_start`, () => { + const out = runToolUse(claude, ['{}']); + assert.equal(toolUseStartName(out, 1), opencode); + }); + } + + it("passes through an unknown tool name without modification", () => { + const out = runToolUse("CustomUnmappedTool", ['{}']); + assert.equal(toolUseStartName(out, 1), "CustomUnmappedTool"); + }); +}); + +describe("SSE processor: argument translation", () => { + it("translates file_path → filePath for Read", () => { + const out = runToolUse("Read", ['{"file_path": "/tmp/test.txt"}']); + assert.deepEqual(finalToolArgs(out, 1), { filePath: "/tmp/test.txt" }); + }); + + it("translates all Edit params", () => { + const out = runToolUse("Edit", [ + '{"file_path": "/f.ts", "old_string": "foo", "new_string": "bar", "replace_all": true}', + ]); + assert.deepEqual(finalToolArgs(out, 1), { + filePath: "/f.ts", + oldString: "foo", + newString: "bar", + replaceAll: true, + }); }); - it("drops inbound Claude-only grep options unsupported by OpenCode", () => { - const out = normalizeInboundStreamChunk( - '{"glob":"*.ts","output_mode":"content","head_limit":10}', - "Grep", + it("translates glob → include for Grep, preserves pattern", () => { + const out = runToolUse("Grep", ['{"pattern": "hello", "glob": "*.ts"}']); + assert.deepEqual(finalToolArgs(out, 1), { pattern: "hello", include: "*.ts" }); + }); + + it("translates activeForm → priority per todo item in TodoWrite", () => { + const out = runToolUse("TodoWrite", [ + '{"todos": [{"content": "fix", "status": "pending", "activeForm": "Fixing"}]}', + ]); + assert.deepEqual(finalToolArgs(out, 1), { + todos: [{ content: "fix", status: "pending", priority: "Fixing" }], + }); + }); + + it("translates Agent subagent_type values", () => { + const out = runToolUse("Agent", [ + '{"description": "d", "prompt": "p", "subagent_type": "general-purpose"}', + ]); + assert.deepEqual(finalToolArgs(out, 1), { + description: "d", + prompt: "p", + subagent_type: "general", + }); + }); + + it("strips prompt and injects default format for WebFetch", () => { + const out = runToolUse("WebFetch", [ + '{"url": "https://example.com", "prompt": "Extract the title"}', + ]); + assert.deepEqual(finalToolArgs(out, 1), { + url: "https://example.com", + format: "markdown", + }); + }); + + it("translates skill → name and strips args for Skill", () => { + const out = runToolUse("Skill", ['{"skill": "commit", "args": "-m fix"}']); + assert.deepEqual(finalToolArgs(out, 1), { name: "commit" }); + }); + + it("leaves Bash args untouched", () => { + const out = runToolUse("Bash", ['{"command": "echo hello"}']); + assert.deepEqual(finalToolArgs(out, 1), { command: "echo hello" }); + }); +}); + +describe("SSE processor: chunk boundary handling", () => { + it("handles args split across many small fragments", () => { + const out = runToolUse("Read", ['{"fi', 'le_', 'pa', 'th":', ' "/', 'tmp/', 'x.t', 'xt"}']); + assert.deepEqual(finalToolArgs(out, 1), { filePath: "/tmp/x.txt" }); + }); + + it("handles multiple SSE events concatenated into one chunk", () => { + const proc = makeProcessor(); + const combined = + sseEvent("content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} }, + }) + + sseEvent("content_block_delta", { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"file_path": "/a.txt"}' }, + }) + + sseEvent("content_block_stop", { + type: "content_block_stop", + index: 1, + }); + const out = proc.feedChunk(combined); + assert.deepEqual(finalToolArgs(out, 1), { filePath: "/a.txt" }); + assert.ok(parseAllEvents(out).some((e) => e.data.type === "content_block_stop")); + }); + + it("handles an SSE event split across two chunks", () => { + const proc = makeProcessor(); + const fullEvent = sseEvent("content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tu_1", name: "Write", input: {} }, + }); + const mid = Math.floor(fullEvent.length / 2); + const first = proc.feedChunk(fullEvent.slice(0, mid)); + assert.equal(first, "", "No complete event yet — should not emit"); + const second = proc.feedChunk(fullEvent.slice(mid)); + assert.equal(toolUseStartName(second, 1), "write"); + }); + + it("passes through text deltas unchanged", () => { + const proc = makeProcessor(); + const out = proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello world" }, + })); + const events = parseAllEvents(out); + assert.equal(events.length, 1); + assert.equal(events[0].data.delta.text, "Hello world"); + }); + + it("does NOT translate tool args inside a text_delta", () => { + // Proves text content isn't mutated: a text_delta containing the + // literal string "file_path" survives intact. + const proc = makeProcessor(); + const out = proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "use file_path to specify path" }, + })); + const events = parseAllEvents(out); + assert.equal(events[0].data.delta.text, "use file_path to specify path"); + }); +}); + +describe("SSE processor: interleaved and concurrent tool_use blocks", () => { + it("keeps per-block state isolated across interleaved deltas", () => { + const proc = makeProcessor(); + let out = ""; + + // Open block 1 (Read) + out += proc.feedChunk(sseEvent("content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} }, + })); + // Open block 2 (Write) — shouldn't be valid per Anthropic's ordering but + // the processor must still keep state by index, not a single "current". + out += proc.feedChunk(sseEvent("content_block_start", { + type: "content_block_start", + index: 2, + content_block: { type: "tool_use", id: "tu_2", name: "Write", input: {} }, + })); + + // Interleave deltas for both blocks + out += proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"file_path": "/read.txt"}' }, + })); + out += proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 2, + delta: { type: "input_json_delta", partial_json: '{"file_path": "/write.txt", "content": "hi"}' }, + })); + + out += proc.feedChunk(sseEvent("content_block_stop", { type: "content_block_stop", index: 1 })); + out += proc.feedChunk(sseEvent("content_block_stop", { type: "content_block_stop", index: 2 })); + + assert.deepEqual(finalToolArgs(out, 1), { filePath: "/read.txt" }); + assert.deepEqual(finalToolArgs(out, 2), { filePath: "/write.txt", content: "hi" }); + }); +}); + +describe("SSE processor: error handling", () => { + it("calls debug callback on malformed JSON in an SSE data frame", () => { + const messages: string[] = []; + const proc = createSseProcessor({ + inboundToolNameMap: INBOUND_TOOL_NAME_MAP, + translateToolArgs: translateToolArgsJsonString, + debug: (m) => messages.push(m), + }); + const malformed = "event: content_block_start\ndata: {not valid\n\n"; + const out = proc.feedChunk(malformed); + // Passed through as-is + assert.equal(out, malformed); + // And we told the operator about it (behind their debug flag) + assert.ok(messages.some((m) => m.includes("JSON.parse failed")), `Expected debug log, got: ${JSON.stringify(messages)}`); + }); + + it("calls debug callback when translateToolArgs throws", () => { + const messages: string[] = []; + const proc = createSseProcessor({ + inboundToolNameMap: INBOUND_TOOL_NAME_MAP, + translateToolArgs: () => { throw new Error("translator blew up"); }, + debug: (m) => messages.push(m), + }); + let out = ""; + out += proc.feedChunk(sseEvent("content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} }, + })); + out += proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"file_path": "/x"}' }, + })); + out += proc.feedChunk(sseEvent("content_block_stop", { type: "content_block_stop", index: 1 })); + assert.ok(messages.some((m) => m.includes("translator blew up"))); + // And we fall back to emitting the raw (untranslated) JSON so downstream doesn't hang + assert.deepEqual(finalToolArgs(out, 1), { file_path: "/x" }); + }); +}); + +describe("SSE processor: flush", () => { + it("flush returns any trailing buffered bytes", () => { + const proc = makeProcessor(); + const partial = "event: content_block_start\ndata: {\"t"; // incomplete frame + assert.equal(proc.feedChunk(partial), ""); + assert.equal(proc.flush(), partial); + // And state is cleared after flush + assert.equal(proc.flush(), ""); + }); + + it("logs a debug warning when a tool_use block is abandoned mid-stream", () => { + // Simulates upstream disconnect after start + some deltas but before stop. + const messages: string[] = []; + const proc = createSseProcessor({ + inboundToolNameMap: INBOUND_TOOL_NAME_MAP, + translateToolArgs: translateToolArgsJsonString, + debug: (m) => messages.push(m), + }); + proc.feedChunk(sseEvent("content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} }, + })); + proc.feedChunk(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"file_path"' }, + })); + // ...and then the stream ends without a content_block_stop. + proc.flush(); + assert.ok( + messages.some((m) => m.includes("abandoned") && m.includes("index=1") && m.includes("tool=Read")), + `Expected abandoned-block warning, got: ${JSON.stringify(messages)}`, ); - assert.equal(out, '{"glob":"*.ts"}'); + }); +}); + +describe("SSE processor: pass-through optimization", () => { + // Pass-through events (message_start, ping, message_delta, text + // content_block_delta, etc.) should not be round-tripped through + // JSON.parse + JSON.stringify — the output bytes should match the input + // exactly when no translation is needed. + it("emits the exact input bytes for ping events (no reserialize)", () => { + const proc = makeProcessor(); + const pingFrame = sseEvent("ping", { type: "ping" }); + const out = proc.feedChunk(pingFrame); + assert.equal(out, pingFrame); + }); + + it("emits the exact input bytes for message_start events", () => { + const proc = makeProcessor(); + // Anthropic message_start payloads include nested objects and arrays — + // passthrough must preserve byte-for-byte equality, including any + // particular key order the API happens to emit. + const frame = + "event: message_start\n" + + 'data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1}}}' + + "\n\n"; + const out = proc.feedChunk(frame); + assert.equal(out, frame); + }); + + it("emits the exact input bytes for text_delta events", () => { + const proc = makeProcessor(); + const frame = sseEvent("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "hello world" }, + }); + const out = proc.feedChunk(frame); + assert.equal(out, frame); + }); + + it("still transforms tool_use content_block_start (optimization does not skip interesting events)", () => { + const proc = makeProcessor(); + const input = sseEvent("content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} }, + }); + const out = proc.feedChunk(input); + assert.notEqual(out, input, "tool_use start should be transformed (name mapped)"); + assert.equal(toolUseStartName(out, 1), "read"); + }); +}); + +describe("parseSseEvent / buildSseEvent round trip", () => { + it("round-trips a simple event", () => { + const original = buildSseEvent("ping", '{"type":"ping"}'); + const parsed = parseSseEvent(original); + assert.deepEqual(parsed, { event: "ping", data: '{"type":"ping"}' }); + }); + + it("returns null for a frame with no data line", () => { + assert.equal(parseSseEvent("event: foo\n\n"), null); + }); + + it("handles a data-only frame (no event line)", () => { + const parsed = parseSseEvent('data: {"ok":true}\n\n'); + assert.deepEqual(parsed, { event: null, data: '{"ok":true}' }); }); }); diff --git a/src/index.ts b/src/index.ts index 697905e..901f8ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,13 +27,12 @@ import { } from "./constants.js"; import { getClaudeTools, - STUB_TOOL_NAMES, - SHARED_TOOL_NAMES, - translateArgsSnakeToCamel, - translateArgsCamelToSnake, + translateArgsOpencodeToClaude, + translateToolArgsJsonString, computeFingerprint, extractFirstUserMessageText, } from "./claude-tools.js"; +import { createSseProcessor } from "./stream.js"; // ── Types ────────────────────────────────────────────────────────── @@ -195,7 +194,8 @@ async function refreshAuth( // Layer 2: Stored refresh token if (!fresh && auth.refresh) { - try { fresh = refreshTokens(auth.refresh); } catch {} + try { fresh = refreshTokens(auth.refresh); } + catch {} } // Layer 3: CLI refresh token @@ -224,23 +224,26 @@ function openBrowser(url: string): void { if (process.platform === "darwin") { const uid = String(process.getuid?.() ?? ""); - const attempts: Array<() => void> = [ - () => execFileSync("/bin/launchctl", ["asuser", uid, "/usr/bin/open", url], { timeout: 5000 }), - () => execFileSync("/usr/bin/open", [url], { timeout: 3000 }), - () => execFileSync("/usr/bin/osascript", ["-e", `open location "${url}"`], { timeout: 3000 }), + const attempts: Array<[string, () => void]> = [ + ["launchctl asuser", () => execFileSync("/bin/launchctl", ["asuser", uid, "/usr/bin/open", url], { timeout: 5000 })], + ["open", () => execFileSync("/usr/bin/open", [url], { timeout: 3000 })], + ["osascript", () => execFileSync("/usr/bin/osascript", ["-e", `open location "${url}"`], { timeout: 3000 })], ]; - for (const attempt of attempts) { - try { attempt(); break; } catch {} + for (const [name, attempt] of attempts) { + try { attempt(); break; } + catch {} } } else if (process.platform === "win32") { - try { execFileSync("cmd", ["/c", "start", "", url], { timeout: 3000 }); } catch {} + try { execFileSync("cmd", ["/c", "start", "", url], { timeout: 3000 }); } + catch {} } else { - const attempts: Array<() => void> = [ - () => execFileSync("/usr/bin/xdg-open", [url], { timeout: 3000 }), - () => execFileSync("/usr/bin/open", [url], { timeout: 3000 }), + const attempts: Array<[string, () => void]> = [ + ["xdg-open", () => execFileSync("/usr/bin/xdg-open", [url], { timeout: 3000 })], + ["open", () => execFileSync("/usr/bin/open", [url], { timeout: 3000 })], ]; - for (const attempt of attempts) { - try { attempt(); break; } catch {} + for (const [name, attempt] of attempts) { + try { attempt(); break; } + catch {} } } })().catch(() => {}); @@ -284,34 +287,15 @@ 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; try { const parsed = JSON.parse(trimmed); if (typeof parsed === "string") return parsed; - } catch {} + } catch { + // Expected for non-JSON text — don't log. + } return text; } @@ -558,13 +542,18 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { } if (!parsed.system) parsed.system = []; - if (!parsed.context_management) { - parsed.context_management = { - edits: [{ type: CLEAR_THINKING_TYPE, keep: "all" }], - }; - } - if (!parsed.output_config) { - parsed.output_config = { effort: DEFAULT_EFFORT }; + // context_management and output_config.effort are only + // supported by thinking-capable models. Sending them to + // haiku (used for title generation) produces a 400. + if (supportsThinking) { + if (!parsed.context_management) { + parsed.context_management = { + edits: [{ type: CLEAR_THINKING_TYPE, keep: "all" }], + }; + } + if (!parsed.output_config) { + parsed.output_config = { effort: DEFAULT_EFFORT }; + } } const profile = auth.access @@ -635,76 +624,16 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { } if (block.type === "tool_use" && block.name) { block.name = mapOutboundToolName(block.name); - // Translate arguments from OpenCode's camelCase to Claude's snake_case + // Consolidated OpenCode → Claude argument translation: + // key renames, Agent/AskUserQuestion/Skill/WebFetch/TodoWrite + // field bridging. Mirrors translateToolArgsJsonString + // (inbound) so round-trips preserve meaning. See + // translateArgsOpencodeToClaude in claude-tools.ts. if (block.input && typeof block.input === "object") { - block.input = translateArgsCamelToSnake( + block.input = translateArgsOpencodeToClaude( block.name, block.input as Record, ); - // Agent: translate OpenCode subagent_type values → Claude values - 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} - if (block.name === "TodoWrite" && Array.isArray((block.input as Record).todos)) { - for (const item of (block.input as Record).todos as Array>) { - if (item.priority && !item.activeForm) { - item.activeForm = item.priority; - delete item.priority; - } - // Map "cancelled" → "completed" (Claude doesn't have cancelled) - if (item.status === "cancelled") { - item.status = "completed"; - } - } - } } } // tool_result blocks reference tool names via tool_use_id, @@ -774,122 +703,42 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { const decoder = new TextDecoder(); const encoder = new TextEncoder(); - // Track the current tool name for argument translation in - // streamed tool_use content_block_start / input_json_delta events. - let currentToolName = ""; + // Parse SSE events on \n\n boundaries, buffer tool_use + // input_json_delta fragments per content-block index, and + // translate the assembled JSON once at content_block_stop. + // This avoids chunk-boundary corruption (e.g. "file_" + "path" + // split across TCP chunks) that regex-on-raw-bytes can't + // handle. See src/stream.ts for the processor implementation. + const processor = createSseProcessor({ + inboundToolNameMap: INBOUND_TOOL_NAME_MAP, + translateToolArgs: translateToolArgsJsonString, + }); return new Response( new ReadableStream({ - async pull(controller) { - const { done, value } = await reader.read(); - if (done) { controller.close(); return; } - let text = decoder.decode(value, { stream: true }); - - // Map tool names: Claude Code → OpenCode - text = text.replace(/"name"\s*:\s*"([^"]+)"/g, (_match, name: string) => { - // Track current tool for argument translation - if (SHARED_TOOL_NAMES.has(name) || STUB_TOOL_NAMES.has(name)) { - currentToolName = name; - } - const mapped = INBOUND_TOOL_NAME_MAP[name]; - return mapped - ? `"name": "${mapped}"` - : `"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 - // known keys on the fly. - if (currentToolName) { - // file_path → filePath - text = text.replace(/"file_path"\s*:/g, () => { - if (currentToolName === "Edit" || currentToolName === "Read" || currentToolName === "Write") { - return '"filePath":'; - } - return '"file_path":'; - }); - // old_string → oldString - text = text.replace(/"old_string"\s*:/g, '"oldString":'); - // new_string → newString - text = text.replace(/"new_string"\s*:/g, '"newString":'); - // replace_all → replaceAll - text = text.replace(/"replace_all"\s*:/g, '"replaceAll":'); - // glob → include (for Grep only) - if (currentToolName === "Grep") { - text = text.replace(/"glob"\s*:/g, '"include":'); - } - // TodoWrite: activeForm → priority - // Claude sends { content, status, activeForm }, OpenCode - // requires { content, status, priority }. Since OpenCode - // validates priority as z.string() (no enum), any string - // value is accepted — the activeForm text works fine. - 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", "general", "explore", and "plan" agents. - // We match the full key:value pair to avoid replacing these - // strings inside prompt/description text. - if (currentToolName === "Agent") { - text = text.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}"`; - }, - ); - 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); + start(controller) { + // Use start() with an async loop rather than pull(): + // Bun's ReadableStream doesn't reliably re-invoke pull() + // when the handler resolves without enqueuing (which + // happens while we're buffering input_json_delta + // fragments), so pull() can stall the stream. + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + const tail = processor.flush(); + if (tail) controller.enqueue(encoder.encode(tail)); + controller.close(); + return; + } + const out = processor.feedChunk(decoder.decode(value, { stream: true })); + if (out) controller.enqueue(encoder.encode(out)); } + } catch (e) { + controller.error(e); } - if (currentToolName === "Skill") { - text = text.replace(/"skill"\s*:/g, '"name":'); - text = stripScalarJsonField(text, "args"); - } - if (currentToolName === "WebFetch") { - text = stripScalarJsonField(text, "prompt"); - } - } - - // Reset tool name tracking on content_block_stop - if (text.includes('"content_block_stop"')) { - currentToolName = ""; - } - - controller.enqueue(encoder.encode(text)); + })(); }, }), { status: response.status, statusText: response.statusText, headers: response.headers }, diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..fb6750a --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,231 @@ +/** + * SSE stream processor for the Anthropic Messages API response. + * + * Anthropic streams tool_use arguments as many tiny input_json_delta + * events whose partial_json fragments can split mid-key (e.g. "file_" + + * "path"). Doing regex substitution on each chunk as it arrives silently + * fails at those splits. This module instead: + * + * 1. Frames on `\n\n` SSE event boundaries (chunks may contain partial + * or multiple events). + * 2. Buffers input_json_delta fragments per tool_use block (keyed by + * content-block index, so interleaved blocks don't corrupt each + * other). + * 3. On content_block_stop, translates the fully-assembled JSON once + * via the injected translateToolArgs callback and emits a single + * synthetic input_json_delta containing the translated payload, + * followed by the stop event. + * + * The translateToolArgs callback is injected rather than hard-coded so + * the same processor can be exercised in isolation from unit tests. + */ + +export function parseSseEvent( + block: string, +): { event: string | null; data: string } | null { + let eventName: string | null = null; + const dataLines: string[] = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + if (dataLines.length === 0) return null; + return { event: eventName, data: dataLines.join("\n") }; +} + +export function buildSseEvent(event: string | null, data: string): string { + return (event ? `event: ${event}\n` : "") + `data: ${data}\n\n`; +} + +/** + * Cheap extraction of the `type` field from an SSE data JSON string + * without parsing the whole payload. Used to decide whether an event is + * interesting enough to round-trip through JSON.parse + JSON.stringify. + * Returns null if the type can't be determined from the first few + * properties — caller should fall back to a full parse in that case. + */ +function peekType(data: string): string | null { + // Match the first top-level "type": "..." occurrence. Doesn't handle + // escaped quotes in the value, but Anthropic type names are plain + // identifiers so that's fine. If anything weird happens, return null + // and force a full parse. + const m = data.match(/^\s*\{\s*"type"\s*:\s*"([a-z_]+)"/); + return m ? m[1] : null; +} + +export type StreamProcessorDeps = { + /** + * Map from Claude's tool name (e.g. "Read") to OpenCode's tool name + * (e.g. "read"). Applied to content_block_start events with type + * "tool_use". + */ + inboundToolNameMap: Record; + + /** + * Translate an assembled tool_use argument JSON string from Claude's + * schema to OpenCode's schema. Receives the raw JSON string and the + * original (Claude) tool name. + */ + translateToolArgs: (json: string, toolName: string) => string; + + /** + * Optional debug sink for swallowed errors. Called with a short + * message describing what was swallowed and why. Typically wired to + * a console.error behind an env flag. + */ + debug?: (msg: string) => void; +}; + +/** + * Create a stateful SSE processor. + * + * `feedChunk(text)` accumulates a raw response chunk into the SSE frame + * buffer, processes every complete event in that buffer, and returns the + * concatenated transformed bytes to emit downstream. Incomplete trailing + * frames stay buffered for the next call. + * + * `flush()` returns any trailing buffered bytes — used at end-of-stream + * to avoid dropping data if the upstream closed mid-frame (Anthropic + * normally ends on a blank line, so this is typically empty). + */ +export function createSseProcessor(deps: StreamProcessorDeps) { + type ToolBlockState = { toolName: string; partialJson: string }; + const toolBlocks = new Map(); + let sseBuffer = ""; + + // Event types that require peeking into the JSON to decide whether/how to + // mutate. Every other event type is a pure pass-through — no need to + // re-serialize, just forward the original bytes. + const INTERESTING_TYPES = new Set([ + "content_block_start", + "content_block_delta", + "content_block_stop", + ]); + + function processEvent(block: string): string { + const parsed = parseSseEvent(block); + if (!parsed) return block; // comment/keepalive line — pass through + const { event, data } = parsed; + + // Cheap sniff: if the event isn't one we care about, forward the raw + // frame. Saves a JSON.parse + JSON.stringify round trip on every + // message_start, ping, message_delta, and text_delta. + const cheapType = peekType(data); + if (cheapType !== null && !INTERESTING_TYPES.has(cheapType)) { + return block; + } + + let obj: unknown; + try { + obj = JSON.parse(data); + } catch (e) { + deps.debug?.(`processEvent: JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`); + return block; + } + + if (obj === null || typeof obj !== "object") { + return block; + } + const rec = obj as Record; + const type = rec.type as string | undefined; + + // content_block_start with tool_use: translate name, register per-index state + if ( + type === "content_block_start" && + rec.content_block && + typeof rec.content_block === "object" && + rec.content_block.type === "tool_use" + ) { + const idx = rec.index as number; + const claudeName: string = rec.content_block.name || ""; + const mapped = deps.inboundToolNameMap[claudeName] || claudeName; + rec.content_block.name = mapped; + if (rec.content_block.input === undefined) rec.content_block.input = {}; + toolBlocks.set(idx, { toolName: claudeName, partialJson: "" }); + return buildSseEvent(event, JSON.stringify(rec)); + } + + // input_json_delta: buffer the fragment, emit nothing yet + if ( + type === "content_block_delta" && + rec.delta && + typeof rec.delta === "object" && + rec.delta.type === "input_json_delta" + ) { + const idx = rec.index as number; + const state = toolBlocks.get(idx); + if (state) { + state.partialJson += String(rec.delta.partial_json ?? ""); + return ""; + } + return block; + } + + // content_block_stop: if this closed a tool_use block, flush a synthetic + // input_json_delta containing the fully translated JSON, then emit the stop. + if (type === "content_block_stop") { + const idx = rec.index as number; + const state = toolBlocks.get(idx); + if (state) { + toolBlocks.delete(idx); + let translated: string; + try { + translated = deps.translateToolArgs(state.partialJson, state.toolName); + } catch (e) { + deps.debug?.(`translateToolArgs threw for ${state.toolName}: ${e instanceof Error ? e.message : String(e)}`); + translated = state.partialJson; + } + const synthDelta = { + type: "content_block_delta", + index: idx, + delta: { type: "input_json_delta", partial_json: translated }, + }; + return ( + buildSseEvent("content_block_delta", JSON.stringify(synthDelta)) + + block + ); + } + return block; + } + + // Interesting type we didn't end up mutating (e.g. a non-tool_use + // content_block_start, or a text_delta inside a content_block_delta). + return block; + } + + function feedChunk(text: string): string { + sseBuffer += text; + let out = ""; + while (true) { + const boundary = sseBuffer.indexOf("\n\n"); + if (boundary === -1) break; + const block = sseBuffer.slice(0, boundary + 2); + sseBuffer = sseBuffer.slice(boundary + 2); + out += processEvent(block); + } + return out; + } + + function flush(): string { + const remaining = sseBuffer; + sseBuffer = ""; + if (toolBlocks.size > 0) { + // The upstream disconnected mid-tool_use without sending + // content_block_stop, so we never emitted a synthetic translated + // delta for these blocks. The consumer will see the start but no + // completed args. Surface it so the operator isn't debugging a + // ghost. + const orphaned = Array.from(toolBlocks.entries()).map( + ([idx, s]) => `index=${idx} tool=${s.toolName} bufferedBytes=${s.partialJson.length}`, + ); + deps.debug?.(`flush: ${toolBlocks.size} tool_use block(s) abandoned mid-stream: ${orphaned.join("; ")}`); + toolBlocks.clear(); + } + return remaining; + } + + return { feedChunk, flush }; +}