Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/tui-opentui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,116 @@ describe("attachSessionBridge", () => {
)
})

test("queued item delivers on a tool-less turn (inference.done, no tool calls)", async () => {
// Regression for CL-5563: reactor.done only fires once, at agent
// shutdown, never between turns — a plain-text reply with no tool calls
// must still drain the queue, or a queued message sits forever.
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
const port = createRecordingPort()
const bridge = attachSessionBridge(shell, port)
try {
bridge.submit("follow up", "queue")
expect(badgeCount(shell.session)).toBe(1)
port.clear()
bridge.handle({ type: "inference.start" })
bridge.handle({
type: "inference.text.delta",
data: { token: "hi" },
})
bridge.handle({ type: "inference.done" })
expect(badgeCount(shell.session)).toBe(0)
const deliver = port.calls.find((c) => c.op === "deliver")
expect(deliver).toEqual({
op: "deliver",
item: expect.objectContaining({
text: "follow up",
kind: "queue",
}),
})
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("run and the phase ramp both return to idle after a tool-less inference.done, with no connector.reply", async () => {
// Regression: a goal-governor / workflow cycle that keeps self-continuing
// may never emit connector.reply, the only other event that clears
// `run` and the turn's `isProcessing`. Without this, every future Enter
// resolves to "queue" (busy is sticky) and, once the workflow stops
// producing cycles, that queued message is never drained — CL-5563's
// bug moved one layer over. The ramp indicator has the same failure
// mode: it reads `isProcessing`, not `run`, so it can say "working"
// forever even once dispatch itself is fixed.
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
const port = createRecordingPort()
const bridge = attachSessionBridge(shell, port)
try {
bridge.handle({ type: "inference.start" })
bridge.handle({
type: "inference.text.delta",
data: { token: "hi" },
})
bridge.handle({ type: "inference.done" })
expect(shell.session.run).toBe("idle")
expect(shell.turnPhase).toBeNull()

port.clear()
bridge.submit("are you still there", "queue")
expect(port.calls).toEqual([
{ op: "sendImmediate", text: "are you still there" },
])
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("run stays busy after inference.done while a tool call is still outstanding", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
const port = createRecordingPort()
const bridge = attachSessionBridge(shell, port)
try {
bridge.handle({ type: "inference.start" })
bridge.handle({
type: "inference.tool_call.start",
data: { call: { id: "c1", name: "bash" } },
})
bridge.handle({ type: "inference.done" })
expect(shell.session.run).toBe("busy")
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("token-by-token deltas grow one assistant row", async () => {
await withTestRenderer(
async (h) => {
Expand Down
32 changes: 15 additions & 17 deletions src/tui-opentui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
streamRowAt,
streamRowCount,
truncateStreamRows,
userRowText,
type AppShell,
} from "./shell.js"
import { rampFor, rampLine } from "./ramp.js"
Expand All @@ -55,10 +56,7 @@ import {
turnStateOnSubmit,
type TurnState,
} from "./turn-state.js"
import {
formatAttachmentSummary,
type PendingImageAttachment,
} from "../tui/image-attachments.js"
import type { PendingImageAttachment } from "../tui/image-attachments.js"
import { toolCallRow } from "./diff.js"
import { toolResultRow } from "./mcp-view.js"
import {
Expand All @@ -68,16 +66,6 @@ import {
} from "./tool-rows.js"
import type { StreamRow } from "./stream.js"
import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js"

/** Transcript echo for a user message, annotated with its attachments. */
function userRowText(
text: string,
attachments: readonly PendingImageAttachment[],
): string {
const summary = formatAttachmentSummary(attachments)
if (summary.length === 0) return text
return text.length === 0 ? `[${summary}]` : `${text}\n[${summary}]`
}
import {
PRODUCTION_REACTOR_TYPES,
createStreamMapContext,
Expand Down Expand Up @@ -764,6 +752,13 @@ export function attachSessionBridge(
)) {
applyInbound(shell, bag, mapped)
}
// inference.done with tool calls still outstanding doesn't settle the
// turn (see turn-state.ts) — the cycle continues, but a boundary still
// passed, so a queued message waiting on it should not wait for the
// turn's eventual end too.
if (event.type === "inference.done" && bag.turn.activeToolCalls.length > 0) {
drainAtBoundary(shell, bag)
}
if (settled) settleRun()
return
}
Expand Down Expand Up @@ -800,10 +795,13 @@ export function attachSessionBridge(
? enqueueSteer(shell.session, t, undefined, attachments)
: enqueue(shell.session, t, "queue", undefined, attachments)
bag.port.enqueue(t, kind)
// Show the message itself, not the internal transition ("queue +1 →
// pending N") — the notice row already carries the depth once, in plain
// language, so this row's job is making the pending item identifiable.
appendStreamRow(shell, {
role: "system",
text: `${kind} +1 → pending ${badgeCount(shell.session)}`,
meta: "queue",
role: "user",
text: userRowText(t, attached),
meta: kind === "steer" ? "steer" : "queue",
})
paintChrome(shell)
}
Expand Down
25 changes: 20 additions & 5 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { stringWidth } from "../tui/view/height.js"
import { listPathSuggestions } from "../tui/components/at-mention/list.js"
import { parseAtState } from "../tui/components/at-mention/parse.js"
import {
formatAttachmentSummary,
readClipboardImage,
type ClipboardImageResult,
type PendingImageAttachment,
Expand Down Expand Up @@ -2761,6 +2762,16 @@ export function setShellRunState(shell: AppShell, run: RunState): void {
paintChrome(shell)
}

/** Transcript echo for a user message, annotated with its attachments. */
export function userRowText(
text: string,
attachments: readonly PendingImageAttachment[],
): string {
const summary = formatAttachmentSummary(attachments)
if (summary.length === 0) return text
return text.length === 0 ? `[${summary}]` : `${text}\n[${summary}]`
}

/** Submit prompt as queue (busy) or immediate user send (idle). */
export function submitPrompt(
shell: AppShell,
Expand Down Expand Up @@ -2801,14 +2812,18 @@ export function submitPrompt(
}

shell.session =
kind === "steer" ? enqueueSteer(shell.session, t) : enqueue(shell.session, t)
kind === "steer"
? enqueueSteer(shell.session, t, undefined, attachments)
: enqueue(shell.session, t, "queue", undefined, attachments)
shell.prompt.value = ""
clearPendingAttachments(shell)
const tag = kind === "steer" ? "steer" : "queue"
// Show the message itself, not the internal transition ("queue +1 →
// pending N") — the notice row already carries the depth once, in plain
// language, so this row's job is making the pending item identifiable.
appendStreamRow(shell, {
role: "system",
text: `${tag} +1 → pending ${badgeCount(shell.session)}`,
meta: "queue",
role: "user",
text: userRowText(t, attachments),
meta: kind === "steer" ? "steer" : "queue",
})
paintChrome(shell)
}
Expand Down
24 changes: 24 additions & 0 deletions src/tui-opentui/turn-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ describe("turnStateFromEvent", () => {
expect(s.isProcessing).toBe(false)
})

test("inference.done with no active tool calls settles the turn", () => {
// Regression for CL-5563/CL-5570: a workflow/goal-governor cycle that
// keeps self-continuing may never emit connector.reply, the usual
// terminator. Without settling here too, isProcessing (and the "working"
// ramp it drives) stays true forever once nothing else arrives.
const s = fold([
{ type: "inference.start" },
{ type: "inference.text.delta" },
{ type: "inference.done" },
])
expect(s.status).toBe("done")
expect(s.isProcessing).toBe(false)
})

test("inference.done with a tool call still outstanding does not settle", () => {
const s = fold([
{ type: "inference.start" },
{ type: "inference.tool_call.end", data: { name: "bash" } },
{ type: "inference.done" },
])
expect(s.isProcessing).toBe(true)
expect(s.status).toBe("running")
})

test("activity clock advances with every event", () => {
const s = fold([{ type: "inference.start" }, { type: "inference.text.delta" }])
expect(s.lastActivityAt).toBe(2)
Expand Down
33 changes: 25 additions & 8 deletions src/tui-opentui/turn-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,19 +285,36 @@ export function turnStateFromEvent(
),
}

/**
* A cycle with no active tool calls left is also a turn's real
* terminator: `connector.reply` (below) is the usual signal, but a
* workflow/goal-governor cycle that keeps self-continuing may never
* emit one, and `reactor.done` fires once at shutdown, never between
* turns. Without settling here, the phase line stays hot ("working")
* forever once nothing more arrives. A cycle that just requested tools
* only ends here, not the turn — those calls are already reflected in
* `activeToolCalls` (streamed before `inference.done`).
*/
case "inference.done":
if (state.activeToolCalls.length > 0) {
return {
...state,
awaitingResponse: false,
streamingType: null,
lastActivityAt: nowMs,
}
}
return {
...state,
awaitingResponse: false,
streamingType: null,
lastActivityAt: nowMs,
...initialTurnState(nowMs),
status: "done",
quota: state.quota,
}

/**
* The turn's real terminator. `agent.send()` resolves on connector.reply,
* and a chat session emits no `reactor.done` until it closes — so without
* this the phase line would stay hot for the rest of the session. A reply
* with tools still outstanding only ends the cycle, not the turn.
* The other turn terminator: `agent.send()` resolves on connector.reply,
* and for the ordinary case above `inference.done` already settled the
* turn a beat earlier, so this is a harmless idempotent re-settle. A
* reply with tools still outstanding only ends the cycle, not the turn.
*/
case "connector.reply":
if (state.activeToolCalls.length > 0) {
Expand Down
Loading