diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs b/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs index 95ed692d74..f0e32aae67 100644 --- a/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs @@ -185,8 +185,16 @@ async function settle(read, expected, { timeout = 2000 } = {}) { * via `useAppNavigation` and therefore needs router context — the same reason * `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory * router. Without it a thought row throws while a tool row happens not to. + * + * `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on + * this turn right now", because that is the situation nearly every case here is + * about. Pass `liveTurnId: null` for reopened history — see the orphaned-step + * tests, where the same items must read as finished. */ -async function renderBlock(items, { streamingItemId = null } = {}) { +async function renderBlock( + items, + { liveTurnId = SHARED.turnId, streamingItemId = null } = {}, +) { const { createElement, useState } = await import("react"); const { render } = await import("@testing-library/react"); const { QueryClient, QueryClientProvider } = await import( @@ -201,11 +209,12 @@ async function renderBlock(items, { streamingItemId = null } = {}) { "./AgentSessionWorkBlock.tsx" ); - const element = (blockItems, streaming) => + const element = (blockItems, streaming, live) => createElement( AgentSessionTranscriptTurnMetaProvider, { value: { + liveTurnId: live, streamingItemId: streaming, }, }, @@ -223,9 +232,9 @@ async function renderBlock(items, { streamingItemId = null } = {}) { let applyState; const Harness = () => { - const [state, setState] = useState({ items, streamingItemId }); + const [state, setState] = useState({ items, liveTurnId, streamingItemId }); applyState = setState; - return element(state.items, state.streamingItemId); + return element(state.items, state.streamingItemId, state.liveTurnId); }; // The bubble presenter reads the posted message through `useQuery`. Provided // unconditionally so that if a rail step ever DID reach that presenter, the @@ -256,9 +265,15 @@ async function renderBlock(items, { streamingItemId = null } = {}) { return { ...view, // Re-render the SAME block id, exactly as the transcript does as work - // streams and then finishes. - stream: (nextItems, streaming = null) => - applyState({ items: nextItems, streamingItemId: streaming }), + // streams and then finishes. `liveTurnId` is carried through so a test can + // model the session going away under a still-running step (agent death), + // not just the step reaching a terminal status. + stream: (nextItems, streaming = null, live = liveTurnId) => + applyState({ + items: nextItems, + liveTurnId: live, + streamingItemId: streaming, + }), summary: () => q('[data-testid="transcript-work-block-summary"]'), previousSteps: () => q('[data-testid="transcript-work-block-previous-steps"]'), @@ -618,6 +633,122 @@ test("a running step pulses and a failed one does not", async () => { ); }); +// ── Orphaned work ──────────────────────────────────────────────────────────── + +/** + * Reopened history must not present as work in progress. + * + * A tool's `executing` status is written when the step starts and never revised + * if the agent dies first, so an abandoned step keeps it forever. In this block + * that status is not a per-row detail — one `running` entry makes the whole + * block active, which suppresses the folded summary line, keeps the rail + * expanded and pulses a bullet. Scrolling back to a crashed turn therefore + * showed the reader live work indefinitely (Codex P2 on #6536). + */ +test("a block whose running step has no live session folds like finished work", async () => { + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: null, streamingItemId: null }, + ); + + const summary = view.summary(); + assert.ok( + summary, + "history gets its folded summary line — the orphaned status must not suppress it", + ); + assert.match( + summary.textContent, + /2 steps$/, + "an abandoned step is not known to have failed, so the count stays neutral", + ); + assert.equal(await view.settleToStepCount(0), 0, "the rail folds away"); + assert.equal( + view.qa(".animate-pulse").length, + 0, + "nothing pulses when nothing is running", + ); +}); + +test("the same items still hold the block open while the turn is live", async () => { + // The paired half: this is what makes the gate meaningful rather than a + // blanket "never trust executing". Identical items, live turn. + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: "turn-1", streamingItemId: null }, + ); + + assert.equal(view.summary(), null, "a live block has no folded line"); + assert.equal(view.stepCount(), 2, "the rail stays open"); + assert.deepEqual(view.glyphStates(), ["settled", "running"]); + assert.equal(view.qa(".animate-pulse").length, 1, "live work pulses"); +}); + +test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => { + // The reason the signal is a turn id and not a boolean: a restarted agent is + // live, but not on this turn, and a global flag would keep this step spinning. + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: "turn-2", streamingItemId: null }, + ); + + assert.ok( + view.summary(), + "this turn is history even though the agent is busy", + ); + assert.equal(await view.settleToStepCount(0), 0); + assert.equal(view.qa(".animate-pulse").length, 0); +}); + +test("a block live when the session ends folds instead of spinning forever", async () => { + const { act } = await import("@testing-library/react"); + // The bug as the reader meets it live: the agent dies mid-step, so the item + // never reaches a terminal status and the only thing that changes is that no + // turn is live any more. + const live = [ + step("a"), + step("b", { status: "executing", completedAt: null }), + ]; + const view = await renderBlock(live, { + liveTurnId: "turn-1", + streamingItemId: "b", + }); + assert.equal(view.stepCount(), 2, "live: the rail is open"); + assert.equal(view.summary(), null); + + // Session gone. The items are BYTE-IDENTICAL — only liveness changed. + await act(async () => { + view.stream(live, null, null); + }); + + assert.ok(view.summary(), "the block settles when its session goes away"); + assert.match(view.summary().textContent, /2 steps$/); + assert.equal(await view.settleToStepCount(0), 0); + assert.equal(view.qa(".animate-pulse").length, 0); +}); + +test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => { + // Deliberate: we do not know what happened to the step, so it renders as the + // neutral step it is with whatever it recorded. A visible "interrupted" + // treatment would be a design addition, not part of this fix. + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: null, streamingItemId: null }, + ); + await view.expand(); + await view.settleToStepCount(2); + + assert.deepEqual( + view.glyphStates(), + ["settled", "settled"], + "no third state was invented for an abandoned step", + ); + assert.equal( + view.qa('[data-testid="transcript-tool-item"]').length, + 2, + "both steps still render through the normal tool presenter", + ); +}); + test("the rail bullet masks the spine with the drawer surface colour", async () => { // The bullet has to mask the spine passing behind it, and the mask must match // the surface the transcript is drawn on. A mask in any other colour shows as @@ -961,6 +1092,7 @@ async function countStepRenders(initialItems, nextItems) { AgentSessionTranscriptTurnMetaProvider, { value: { + liveTurnId: items[items.length - 1].turnId, streamingItemId: items[items.length - 1].id, }, }, diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx b/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx index af20f73f49..27055c1994 100644 --- a/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx +++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx @@ -202,8 +202,11 @@ export function AgentSessionWorkBlockSegment({ // One projection per render, shared by the summary counts and the rail, so // "is this step failed?" has exactly one answer in this component. const entries = React.useMemo( - () => projectWorkBlockEntries(block.items), - [block.items], + () => + projectWorkBlockEntries(block.items, { + liveTurnId: turnMeta.liveTurnId, + }), + [block.items, turnMeta.liveTurnId], ); const status = React.useMemo( () => diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs index 477ed54ced..1e7d9964b2 100644 --- a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs @@ -201,6 +201,85 @@ test("buildConversationTurnMeta reads a trailing single block directly", () => { assert.equal(meta.streamingItemId, "life:orphan"); }); +// ---- buildConversationTurnMeta: liveTurnId ---- + +/** + * The work block cannot tell an abandoned step from a running one on its own: a + * tool keeps its `executing` status forever if the agent dies mid-step. Turn + * ownership is knowledge only the list has, so it is published here. + */ +test("buildConversationTurnMeta names the live turn so the block can gate running steps", () => { + const meta = buildConversationTurnMeta( + [turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])], + { isTurnLive: true, variant: "conversation" }, + ); + + assert.equal(meta.liveTurnId, "turn-1"); +}); + +test("buildConversationTurnMeta reports no live turn when the turn is idle", () => { + // This is the case the orphaned-step bug lived in: history reopened, nothing + // live, but an item still claiming `executing`. + const meta = buildConversationTurnMeta( + [turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])], + { isTurnLive: false, variant: "conversation" }, + ); + + assert.equal(meta.liveTurnId, null); + assert.equal( + meta, + EMPTY_TRANSCRIPT_TURN_META, + "an idle turn still allocates nothing", + ); +}); + +test("buildConversationTurnMeta names the live turn even when a lifecycle row trails it", () => { + // A compaction notice arrives as a `single` block AFTER the turn it belongs + // to, so "the last block" is not always the live turn — but the last turn is. + // Reading the last block's kind alone would report no live turn and gate every + // running step off, which is the mirror image of the bug being fixed. + const meta = buildConversationTurnMeta( + [ + turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]), + { + kind: "single", + item: item({ + id: "life:compact", + type: "lifecycle", + renderClass: "status", + title: "Context compacted", + text: "", + timestamp: "2026-06-14T19:00:20.000Z", + }), + }, + ], + { isTurnLive: true, variant: "conversation" }, + ); + + assert.equal(meta.liveTurnId, "turn-1"); +}); + +test("buildConversationTurnMeta reports no live turn when there is no turn at all", () => { + const meta = buildConversationTurnMeta( + [ + { + kind: "single", + item: item({ + id: "life:1", + type: "lifecycle", + renderClass: "status", + title: "Session started", + text: "", + timestamp: "2026-06-14T19:00:00.000Z", + }), + }, + ], + { isTurnLive: true, variant: "conversation" }, + ); + + assert.equal(meta.liveTurnId, null); +}); + // ---- parsePlanChecklist ---- test("parsePlanChecklist reads the checkbox markdown the transcript builds", () => { diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts index cf5fa5301d..6456225981 100644 --- a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts +++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts @@ -29,13 +29,19 @@ function turnSegmentItems(segment: TranscriptTurnSegment): TranscriptItem[] { } /** - * Derive the `conversation` variant's streaming hint from the display blocks. + * Derive the `conversation` variant's live-turn hints from the display blocks. * - * Only the trailing leaf item of the final turn can be mid-stream, and only - * when the agent's turn is actually live. The work block reads this to decide - * whether it is still working: a thought or note streaming in carries no status - * of its own, so without this hint a block whose last step is prose would fold - * while the reader was still watching it arrive. + * Two things the work block cannot see for itself: + * + * - **`streamingItemId`** — the trailing leaf item of the final turn, and only + * when the agent's turn is actually live. The block reads this to decide + * whether it is still working: a thought or note streaming in carries no + * status of its own, so without this hint a block whose last step is prose + * would fold while the reader was still watching it arrive. + * - **`liveTurnId`** — which turn a session currently owns. A tool item keeps + * its `executing` status forever if the agent dies mid-step, so status alone + * cannot tell a running step from an abandoned one; the block needs to know + * whose turn is live to decide (see `AgentSessionTranscriptTurnMeta`). * * Returns the shared empty value for non-conversation variants so the default * and compactPreview paths allocate nothing and stay byte-identical. @@ -49,13 +55,35 @@ export function buildConversationTurnMeta( } const lastBlock = displayBlocks[displayBlocks.length - 1]; + // The live turn is the last turn in the transcript: work arrives in order, so + // a turn with anything after it has already been superseded. Read from the + // blocks rather than from the active-turn store because the store's turn ids + // and the transcript's are populated by different paths, and a mismatch would + // silently gate every step off. + const liveTurnId = lastTurnId(displayBlocks); + if (lastBlock?.kind === "single") { - return { streamingItemId: lastBlock.item.id }; + return { liveTurnId, streamingItemId: lastBlock.item.id }; } if (lastBlock?.kind !== "turn") { - return EMPTY_TRANSCRIPT_TURN_META; + return { liveTurnId, streamingItemId: null }; } const items = lastBlock.segments.flatMap(turnSegmentItems); - return { streamingItemId: items[items.length - 1]?.id ?? null }; + return { liveTurnId, streamingItemId: items[items.length - 1]?.id ?? null }; +} + +/** + * The id of the last turn block, ignoring anything that trails it. + * + * A lifecycle row (a compaction notice, a session boundary) can land after the + * turn it belongs to and arrives as a `single` block, so the last block is not + * always the live turn — but the last *turn* is. + */ +function lastTurnId(displayBlocks: TranscriptDisplayBlock[]): string | null { + for (let index = displayBlocks.length - 1; index >= 0; index -= 1) { + const block = displayBlocks[index]; + if (block.kind === "turn") return block.turnId; + } + return null; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts index bc085f7b74..aab5ed1d20 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts @@ -34,9 +34,26 @@ export function useAgentSessionTranscriptVariant() { export type AgentSessionTranscriptTurnMeta = { /** Trailing item of a live turn, or null when nothing is streaming. */ streamingItemId: string | null; + /** + * The turn that is actually live, or null when no turn is. + * + * A tool item's `executing`/`pending` status is not evidence that work is + * happening: an agent that dies after emitting a tool start leaves that + * status on the item permanently, so reopened history still claims to be + * mid-step. Whether a session owns that step is knowledge only the list has, + * which is why it is published here rather than re-derived per row. + * + * A turn id rather than a boolean, because "some turn is live" is not the + * question. An agent that crashed during turn 1 and is now working on turn 2 + * is live, yet turn 1's abandoned step is no more running than before — a + * global flag would keep it spinning in exactly the case a restarted agent + * makes common. + */ + liveTurnId: string | null; }; export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = { + liveTurnId: null, streamingItemId: null, }; diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs index ce5f9bf7ad..35cfb8ff33 100644 --- a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs @@ -18,6 +18,24 @@ import { const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; +/** + * Project the block's items with the block's own turn live — i.e. the agent is + * working on `turn-1` right now, which is the turn every fixture here belongs + * to. Most projection cases are about what a step looks like while its own turn + * is being worked, so this is the default lens. + */ +const projectLive = (items) => + projectWorkBlockEntries(items, { liveTurnId: "turn-1" }); + +/** + * Project with no live turn — reopened history, or a session that has ended. + * The distinction matters because a tool item's `executing` status is written + * once at the start and never revised if the agent dies, so it is only evidence + * of work in flight when a session still owns the turn. + */ +const projectHistory = (items) => + projectWorkBlockEntries(items, { liveTurnId: null }); + function thought(id, timestamp = "2026-06-14T19:00:02.000Z") { return { ...SHARED, @@ -286,7 +304,7 @@ test("every item projects to exactly one rail kind, paired with its own item typ // item: }` does not type-check — but a hand-written swap between the // two prose branches is the easy mistake, and TypeScript is stripped at // runtime, so the pairing is asserted here as well. - const entries = projectWorkBlockEntries([ + const entries = projectLive([ thought("thought:1"), message("msg:interim", "assistant"), tool("tool:1"), @@ -312,7 +330,7 @@ test("every item projects to exactly one rail kind, paired with its own item typ }); test("only a tool step can be running or failed", () => { - const stateOf = (item) => projectWorkBlockEntries([item])[0].state; + const stateOf = (item) => projectLive([item])[0].state; assert.equal(stateOf(tool("t", { status: "executing" })), "running"); assert.equal(stateOf(tool("t", { status: "pending" })), "running"); @@ -339,18 +357,99 @@ test("a step that is both running and errored still reads as running", () => { // attempt executes. Reporting it as failed would fold a live block's count to // "N steps · 1 failed" while the work is still in flight. assert.equal( - projectWorkBlockEntries([ - tool("t", { status: "executing", isError: true }), - ])[0].state, + projectLive([tool("t", { status: "executing", isError: true })])[0].state, "running", ); }); +// ---- liveness ---- + +/** + * An abandoned step is not a running step. + * + * `executing` is written when a step starts and never revised if the agent dies + * first, so reopened history keeps that status forever. In this block `running` + * is not just a glyph — one running entry makes `summarizeWorkBlock` report + * `isActive`, which suppresses the folded summary line and holds the rail open. + * Ungated, a single orphaned step therefore renders finished history as work in + * progress, pulsing indefinitely. + */ +test("an executing step whose turn is not live reads as settled, not running", () => { + const orphan = tool("t", { status: "executing", completedAt: null }); + + assert.equal( + projectLive([orphan])[0].state, + "running", + "the same item IS running while a session owns its turn", + ); + assert.equal( + projectHistory([orphan])[0].state, + "settled", + "with no live turn the status only says the step began, not that it is happening", + ); + assert.equal( + projectWorkBlockEntries([orphan], { liveTurnId: "turn-2" })[0].state, + "settled", + "an agent live on a LATER turn does not resurrect an earlier turn's abandoned step", + ); +}); + +test("a pending step whose turn is not live reads as settled too", () => { + // Both in-flight statuses go through the same gate; `pending` is the one a + // crash between queue and start leaves behind. + const queued = tool("t", { status: "pending", completedAt: null }); + assert.equal(projectLive([queued])[0].state, "running"); + assert.equal(projectHistory([queued])[0].state, "settled"); +}); + +test("an abandoned step is settled rather than failed, so it never inflates the failure count", () => { + // We do not know an abandoned step failed — only that nobody finished it. + // Counting it as a failure would put "1 failed" on the folded line for work + // that may well have succeeded without its terminal update being recorded. + const status = summarizeWorkBlock( + projectHistory([ + tool("tool:1"), + tool("tool:2", { status: "executing", completedAt: null }), + ]), + { streamingItemId: null }, + ); + assert.deepEqual(status, { count: 2, failedCount: 0, isActive: false }); + assert.equal( + formatWorkBlockSummaryLabel(status), + "2 steps", + "reopened history folds to a neutral count", + ); +}); + +test("a genuinely failed step is still failed when its turn is not live", () => { + // The gate is only about the in-flight statuses. A step that recorded a + // failure recorded a fact, and history must keep reporting it. + const failed = () => tool("t", { isError: true, status: "failed" }); + assert.equal(projectHistory([failed()])[0].state, "failed"); + assert.equal( + summarizeWorkBlock(projectHistory([failed()]), { streamingItemId: null }) + .failedCount, + 1, + ); +}); + +test("a step with no turn id is not owned by a live turn", () => { + // `null === null` must not read as ownership: an item that never recorded a + // turn cannot be shown to belong to the live one. + assert.equal( + projectWorkBlockEntries( + [tool("t", { status: "executing", completedAt: null, turnId: null })], + { liveTurnId: null }, + )[0].state, + "settled", + ); +}); + // ---- status ---- test("summarizeWorkBlock counts steps and failures", () => { const status = summarizeWorkBlock( - projectWorkBlockEntries([ + projectLive([ tool("tool:1"), tool("tool:2", { isError: true, status: "failed" }), thought("thought:1"), @@ -363,9 +462,7 @@ test("summarizeWorkBlock counts steps and failures", () => { test("a block is active when a step is running OR when it holds the streaming item", () => { assert.equal( summarizeWorkBlock( - projectWorkBlockEntries([ - tool("tool:1", { status: "executing", completedAt: null }), - ]), + projectLive([tool("tool:1", { status: "executing", completedAt: null })]), { streamingItemId: null }, ).isActive, true, @@ -373,7 +470,7 @@ test("a block is active when a step is running OR when it holds the streaming it ); // A thought streaming in carries no tool status, so status alone would miss it. - const streamingThought = projectWorkBlockEntries([thought("thought:1")]); + const streamingThought = projectLive([thought("thought:1")]); assert.equal( summarizeWorkBlock(streamingThought, { streamingItemId: "thought:1" }) .isActive, @@ -419,9 +516,7 @@ test("the previous-steps label is singular for one step", () => { const entryIds = (entries) => entries.map((entry) => entry.item.id); test("a live block shows the last N steps in true order and hides the rest", () => { - const entries = projectWorkBlockEntries( - ["a", "b", "c", "d", "e"].map((id) => tool(id)), - ); + const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id))); const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, { isActive: true, }); @@ -436,9 +531,7 @@ test("a live block shows the last N steps in true order and hides the rest", () }); test("a finished block shows every step", () => { - const entries = projectWorkBlockEntries( - ["a", "b", "c", "d", "e"].map((id) => tool(id)), - ); + const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id))); const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, { isActive: false, }); @@ -447,9 +540,7 @@ test("a finished block shows every step", () => { }); test("a live block at or under the window size hides nothing", () => { - const entries = projectWorkBlockEntries( - ["a", "b", "c"].map((id) => tool(id)), - ); + const entries = projectLive(["a", "b", "c"].map((id) => tool(id))); const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, { isActive: true, }); diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts index 893ed34c2a..9d076f84f1 100644 --- a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts @@ -263,10 +263,30 @@ export type WorkBlockEntry = * Order matters: a tool can carry a stale `isError` from a retry while the new * attempt executes, and reporting that as failed would fold a live block's * count to "N steps · 1 failed" while the work is still in flight. + * + * `executing`/`pending` only counts as *running* when a session actually owns + * the step's turn. That status is written when the step starts and is never + * revised if the agent dies first, so on its own it says "this step began", not + * "this step is happening" — reopened history full of abandoned steps would + * otherwise present as work in progress forever. See `liveTurnId`. + * + * An abandoned step is reported as `settled`, not as a new state: we do not + * know that it failed, so it must not count toward the folded line's + * `N failed`, and inventing a third outcome would put a marker on the rail for + * something the reader cannot act on. It renders as the neutral step it is, + * with whatever detail it managed to record. */ -function toolEntryState(item: WorkBlockToolItem): WorkBlockEntryState { +function toolEntryState( + item: WorkBlockToolItem, + liveTurnId: string | null, +): WorkBlockEntryState { if (item.status === "executing" || item.status === "pending") { - return "running"; + // Compared rather than tested for truthiness: an item with no turn id + // cannot be owned by a live turn, and `null === null` must not read as + // ownership. + return liveTurnId !== null && item.turnId === liveTurnId + ? "running" + : "settled"; } if (item.isError || item.status === "failed") return "failed"; return "settled"; @@ -281,22 +301,32 @@ function toolEntryState(item: WorkBlockToolItem): WorkBlockEntryState { * whole point of the closed union — a new item type must fail loudly here * instead of falling through to the tool branch and wearing a wrench. */ -function projectWorkBlockEntry(item: WorkBlockItem): WorkBlockEntry { +function projectWorkBlockEntry( + item: WorkBlockItem, + liveTurnId: string | null, +): WorkBlockEntry { switch (item.type) { case "thought": return { item, kind: "thought", state: "settled" }; case "message": return { item, kind: "note", state: "settled" }; case "tool": - return { item, kind: "tool", state: toolEntryState(item) }; + return { item, kind: "tool", state: toolEntryState(item, liveTurnId) }; } } -/** Project a block's items into rail entries, in true arrival order. */ +/** + * Project a block's items into rail entries, in true arrival order. + * + * `liveTurnId` is required rather than optional: whether a step is running is + * not a property of the step alone, and a default would let a caller that has + * not thought about liveness get the old spins-forever behaviour silently. + */ export function projectWorkBlockEntries( items: WorkBlockItem[], + options: { liveTurnId: string | null }, ): WorkBlockEntry[] { - return items.map(projectWorkBlockEntry); + return items.map((item) => projectWorkBlockEntry(item, options.liveTurnId)); } export type WorkBlockStatus = {