finish phase 1: correct the stale comments, and put the ndjson mapping under test

Path names: the serve's cwd is DATA_PATH/opencode_server, not opencode-sidecar. Two comments said
otherwise and would send the next reader to a directory that does not exist.

Version pin: the comment claimed "verified live against 1.17.9" as though that were a property of the
code. It is a property of whichever binary is installed, and this project already runs two — 1.17.9 here,
1.18.11 on the other machine. Says so now, and points at the test as the thing that actually enforces it.

Tests, the first on the OpenCode path. `runner.ts`'s NDJSON → ChatEvent mapping was described as pure and
untested; it was untested but not pure — it lived inside `handleLine` as a closure over `emit`, the
accumulated cost and a reported-session flag, so it could not be called without spawning a binary.

Extracted as `mapRunLine`, genuinely pure: line in, {sessionId, events, costDelta} out. The two concerns
that span lines stay with the caller, because they are not properties of a line — emitting the session id
exactly once, and accumulating cost across steps. Behaviour is unchanged.

11 tests over what the mapping forwards, what it drops and what it must not turn into NaN. The last one
matters: a missing `cost` on a step_finish would otherwise propagate NaN into the turn total.

Phase 1 is complete: dead code deleted (previous commit), comments corrected, tests added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 02:58:11 +00:00
co-authored by Claude Opus 5
parent d7b223127a
commit 8b409e8af8
4 changed files with 208 additions and 52 deletions
+95 -50
View File
@@ -35,7 +35,12 @@ type RunHandle = { proc: Subprocess; killedByUser: boolean };
// One turn per sessionKey; a new turn supersedes any stale process for that key.
const running = new Map<string, RunHandle>();
// Shape of `opencode run --format json` events (verified live against 1.17.9).
// Shape of `opencode run --format json` events.
//
// Verified live against opencode 1.17.9 (this server) and reported working on 1.18.11 elsewhere. Nothing
// enforces either — the binary is whatever is installed on the machine, and two machines in this project
// already differ. `runner.test.ts` pins the mapping itself so a shape change fails a test rather than a
// turn; if it starts failing, re-read the NDJSON from the installed binary before editing the test.
type RunPart = {
type?: string;
text?: string;
@@ -158,60 +163,22 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
})();
function handleLine(line: string): void {
let evt: RunEvent;
try {
evt = JSON.parse(line) as RunEvent;
} catch {
return; // non-JSON log line
}
const mapped = mapRunLine(line);
if (!mapped) return;
// Report the OpenCode session id once, so the API can resume it (`--session`) next turn.
if (!reportedSession && evt.sessionID) {
if (!reportedSession && mapped.sessionId) {
reportedSession = true;
emit({ type: 'opencode:session', sessionKey, sessionId: evt.sessionID });
emit({ type: 'opencode:session', sessionKey, sessionId: mapped.sessionId });
}
switch (evt.type) {
case 'text': {
// `run` emits complete text parts (not token deltas) — forward each as final text.
const text = evt.part?.text;
if (typeof text === 'string' && text.length > 0) emitEvent({ type: 'text', text });
return;
}
case 'tool_use': {
// In `run`, the tool part arrives already resolved (status + output). Emit start then result.
const part = evt.part;
if (!part || part.type !== 'tool' || !part.callID) return;
const st = part.state ?? {};
emitEvent({
type: 'tool:start',
toolCallId: part.callID,
toolName: part.tool ?? 'tool',
toolInput: (st.input as Record<string, unknown>) ?? {},
});
const isError = st.status === 'error';
emitEvent({
type: 'tool:result',
toolCallId: part.callID,
output: String((isError ? st.error : st.output) ?? ''),
isError,
});
return;
}
case 'step_finish': {
// Accumulate per-step tokens/cost into the turn's MessageCost.
const part = evt.part;
const t = part?.tokens;
cost = {
inputTokens: cost.inputTokens + (t?.input ?? 0),
outputTokens: cost.outputTokens + (t?.output ?? 0),
totalUSD: cost.totalUSD + (typeof part?.cost === 'number' ? part.cost : 0),
};
return;
}
default:
return; // step_start etc. — nothing to forward
}
cost = {
inputTokens: cost.inputTokens + mapped.costDelta.inputTokens,
outputTokens: cost.outputTokens + mapped.costDelta.outputTokens,
totalUSD: cost.totalUSD + mapped.costDelta.totalUSD,
};
for (const event of mapped.events) emitEvent(event);
}
// ── Completion: process exit is the authoritative turn-done signal ──
@@ -244,3 +211,81 @@ export function killOpenCodeTurn(sessionKey: string): void {
}
// proc.exited fires → finish({ type: 'stopped' }).
}
// ── The NDJSON → ChatEvent mapping, as a pure function ──
//
// Extracted from `handleLine` so it can be tested without spawning a binary. This is the piece most
// likely to break against a new OpenCode release — the event shape is not a stable contract and this
// project already runs two different versions across two machines — and it was the only untested part
// of the path. `runner.test.ts` pins it.
//
// Pure by construction: it takes a line and returns what should happen, holding no state. The caller
// owns the two stateful concerns, because they span lines rather than belonging to one: emitting the
// session id exactly once, and accumulating cost across steps.
export type MappedRunLine = {
/** Present on any event that names a session; the caller emits it only the first time. */
sessionId?: string;
/** Events to forward, in order. A tool part yields `tool:start` then `tool:result`. */
events: ChatEvent[];
/** Per-step tokens/cost to add to the turn total. Zeroes for every non-`step_finish` line. */
costDelta: { inputTokens: number; outputTokens: number; totalUSD: number };
};
const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
/** `null` for a line that is not JSON at all — `opencode` interleaves plain log lines with the stream. */
export function mapRunLine(line: string): MappedRunLine | null {
let evt: RunEvent;
try {
evt = JSON.parse(line) as RunEvent;
} catch {
return null; // non-JSON log line
}
const sessionId = evt.sessionID;
const events: ChatEvent[] = [];
switch (evt.type) {
case 'text': {
// `run` emits complete text parts (not token deltas) — forward each as final text.
const text = evt.part?.text;
if (typeof text === 'string' && text.length > 0) events.push({ type: 'text', text });
return { sessionId, events, costDelta: NO_COST };
}
case 'tool_use': {
// In `run`, the tool part arrives already resolved (status + output). Emit start then result.
const part = evt.part;
if (!part || part.type !== 'tool' || !part.callID) return { sessionId, events, costDelta: NO_COST };
const st = part.state ?? {};
events.push({
type: 'tool:start',
toolCallId: part.callID,
toolName: part.tool ?? 'tool',
toolInput: (st.input as Record<string, unknown>) ?? {},
});
const isError = st.status === 'error';
events.push({
type: 'tool:result',
toolCallId: part.callID,
output: String((isError ? st.error : st.output) ?? ''),
isError,
});
return { sessionId, events, costDelta: NO_COST };
}
case 'step_finish': {
const t = evt.part?.tokens;
return {
sessionId,
events,
costDelta: {
inputTokens: t?.input ?? 0,
outputTokens: t?.output ?? 0,
totalUSD: typeof evt.part?.cost === 'number' ? evt.part.cost : 0,
},
};
}
default:
return { sessionId, events, costDelta: NO_COST }; // step_start etc. — nothing to forward
}
}