From dafe29954c620ea3b16483d45cdeffa69b2c43ec Mon Sep 17 00:00:00 2001 From: Ben Nasraoui Date: Wed, 15 Apr 2026 14:35:09 +1000 Subject: [PATCH 1/2] refactor: rearchitect tool arg translation on top of v1.10.2 upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR was previously based on pre-v1.10.2 main. Upstream has since landed PRs #9 (OAuth), #10 (Windows compat), #11 (shared tool mappings), and #12 (CI). Rebased onto current main and reworked our improvements to layer cleanly on top of upstream's tool-mapping work. ## What changed from upstream v1.10.2 Upstream #11 uses regex-on-raw-bytes to translate tool arguments as they stream in. That works when the whole JSON fits in one chunk, but fails silently when Anthropic splits a tool argument across a TCP boundary — e.g. "file_" in one chunk and "path" in the next. The regex never matches a split key, so the consumer receives untranslated snake_case and produces tool validation errors. This PR replaces that approach with three structural changes: 1. **SSE event framing (src/stream.ts).** New module with a stateful processor that parses SSE frames on \n\n boundaries, buffers input_json_delta fragments per content-block index, and emits a single translated input_json_delta at content_block_stop. Chunk-boundary corruption becomes structurally impossible. Uses start() + async loop instead of pull() — Bun's ReadableStream doesn't reliably re-invoke pull() when the handler resolves without enqueuing (which happens while we're buffering deltas). 2. **JSON.parse for tool arg translation (src/claude-tools.ts).** translateToolArgsJsonString parses the assembled JSON, walks the object, and serializes back. Key renames on parsed objects can't corrupt string values that happen to contain key names (e.g. a TodoWrite item whose content literally says "activeForm", or a Bash command with "file_path=" inside a heredoc). Replaces every regex substitution from #11. 3. **Consolidated outbound translation (translateArgsOpencodeToClaude).** The inbound path is now a single function; the outbound path in index.ts body transform was a wall of nested if-blocks. Extracted to a sibling function so inbound/outbound stay in lockstep — next bug fix touches one place. ## What was carried forward from #11 All of upstream's tool-mapping fixes are preserved: - Agent: subagent_type required; general-purpose ↔ general; strip model/run_in_background/isolation inbound; strip task_id/command outbound; default subagent_type=general inbound - AskUserQuestion: bidirectional multiSelect ↔ multiple per question - Skill: skill ↔ name; strip args inbound - WebFetch: strip prompt inbound, inject default format=markdown; synthesize prompt from format outbound, strip timeout - Bash: strip run_in_background/dangerouslyDisableSandbox inbound - Read: strip pages inbound - Grep: strip output_mode/-B/-A/-C/context/-n/-i/type/head_limit/ offset/multiline inbound - TodoWrite: bidirectional activeForm ↔ priority; cancelled → completed outbound ## Other changes - context_management and output_config.effort only injected for thinking-capable models (opus, sonnet-4-6). Previously sent to haiku too, which 400s. - Silent try/catch {} blocks now log via debugLog() gated by OPENCODE_CLAUDE_BRIDGE_DEBUG=1. Diagnosable without source changes. - flush() logs a debug warning if a tool_use block is abandoned mid-stream (upstream disconnect between start and stop). ## Tests 81 unit tests (up from 11). Test file imports actual production modules (createSseProcessor from ./stream, translateToolArgsJsonString and translateArgsOpencodeToClaude from ./claude-tools) — not local reimplementations. Assertions parse SSE events and use assert.deepEqual on object shape rather than substring matches. Covers: - Every INBOUND_TOOL_NAME_MAP entry (name mapping) - Every tool's argument translation (including the corruption cases) - Chunk boundary splits (fragmented args, events across chunks) - Interleaved tool_use blocks (per-block state isolation) - Error paths (malformed JSON, translator throws, abandoned blocks) - Byte-exact pass-through for ping/message_start/text_delta - Round-trip symmetry between inbound/outbound translation - All inbound field stripping (Agent/Bash/Read/Grep/Skill) - All outbound field handling (Agent/AskUserQuestion/WebFetch/TodoWrite) ## Verified end-to-end via opencode run Read, Edit, Write, Grep, Glob, Bash, Agent, WebFetch, Skill — all working. WebSearch remains a stub (not in OpenCode's AVAILABLE_TOOLS). --- src/claude-tools.ts | 221 ++++++++-- src/index.test.ts | 951 +++++++++++++++++++++++++++++++++----------- src/index.ts | 346 ++++++---------- src/stream.ts | 231 +++++++++++ 4 files changed, 1253 insertions(+), 496 deletions(-) create mode 100644 src/stream.ts 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..1de7d07 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 ────────────────────────────────────────────────────────── @@ -159,6 +158,23 @@ type PluginClient = { const CLAUDE_PREFIX = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +/** + * Diagnostic logger gated by OPENCODE_CLAUDE_BRIDGE_DEBUG=1. + * + * The bridge defensively swallows errors in optional-feature paths + * (fingerprint, profile fetch, keychain bootstrap, OAuth browser open, + * body transform) so that a transient failure never breaks the actual + * request. Silence is fine for the happy path but kills diagnosability + * when a user reports "tools don't work" — set the env var to route + * those errors to stderr. + */ +const DEBUG = process.env.OPENCODE_CLAUDE_BRIDGE_DEBUG === "1"; +function debugLog(msg: string, err?: unknown): void { + if (!DEBUG) return; + const suffix = err instanceof Error ? `: ${err.message}` : err !== undefined ? `: ${String(err)}` : ""; + console.error(`[opencode-claude-bridge] ${msg}${suffix}`); +} + const oauthProfileCache = new Map>(); const SYSTEM_PROMPT_CACHE_PATH = process.env.ANTHROPIC_SYSTEM_PROMPT_PATH || join(process.env.HOME || "", ".cache", "opencode-claude-bridge", "claude-system-prompt.json"); @@ -191,11 +207,12 @@ async function refreshAuth( try { const kt = getClaudeTokens(); if (kt && kt.expires > Date.now() + 60_000) fresh = kt; - } catch {} + } catch (e) { debugLog("refreshAuth: keychain read failed", e); } // Layer 2: Stored refresh token if (!fresh && auth.refresh) { - try { fresh = refreshTokens(auth.refresh); } catch {} + try { fresh = refreshTokens(auth.refresh); } + catch (e) { debugLog("refreshAuth: stored refresh token failed", e); } } // Layer 3: CLI refresh token @@ -204,7 +221,7 @@ async function refreshAuth( const creds = readClaudeCredentials(); if (creds?.claudeAiOauth?.refreshToken) fresh = refreshTokens(creds.claudeAiOauth.refreshToken); - } catch {} + } catch (e) { debugLog("refreshAuth: CLI refresh token failed", e); } } if (fresh) { @@ -224,26 +241,29 @@ 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 (e) { debugLog(`openBrowser: ${name} failed`, e); } } } else if (process.platform === "win32") { - try { execFileSync("cmd", ["/c", "start", "", url], { timeout: 3000 }); } catch {} + try { execFileSync("cmd", ["/c", "start", "", url], { timeout: 3000 }); } + catch (e) { debugLog("openBrowser: cmd start failed", e); } } 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 (e) { debugLog(`openBrowser: ${name} failed`, e); } } } - })().catch(() => {}); + })().catch((e) => debugLog("openBrowser: IIFE rejected", e)); } /** Merge HeadersInit (Headers | string[][] | Record) onto a Headers object. */ @@ -284,34 +304,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; } @@ -358,7 +359,12 @@ function readCachedClaudePromptCache(): ClaudeSystemPromptCache | null { const parsed = JSON.parse(raw) as ClaudeSystemPromptCache; if (!Array.isArray(parsed.system) || parsed.system.length === 0) return null; return parsed; - } catch { + } catch (e) { + // File-not-found is the expected case on a fresh install — only log + // other errors (permissions, malformed JSON). + if (e instanceof Error && !e.message.includes("ENOENT")) { + debugLog("readCachedClaudePromptCache failed", e); + } return null; } } @@ -367,10 +373,13 @@ function getStableDeviceId(): string { let who = "unknown"; try { who = `${userInfo().username}@${hostname()}`; - } catch { + } catch (e) { + debugLog("getStableDeviceId: userInfo/hostname failed, falling back to env", e); try { who = `${process.env.USER || process.env.USERNAME || "unknown"}@${hostname()}`; - } catch {} + } catch (e2) { + debugLog("getStableDeviceId: env fallback failed, using 'unknown'", e2); + } } return createHash("sha256").update(who).digest("hex"); } @@ -409,7 +418,8 @@ async function fetchOAuthProfile(accessToken: string): Promise { try { const tokens = getClaudeTokens(); if (tokens) await storeAuth(client, tokens); - } catch {} + } catch (e) { debugLog("init: keychain bootstrap failed", e); } return { "experimental.chat.system.transform": ( @@ -476,7 +486,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { await storeAuth(client, tokens); auth = { type: "oauth", ...tokens }; } - } catch {} + } catch (e) { debugLog("loader: keychain auto-bootstrap failed", e); } } // API key mode — set the key in env and let the SDK handle everything @@ -558,13 +568,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 +650,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, @@ -714,7 +669,9 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { } body = JSON.stringify(parsed); - } catch {} + } catch (e) { + debugLog("fetch: body transform failed — request will go out unmodified", e); + } } // ── URL: add ?beta=true ── @@ -725,7 +682,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { : input instanceof URL ? input.toString() : input.url, ); - } catch {} + } catch (e) { debugLog("fetch: URL parse failed", e); } if (requestUrl?.pathname === "/messages") { requestUrl.pathname = "/v1/messages"; @@ -774,122 +731,43 @@ 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, + debug: (msg) => debugLog(`stream: ${msg}`), + }); 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 }; +} From 59d7f85ec7b4acc5518306a38e8a277167d92f7e Mon Sep 17 00:00:00 2001 From: Cody Moore Date: Thu, 16 Apr 2026 08:29:02 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Drop=20debugLog=20additions=20=E2=80=94=20k?= =?UTF-8?q?eep=20scope=20to=20tool=20arg=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip the OPENCODE_CLAUDE_BRIDGE_DEBUG debug logger and revert the catch sites it wired into back to silent catches. The SSE processor's optional debug hook in stream.ts stays in place (used by tests) but is no longer wired from index.ts. --- src/index.ts | 59 +++++++++++++--------------------------------------- 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1de7d07..901f8ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -158,23 +158,6 @@ type PluginClient = { const CLAUDE_PREFIX = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; -/** - * Diagnostic logger gated by OPENCODE_CLAUDE_BRIDGE_DEBUG=1. - * - * The bridge defensively swallows errors in optional-feature paths - * (fingerprint, profile fetch, keychain bootstrap, OAuth browser open, - * body transform) so that a transient failure never breaks the actual - * request. Silence is fine for the happy path but kills diagnosability - * when a user reports "tools don't work" — set the env var to route - * those errors to stderr. - */ -const DEBUG = process.env.OPENCODE_CLAUDE_BRIDGE_DEBUG === "1"; -function debugLog(msg: string, err?: unknown): void { - if (!DEBUG) return; - const suffix = err instanceof Error ? `: ${err.message}` : err !== undefined ? `: ${String(err)}` : ""; - console.error(`[opencode-claude-bridge] ${msg}${suffix}`); -} - const oauthProfileCache = new Map>(); const SYSTEM_PROMPT_CACHE_PATH = process.env.ANTHROPIC_SYSTEM_PROMPT_PATH || join(process.env.HOME || "", ".cache", "opencode-claude-bridge", "claude-system-prompt.json"); @@ -207,12 +190,12 @@ async function refreshAuth( try { const kt = getClaudeTokens(); if (kt && kt.expires > Date.now() + 60_000) fresh = kt; - } catch (e) { debugLog("refreshAuth: keychain read failed", e); } + } catch {} // Layer 2: Stored refresh token if (!fresh && auth.refresh) { try { fresh = refreshTokens(auth.refresh); } - catch (e) { debugLog("refreshAuth: stored refresh token failed", e); } + catch {} } // Layer 3: CLI refresh token @@ -221,7 +204,7 @@ async function refreshAuth( const creds = readClaudeCredentials(); if (creds?.claudeAiOauth?.refreshToken) fresh = refreshTokens(creds.claudeAiOauth.refreshToken); - } catch (e) { debugLog("refreshAuth: CLI refresh token failed", e); } + } catch {} } if (fresh) { @@ -248,11 +231,11 @@ function openBrowser(url: string): void { ]; for (const [name, attempt] of attempts) { try { attempt(); break; } - catch (e) { debugLog(`openBrowser: ${name} failed`, e); } + catch {} } } else if (process.platform === "win32") { try { execFileSync("cmd", ["/c", "start", "", url], { timeout: 3000 }); } - catch (e) { debugLog("openBrowser: cmd start failed", e); } + catch {} } else { const attempts: Array<[string, () => void]> = [ ["xdg-open", () => execFileSync("/usr/bin/xdg-open", [url], { timeout: 3000 })], @@ -260,10 +243,10 @@ function openBrowser(url: string): void { ]; for (const [name, attempt] of attempts) { try { attempt(); break; } - catch (e) { debugLog(`openBrowser: ${name} failed`, e); } + catch {} } } - })().catch((e) => debugLog("openBrowser: IIFE rejected", e)); + })().catch(() => {}); } /** Merge HeadersInit (Headers | string[][] | Record) onto a Headers object. */ @@ -359,12 +342,7 @@ function readCachedClaudePromptCache(): ClaudeSystemPromptCache | null { const parsed = JSON.parse(raw) as ClaudeSystemPromptCache; if (!Array.isArray(parsed.system) || parsed.system.length === 0) return null; return parsed; - } catch (e) { - // File-not-found is the expected case on a fresh install — only log - // other errors (permissions, malformed JSON). - if (e instanceof Error && !e.message.includes("ENOENT")) { - debugLog("readCachedClaudePromptCache failed", e); - } + } catch { return null; } } @@ -373,13 +351,10 @@ function getStableDeviceId(): string { let who = "unknown"; try { who = `${userInfo().username}@${hostname()}`; - } catch (e) { - debugLog("getStableDeviceId: userInfo/hostname failed, falling back to env", e); + } catch { try { who = `${process.env.USER || process.env.USERNAME || "unknown"}@${hostname()}`; - } catch (e2) { - debugLog("getStableDeviceId: env fallback failed, using 'unknown'", e2); - } + } catch {} } return createHash("sha256").update(who).digest("hex"); } @@ -418,8 +393,7 @@ async function fetchOAuthProfile(accessToken: string): Promise { try { const tokens = getClaudeTokens(); if (tokens) await storeAuth(client, tokens); - } catch (e) { debugLog("init: keychain bootstrap failed", e); } + } catch {} return { "experimental.chat.system.transform": ( @@ -486,7 +460,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { await storeAuth(client, tokens); auth = { type: "oauth", ...tokens }; } - } catch (e) { debugLog("loader: keychain auto-bootstrap failed", e); } + } catch {} } // API key mode — set the key in env and let the SDK handle everything @@ -669,9 +643,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { } body = JSON.stringify(parsed); - } catch (e) { - debugLog("fetch: body transform failed — request will go out unmodified", e); - } + } catch {} } // ── URL: add ?beta=true ── @@ -682,7 +654,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { : input instanceof URL ? input.toString() : input.url, ); - } catch (e) { debugLog("fetch: URL parse failed", e); } + } catch {} if (requestUrl?.pathname === "/messages") { requestUrl.pathname = "/v1/messages"; @@ -740,7 +712,6 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { const processor = createSseProcessor({ inboundToolNameMap: INBOUND_TOOL_NAME_MAP, translateToolArgs: translateToolArgsJsonString, - debug: (msg) => debugLog(`stream: ${msg}`), }); return new Response(