From 8db93ec188e98fff1a49079e8c197698857247d8 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 8 Jun 2026 20:22:51 -0700 Subject: [PATCH 1/2] fix: map opencode thinking events --- README.md | 2 +- src/anthropic.mjs | 41 +++++++++++++++ test/anthropic.test.mjs | 114 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 test/anthropic.test.mjs diff --git a/README.md b/README.md index 4ab83b0..b3fce36 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ Server-Sent Events. Frames are `event: \ndata: \n\n`. Anthropic even | Event | Data | | --- | --- | | `agent.message` | `{content:[{type:"text",text}], model}` | -| `agent.thinking` | reasoning delta | +| `agent.thinking` | `{thinking, content:[{type:"thinking",text}], model}` | | `agent.tool_use` | tool call | | `agent.tool_result` | tool result | | `session.status_running` | session became active | diff --git a/src/anthropic.mjs b/src/anthropic.mjs index cd657f8..59cf22f 100644 --- a/src/anthropic.mjs +++ b/src/anthropic.mjs @@ -99,6 +99,8 @@ export function translateOpencodeEvent(raw, ctx) { // skipped: it fires for the echoed user message and again as the final // assistant duplicate, so emitting it would double-send and echo input. case "message.part.delta": { + const thinking = thinkingText(props); + if (thinking) return thinkingEvent(thinking, ctx.model); const text = props.delta?.text || (typeof props.delta === "string" ? props.delta : "") || @@ -128,6 +130,16 @@ export function translateOpencodeEvent(raw, ctx) { } return null; } + case "agent.thinking": + case "agent.reasoning": + case "thinking": + case "thinking_delta": + case "reasoning": + case "reasoning-delta": { + const thinking = thinkingText(props, { allowBareDelta: true }); + if (!thinking) return null; + return thinkingEvent(thinking, ctx.model); + } case "session.status": { const status = props.status?.type; if (status === "busy") { @@ -171,3 +183,32 @@ export function translateOpencodeEvent(raw, ctx) { } } } + +function thinkingText(props, { allowBareDelta = false } = {}) { + const partType = props.part?.type; + const isThinkingPart = partType === "thinking" || partType === "reasoning"; + return ( + props.text || + props.thinking || + props.reasoning || + props.delta?.thinking || + props.delta?.reasoning || + (isThinkingPart && props.delta?.text) || + (isThinkingPart && typeof props.delta === "string" ? props.delta : "") || + (allowBareDelta && typeof props.delta === "string" ? props.delta : "") || + props.part?.thinking || + props.part?.reasoning || + "" + ); +} + +function thinkingEvent(thinking, model) { + return { + event: "agent.thinking", + data: { + thinking, + content: [{ type: "thinking", text: thinking }], + model: model || null, + }, + }; +} diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs new file mode 100644 index 0000000..1bba5b7 --- /dev/null +++ b/test/anthropic.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { translateOpencodeEvent } from "../src/anthropic.mjs"; + +const ctx = { sessionId: "ses_123", model: "claude-sonnet-4-6" }; + +test("message deltas still translate to agent.message", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "ses_123", + delta: { text: "hello" }, + }, + }, + ctx, + ), + { + event: "agent.message", + data: { + content: [{ type: "text", text: "hello" }], + model: "claude-sonnet-4-6", + }, + }, + ); +}); + +test("reasoning part deltas translate to agent.thinking", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "ses_123", + part: { type: "reasoning" }, + delta: { text: "I should inspect the code." }, + }, + }, + ctx, + ), + { + event: "agent.thinking", + data: { + thinking: "I should inspect the code.", + content: [{ type: "thinking", text: "I should inspect the code." }], + model: "claude-sonnet-4-6", + }, + }, + ); +}); + +test("thinking delta events translate to agent.thinking", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "thinking_delta", + properties: { + sessionID: "ses_123", + delta: { thinking: "Need a minimal patch." }, + }, + }, + ctx, + ), + { + event: "agent.thinking", + data: { + thinking: "Need a minimal patch.", + content: [{ type: "thinking", text: "Need a minimal patch." }], + model: "claude-sonnet-4-6", + }, + }, + ); +}); + +test("reasoning delta strings translate to agent.thinking", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "reasoning-delta", + properties: { + sessionID: "ses_123", + delta: "Try the narrow fix first.", + }, + }, + ctx, + ), + { + event: "agent.thinking", + data: { + thinking: "Try the narrow fix first.", + content: [{ type: "thinking", text: "Try the narrow fix first." }], + model: "claude-sonnet-4-6", + }, + }, + ); +}); + +test("events for another session are dropped", () => { + assert.equal( + translateOpencodeEvent( + { + type: "thinking_delta", + properties: { + sessionID: "ses_other", + delta: { thinking: "not this session" }, + }, + }, + ctx, + ), + null, + ); +}); From 190a086237799c1f38ccec5b4053d65f8bc8fbec Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 8 Jun 2026 20:34:29 -0700 Subject: [PATCH 2/2] fix: map opencode tool results --- src/anthropic.mjs | 82 +++++++++++++++++++++++++++------- test/anthropic.test.mjs | 99 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 15 deletions(-) diff --git a/src/anthropic.mjs b/src/anthropic.mjs index 59cf22f..11ee5a2 100644 --- a/src/anthropic.mjs +++ b/src/anthropic.mjs @@ -119,14 +119,7 @@ export function translateOpencodeEvent(raw, ctx) { // Text updates are skipped (deltas already streamed them). const part = props.part || {}; if (part.type === "tool" || part.tool) { - return { - event: "agent.tool_use", - data: { - tool: part.tool ?? null, - input: part.state?.input ?? null, - status: part.state?.status ?? null, - }, - }; + return toolPartEvent(part, ctx); } return null; } @@ -171,19 +164,78 @@ export function translateOpencodeEvent(raw, ctx) { props.part?.type === "tool" || (typeof raw.type === "string" && raw.type.includes("tool")); if (isTool) { - return { - event: "agent.tool_use", - data: { - tool: props.part?.tool ?? null, - input: props.part?.state?.input ?? null, - }, - }; + return toolPartEvent(props.part || props, ctx); } return null; } } } +function toolPartEvent(part, ctx) { + const id = toolPartId(part, ctx); + const name = part.tool || part.name || "tool"; + const state = part.state || {}; + const status = state.status || part.status || null; + const rawInput = state.input ?? part.input; + const input = status === "pending" && isEmptyObject(rawInput) ? undefined : rawInput; + const output = state.output ?? state.result ?? part.output ?? part.result; + const error = state.error ?? part.error; + + if (status === "completed" || error != null || output != null) { + const data = { + tool_use_id: id, + name, + tool: name, + content: toolResultContent(output, error), + }; + if (output !== undefined) data.output = output; + if (error !== undefined) data.error = error; + return { + event: "agent.tool_result", + data, + }; + } + + const data = { + id, + name, + tool: name, + status, + }; + if (input !== undefined) data.input = input; + return { + event: "agent.tool_use", + data, + }; +} + +function toolPartId(part, ctx) { + return ( + part.id || + part.toolCallID || + part.tool_call_id || + part.callID || + part.messageID || + `${ctx.sessionId || "session"}:${part.tool || part.name || "tool"}` + ); +} + +function toolResultContent(output, error) { + const value = error ?? output ?? ""; + if (Array.isArray(value)) return value; + if (typeof value === "string") return [{ type: "text", text: value }]; + return [{ type: "json", json: value }]; +} + +function isEmptyObject(value) { + return ( + value != null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0 + ); +} + function thinkingText(props, { allowBareDelta = false } = {}) { const partType = props.part?.type; const isThinkingPart = partType === "thinking" || partType === "reasoning"; diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs index 1bba5b7..265dc8a 100644 --- a/test/anthropic.test.mjs +++ b/test/anthropic.test.mjs @@ -97,6 +97,105 @@ test("reasoning delta strings translate to agent.thinking", () => { ); }); +test("pending tool updates include stable id and name without empty input", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "message.part.updated", + properties: { + sessionID: "ses_123", + part: { + id: "part_tool_1", + type: "tool", + tool: "sandbox_exec", + state: { + status: "pending", + input: {}, + }, + }, + }, + }, + ctx, + ), + { + event: "agent.tool_use", + data: { + id: "part_tool_1", + name: "sandbox_exec", + tool: "sandbox_exec", + status: "pending", + }, + }, + ); +}); + +test("running tool updates include the current input", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "message.part.updated", + properties: { + sessionID: "ses_123", + part: { + id: "part_tool_1", + type: "tool", + tool: "sandbox_exec", + state: { + status: "running", + input: { command: "echo \"hello world\"" }, + }, + }, + }, + }, + ctx, + ), + { + event: "agent.tool_use", + data: { + id: "part_tool_1", + name: "sandbox_exec", + tool: "sandbox_exec", + input: { command: "echo \"hello world\"" }, + status: "running", + }, + }, + ); +}); + +test("completed tool updates translate to agent.tool_result with output", () => { + assert.deepEqual( + translateOpencodeEvent( + { + type: "message.part.updated", + properties: { + sessionID: "ses_123", + part: { + id: "part_tool_1", + type: "tool", + tool: "sandbox_exec", + state: { + status: "completed", + input: { command: "echo \"hello world\"" }, + output: "hello world\n", + }, + }, + }, + }, + ctx, + ), + { + event: "agent.tool_result", + data: { + tool_use_id: "part_tool_1", + name: "sandbox_exec", + tool: "sandbox_exec", + content: [{ type: "text", text: "hello world\n" }], + output: "hello world\n", + }, + }, + ); +}); + test("events for another session are dropped", () => { assert.equal( translateOpencodeEvent(