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:
@@ -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
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user