fix(desktop): settle a work block whose running step has no live session

A tool item's `executing`/`pending` status is written when the step starts
and never revised if the agent dies first, so an abandoned step keeps it
forever. On the activity feed that was a stale row label. In the work
block this commit introduces it is a MODE: one `running` entry makes
`summarizeWorkBlock` report `isActive`, which suppresses the folded
summary line, holds the rail open and pulses a bullet. Scrolling back to
a crashed turn therefore showed the reader live work indefinitely
(Codex on #6536, discussion_r3848214880 — not stale, and worse here than
on the card it was filed against).

Status alone cannot answer "is this step happening?", so the block is
given the missing half: `AgentSessionTranscriptTurnMeta.liveTurnId`,
published by the list from the same display blocks it already reads for
`streamingItemId`. `toolEntryState` treats `executing`/`pending` as
`running` only when a session owns that step's turn.

A turn id, not a boolean. "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. The comparison is explicit (`liveTurnId !== null && ...`) so an
item with no turn id cannot match a null live turn by `null === null`.

An abandoned step is reported as `settled`, not as a new state and not as
`failed`, per core-02:

- We do not know it failed — only that nobody finished it — so it must
  not count toward the folded line's `N failed`. It folds to a neutral
  `N steps`.
- No third entry state and no new glyph. It renders as the neutral step
  it is, with the same muted detail the list already shows for it outside
  a block. A visible "interrupted" marker would be a design addition,
  not a bug fix.

`liveTurnId` is read from the display 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 — the mirror image of this bug. It is the last *turn* block, not
the last block: a compaction notice arrives as a `single` block after the
turn it belongs to, so reading the final block's kind would report no
live turn at all.

`projectWorkBlockEntries` takes the option as a required argument rather
than defaulting it, so a future caller that has not thought about
liveness cannot silently get the spins-forever behaviour back.

Non-vacuous by mutation, each mutant run in isolation against the unit
suite:

- gate removed (always `running`): 4 failures.
- gate inverted (`executing` never `running`): 5 — this is the fix that
  would have "passed" the bug report while breaking live work.
- truthiness instead of turn ownership (any live turn resurrects an old
  abandoned step): 1.
- `item.turnId === liveTurnId` without the null guard: 1.
- abandoned step reported `failed` instead of `settled`: 4.
- `liveTurnId` forced null in the meta builder: 2.
- `lastTurnId` reading only the final block: 1.

Measured in a real browser as well, not only JSDOM, because the failure
mode is presentational and animation-dependent — a unit test can assert
class names while the rendered rail is still wrong (the interim-note and
relay-send bugs on this branch were both invisible to the unit suite).
Two seeded scenarios in the preview harness, driving the real component
through the cover drawer under Buzz Dark:

- agent panic mid-step (the terminal `crates/buzz-acp` actually emits):
  folded label `3 steps`, rail collapses to 0 rows, `getAnimations`
  reports 0 infinite animations in the block, and opened the rail reads
  `["settled","settled","settled"]`.
- the same events with no panic: no folded line, rail open, states
  `["settled","settled","running"]`, and exactly 1 infinite animation —
  the running bullet really does pulse.

Both browser scenarios were mutation-checked too: removing the gate fails
the first and passes the second, inverting it fails the second and passes
the first, so neither can be satisfied by a one-sided fix. (Those two
specs are not in this commit — they need Slice A's `conversation` variant
pin to be reachable, which is not on this branch.)

The row outside a block is untouched: `buildCompactToolSummary` still
derives its own `running` from status, and no file under
`activityRenderClasses/` or `agentSessionToolSummary.ts` is modified here.

Verified at this tree: full desktop suite 5486 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing).

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
ss-dev-02
2026-08-24 18:09:14 -07:00
parent ac794843a6
commit fe57b7ac7b
7 changed files with 423 additions and 43 deletions

View File

@@ -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,
},
},

View File

@@ -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(
() =>

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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: <thought> }` 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,
});

View File

@@ -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 = {