Skip to content

Commit fed83e5

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 f78c51b commit fed83e5

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
@@ -146,6 +146,7 @@ describe("image attachments", () => {
146146
setShellBridgeHooks(shell, {
147147
onSubmit: (_text, _kind, attachments) => seen.push(attachments),
148148
onInterrupt: () => {},
149+
onCancelLast: () => {},
149150
exclusive: true,
150151
})
151152
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }))
@@ -164,6 +165,7 @@ describe("image attachments", () => {
164165
setShellBridgeHooks(shell, {
165166
onSubmit: (text) => texts.push(text),
166167
onInterrupt: () => {},
168+
onCancelLast: () => {},
167169
exclusive: true,
168170
})
169171
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }))
@@ -195,6 +197,7 @@ describe("text paste", () => {
195197
setShellBridgeHooks(shell, {
196198
onSubmit: (text) => submitted.push(text),
197199
onInterrupt: () => {},
200+
onCancelLast: () => {},
198201
exclusive: true,
199202
})
200203
setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP }))
@@ -283,6 +286,7 @@ describe("sent-message recall", () => {
283286
setShellBridgeHooks(shell, {
284287
onSubmit: () => {},
285288
onInterrupt: () => {},
289+
onCancelLast: () => {},
286290
exclusive: true,
287291
})
288292
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,
@@ -152,6 +153,7 @@ import {
152153
} from "./copy-path.js"
153154
import {
154155
badgeCount,
156+
cancelLast,
155157
clearInterruptFlash,
156158
createSessionQueue,
157159
enqueue,
@@ -226,6 +228,8 @@ export type ShellBridgeHooks = {
226228
attachments?: readonly PendingImageAttachment[],
227229
) => void
228230
onInterrupt: () => void
231+
/** Cancel the most recently queued item; a no-op if the queue is empty. */
232+
onCancelLast: () => void
229233
exclusive: boolean
230234
}
231235

@@ -2568,6 +2572,16 @@ export function setShellRunState(shell: AppShell, run: RunState): void {
25682572
paintChrome(shell)
25692573
}
25702574

2575+
/** Transcript echo for a user message, annotated with its attachments. */
2576+
function userRowText(
2577+
text: string,
2578+
attachments: readonly PendingImageAttachment[],
2579+
): string {
2580+
const summary = formatAttachmentSummary(attachments)
2581+
if (summary.length === 0) return text
2582+
return text.length === 0 ? `[${summary}]` : `${text}\n[${summary}]`
2583+
}
2584+
25712585
/** Submit prompt as queue (busy) or immediate user send (idle). */
25722586
export function submitPrompt(
25732587
shell: AppShell,
@@ -2608,14 +2622,18 @@ export function submitPrompt(
26082622
}
26092623

26102624
shell.session =
2611-
kind === "steer" ? enqueueSteer(shell.session, t) : enqueue(shell.session, t)
2625+
kind === "steer"
2626+
? enqueueSteer(shell.session, t, undefined, attachments)
2627+
: enqueue(shell.session, t, "queue", undefined, attachments)
26122628
shell.prompt.value = ""
26132629
clearPendingAttachments(shell)
2614-
const tag = kind === "steer" ? "steer" : "queue"
2630+
// Show the message itself, not the internal transition ("queue +1 →
2631+
// pending N") — the notice row already carries the depth once, in plain
2632+
// language, so this row's job is making the pending item identifiable.
26152633
appendStreamRow(shell, {
2616-
role: "system",
2617-
text: `${tag} +1 → pending ${badgeCount(shell.session)}`,
2618-
meta: "queue",
2634+
role: "user",
2635+
text: userRowText(t, attachments),
2636+
meta: kind === "steer" ? "steer" : "queue",
26192637
})
26202638
paintChrome(shell)
26212639
}
@@ -2643,6 +2661,25 @@ export function interruptShell(shell: AppShell): void {
26432661
applyShellInterrupt(shell)
26442662
}
26452663

2664+
/** Local cancel-last mutation (no bridge re-entry). */
2665+
export function applyShellCancelLast(shell: AppShell): void {
2666+
const { state, item } = cancelLast(shell.session)
2667+
if (!item) return
2668+
shell.session = state
2669+
setStatusFlash(shell, `cancelled queued message: ${item.text || "(no text)"}`)
2670+
paintChrome(shell)
2671+
}
2672+
2673+
/** Ctrl+X: cancel the most recently queued message, if any. */
2674+
export function cancelLastQueued(shell: AppShell): void {
2675+
const hooks = getShellBridgeHooks(shell)
2676+
if (hooks?.exclusive) {
2677+
hooks.onCancelLast()
2678+
return
2679+
}
2680+
applyShellCancelLast(shell)
2681+
}
2682+
26462683
export function clearShellInterruptFlash(shell: AppShell): void {
26472684
shell.session = clearInterruptFlash(shell.session)
26482685
paintChrome(shell)
@@ -4880,6 +4917,12 @@ export function createAppShell(
48804917
return
48814918
}
48824919

4920+
if (key.ctrl && key.name === "x") {
4921+
key.preventDefault()
4922+
cancelLastQueued(shell)
4923+
return
4924+
}
4925+
48834926
if (
48844927
(key.name === "return" || key.name === "enter") &&
48854928
(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)