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