mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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:
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user