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>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user