From dcff30dfd408eaa5db637f3b83bb02a20d25a108 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:59:25 -0700 Subject: [PATCH 1/3] Add closed-set test for the status ticker's activity state The ticker currently renders whatever raw tool identifier is executing. This test pins the fix: the rendered label must always be a member of a small closed set of activity states, never a tool, MCP server, or plugin name, and stalled/waiting-on-operator must render distinctly. --- src/tui-opentui/session-chrome.test.ts | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/tui-opentui/session-chrome.test.ts b/src/tui-opentui/session-chrome.test.ts index 12f67eed9..da32bf233 100644 --- a/src/tui-opentui/session-chrome.test.ts +++ b/src/tui-opentui/session-chrome.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { + ACTIVITY_STATES, classifyAgentSendFailure, classifySendFailureMessage, resolveRampPhase, @@ -9,6 +10,76 @@ import { shouldSettleUiAfterSendFailure, } from "./session-chrome.js" +// The load-bearing guarantee: whatever tool identifier, MCP server name, or +// plugin name the runtime hands us, the rendered ticker string must land in +// the small closed set of human activity states — never the raw identifier. +// A previously-unmapped tool (or one this test doesn't enumerate) must still +// fall back into the set rather than leaking through verbatim. +describe("resolveTurnLabel closed-set guarantee", () => { + const leakingIdentifiers = [ + "run_shell", + "grep", + "read_file", + "write_file", + "edit_file", + "search_files", + "list_dir", + "web_search", + "web_fetch", + "manage_tasks", + "task", + "submit_output", + "ask_operator", + "mcp__glitchtip__authenticate", + "mcp__railway__deploy", + "some_未knownしplugin_tool", + "a-plugin-defined-tool-name", + "totally_unmapped_future_tool", + ] + + for (const currentToolName of leakingIdentifiers) { + test(`"${currentToolName}" resolves to a member of the closed set`, () => { + const label = resolveTurnLabel({ + isProcessing: true, + status: "running", + awaitingResponse: false, + currentToolName, + streamingType: "tool", + }) + expect(label).not.toBe(currentToolName) + expect(ACTIVITY_STATES).toContain(label) + }) + } + + test("a stalled turn renders a distinct stalled state", () => { + const label = resolveTurnLabel( + { + isProcessing: true, + status: "running", + awaitingResponse: false, + currentToolName: "run_shell", + streamingType: "tool", + }, + true, + ) + expect(label).toBe("stalled") + expect(ACTIVITY_STATES).toContain(label) + }) + + test("waiting on the operator is distinguishable from working", () => { + const label = resolveTurnLabel({ + isProcessing: true, + status: "blocked", + awaitingResponse: false, + currentToolName: "run_shell", + streamingType: "tool", + }) + expect(label).toBe("waiting") + expect(label).not.toBe("working") + expect(ACTIVITY_STATES).toContain(label) + }) +}) + describe("resolveTurnLabel", () => { test("idle processing off yields no label", () => { expect( From 7f35cb8d99d41a3d7717ed70b29846ab12d3998b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:02:39 -0700 Subject: [PATCH 2/3] Render a semantic activity state in the status ticker, not raw tool names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ticker rendered whatever tool identifier was currently executing — internal plumbing vocabulary leaking into a product surface, and redundant with the transcript, which already shows the tool call. Replace it with a closed set of human activity states (thinking, planning, researching, building, working, waiting, stalled, stopping). The execution-to-state mapping lives in one place with an explicit fallback to 'working', so an unmapped tool, MCP server, or plugin name can never reach the ticker and adding a tool needs no ticker change. Stalled reuses the existing stall-watchdog signal rather than a second notion of stuck, and waiting on operator approval is now its own state distinct from active work. --- src/tui-opentui/runtime-bridge.ts | 3 +- src/tui-opentui/session-chrome.test.ts | 30 ++++------- src/tui-opentui/session-chrome.ts | 70 +++++++++++++++++++++----- src/tui-opentui/turn-monitor.test.ts | 12 +++-- 4 files changed, 75 insertions(+), 40 deletions(-) diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index a461ba7a4..53ecd49a2 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -805,9 +805,8 @@ export function attachSessionBridge( awaitingResponse: turn.awaitingResponse, currentToolName: turn.currentToolName, streamingType: turn.streamingType, - streamTokenCount: turn.streamTokenCount, } - const label = resolveTurnLabel(input) + const label = resolveTurnLabel(input, isStalled) if (label === undefined) { // The bottom-left status slot rides the same re-entry as the landing // mark, so it crossfades between phases without a timer of its own. diff --git a/src/tui-opentui/session-chrome.test.ts b/src/tui-opentui/session-chrome.test.ts index da32bf233..b992f68b8 100644 --- a/src/tui-opentui/session-chrome.test.ts +++ b/src/tui-opentui/session-chrome.test.ts @@ -47,7 +47,7 @@ describe("resolveTurnLabel closed-set guarantee", () => { streamingType: "tool", }) expect(label).not.toBe(currentToolName) - expect(ACTIVITY_STATES).toContain(label) + expect(ACTIVITY_STATES).toContain(label!) }) } @@ -63,7 +63,7 @@ describe("resolveTurnLabel closed-set guarantee", () => { true, ) expect(label).toBe("stalled") - expect(ACTIVITY_STATES).toContain(label) + expect(ACTIVITY_STATES).toContain(label!) }) test("waiting on the operator is distinguishable from working", () => { @@ -76,7 +76,7 @@ describe("resolveTurnLabel closed-set guarantee", () => { }) expect(label).toBe("waiting") expect(label).not.toBe("working") - expect(ACTIVITY_STATES).toContain(label) + expect(ACTIVITY_STATES).toContain(label!) }) }) @@ -93,7 +93,7 @@ describe("resolveTurnLabel", () => { ).toBeUndefined() }) - test("blocked gate shows approval wait", () => { + test("blocked gate shows a waiting-on-operator state", () => { expect( resolveTurnLabel({ isProcessing: true, @@ -102,7 +102,7 @@ describe("resolveTurnLabel", () => { currentToolName: "run_shell", streamingType: "tool", }), - ).toBe("blocked") + ).toBe("waiting") }) test("stopping beats tool phase", () => { @@ -117,7 +117,7 @@ describe("resolveTurnLabel", () => { ).toBe("stopping") }) - test("tool phase beats generic working", () => { + test("tool phase maps to its semantic activity, never the raw name", () => { expect( resolveTurnLabel({ isProcessing: true, @@ -126,7 +126,7 @@ describe("resolveTurnLabel", () => { currentToolName: "grep", streamingType: "tool", }), - ).toBe("grep") + ).toBe("researching") }) test("thinking and text phases", () => { @@ -140,8 +140,8 @@ describe("resolveTurnLabel", () => { resolveTurnLabel({ ...base, streamingType: "thinking" }), ).toBe("thinking") expect( - resolveTurnLabel({ ...base, streamingType: "text", streamTokenCount: 7 }), - ).toBe("streaming 7 tok") + resolveTurnLabel({ ...base, streamingType: "text" }), + ).toBe("working") expect( resolveTurnLabel({ ...base, @@ -150,18 +150,6 @@ describe("resolveTurnLabel", () => { }), ).toBe("working") }) - - test("text phase with no count yet reads zero", () => { - expect( - resolveTurnLabel({ - isProcessing: true, - status: "running", - awaitingResponse: false, - currentToolName: null, - streamingType: "text", - }), - ).toBe("streaming 0 tok") - }) }) describe("resolveRampPhase", () => { diff --git a/src/tui-opentui/session-chrome.ts b/src/tui-opentui/session-chrome.ts index 943dd506e..c84195854 100644 --- a/src/tui-opentui/session-chrome.ts +++ b/src/tui-opentui/session-chrome.ts @@ -23,8 +23,53 @@ export type TurnLabelInput = { readonly awaitingResponse: boolean readonly currentToolName: string | null readonly streamingType: "text" | "thinking" | "tool" | null - /** Text deltas seen so far this turn; read only while `streamingType` is `text`. */ - readonly streamTokenCount?: number +} + +/** + * Closed set the status ticker is allowed to render. Every path through + * `resolveTurnLabel` returns one of these — never a tool identifier, MCP + * server name, or plugin name. This is what the leak-prevention test checks + * membership against, so it must stay the single source of truth for "what + * can appear in the ticker." + */ +export const ACTIVITY_STATES = [ + "thinking", + "planning", + "researching", + "building", + "working", + "waiting", + "stalled", + "stopping", +] as const + +export type ActivityState = (typeof ACTIVITY_STATES)[number] + +/** + * Execution → activity-state mapping, kept in this one place with an + * explicit fallback so a newly added tool (built-in, MCP, or plugin) renders + * a generic "working" state instead of leaking its identifier — no ticker + * change is required to add a tool correctly. + */ +const TOOL_ACTIVITY_STATES: Readonly> = { + read_file: "researching", + search_files: "researching", + grep: "researching", + list_dir: "researching", + web_search: "researching", + web_fetch: "researching", + write_file: "building", + edit_file: "building", + run_shell: "building", + manage_tasks: "planning", + task: "planning", + ask_operator: "waiting", + submit_output: "working", +} + +function activityStateForTool(name: string | null): ActivityState { + if (name === null) return "working" + return TOOL_ACTIVITY_STATES[name] ?? "working" } /** @@ -32,22 +77,23 @@ export type TurnLabelInput = { * unpunctuated — the ramp's color and motion carry the state, so the word only * has to name it. Returns undefined when idle so the phase segment disappears. * - * Text streaming carries a live count (`streaming 7 tok`) rather than the - * bare word: it is the one phase with something to count, and the count is - * what tells the operator the slot is not stalled. + * `isStalled` is the caller's own `shouldNoticeStall`/`isStalledForDisplay` + * result (see stall-watchdog.ts) — this function does not re-derive + * staleness, it only ranks "stalled" against the other phases so the ticker + * and the ramp never disagree about which runs look stuck. */ -export function resolveTurnLabel(input: TurnLabelInput): string | undefined { +export function resolveTurnLabel( + input: TurnLabelInput, + isStalled: boolean = false, +): ActivityState | undefined { if (!input.isProcessing) return undefined - if (input.status === "blocked") return "blocked" + if (input.status === "blocked") return "waiting" if (input.status === "stopping" || input.status === "stopped") { return "stopping" } - if (input.currentToolName !== null) return input.currentToolName - if (input.streamingType === "tool") return "tool" + if (isStalled) return "stalled" + if (input.currentToolName !== null) return activityStateForTool(input.currentToolName) if (input.streamingType === "thinking") return "thinking" - if (input.streamingType === "text") { - return `streaming ${String(input.streamTokenCount ?? 0)} tok` - } return "working" } diff --git a/src/tui-opentui/turn-monitor.test.ts b/src/tui-opentui/turn-monitor.test.ts index 0ac5b2cd3..760d0b2d5 100644 --- a/src/tui-opentui/turn-monitor.test.ts +++ b/src/tui-opentui/turn-monitor.test.ts @@ -73,16 +73,18 @@ describe("turn progress label", () => { expect(t.shell.lockupPhase).toBe("thinking") t.bridge.handle({ type: "inference.text.delta", data: { token: "hi" } }) - expect(t.shell.lockupPhase).toBe("streaming 1 tok") + expect(t.shell.lockupPhase).toBe("working") t.bridge.handle({ type: "inference.text.delta", data: { token: " there" } }) - expect(t.shell.lockupPhase).toBe("streaming 2 tok") + expect(t.shell.lockupPhase).toBe("working") t.bridge.handle({ type: "inference.tool_call.end", data: { name: "bash", callId: "c1" }, }) - expect(t.shell.lockupPhase).toBe("bash") + // Unmapped tool identifiers fall back to the generic working state + // rather than leaking the raw name. + expect(t.shell.lockupPhase).toBe("working") t.bridge.handle({ type: "reactor.done", data: {} }) expect(t.shell.lockupPhase).toBeNull() @@ -223,7 +225,7 @@ describe("turn progress label", () => { }) }) - test("an open permission overlay freezes the ramp and reads blocked", async () => { + test("an open permission overlay freezes the ramp and reads waiting", async () => { await withTestRenderer(async (h) => { const t: Harness = await setup(h) try { @@ -231,7 +233,7 @@ describe("turn progress label", () => { t.shell.overlayKind = "permissions" t.bridge.gateOpened() t.tick() - expect(t.shell.lockupPhase).toBe("blocked") + expect(t.shell.lockupPhase).toBe("waiting") // Frozen is the signal: the ramp must not move while a human is asked. const frozen = t.shell.lockupPhase From 3ecfa9baf92bc97ab34b788ebfb77dba2afe3f50 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:19:14 -0700 Subject: [PATCH 3/3] Make the ticker's closed set compiler-enforced and fix review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppShell.lockupPhase and LockupFrame.phase in shell.ts are now typed ActivityState | null instead of string | null, so a raw tool identifier reaching the ticker is a type error at the setLockupFrame boundary, not only a test failure. lockup.ts's LockupInput.phase stays a generic string deliberately — its own tests exercise arbitrary CJK/astral text to check width math unrelated to the activity vocabulary, and the leak boundary is already closed one layer up. - isStalled on resolveTurnLabel is now required, matching resolveRampPhase; a caller that forgets it is the exact bug this state exists to prevent. - runtime-bridge.ts's second stall check site now calls isStalledForDisplay instead of re-deriving 'not quiet' from stallLevel's result, so the two call sites share one definition of stalled. - Dropped unread TurnLabelInput.awaitingResponse. - docs/TUI.md corrected: it described the phase slot as showing 'the running tool's name', which this change makes false; it now points at ACTIVITY_STATES as the source of truth. - Added delete_file/advance_workflow/tool_search/search_agents to the tool-to-state table. - Test fallback case swapped from the fictional 'bash' tool to a real MCP identifier, since 'bash' cannot occur at runtime. --- docs/TUI.md | 11 ++- src/tui-opentui/runtime-bridge.ts | 6 +- src/tui-opentui/session-chrome.test.ts | 109 +++++++++++++------------ src/tui-opentui/session-chrome.ts | 17 ++-- src/tui-opentui/shell.ts | 16 +++- src/tui-opentui/turn-monitor.test.ts | 6 +- 6 files changed, 95 insertions(+), 70 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 0a8ecb48c..2583f46a4 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -52,9 +52,14 @@ branch at its right (`AppShell.promptTopRule` / `promptBottomRule`, `src/tui-opentui/shell.ts`). Both rules cost zero transcript rows because they ride the prompt box's own border. -While a turn is live the lockup slot swaps the wordmark for the phase word — -`thinking`, `streaming 12 tok`, the running tool's name — led by a single -density cell (`rampPulse`, `src/tui-opentui/ramp.ts`). The cell, not the word, +While a turn is live the lockup slot swaps the wordmark for a semantic +activity word — never the raw tool, MCP server, or plugin identifier that is +actually executing. `resolveTurnLabel` (`src/tui-opentui/session-chrome.ts`) +maps execution onto the closed set `ACTIVITY_STATES` exported from that +module (`thinking`, `planning`, `researching`, `building`, `working`, +`waiting`, `stalled`, `stopping`); that export is the source of truth for +what the slot can say, not this list. It is led by a single density cell +(`rampPulse`, `src/tui-opentui/ramp.ts`). The cell, not the word, is what says whether the session is healthy, and it carries four states: | State | Cell | Reads as | diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index 53ecd49a2..35433db3b 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -802,7 +802,6 @@ export function attachSessionBridge( const input = { isProcessing: turn.isProcessing, status: turn.status, - awaitingResponse: turn.awaitingResponse, currentToolName: turn.currentToolName, streamingType: turn.streamingType, } @@ -1036,7 +1035,10 @@ export function attachSessionBridge( setStatusFlash(shell, STALL_NOTICE_MESSAGE) } - paintPhaseAt(nowMs, level !== "quiet") + // Same "is this stalled at all" question `paintPhase` asks above — call + // the one definition (`isStalledForDisplay`) rather than re-deriving it + // from `stallLevel`'s result, so the two call sites can never disagree. + paintPhaseAt(nowMs, isStalledForDisplay(stallArgs)) } setShellBridgeHooks(shell, { diff --git a/src/tui-opentui/session-chrome.test.ts b/src/tui-opentui/session-chrome.test.ts index b992f68b8..58fcbc203 100644 --- a/src/tui-opentui/session-chrome.test.ts +++ b/src/tui-opentui/session-chrome.test.ts @@ -39,13 +39,15 @@ describe("resolveTurnLabel closed-set guarantee", () => { for (const currentToolName of leakingIdentifiers) { test(`"${currentToolName}" resolves to a member of the closed set`, () => { - const label = resolveTurnLabel({ - isProcessing: true, - status: "running", - awaitingResponse: false, - currentToolName, - streamingType: "tool", - }) + const label = resolveTurnLabel( + { + isProcessing: true, + status: "running", + currentToolName, + streamingType: "tool", + }, + false, + ) expect(label).not.toBe(currentToolName) expect(ACTIVITY_STATES).toContain(label!) }) @@ -56,7 +58,6 @@ describe("resolveTurnLabel closed-set guarantee", () => { { isProcessing: true, status: "running", - awaitingResponse: false, currentToolName: "run_shell", streamingType: "tool", }, @@ -67,13 +68,15 @@ describe("resolveTurnLabel closed-set guarantee", () => { }) test("waiting on the operator is distinguishable from working", () => { - const label = resolveTurnLabel({ - isProcessing: true, - status: "blocked", - awaitingResponse: false, - currentToolName: "run_shell", - streamingType: "tool", - }) + const label = resolveTurnLabel( + { + isProcessing: true, + status: "blocked", + currentToolName: "run_shell", + streamingType: "tool", + }, + false, + ) expect(label).toBe("waiting") expect(label).not.toBe("working") expect(ACTIVITY_STATES).toContain(label!) @@ -83,49 +86,57 @@ describe("resolveTurnLabel closed-set guarantee", () => { describe("resolveTurnLabel", () => { test("idle processing off yields no label", () => { expect( - resolveTurnLabel({ - isProcessing: false, - status: "idle", - awaitingResponse: false, - currentToolName: null, - streamingType: null, - }), + resolveTurnLabel( + { + isProcessing: false, + status: "idle", + currentToolName: null, + streamingType: null, + }, + false, + ), ).toBeUndefined() }) test("blocked gate shows a waiting-on-operator state", () => { expect( - resolveTurnLabel({ - isProcessing: true, - status: "blocked", - awaitingResponse: false, - currentToolName: "run_shell", - streamingType: "tool", - }), + resolveTurnLabel( + { + isProcessing: true, + status: "blocked", + currentToolName: "run_shell", + streamingType: "tool", + }, + false, + ), ).toBe("waiting") }) test("stopping beats tool phase", () => { expect( - resolveTurnLabel({ - isProcessing: true, - status: "stopping", - awaitingResponse: false, - currentToolName: "grep", - streamingType: "tool", - }), + resolveTurnLabel( + { + isProcessing: true, + status: "stopping", + currentToolName: "grep", + streamingType: "tool", + }, + false, + ), ).toBe("stopping") }) test("tool phase maps to its semantic activity, never the raw name", () => { expect( - resolveTurnLabel({ - isProcessing: true, - status: "running", - awaitingResponse: true, - currentToolName: "grep", - streamingType: "tool", - }), + resolveTurnLabel( + { + isProcessing: true, + status: "running", + currentToolName: "grep", + streamingType: "tool", + }, + false, + ), ).toBe("researching") }) @@ -133,21 +144,16 @@ describe("resolveTurnLabel", () => { const base = { isProcessing: true, status: "running" as const, - awaitingResponse: false, currentToolName: null, } expect( - resolveTurnLabel({ ...base, streamingType: "thinking" }), + resolveTurnLabel({ ...base, streamingType: "thinking" }, false), ).toBe("thinking") expect( - resolveTurnLabel({ ...base, streamingType: "text" }), + resolveTurnLabel({ ...base, streamingType: "text" }, false), ).toBe("working") expect( - resolveTurnLabel({ - ...base, - awaitingResponse: true, - streamingType: null, - }), + resolveTurnLabel({ ...base, streamingType: null }, false), ).toBe("working") }) }) @@ -155,7 +161,6 @@ describe("resolveTurnLabel", () => { describe("resolveRampPhase", () => { const base = { isProcessing: true, - awaitingResponse: false, currentToolName: null, streamingType: null, } diff --git a/src/tui-opentui/session-chrome.ts b/src/tui-opentui/session-chrome.ts index c84195854..deb51e3d1 100644 --- a/src/tui-opentui/session-chrome.ts +++ b/src/tui-opentui/session-chrome.ts @@ -20,7 +20,6 @@ export type TurnStatus = export type TurnLabelInput = { readonly isProcessing: boolean readonly status: TurnStatus - readonly awaitingResponse: boolean readonly currentToolName: string | null readonly streamingType: "text" | "thinking" | "tool" | null } @@ -61,8 +60,12 @@ const TOOL_ACTIVITY_STATES: Readonly> = { write_file: "building", edit_file: "building", run_shell: "building", + delete_file: "building", manage_tasks: "planning", task: "planning", + advance_workflow: "planning", + tool_search: "researching", + search_agents: "researching", ask_operator: "waiting", submit_output: "working", } @@ -77,14 +80,16 @@ function activityStateForTool(name: string | null): ActivityState { * unpunctuated — the ramp's color and motion carry the state, so the word only * has to name it. Returns undefined when idle so the phase segment disappears. * - * `isStalled` is the caller's own `shouldNoticeStall`/`isStalledForDisplay` - * result (see stall-watchdog.ts) — this function does not re-derive - * staleness, it only ranks "stalled" against the other phases so the ticker - * and the ramp never disagree about which runs look stuck. + * `isStalled` is the caller's own `isStalledForDisplay` result (see + * stall-watchdog.ts) — this function does not re-derive staleness, it only + * ranks "stalled" against the other phases so the ticker and the ramp never + * disagree about which runs look stuck. Required, not defaulted: a caller + * that forgets to pass it is exactly the bug this state exists to prevent — + * a wedged run silently painted as ordinary work. */ export function resolveTurnLabel( input: TurnLabelInput, - isStalled: boolean = false, + isStalled: boolean, ): ActivityState | undefined { if (!input.isProcessing) return undefined if (input.status === "blocked") return "waiting" diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 47efdee0d..ba0ae4228 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -69,6 +69,7 @@ import { type LockupInput, } from "./lockup.js" import type { RampPhase, StallAge } from "./ramp.js" +import type { ActivityState } from "./session-chrome.js" import { BORDER, composeCostContextMeter, @@ -641,8 +642,12 @@ export type AppShell = { */ lockupNowMs: number lockupAnimating: boolean - /** Live phase word the slot shows, or null for the idle wordmark. */ - lockupPhase: string | null + /** + * Live activity state the slot shows, or null for the idle wordmark. + * Typed to the closed set (not `string`) so a raw tool/MCP/plugin + * identifier reaching this field is a compile error, not just a test one. + */ + lockupPhase: ActivityState | null /** Clock reading when `lockupPhase` last changed — the fade's origin. */ lockupChangedMs: number /** Density ramp phase for the same turn — drives the slot's pulse cell and tint. */ @@ -876,8 +881,11 @@ function syncLandingSuggestions(shell: AppShell): void { export type LockupFrame = { readonly nowMs: number readonly animating: boolean - /** Live phase word, or null for the idle wordmark. */ - readonly phase: string | null + /** + * Live activity state, or null for the idle wordmark. Typed to the closed + * set so the caller cannot hand this a raw tool identifier. + */ + readonly phase: ActivityState | null /** The turn's ramp phase, or null when idle. */ readonly rampPhase: RampPhase | null /** How long the turn has been stalled, or null when it is not stalled. */ diff --git a/src/tui-opentui/turn-monitor.test.ts b/src/tui-opentui/turn-monitor.test.ts index 760d0b2d5..7ef061c1f 100644 --- a/src/tui-opentui/turn-monitor.test.ts +++ b/src/tui-opentui/turn-monitor.test.ts @@ -80,10 +80,10 @@ describe("turn progress label", () => { t.bridge.handle({ type: "inference.tool_call.end", - data: { name: "bash", callId: "c1" }, + data: { name: "mcp__glitchtip__resolve_issue", callId: "c1" }, }) - // Unmapped tool identifiers fall back to the generic working state - // rather than leaking the raw name. + // Unmapped tool identifiers — including MCP tools — fall back to the + // generic working state rather than leaking the raw name. expect(t.shell.lockupPhase).toBe("working") t.bridge.handle({ type: "reactor.done", data: {} })