Skip to content

Commit db708a2

Browse files
committed
Live-clock in-flight tool rows and clear the stale stall notice
An ordinary tool call's row sat on a static pending mark for the whole call, indistinguishable from a hung turn once a slow tool ran for minutes. Give it the same live elapsed clock Task rows already carry, skipping calls whose row already states its own fact (a diff's +/- count) and dropping the clock once the answer lands. The "no response for a while" notice had no ttl by design (it must stay up for as long as the silence lasts) but nothing ever cleared it when the run started producing again, so it lingered on screen after the turn was visibly alive. Clear it on the next tick once the stall level drops back to quiet. Fixes CL-6894 https://linear.app/abklabs/issue/CL-6894
1 parent 04b767b commit db708a2

3 files changed

Lines changed: 183 additions & 2 deletions

File tree

src/tui/runtime-bridge.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,101 @@ describe("syncAgentProgress", () => {
944944
})
945945
})
946946

947+
describe("in-flight tool row elapsed time", () => {
948+
test("an ordinary pending call's row grows a live clock, then loses it to the answer", async () => {
949+
await withTestRenderer(
950+
async (h) => {
951+
const shell = createAppShell(h.renderer, {
952+
terminal: { columns: 80, rows: 24 },
953+
wireKeys: false,
954+
run: "busy",
955+
})
956+
let nowMs = 0
957+
let tick: (() => void) | undefined
958+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
959+
now: () => nowMs,
960+
schedule: (fn) => {
961+
tick = fn
962+
return () => {
963+
tick = undefined
964+
}
965+
},
966+
})
967+
try {
968+
bridge.handle({ type: "inference.start", data: {} })
969+
bridge.handle({
970+
type: "inference.tool_call.end",
971+
data: { name: "run_shell", callId: "c1", arguments: "sleep 30" },
972+
})
973+
const index = streamRowCount(shell) - 1
974+
expect(shell.streamLog[index]!.stat).toBeUndefined()
975+
976+
nowMs = 65_000
977+
tick?.()
978+
expect(shell.streamLog[index]!.stat).toBe("1:05")
979+
980+
bridge.handle({
981+
type: "tool.done",
982+
data: { result: { callId: "c1", name: "run_shell", content: "ok", isError: false } },
983+
})
984+
// The elapsed clock was scaffolding for the wait, not a fact worth
985+
// keeping — the answer's own addendum takes the row over.
986+
expect(shell.streamLog[index]!.stat).not.toBe("1:05")
987+
} finally {
988+
bridge.dispose()
989+
shell.dispose()
990+
}
991+
},
992+
{ width: 80, height: 24 },
993+
)
994+
})
995+
996+
test("a diff call keeps its own +/- stat instead of an elapsed clock", async () => {
997+
await withTestRenderer(
998+
async (h) => {
999+
const shell = createAppShell(h.renderer, {
1000+
terminal: { columns: 80, rows: 24 },
1001+
wireKeys: false,
1002+
run: "busy",
1003+
})
1004+
let nowMs = 0
1005+
let tick: (() => void) | undefined
1006+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
1007+
now: () => nowMs,
1008+
schedule: (fn) => {
1009+
tick = fn
1010+
return () => {
1011+
tick = undefined
1012+
}
1013+
},
1014+
})
1015+
try {
1016+
bridge.handle({ type: "inference.start", data: {} })
1017+
bridge.handle({
1018+
type: "inference.tool_call.end",
1019+
data: {
1020+
name: "write_file",
1021+
callId: "c1",
1022+
arguments: JSON.stringify({ path: "a.txt", content: "hi\n" }),
1023+
},
1024+
})
1025+
const index = streamRowCount(shell) - 1
1026+
const before = shell.streamLog[index]!.stat
1027+
expect(before).toContain("+")
1028+
1029+
nowMs = 65_000
1030+
tick?.()
1031+
expect(shell.streamLog[index]!.stat).toBe(before)
1032+
} finally {
1033+
bridge.dispose()
1034+
shell.dispose()
1035+
}
1036+
},
1037+
{ width: 80, height: 24 },
1038+
)
1039+
})
1040+
})
1041+
9471042
describe("task checklist calls stay out of the transcript", () => {
9481043
test("a manage_tasks call and its result paint no rows", async () => {
9491044
await withTestRenderer(

src/tui/runtime-bridge.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import type { StreamRow } from "./stream.js"
7373
import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js"
7474
import {
7575
agentProgress,
76+
clockLabel,
7677
fleetProgress,
7778
type AgentProgressSession,
7879
} from "./agent-progress.js"
@@ -356,6 +357,12 @@ type BridgeBag = {
356357
now: () => number
357358
/** Transcript row each in-flight call occupies, so its result can resolve it. */
358359
toolRows: Map<string, number>
360+
/**
361+
* When each in-flight ordinary tool call started, so its row can carry a
362+
* live elapsed clock instead of sitting on a static pending mark for the
363+
* length of a slow call — the one case a healthy turn reads as dead.
364+
*/
365+
toolCallStartedAt: Map<string, number>
359366
/** Row of the newest in-flight call, for results that carry no call id. */
360367
lastToolRow: number
361368
/**
@@ -564,7 +571,15 @@ function applyToolCall(
564571
} else {
565572
appendStreamRow(shell, row)
566573
}
567-
if (event.callId !== undefined) bag.toolRows.set(event.callId, index)
574+
if (event.callId !== undefined) {
575+
bag.toolRows.set(event.callId, index)
576+
// A diff call's row already carries a "+n/-n" stat — that is the fact
577+
// worth keeping, not an elapsed clock, so only ordinary calls (no stat of
578+
// their own) pick up the live timer.
579+
if (row.stat === undefined) {
580+
bag.toolCallStartedAt.set(event.callId, bag.now())
581+
}
582+
}
568583
if (event.callId !== undefined && event.name === TASK_TOOL_NAME) {
569584
bag.taskCallIds.add(event.callId)
570585
}
@@ -590,13 +605,20 @@ function applyToolResult(
590605
})
591606
const tracked =
592607
event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined
608+
// The elapsed clock was scaffolding for the wait, not a fact about the
609+
// call — clear it before the merge so it never crowds out the answer's own
610+
// addendum (e.g. "3 lines") the way a diff's own +/- count is allowed to.
611+
const clockOwned =
612+
event.callId !== undefined && bag.toolCallStartedAt.has(event.callId)
593613
if (event.callId !== undefined) {
594614
bag.toolRows.delete(event.callId)
615+
bag.toolCallStartedAt.delete(event.callId)
595616
bag.taskCallIds.delete(event.callId)
596617
}
597618
if (bag.toolRows.size === 0) shell.inFlightTool = null
598619
const index = tracked ?? bag.lastToolRow
599-
const call = streamRowAt(shell, index)
620+
const rawCall = streamRowAt(shell, index)
621+
const call = clockOwned && rawCall !== undefined ? omitStat(rawCall) : rawCall
600622
if (call === undefined || call.pending !== true) {
601623
appendStreamRow(shell, result)
602624
return
@@ -642,6 +664,40 @@ function syncAgentProgress(
642664
}
643665
}
644666

667+
/** Drop `stat` entirely rather than set it `undefined` (exactOptionalPropertyTypes). */
668+
function omitStat(row: StreamRow): StreamRow {
669+
const { stat: _stat, ...rest } = row
670+
return rest
671+
}
672+
673+
/**
674+
* Refresh every plain in-flight tool call's row with how long it has been
675+
* running. A `task` dispatch already gets this (and more) from
676+
* `syncAgentProgress`, so those calls are skipped here rather than double
677+
* painted. Without a live clock an ordinary call's row sits on a static
678+
* pending mark for however long the tool takes — indistinguishable from a
679+
* hung turn once that stretches past a few seconds.
680+
*/
681+
function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
682+
if (bag.toolCallStartedAt.size === 0) return
683+
for (const [callId, startedAt] of bag.toolCallStartedAt) {
684+
if (bag.taskCallIds.has(callId)) continue
685+
const index = bag.toolRows.get(callId)
686+
if (index === undefined) {
687+
bag.toolCallStartedAt.delete(callId)
688+
continue
689+
}
690+
const row = streamRowAt(shell, index)
691+
if (row === undefined || row.pending !== true) {
692+
bag.toolCallStartedAt.delete(callId)
693+
continue
694+
}
695+
const stat = clockLabel(nowMs - startedAt)
696+
if (row.stat === stat) continue
697+
replaceStreamRowAt(shell, index, { ...row, stat })
698+
}
699+
}
700+
645701
/**
646702
* Retract everything the failed attempt painted, then forget the row
647703
* bookkeeping that pointed into it — a rolled-back tool call has no row left
@@ -655,6 +711,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void {
655711
for (const [callId, index] of [...bag.toolRows]) {
656712
if (index >= boundary) {
657713
bag.toolRows.delete(callId)
714+
bag.toolCallStartedAt.delete(callId)
658715
bag.taskCallIds.delete(callId)
659716
}
660717
}
@@ -802,6 +859,7 @@ export function attachSessionBridge(
802859
quotaFired: false,
803860
now,
804861
toolRows: new Map(),
862+
toolCallStartedAt: new Map(),
805863
lastToolRow: -1,
806864
taskCallIds: new Set(),
807865
agentSessions: [],
@@ -878,6 +936,7 @@ export function attachSessionBridge(
878936
if (bag.openRow !== null && bag.openRow.kind === "thinking") {
879937
advanceOpenReveal(shell, bag.openRow, nowMs)
880938
}
939+
syncToolElapsed(shell, bag, nowMs)
881940
const input = {
882941
isProcessing: turn.isProcessing,
883942
status: turn.status,
@@ -1162,6 +1221,11 @@ export function attachSessionBridge(
11621221
const level = stallLevel(stallArgs)
11631222
if (level === "notice") {
11641223
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
1224+
} else if (shell.statusFlash === STALL_NOTICE_MESSAGE) {
1225+
// Activity resumed after the notice was posted: it carries no ttl (it
1226+
// must stay up for as long as the silence lasts), so nothing else would
1227+
// ever take it down once the run starts producing again.
1228+
setStatusFlash(shell, null)
11651229
}
11661230

11671231
// Same "is this stalled at all" question `paintPhase` asks above — call

src/tui/turn-monitor.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,28 @@ describe("stall watchdog", () => {
326326
})
327327
})
328328

329+
test("clears the notice once activity resumes, rather than leaving it up", async () => {
330+
await withTestRenderer(async (h) => {
331+
const t: Harness = await setup(h)
332+
try {
333+
t.bridge.submit("build it", "immediate")
334+
t.port.clear()
335+
336+
t.advance(500)
337+
t.tick()
338+
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)
339+
340+
// The model starts producing again — the notice must not linger past
341+
// the silence it was reporting.
342+
t.bridge.handle({ type: "inference.text.delta", data: { token: "ok" } })
343+
t.tick()
344+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
345+
} finally {
346+
t.bridge.dispose()
347+
}
348+
})
349+
})
350+
329351
test("aborts and flashes once a mid-stream hang crosses the stall timeout", async () => {
330352
await withTestRenderer(async (h) => {
331353
const t: Harness = await setup(h)

0 commit comments

Comments
 (0)