Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ Server-Sent Events. Frames are `event: <type>\ndata: <json>\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 |
Expand Down
123 changes: 108 additions & 15 deletions src/anthropic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +102 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve reasoning deltas as thinking events

When using OpenCode with reasoning-capable models, the real message.part.delta events carry {sessionID, messageID, partID, field, delta} and do not include part.type; the preceding message.part.updated contains the reasoning part. Because this check only recognizes props.part.type, reasoning deltas fall through to the normal text path and are emitted as agent.message instead of agent.thinking, leaking/thinning the newly documented thinking stream for those models.

Useful? React with 👍 / 👎.

const text =
props.delta?.text ||
(typeof props.delta === "string" ? props.delta : "") ||
Expand All @@ -117,17 +119,20 @@ 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;
}
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") {
Expand Down Expand Up @@ -159,15 +164,103 @@ 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";
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,
},
};
}
213 changes: 213 additions & 0 deletions test/anthropic.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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("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(
{
type: "thinking_delta",
properties: {
sessionID: "ses_other",
delta: { thinking: "not this session" },
},
},
ctx,
),
null,
);
});