Skip to content

Commit f1e56e7

Browse files
committed
refactor: address second-pass review feedback
Four cleanup items from the second review: 1. Delete dead translateArgsSnakeToCamel — the new translateToolArgsJsonString inlined its logic on the JSON-string path. The exported function was imported but never called. Removed the export too; nothing else uses it. 2. Consolidate outbound translation into translateArgsOpencodeToClaude. The inbound path was cleanly centralized in translateToolArgsJsonString but the outbound path in index.ts was still a wall of inline if (block.name === …) branches. Now both directions are single-call, so the next bug fix touches one place. Adds a round-trip unit test to verify the two functions are proper inverses for translatable keys. 3. Skip JSON.parse + JSON.stringify for pass-through events in stream.ts. Previously every ping, message_start, message_delta, and text_delta got round-tripped "so tests can rely on uniformity" — that's tests dictating production behavior, and a measurable waste on the hot streaming path. peekType() does a cheap regex sniff of the "type" field; only content_block_* get the full parse. Adds tests asserting byte-exact passthrough for ping/message_start/text_delta. 4. flush() now logs a debug warning when a tool_use block is abandoned mid-stream (upstream disconnected after content_block_start + deltas but before content_block_stop). Previously silent — the consumer would see half a tool call with no args and nothing would explain why.
1 parent 41b725e commit f1e56e7

4 files changed

Lines changed: 217 additions & 62 deletions

File tree

src/claude-tools.ts

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -142,41 +142,46 @@ export const PARAM_CAMEL_TO_SNAKE: Record<string, Record<string, string>> = {
142142
};
143143

