From a058cbb3fdfa41ca0e8c3396f4bf9f652d57d6b8 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:17:26 +0100 Subject: [PATCH] inject a mid-turn message instead of replacing the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C server half, and a real flaw in phase B. On the subprocess path a second message could only supersede — kill the process, start again, lose the turn — because opencode run has no input channel. The serve takes another prompt into the running turn, so a message arriving mid-turn is handed over with delivery steer and the existing turn is left exactly as it is. Keeping the same turn object is the load-bearing part. Phase B retired it and registered a replacement, which stops officer routing events the serve is still producing while the serve carries on regardless: output goes nowhere and the turn looks hung. Verified end to end through the chat socket — sent a count to 50, injected a change of plan eight seconds in, and BANANA INJECTED came back inside the same turn with deltas streaming throughout. No client change was needed. Officer composer already sends while generating; the difference is only what the sidecar does with it. Co-Authored-By: Claude Opus 5 --- docs/opencode-serve-migration-plan.md | 13 +++++++ src/servers/sidecar/opencode/serve-runner.ts | 38 +++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) 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 ?? '';