Skip to content

Commit 4dec68c

Browse files
committed
Drain the mid-run queue on inference.done, not reactor.done
reactor.done only fires once, at agent shutdown, never between turns, so a plain-text reply with no tool calls left queued messages stranded forever (reproduced in the tool-less-turn regression test). inference.done fires on every reactor cycle regardless of what the director does next, so it also covers goal-governor and workflow continuations that never produce a settled connector.reply. Also replace the internal-state readout ("queue +1 -> pending N", the "queue" meta label repeated on the same row) with the queued message's own text and attachments, and add Ctrl+X to cancel the most recently queued item before it dispatches.
1 parent cf3bb84 commit 4dec68c

10 files changed

Lines changed: 169 additions & 10 deletions

src/tui-opentui/keybindings.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
439439
onInterrupt: () => {
440440
interrupted++
441441
},
442+
onCancelLast: () => {},
442443
exclusive: true,
443444
})
444445
setShellExitHandler(shell, () => {
@@ -454,6 +455,23 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
454455
},
455456
},
456457

458+
"Ctrl+X": {
459+
group: "session",
460+
probe: ({ h, shell, chords }) => {
461+
let cancelled = 0
462+
setShellBridgeHooks(shell, {
463+
onSubmit: () => {},
464+
onInterrupt: () => {},
465+
onCancelLast: () => {
466+
cancelled++
467+
},
468+
exclusive: true,
469+
})
470+
press(h, chords[0])
471+
expect(cancelled).toBe(1)
472+
},
473+
},
474+
457475
"Ctrl+D": {
458476
group: "host",
459477
// Probed on a mounted host rather than a bare shell: the row claims a
@@ -536,6 +554,7 @@ function recordSubmits(
536554
setShellBridgeHooks(shell, {
537555
onSubmit: (text, kind) => sent.push({ text, kind }),
538556
onInterrupt: () => {},
557+
onCancelLast: () => {},
539558
exclusive: true,
540559
})
541560
return sent

src/tui-opentui/keybindings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2323
{ keys: "Enter", description: "queue the message mid-run (badge); send straight through when idle" },
2424
{ keys: "Alt+Enter", description: "steer at the next tool boundary; does nothing unless a run is busy" },
2525
{ keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" },
26+
{ keys: "Ctrl+X", description: "cancel the most recently queued message, if any" },
2627
{ keys: "Ctrl+O", description: "open the command palette; press again to close it" },
2728
{ keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },
2829
{ keys: "Alt+M", description: "take the mouse for click-to-expand and drag-scroll; off by default so drag-select and copy work" },

src/tui-opentui/prompt-chrome.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe("bare exit / quit at the prompt", () => {
4545
setShellBridgeHooks(shell, {
4646
onSubmit: (text) => sent.push(text),
4747
onInterrupt: () => {},
48+
onCancelLast: () => {},
4849
exclusive: true,
4950
})
5051
setShellExitHandler(shell, () => {
@@ -66,6 +67,7 @@ describe("bare exit / quit at the prompt", () => {
6667
setShellBridgeHooks(shell, {
6768
onSubmit: (text) => sent.push(text),
6869
onInterrupt: () => {},
70+
onCancelLast: () => {},
6971
exclusive: true,
7072
})
7173
setShellExitHandler(shell, () => {
@@ -84,6 +86,7 @@ describe("bare exit / quit at the prompt", () => {
8486
setShellBridgeHooks(shell, {
8587
onSubmit: (text) => sent.push(text),
8688
onInterrupt: () => {},
89+
onCancelLast: () => {},
8790
exclusive: true,
8891
})
8992
shell.prompt.value = "exit"

src/tui-opentui/prompt-features.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ describe("image attachments", () => {
115115
setShellBridgeHooks(shell, {
116116
onSubmit: (_text, _kind, attachments) => seen.push(attachments),
117117
onInterrupt: () => {},
118+
onCancelLast: () => {},
118119
exclusive: true,
119120
})
120121
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }))
@@ -133,6 +134,7 @@ describe("image attachments", () => {
133134
setShellBridgeHooks(shell, {
134135
onSubmit: (text) => texts.push(text),
135136
onInterrupt: () => {},
137+
onCancelLast: () => {},
136138
exclusive: true,
137139
})
138140
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }))
@@ -164,6 +166,7 @@ describe("text paste", () => {
164166
setShellBridgeHooks(shell, {
165167
onSubmit: (text) => submitted.push(text),
166168
onInterrupt: () => {},
169+
onCancelLast: () => {},
167170
exclusive: true,
168171
})
169172
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }))
@@ -252,6 +255,7 @@ describe("sent-message recall", () => {
252255
setShellBridgeHooks(shell, {
253256
onSubmit: () => {},
254257
onInterrupt: () => {},
258+
onCancelLast: () => {},
255259
exclusive: true,
256260
})
257261
shell.prompt.value = "remember me"

src/tui-opentui/runtime-bridge.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,47 @@ describe("attachSessionBridge", () => {
214214
)
215215
})
216216

217+
test("queued item delivers on a tool-less turn (inference.done, no tool calls)", async () => {
218+
// Regression for CL-5563: reactor.done only fires once, at agent
219+
// shutdown, never between turns — a plain-text reply with no tool calls
220+
// must still drain the queue, or a queued message sits forever.
221+
await withTestRenderer(
222+
async (h) => {
223+
const shell = createAppShell(h.renderer, {
224+
terminal: { columns: 80, rows: 24 },
225+
wireKeys: false,
226+
run: "busy",
227+
})
228+
const port = createRecordingPort()
229+
const bridge = attachSessionBridge(shell, port)
230+
try {
231+
bridge.submit("follow up", "queue")
232+
expect(badgeCount(shell.session)).toBe(1)
233+
port.clear()
234+
bridge.handle({ type: "inference.start" })
235+
bridge.handle({
236+
type: "inference.text.delta",
237+
data: { token: "hi" },
238+
})
239+
bridge.handle({ type: "inference.done" })
240+
expect(badgeCount(shell.session)).toBe(0)
241+
const deliver = port.calls.find((c) => c.op === "deliver")
242+
expect(deliver).toEqual({
243+
op: "deliver",
244+
item: expect.objectContaining({
245+
text: "follow up",
246+
kind: "queue",
247+
}),
248+
})
249+
} finally {
250+
bridge.dispose()
251+
shell.dispose()
252+
}
253+
},
254+
{ width: 80, height: 24 },
255+
)
256+
})
257+
217258
test("token-by-token deltas grow one assistant row", async () => {
218259
await withTestRenderer(
219260
async (h) => {

src/tui-opentui/runtime-bridge.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "./session-queue.js"
1818
import {
1919
appendStreamRow,
20+
applyShellCancelLast,
2021
applyShellInterrupt,
2122
clearShellBridgeHooks,
2223
paintChrome,
@@ -800,10 +801,13 @@ export function attachSessionBridge(
800801
? enqueueSteer(shell.session, t, undefined, attachments)
801802
: enqueue(shell.session, t, "queue", undefined, attachments)
802803
bag.port.enqueue(t, kind)
804+
// Show the message itself, not the internal transition ("queue +1 →
805+
// pending N") — the notice row already carries the depth once, in plain
806+
// language, so this row's job is making the pending item identifiable.
803807
appendStreamRow(shell, {
804-
role: "system",
805-
text: `${kind} +1 → pending ${badgeCount(shell.session)}`,
806-
meta: "queue",
808+
role: "user",
809+
text: userRowText(t, attached),
810+
meta: kind === "steer" ? "steer" : "queue",
807811
})
808812
paintChrome(shell)
809813
}
@@ -821,6 +825,11 @@ export function attachSessionBridge(
821825
paintPhase()
822826
}
823827

828+
const doCancelLast = (): void => {
829+
if (bag.disposed) return
830+
applyShellCancelLast(shell)
831+
}
832+
824833
const tick = (): void => {
825834
if (bag.disposed) return
826835
const nowMs = now()
@@ -884,6 +893,7 @@ export function attachSessionBridge(
884893
onInterrupt: () => {
885894
doInterrupt()
886895
},
896+
onCancelLast: doCancelLast,
887897
exclusive: true,
888898
})
889899

src/tui-opentui/session-queue.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test"
22
import {
33
badgeCount,
4+
cancelLast,
45
clearInterruptFlash,
56
createSessionQueue,
67
drainOne,
@@ -51,6 +52,25 @@ describe("session-queue", () => {
5152
expect(d3.item?.text).toBe("q1")
5253
})
5354

55+
test("cancelLast retracts the most recently queued item, not necessarily the drain-order head", () => {
56+
let s = createSessionQueue("busy")
57+
s = enqueue(s, "q1")
58+
s = enqueueSteer(s, "s1")
59+
const c1 = cancelLast(s)
60+
expect(c1.item?.text).toBe("s1")
61+
expect(badgeCount(c1.state)).toBe(1)
62+
const c2 = cancelLast(c1.state)
63+
expect(c2.item?.text).toBe("q1")
64+
expect(badgeCount(c2.state)).toBe(0)
65+
})
66+
67+
test("cancelLast on an empty queue is a no-op", () => {
68+
const s = createSessionQueue("busy")
69+
const c = cancelLast(s)
70+
expect(c.item).toBeNull()
71+
expect(c.state).toBe(s)
72+
})
73+
5474
test("Ctrl+C interrupt clears pending + sets flash + idle", () => {
5575
let s = createSessionQueue("busy")
5676
s = enqueue(s, "a")

src/tui-opentui/session-queue.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ export function drainOrder(
121121
return [...steers, ...queues]
122122
}
123123

124+
/**
125+
* Cancel the most recently queued item (undo-last), so an operator who
126+
* queued the wrong message can retract it before it dispatches.
127+
*/
128+
export function cancelLast(
129+
state: SessionQueueState,
130+
): { state: SessionQueueState; item: QueueItem | null } {
131+
const item = state.items[state.items.length - 1] ?? null
132+
if (!item) return { state, item: null }
133+
return {
134+
state: { ...state, items: state.items.slice(0, -1) },
135+
item,
136+
}
137+
}
138+
124139
/** Pop next delivery item (steer-first). */
125140
export function drainOne(
126141
state: SessionQueueState,

src/tui-opentui/shell.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { stringWidth } from "../tui/view/height.js"
3232
import { listPathSuggestions } from "../tui/components/at-mention/list.js"
3333
import { parseAtState } from "../tui/components/at-mention/parse.js"
3434
import {
35+
formatAttachmentSummary,
3536
readClipboardImage,
3637
type ClipboardImageResult,
3738
type PendingImageAttachment,
@@ -151,6 +152,7 @@ import {
151152
} from "./copy-path.js"
152153
import {
153154
badgeCount,
155+
cancelLast,
154156
clearInterruptFlash,
155157
createSessionQueue,
156158
enqueue,
@@ -225,6 +227,8 @@ export type ShellBridgeHooks = {
225227
attachments?: readonly PendingImageAttachment[],
226228
) => void
227229
onInterrupt: () => void
230+
/** Cancel the most recently queued item; a no-op if the queue is empty. */
231+
onCancelLast: () => void
228232
exclusive: boolean
229233
}
230234

@@ -2501,6 +2505,16 @@ export function setShellRunState(shell: AppShell, run: RunState): void {
25012505
paintChrome(shell)
25022506
}
25032507

2508+
/** Transcript echo for a user message, annotated with its attachments. */
2509+
function userRowText(
2510+
text: string,
2511+
attachments: readonly PendingImageAttachment[],
2512+
): string {
2513+
const summary = formatAttachmentSummary(attachments)
2514+
if (summary.length === 0) return text
2515+
return text.length === 0 ? `[${summary}]` : `${text}\n[${summary}]`
2516+
}
2517+
25042518
/** Submit prompt as queue (busy) or immediate user send (idle). */
25052519
export function submitPrompt(
25062520
shell: AppShell,
@@ -2541,14 +2555,18 @@ export function submitPrompt(
25412555
}
25422556

25432557
shell.session =
2544-
kind === "steer" ? enqueueSteer(shell.session, t) : enqueue(shell.session, t)
2558+
kind === "steer"
2559+
? enqueueSteer(shell.session, t, undefined, attachments)
2560+
: enqueue(shell.session, t, "queue", undefined, attachments)
25452561
shell.prompt.value = ""
25462562
clearPendingAttachments(shell)
2547-
const tag = kind === "steer" ? "steer" : "queue"
2563+
// Show the message itself, not the internal transition ("queue +1 →
2564+
// pending N") — the notice row already carries the depth once, in plain
2565+
// language, so this row's job is making the pending item identifiable.
25482566
appendStreamRow(shell, {
2549-
role: "system",
2550-
text: `${tag} +1 → pending ${badgeCount(shell.session)}`,
2551-
meta: "queue",
2567+
role: "user",
2568+
text: userRowText(t, attachments),
2569+
meta: kind === "steer" ? "steer" : "queue",
25522570
})
25532571
paintChrome(shell)
25542572
}
@@ -2576,6 +2594,25 @@ export function interruptShell(shell: AppShell): void {
25762594
applyShellInterrupt(shell)
25772595
}
25782596

2597+
/** Local cancel-last mutation (no bridge re-entry). */
2598+
export function applyShellCancelLast(shell: AppShell): void {
2599+
const { state, item } = cancelLast(shell.session)
2600+
if (!item) return
2601+
shell.session = state
2602+
setStatusFlash(shell, `cancelled queued message: ${item.text || "(no text)"}`)
2603+
paintChrome(shell)
2604+
}
2605+
2606+
/** Ctrl+X: cancel the most recently queued message, if any. */
2607+
export function cancelLastQueued(shell: AppShell): void {
2608+
const hooks = getShellBridgeHooks(shell)
2609+
if (hooks?.exclusive) {
2610+
hooks.onCancelLast()
2611+
return
2612+
}
2613+
applyShellCancelLast(shell)
2614+
}
2615+
25792616
export function clearShellInterruptFlash(shell: AppShell): void {
25802617
shell.session = clearInterruptFlash(shell.session)
25812618
paintChrome(shell)
@@ -4800,6 +4837,12 @@ export function createAppShell(
48004837
return
48014838
}
48024839

4840+
if (key.ctrl && key.name === "x") {
4841+
key.preventDefault()
4842+
cancelLastQueued(shell)
4843+
return
4844+
}
4845+
48034846
if (
48044847
(key.name === "return" || key.name === "enter") &&
48054848
(key.meta || key.option) &&

src/tui-opentui/stream-event-map.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,11 @@ function mapEvent(
310310

311311
case "inference.done":
312312
// Cycle settled: disarm so a pre-commit retry belonging to the *next*
313-
// cycle cannot retract this one's rows.
314-
return disarmAttempt(ctx)
313+
// cycle cannot retract this one's rows. inference.done is the only
314+
// turn boundary the reactor guarantees per cycle (reactor.done fires
315+
// once, at agent shutdown, never between turns) — drain the mid-run
316+
// queue here so a text-only reply doesn't strand queued messages.
317+
return [...disarmAttempt(ctx), { type: "tool.boundary" }]
315318

316319
case "inference.retry": {
317320
const armed = ctx?.attemptArmed === true

0 commit comments

Comments
 (0)