inject a mid-turn message instead of replacing the turn

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:17:26 +01:00
co-authored by Claude Opus 5
parent a06422bd4c
commit a058cbb3fd
2 changed files with 46 additions and 5 deletions
+13
View File
@@ -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`, 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. `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 ## Shape of the work
**Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing **Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing
+33 -5
View File
@@ -168,11 +168,39 @@ export async function runOpenCodeTurnOnServe(
ensureEventStream(config); ensureEventStream(config);
// Supersede any turn still registered under this key. Unlike the subprocess path there is no process // ── A message arriving while a turn is running is an INJECTION, not a new turn ──
// 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. // This is where the subprocess and the serve genuinely part company. `opencode run` had no input
const stale = bySessionKey.get(sessionKey); // channel, so a second message could only supersede: kill the process, start again, lose the turn.
if (stale) retire(stale, null); // 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 ?? ''; let openCodeSessionId = params.resumeSessionId ?? '';