diff --git a/docs/opencode-serve-migration-plan.md b/docs/opencode-serve-migration-plan.md index 594b6ba0..92208fa9 100644 --- a/docs/opencode-serve-migration-plan.md +++ b/docs/opencode-serve-migration-plan.md @@ -58,6 +58,19 @@ Each of these cost time to find. None is in the API docs. 5. **The event names are `session.next.*`** — `step.started`, `text.started`, `text.ended`, `tool.called`, `tool.success`, `step.ended`, `step.failed`. Not the shapes `mapRunLine` handles. +## Status, 2026-08-10 + +- **Phase A — done.** `serve-events.ts` + tests, fixtures captured from real turns. +- **Phase B — done, behind `OPENCODE_TURNS=serve` (default: subprocess).** `serve-runner.ts`. Verified + end to end through the chat socket: tool call, tool result, **3 streaming deltas**, text, cost. Stop is + an interrupt and the session survives it. +- **Phase C — server half done.** A message sent while a turn runs is injected with `delivery: "steer"` + into the RUNNING turn, verified end to end. No client change was needed: officer's composer already + sends mid-turn, and the subprocess path was superseding where the serve steers. +- **Phase D — not started, deliberately.** + +Not yet lived with. Nothing here has run a real conversation with a person at the other end. + ## Shape of the work **Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index 7b7834a8..6160f79a 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -168,11 +168,39 @@ export async function runOpenCodeTurnOnServe( ensureEventStream(config); - // Supersede any turn still registered under this key. Unlike the subprocess path there is no process - // to kill — the serve owns execution — so this is bookkeeping only, and the old turn is retired - // silently rather than reporting an error against a key that now belongs to its replacement. - const stale = bySessionKey.get(sessionKey); - if (stale) retire(stale, null); + // ── A message arriving while a turn is running is an INJECTION, not a new turn ── + // + // This is where the subprocess and the serve genuinely part company. `opencode run` had no input + // channel, so a second message could only supersede: kill the process, start again, lose the turn. + // The serve takes another prompt into the RUNNING turn, so the right move is to hand it over and keep + // the existing turn exactly as it is. + // + // Keeping the same turn object is the load-bearing part. Retiring it and registering a replacement — + // which is what this did at first — stops officer routing the events the serve is still producing, + // while the serve carries on regardless. The output goes nowhere and the turn looks hung. + // + // `steer` because the user typed it during the turn and means it now; officer's composer already + // treats a send-while-generating as "add this to what you are doing". A prompt sent when nothing is + // running takes `queue`, which is a no-op with an empty queue but never accidentally merges two + // messages into one turn. + const live = bySessionKey.get(sessionKey); + if (live && !live.done) { + try { + await serveJson(config, `/api/session/${live.openCodeSessionId}/prompt`, { + method: 'POST', + body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'steer' }), + cwd, + }); + } catch (err) { + // The turn itself is unharmed — only the injection failed — so say so and leave it running. + emit({ + type: 'opencode:event', + sessionKey, + event: { type: 'error', message: `OpenCode would not take that mid-turn: ${errText(err)}` }, + }); + } + return; + } let openCodeSessionId = params.resumeSessionId ?? '';