Files
platform/src/servers/api/chat/opencode/event-mapper.ts
T
pastilhasandClaude Opus 4.8 ad32c7516e chat: add OpenCode as a second harness — live turn (Phase 1)
Introduces an OpenCode chat harness alongside Claude, driven over HTTP + SSE against
a persistent `opencode serve`, emitting the same ChatEvent contract so the entire
chat UI and createEventHandler pipeline are unchanged.

New servers/api/chat/opencode/:
- server-manager.ts — one warm `opencode serve` per cwd (free port, health-gated,
  respawn on exit; HOME set so it reads the user's ~/.local/share/opencode auth).
  Binary pinned via OPENCODE_BIN (installed is 1.17.9; the 1.18.4 upgrade never landed).
- client.ts — per-server HTTP calls (/session create, /message, /abort) + a single
  reconnecting `/event` SSE stream demuxed to per-session listeners.
- event-mapper.ts — SSE → ChatEvent. Verified live against 1.17.9: message.part.delta
  → delta, tool parts → tool:start/tool:result, message.updated → cost, session.idle
  → result. Crucially, deltas are gated on partID being a `text` part (declared before
  its deltas) so the model's reasoning — which also streams as field:'text' — is
  dropped, matching the Claude harness hiding thinking.
- state.ts — sessionKey ↔ opencode ses_ id map for resume.

channels/send-opencode.ts — the OpenCode analog of send-claude-code: ensure serve,
create/reuse session, subscribe, post the message, forward mapped events; kill = abort.

websocket.ts — replaces the Claude-only coercion with harness routing:
provider 'claude-code' → Claude sidecar, everything else → handleOpenCodeChat.
handleStop aborts the right harness.

Verified end-to-end (streaming text, tool call/result, cost, abort) against a
throwaway serve using the free deepseek model — no prod restart involved. UI-level
model selection + session history follow in Phases 2–3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:29:55 +00:00

141 lines
5.0 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).
// createEventHandler flushes the assistant text buffer on tool:start and result, so no explicit `text`
// event is needed — the streamed answer 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;
}
};
}