Files
platform/src/servers/api/chat/opencode/event-mapper.ts
T
pastilhasandClaude Opus 4.8 71e39b7639 move opencode's turn output into its own sidecar
The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.

Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log
verbatim: translate, commit, then deliver the finished message with its cursor
id as `opencode:message`. Officer folds it into the in-memory transcript and
relays it, exactly as it now does for claude — `createEventHandler` (166 lines,
a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in
officer writes to chat_session_events any more.

`opencode:event` stops being a wire event; it is the runner's internal report to
the sidecar it runs in, typed as such so it cannot leak back onto the socket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:18:09 +00:00

147 lines
5.1 KiB
TypeScript

import type { ChatEvent, MessageCost } from '../types';
// Translates OpenCode's SSE events into our internal ChatEvent union — the OpenCode analog of
// sidecar/claude/stream-parser.ts. Shapes verified live against opencode 1.17.9's `GET /event`:
//
// message.part.updated { part:{ id, type } } → learn partID→type
// message.part.delta { partID, delta } → delta (only if partID is a text part)
// message.part.updated { part:{ type:'tool', callID, tool, state } } → tool:start / tool:result
// message.updated { info:{ role:'assistant', cost, tokens } } → stash final cost
// session.idle → result (turn complete)
// session.error → error
//
// IMPORTANT: deltas always carry `field:'text'` even for the model's *reasoning* — reasoning and answer
// are only distinguishable by the delta's part TYPE (a message.part.updated declaring the part as
// `reasoning` vs `text` always precedes that part's deltas). So we gate deltas on partID being a `text`
// part; reasoning-part deltas are dropped (parity with the Claude harness, which hides thinking).
// The turn translator (`sidecar/claude/turn-stream.ts`, shared by both harnesses) flushes the assistant
// text buffer on tool:start and result, so no explicit `text` event is needed — the deltas are enough.
export type OpenCodeEvent = {
id?: string;
type: string;
properties?: Record<string, unknown>;
};
type ToolState = {
status?: string;
input?: Record<string, unknown>;
output?: string;
error?: string;
};
type OpenCodePart = {
id?: string;
type?: string;
callID?: string;
tool?: string;
field?: string;
state?: ToolState;
};
type AssistantInfo = {
role?: string;
cost?: number;
tokens?: { input?: number; output?: number };
};
/**
* Returns a stateful handler that maps raw OpenCode events to ChatEvents, invoking `onEvent` for each.
* State tracks per-callID tool progress (to emit start/result exactly once) and the latest assistant
* cost/tokens (emitted with the terminal `result`).
*/
export function createEventMapper(onEvent: (event: ChatEvent) => void) {
const toolStarted = new Set<string>();
const toolFinished = new Set<string>();
const partTypes = new Map<string, string>(); // partID → type (declared before that part's deltas)
let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
let done = false;
const finish = (event: ChatEvent) => {
if (done) return;
done = true;
onEvent(event);
};
return function handle(evt: OpenCodeEvent): void {
const p = evt.properties ?? {};
switch (evt.type) {
case 'message.part.delta': {
// Only stream deltas belonging to a `text` part — reasoning parts also emit field:'text' deltas.
const partID = p.partID as string | undefined;
if (partID && partTypes.get(partID) === 'text' && typeof p.delta === 'string' && p.delta.length > 0) {
onEvent({ type: 'delta', text: p.delta });
}
return;
}
case 'message.part.updated': {
const part = p.part as OpenCodePart | undefined;
if (!part) return;
if (part.id && part.type) partTypes.set(part.id, part.type);
if (part.type !== 'tool' || !part.callID) return;
const callID = part.callID;
const status = part.state?.status;
if ((status === 'running' || status === 'completed' || status === 'error') && !toolStarted.has(callID)) {
toolStarted.add(callID);
onEvent({
type: 'tool:start',
toolCallId: callID,
toolName: part.tool ?? 'tool',
toolInput: part.state?.input ?? {},
});
}
if (status === 'completed' && !toolFinished.has(callID)) {
toolFinished.add(callID);
onEvent({
type: 'tool:result',
toolCallId: callID,
output: String(part.state?.output ?? ''),
isError: false,
});
} else if (status === 'error' && !toolFinished.has(callID)) {
toolFinished.add(callID);
onEvent({
type: 'tool:result',
toolCallId: callID,
output: String(part.state?.error ?? 'Tool failed'),
isError: true,
});
}
return;
}
case 'message.updated': {
const info = (p.info as AssistantInfo | undefined) ?? (p as AssistantInfo) ?? {};
if (info.role === 'assistant' && info.tokens) {
cost = {
inputTokens: info.tokens.input ?? 0,
outputTokens: info.tokens.output ?? 0,
totalUSD: info.cost ?? 0,
};
}
return;
}
case 'session.idle': {
finish({ type: 'result', cost });
return;
}
case 'session.error': {
const error = p.error;
const message =
typeof error === 'string' ? error : ((error as { message?: string })?.message ?? 'OpenCode error');
finish({ type: 'error', message });
return;
}
default:
return;
}
};
}