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>
147 lines
5.1 KiB
TypeScript
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;
|
|
}
|
|
};
|
|
}
|