144144
/**
145-
* Translate tool_use arguments from Claude's snake_case to OpenCode's camelCase.
146-
* Returns a new object with translated keys.
145+
* Translate tool_use arguments from OpenCode's schema to Claude's schema.
146+
* Used on the outbound path (message history being sent back to the API).
147+
*
148+
* This is the counterpart to translateToolArgsJsonString — they must stay
149+
* in lockstep so round-trips preserve meaning.
147150
*/
148-
export function translateArgsSnakeToCamel(
151+
export function translateArgsOpencodeToClaude(
149152
toolName: string,
150153
args: Record<string, unknown>,
151154
): Record<string, unknown> {
152-
const map = PARAM_SNAKE_TO_CAMEL[toolName];
153-
if (!map || Object.keys(map).length === 0) return args;
154-
155-
const result: Record<string, unknown> = {};
156-
for (const [key, value] of Object.entries(args)) {
157-
const newKey = map[key] || key;
158-
result[newKey] = value;
155+
// 1. Top-level key renames (camelCase → snake_case)
156+
const map = PARAM_CAMEL_TO_SNAKE[toolName];
157+
let out: Record<string, unknown> = args;
158+
if (map && Object.keys(map).length > 0) {
159+
out = {};
160+
for (const [k, v] of Object.entries(args)) {
161+
out[map[k] || k] = v;
162+
}
159163
}
160-
return result;
161-
}
162164

163-
/**
164-
* Translate tool_use arguments from OpenCode's camelCase to Claude's snake_case.
165-
* Returns a new object with translated keys.
166-
*/
167-
export function translateArgsCamelToSnake(
168-
toolName: string,
169-
args: Record<string, unknown>,
170-
): Record<string, unknown> {
171-
const map = PARAM_CAMEL_TO_SNAKE[toolName];
172-
if (!map || Object.keys(map).length === 0) return args;
165+
// 2. Agent: translate OpenCode subagent_type values → Claude values
166+
if (toolName === "Agent" && typeof out.subagent_type === "string") {
167+
const mapped = AGENT_TYPE_OPENCODE_TO_CLAUDE[out.subagent_type];
168+
if (mapped) out.subagent_type = mapped;
169+
}
173170

174-
const result: Record<string, unknown> = {};
175-
for (const [key, value] of Object.entries(args)) {
176-
const newKey = map[key] || key;
177-
result[newKey] = value;
171+
// 3. TodoWrite: OpenCode { priority, status∈{…,cancelled} } → Claude { activeForm, status∈{pending,in_progress,completed} }
172+
if (toolName === "TodoWrite" && Array.isArray(out.todos)) {
173+
for (const item of out.todos as Array<Record<string, unknown>>) {
174+
if (item.priority && !item.activeForm) {
175+
item.activeForm = item.priority;
176+
delete item.priority;
177+
}
178+
if (item.status === "cancelled") {
179+
item.status = "completed";
180+
}
181+
}
178182
}
179-
return result;
183+
184+
return out;
180185
}
181186

182187
/**

src/index.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import assert from "node:assert/strict";
1212

1313
import {
1414
translateToolArgsJsonString,
15+
translateArgsOpencodeToClaude,
1516
AGENT_TYPE_CLAUDE_TO_OPENCODE,
1617
AGENT_TYPE_OPENCODE_TO_CLAUDE,
1718
} from "./claude-tools.js";
@@ -234,6 +235,56 @@ describe("translateToolArgsJsonString", () => {
234235
});
235236
});
236237

238+
// ── translateArgsOpencodeToClaude: outbound translation on parsed objects ───
239+
240+
describe("translateArgsOpencodeToClaude", () => {
241+
it("renames camelCase keys to snake_case for Edit", () => {
242+
const out = translateArgsOpencodeToClaude("Edit", {
243+
filePath: "/f.ts",
244+
oldString: "a",
245+
newString: "b",
246+
replaceAll: true,
247+
});
248+
assert.deepEqual(out, {
249+
file_path: "/f.ts",
250+
old_string: "a",
251+
new_string: "b",
252+
replace_all: true,
253+
});
254+
});
255+
256+
it("maps OpenCode subagent_type values back to Claude", () => {
257+
assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "general" }).subagent_type, "general-purpose");
258+
assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "build" }).subagent_type, "general-purpose");
259+
assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "explore" }).subagent_type, "Explore");
260+
assert.equal(translateArgsOpencodeToClaude("Agent", { subagent_type: "plan" }).subagent_type, "Plan");
261+
});
262+
263+
it("renames priority → activeForm in TodoWrite and collapses cancelled → completed", () => {
264+
const out = translateArgsOpencodeToClaude("TodoWrite", {
265+
todos: [
266+
{ content: "a", status: "pending", priority: "Doing a" },
267+
{ content: "b", status: "cancelled", priority: "Was doing b" },
268+
],
269+
});
270+
assert.deepEqual(out, {
271+
todos: [
272+
{ content: "a", status: "pending", activeForm: "Doing a" },
273+
{ content: "b", status: "completed", activeForm: "Was doing b" },
274+
],
275+
});
276+
});
277+
278+
it("is the inverse of translateToolArgsJsonString for the Edit round trip", () => {
279+
// Round-trip: Claude inbound → OpenCode → Claude outbound should be identity
280+
// for keys that have a bidirectional mapping.
281+
const claudeArgs = { file_path: "/f.ts", old_string: "a", new_string: "b" };
282+
const opencodeArgs = JSON.parse(translateToolArgsJsonString(JSON.stringify(claudeArgs), "Edit"));
283+
const roundTripped = translateArgsOpencodeToClaude("Edit", opencodeArgs);
284+
assert.deepEqual(roundTripped, claudeArgs);
285+
});
286+
});
287+
237288
// ── Agent type map integrity ─────────────────────────────────────────────────
238289

239290
describe("Agent type maps", () => {
@@ -587,6 +638,81 @@ describe("SSE processor: flush", () => {
587638
// And state is cleared after flush
588639
assert.equal(proc.flush(), "");
589640
});
641+
642+
it("logs a debug warning when a tool_use block is abandoned mid-stream", () => {
643+
// Simulates upstream disconnect after start + some deltas but before stop.
644+
const messages: string[] = [];
645+
const proc = createSseProcessor({
646+
inboundToolNameMap: INBOUND_TOOL_NAME_MAP,
647+
translateToolArgs: translateToolArgsJsonString,
648+
debug: (m) => messages.push(m),
649+
});
650+
proc.feedChunk(sseEvent("content_block_start", {
651+
type: "content_block_start",
652+
index: 1,
653+
content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} },
654+
}));
655+
proc.feedChunk(sseEvent("content_block_delta", {
656+
type: "content_block_delta",
657+
index: 1,
658+
delta: { type: "input_json_delta", partial_json: '{"file_path"' },
659+
}));
660+
// ...and then the stream ends without a content_block_stop.
661+
proc.flush();
662+
assert.ok(
663+
messages.some((m) => m.includes("abandoned") && m.includes("index=1") && m.includes("tool=Read")),
664+
`Expected abandoned-block warning, got: ${JSON.stringify(messages)}`,
665+
);
666+
});
667+
});
668+
669+
describe("SSE processor: pass-through optimization", () => {
670+
// Pass-through events (message_start, ping, message_delta, text
671+
// content_block_delta, etc.) should not be round-tripped through
672+
// JSON.parse + JSON.stringify — the output bytes should match the input
673+
// exactly when no translation is needed.
674+
it("emits the exact input bytes for ping events (no reserialize)", () => {
675+
const proc = makeProcessor();
676+
const pingFrame = sseEvent("ping", { type: "ping" });
677+
const out = proc.feedChunk(pingFrame);
678+
assert.equal(out, pingFrame);
679+
});
680+
681+
it("emits the exact input bytes for message_start events", () => {
682+
const proc = makeProcessor();
683+
// Anthropic message_start payloads include nested objects and arrays —
684+
// passthrough must preserve byte-for-byte equality, including any
685+
// particular key order the API happens to emit.
686+
const frame =
687+
"event: message_start\n" +
688+
'data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1}}}' +
689+
"\n\n";
690+
const out = proc.feedChunk(frame);
691+
assert.equal(out, frame);
692+
});
693+
694+
it("emits the exact input bytes for text_delta events", () => {
695+
const proc = makeProcessor();
696+
const frame = sseEvent("content_block_delta", {
697+
type: "content_block_delta",
698+
index: 0,
699+
delta: { type: "text_delta", text: "hello world" },
700+
});
701+
const out = proc.feedChunk(frame);
702+
assert.equal(out, frame);
703+
});
704+
705+
it("still transforms tool_use content_block_start (optimization does not skip interesting events)", () => {
706+
const proc = makeProcessor();
707+
const input = sseEvent("content_block_start", {
708+
type: "content_block_start",
709+
index: 1,
710+
content_block: { type: "tool_use", id: "tu_1", name: "Read", input: {} },
711+
});
712+
const out = proc.feedChunk(input);
713+
assert.notEqual(out, input, "tool_use start should be transformed (name mapped)");
714+
assert.equal(toolUseStartName(out, 1), "read");
715+
});
590716
});
591717

592718
describe("parseSseEvent / buildSseEvent round trip", () => {

src/index.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,8 @@ import {
2626
} from "./constants.js";
2727
import {
2828
getClaudeTools,
29-
translateArgsSnakeToCamel,
30-
translateArgsCamelToSnake,
29+
translateArgsOpencodeToClaude,
3130
translateToolArgsJsonString,
32-
AGENT_TYPE_OPENCODE_TO_CLAUDE,
3331
computeFingerprint,
3432
extractFirstUserMessageText,
3533
} from "./claude-tools.js";
@@ -615,34 +613,16 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
615613
}
616614
if (block.type === "tool_use" && block.name) {
617615
block.name = mapOutboundToolName(block.name);
618-
// Translate arguments from OpenCode's camelCase to Claude's snake_case
616+
// Consolidated OpenCode → Claude argument translation:
617+
// key renames, Agent subagent_type values, TodoWrite
618+
// field/status mapping. Mirrors the inbound
619+
// translateToolArgsJsonString so round-trips preserve
620+
// meaning.
619621
if (block.input && typeof block.input === "object") {
620-
block.input = translateArgsCamelToSnake(
622+
block.input = translateArgsOpencodeToClaude(
621623
block.name,
622624
block.input as Record<string, unknown>,
623625
);
624-
// Agent: translate OpenCode subagent_type values → Claude values
625-
if (block.name === "Agent" && typeof (block.input as Record<string, unknown>).subagent_type === "string") {
626-
const cur = (block.input as Record<string, unknown>).subagent_type as string;
627-
if (AGENT_TYPE_OPENCODE_TO_CLAUDE[cur]) {
628-
(block.input as Record<string, unknown>).subagent_type = AGENT_TYPE_OPENCODE_TO_CLAUDE[cur];
629-
}
630-
}
631-
// TodoWrite: translate OpenCode fields → Claude fields
632-
// OpenCode: { content, status, priority } with status ∈ {pending, in_progress, completed, cancelled}
633-
// Claude: { content, status, activeForm } with status ∈ {pending, in_progress, completed}
634-
if (block.name === "TodoWrite" && Array.isArray((block.input as Record<string, unknown>).todos)) {
635-
for (const item of (block.input as Record<string, unknown>).todos as Array<Record<string, unknown>>) {
636-
if (item.priority && !item.activeForm) {
637-
item.activeForm = item.priority;
638-
delete item.priority;
639-
}
640-
// Map "cancelled" → "completed" (Claude doesn't have cancelled)
641-
if (item.status === "cancelled") {
642-
item.status = "completed";
643-
}
644-
}
645-
}
646626
}
647627
}
648628
// tool_result blocks reference tool names via tool_use_id,

src/stream.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ export function buildSseEvent(event: string | null, data: string): string {
4040
return (event ? `event: ${event}\n` : "") + `data: ${data}\n\n`;
4141
}
4242

43+
/**
44+
* Cheap extraction of the `type` field from an SSE data JSON string
45+
* without parsing the whole payload. Used to decide whether an event is
46+
* interesting enough to round-trip through JSON.parse + JSON.stringify.
47+
* Returns null if the type can't be determined from the first few
48+
* properties — caller should fall back to a full parse in that case.
49+
*/
50+
function peekType(data: string): string | null {
51+
// Match the first top-level "type": "..." occurrence. Doesn't handle
52+
// escaped quotes in the value, but Anthropic type names are plain
53+
// identifiers so that's fine. If anything weird happens, return null
54+
// and force a full parse.
55+
const m = data.match(/^\s*\{\s*"type"\s*:\s*"([a-z_]+)"/);
56+
return m ? m[1] : null;
57+
}
58+
4359
export type StreamProcessorDeps = {
4460
/**
4561
* Map from Claude's tool name (e.g. "Read") to OpenCode's tool name
@@ -80,11 +96,28 @@ export function createSseProcessor(deps: StreamProcessorDeps) {
8096
const toolBlocks = new Map<number, ToolBlockState>();
8197
let sseBuffer = "";
8298

99+
// Event types that require peeking into the JSON to decide whether/how to
100+
// mutate. Every other event type is a pure pass-through — no need to
101+
// re-serialize, just forward the original bytes.
102+
const INTERESTING_TYPES = new Set([
103+
"content_block_start",
104+
"content_block_delta",
105+
"content_block_stop",
106+
]);
107+
83108
function processEvent(block: string): string {
84109
const parsed = parseSseEvent(block);
85110
if (!parsed) return block; // comment/keepalive line — pass through
86111
const { event, data } = parsed;
87112

113+
// Cheap sniff: if the event isn't one we care about, forward the raw
114+
// frame. Saves a JSON.parse + JSON.stringify round trip on every
115+
// message_start, ping, message_delta, and text_delta.
116+
const cheapType = peekType(data);
117+
if (cheapType !== null && !INTERESTING_TYPES.has(cheapType)) {
118+
return block;
119+
}
120+
88121
let obj: unknown;
89122
try {
90123
obj = JSON.parse(data);
@@ -94,7 +127,7 @@ export function createSseProcessor(deps: StreamProcessorDeps) {
94127
}
95128

96129
if (obj === null || typeof obj !== "object") {
97-
return buildSseEvent(event, JSON.stringify(obj));
130+
return block;
98131
}
99132
const rec = obj as Record<string, any>;
100133
const type = rec.type as string | undefined;
@@ -128,7 +161,7 @@ export function createSseProcessor(deps: StreamProcessorDeps) {
128161
state.partialJson += String(rec.delta.partial_json ?? "");
129162
return "";
130163
}
131-
return buildSseEvent(event, JSON.stringify(rec));
164+
return block;
132165
}
133166

134167
// content_block_stop: if this closed a tool_use block, flush a synthetic
@@ -152,16 +185,15 @@ export function createSseProcessor(deps: StreamProcessorDeps) {
152185
};
153186
return (
154187
buildSseEvent("content_block_delta", JSON.stringify(synthDelta)) +
155-
buildSseEvent(event, JSON.stringify(rec))
188+
block
156189
);
157190
}
158-
return buildSseEvent(event, JSON.stringify(rec));
191+
return block;
159192
}
160193

161-
// Everything else (message_start, ping, text deltas, etc.): pass through.
162-
// We still round-trip through JSON.parse/stringify so every event goes
163-
// through the same path — tests can rely on this.
164-
return buildSseEvent(event, JSON.stringify(rec));
194+
// Interesting type we didn't end up mutating (e.g. a non-tool_use
195+
// content_block_start, or a text_delta inside a content_block_delta).
196+
return block;
165197
}
166198

167199
function feedChunk(text: string): string {
@@ -180,6 +212,18 @@ export function createSseProcessor(deps: StreamProcessorDeps) {
180212
function flush(): string {
181213
const remaining = sseBuffer;
182214
sseBuffer = "";
215+
if (toolBlocks.size > 0) {
216+
// The upstream disconnected mid-tool_use without sending
217+
// content_block_stop, so we never emitted a synthetic translated
218+
// delta for these blocks. The consumer will see the start but no
219+
// completed args. Surface it so the operator isn't debugging a
220+
// ghost.
221+
const orphaned = Array.from(toolBlocks.entries()).map(
222+
([idx, s]) => `index=${idx} tool=${s.toolName} bufferedBytes=${s.partialJson.length}`,
223+
);
224+
deps.debug?.(`flush: ${toolBlocks.size} tool_use block(s) abandoned mid-stream: ${orphaned.join("; ")}`);
225+
toolBlocks.clear();
226+
}
183227
return remaining;
184228
}
185229

0 commit comments

Comments
 (0)