From 7f12d89b42c6c9a66a3e9e9b87d4aaf682a95387 Mon Sep 17 00:00:00 2001 From: yukachen Date: Sun, 20 Sep 2026 11:44:43 +0800 Subject: [PATCH 1/4] fix(workflows): stop safeStringify node budget from silently truncating artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four safeStringify callers that write workflow.json, transcripts.json, and result.json passed only maxBytes and inherited serialization.ts's DEFAULT_MAX_NODES = 20_000. That global node counter is reached long before the byte budget, so toSerializable inserted a "[truncated: node limit]" marker and broke — producing a structurally valid file that reads back as a clean `completed` run while trailing data was silently gone. No reader consumes the marker. Reachable on default config: 12 agents each with ~202 transcript entries wrote only 11 of 12 transcripts at 28% of the 2 MiB cap; the 12th hydrated as an empty transcript in /workflows with no notice. Pin maxNodes to each artifact's byte budget so the honest, reported byte cap is the only binding limit (a value dense enough to reach N nodes always serializes to more than N bytes). For transcripts.json, additionally assemble the file with a new boundedTranscriptsArtifact helper that keeps whole agents in index order until the byte budget is reached and reports the dropped tail via a new transcriptsOmitted field, surfaced in the dashboard report and completion alerts (mirroring logsDropped). The manifest and result.json fall back to the existing honest byte-cap stub on genuine overflow. Fixes #558. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/workflows/artifacts.ts | 98 ++++++- extensions/workflows/completion-projection.ts | 5 + extensions/workflows/dashboard.ts | 28 ++ extensions/workflows/model.ts | 5 + .../workflows/artifact-node-budget.test.ts | 254 ++++++++++++++++++ 5 files changed, 381 insertions(+), 9 deletions(-) create mode 100644 tests/extensions/workflows/artifact-node-budget.test.ts diff --git a/extensions/workflows/artifacts.ts b/extensions/workflows/artifacts.ts index ac5995c1..47dda28d 100644 --- a/extensions/workflows/artifacts.ts +++ b/extensions/workflows/artifacts.ts @@ -31,6 +31,16 @@ const AGENT_RESULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024; const WORKFLOW_MANIFEST_MAX_BYTES = 1024 * 1024; const WORKFLOW_TRANSCRIPTS_MAX_BYTES = 2 * 1024 * 1024; const WORKFLOW_COMMIT_MAX_BYTES = 3 * 1024 * 1024; + +// safeStringify's node budget must never bite before the byte budget. Its +// default (serialization.ts DEFAULT_MAX_NODES = 20_000) is far below what these +// byte budgets allow, so it silently dropped trailing artifact fields and whole +// agent transcripts while the file sat well under its byte cap (issue #558). A +// value dense enough to reach N nodes always serializes to more than N bytes, so +// pinning maxNodes to the byte budget makes the honest, reported byte cap the +// only binding limit here. +const WORKFLOW_MANIFEST_MAX_NODES = WORKFLOW_MANIFEST_MAX_BYTES; +const WORKFLOW_TRANSCRIPTS_MAX_NODES = WORKFLOW_TRANSCRIPTS_MAX_BYTES; export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500; const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]"; const TRANSCRIPT_TRUNCATION_MARKER = @@ -417,6 +427,70 @@ export function boundedArtifactTranscript( return [initial, marker, ...tail]; } +export interface BoundedTranscriptsArtifact { + /** Ready-to-write `transcripts.json` content: `{ [agentIndex]: entries[] }`. */ + content: string; + /** Whole agents dropped from the tail, and their bounded entry count. */ + omitted: { agents: number; entries: number }; +} + +/** + * Assemble `transcripts.json`, keeping whole agents in index order until the + * next one would exceed the byte budget. The dropped tail is counted and + * reported (`transcriptsOmitted`) rather than silently discarded by the + * serializer's node budget, which previously stopped at 20_000 nodes — roughly + * 11 full agents — while the file sat at a quarter of its byte cap (issue #558). + */ +export function boundedTranscriptsArtifact( + agents: readonly { index: number; transcript: TranscriptEntry[] }[], + options: { maxBytes?: number; maxNodes?: number } = {}, +): BoundedTranscriptsArtifact { + const maxBytes = Math.max( + 256, + options.maxBytes ?? WORKFLOW_TRANSCRIPTS_MAX_BYTES, + ); + const maxNodes = options.maxNodes ?? WORKFLOW_TRANSCRIPTS_MAX_NODES; + // Bound each agent first; a single agent is capped well under maxBytes by + // boundedArtifactTranscript, so at least one agent always fits. + const bounded = agents.map((agent) => ({ + index: agent.index, + entries: boundedArtifactTranscript(agent.transcript), + })); + const prefixObject = (count: number) => + Object.fromEntries( + bounded.slice(0, count).map((b) => [b.index, b.entries]), + ); + // Measure the uncapped pretty-printed size (the shape safeStringify emits for + // these plain, pre-bounded entries) so a tail that overflows the byte cap is + // detected here instead of turning the whole file into a preview stub. + const prefixBytes = (count: number) => + textBytes(JSON.stringify(prefixObject(count), null, 2)); + + let kept = bounded.length; + if (prefixBytes(bounded.length) > maxBytes) { + // Binary-search the largest whole-agent prefix that fits. + let lo = 0; + let hi = bounded.length; + while (lo < hi) { + const mid = Math.floor((lo + hi + 1) / 2); + if (prefixBytes(mid) <= maxBytes) lo = mid; + else hi = mid - 1; + } + kept = lo; + } + + let omittedEntries = 0; + for (let i = kept; i < bounded.length; i++) { + omittedEntries += bounded[i]!.entries.length; + } + // The kept prefix already fits maxBytes and holds far fewer than maxNodes + // nodes, so safeStringify writes it in full without a second truncation. + return { + content: safeStringify(prefixObject(kept), { maxBytes, maxNodes }), + omitted: { agents: bounded.length - kept, entries: omittedEntries }, + }; +} + function writeRunFile(runDir: string, name: string, content: string) { writeFileAtomic(path.join(runDir, name), content); } @@ -434,6 +508,7 @@ export function persistWorkflowTerminalState( delete terminalManifest.transcriptArtifact; const content = safeStringify(terminalManifest, { maxBytes: WORKFLOW_MANIFEST_MAX_BYTES, + maxNodes: WORKFLOW_MANIFEST_MAX_NODES, }); writeRunFile(runDir, "workflow.json", content); return sha256(content); @@ -478,12 +553,17 @@ export function persistWorkflowJson( journal?: WorkflowJournalSource, ) { refreshWorkflowGraph(details); - const transcripts = Object.fromEntries( - details.agents.map((agent) => [ - agent.index, - boundedArtifactTranscript(agent.transcript), - ]), - ); + const boundedTranscripts = boundedTranscriptsArtifact(details.agents, { + maxBytes: WORKFLOW_TRANSCRIPTS_MAX_BYTES, + maxNodes: WORKFLOW_TRANSCRIPTS_MAX_NODES, + }); + // Recomputed each persist, so set-or-clear rather than accumulate: the field + // must describe the transcripts.json actually written this time. + if (boundedTranscripts.omitted.agents > 0) { + details.transcriptsOmitted = boundedTranscripts.omitted; + } else { + delete details.transcriptsOmitted; + } // Publish terminal execution facts before dependent side artifacts. If a // later artifact write fails, readers still see an explained terminal run @@ -502,9 +582,7 @@ export function persistWorkflowJson( const artifactWrites: WorkflowArtifactWrite[] = [ { name: "transcripts.json", - content: safeStringify(transcripts, { - maxBytes: WORKFLOW_TRANSCRIPTS_MAX_BYTES, - }), + content: boundedTranscripts.content, }, ]; // Written alongside the rest so it inherits atomic write, 500ms coalescing, @@ -529,6 +607,7 @@ export function persistWorkflowJson( name: "result.json", content: safeStringify(details.result, { maxBytes: WORKFLOW_MANIFEST_MAX_BYTES, + maxNodes: WORKFLOW_MANIFEST_MAX_NODES, }), }); } @@ -542,6 +621,7 @@ export function persistWorkflowJson( }; const manifest = safeStringify(compact, { maxBytes: WORKFLOW_MANIFEST_MAX_BYTES, + maxNodes: WORKFLOW_MANIFEST_MAX_NODES, }); if (predecessorSha256 !== undefined) { diff --git a/extensions/workflows/completion-projection.ts b/extensions/workflows/completion-projection.ts index e22a88ec..ca8f784f 100644 --- a/extensions/workflows/completion-projection.ts +++ b/extensions/workflows/completion-projection.ts @@ -98,6 +98,11 @@ function completionAlerts(details: WorkflowDetails) { if (details.logsDropped) { alerts.push(`${details.logsDropped} earlier log line(s) dropped`); } + if (details.transcriptsOmitted) { + alerts.push( + `${details.transcriptsOmitted.agents} agent transcript(s) omitted from transcripts.json (byte budget)`, + ); + } for (const entry of details.logs ?? []) { if (!isDroppedWorkLog(entry)) continue; diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index d8c12e8b..3606c0f0 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -305,6 +305,22 @@ function normalizeDelivery(value: unknown): WorkflowDetails["delivery"] { }; } +function normalizeTranscriptsOmitted( + value: unknown, +): WorkflowDetails["transcriptsOmitted"] { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + const positiveInt = (candidate: unknown) => + typeof candidate === "number" && + Number.isSafeInteger(candidate) && + candidate > 0 + ? candidate + : 0; + const agents = positiveInt(record.agents); + if (agents <= 0) return undefined; + return { agents, entries: positiveInt(record.entries) }; +} + function normalizeTranscript(value: unknown): TranscriptEntry[] { if (!Array.isArray(value)) return []; const transcript: TranscriptEntry[] = []; @@ -518,6 +534,10 @@ export function normalizePersistedWorkflowDetails( } } + const transcriptsOmitted = normalizeTranscriptsOmitted( + record.transcriptsOmitted, + ); + return { runId, sessionId: @@ -550,6 +570,7 @@ export function normalizePersistedWorkflowDetails( ...(typeof record.logsDropped === "number" && record.logsDropped > 0 ? { logsDropped: record.logsDropped } : {}), + ...(transcriptsOmitted ? { transcriptsOmitted } : {}), ...(agents.some((agent) => agent.callId) ? { graph: projectWorkflowGraph(workflowGraphRecords(agents)), @@ -819,6 +840,13 @@ export function buildWorkflowReport(details: WorkflowDetails): string { if (totals) lines.push(`- Usage: ${totals}`); if (details.description) lines.push("", details.description); if (details.error) lines.push("", `**Error:** ${details.error}`); + if (details.transcriptsOmitted) { + const { agents, entries } = details.transcriptsOmitted; + lines.push( + "", + `_${agents} of ${details.agents.length} agent transcript(s) (${entries} entries) omitted from transcripts.json to stay within its byte budget; execution facts above are complete._`, + ); + } for (const group of phaseGroups(details, true)) { lines.push("", `## ${group.title}`, ""); diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index 946ba84b..26c5579d 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -183,6 +183,11 @@ export interface WorkflowDetails { logs?: WorkflowLogEntry[]; /** Oldest lines discarded once the ring filled, reported rather than hidden. */ logsDropped?: number; + /** + * Whole agent transcripts dropped from transcripts.json when the run exceeded + * that artifact's byte budget, reported rather than silently truncated. + */ + transcriptsOmitted?: { agents: number; entries: number }; result?: unknown; resultArtifact?: string; transcriptArtifact?: string; diff --git a/tests/extensions/workflows/artifact-node-budget.test.ts b/tests/extensions/workflows/artifact-node-budget.test.ts new file mode 100644 index 00000000..0e1fbb20 --- /dev/null +++ b/tests/extensions/workflows/artifact-node-budget.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + boundedTranscriptsArtifact, + persistWorkflowJson, +} from "../../../extensions/workflows/artifacts.ts"; +import { + buildWorkflowReport, + normalizePersistedWorkflowDetails, +} from "../../../extensions/workflows/dashboard.ts"; +import { + type AgentRecord, + emptyUsage, + type TranscriptEntry, + type WorkflowDetails, +} from "../../../extensions/workflows/model.ts"; + +// The upstream ceiling for a single agent's transcript. A run of ordinary +// review agents each reaching this is what surfaced the silent node-budget +// truncation in issue #558. +const TRANSCRIPT_ENTRIES = 202; + +function transcript(entries = TRANSCRIPT_ENTRIES): TranscriptEntry[] { + return Array.from({ length: entries }, (_, index) => ({ + role: (index % 2 === 0 ? "tool" : "toolResult") as TranscriptEntry["role"], + text: `Read src/module-${index}.ts`, + name: "Read", + toolCallId: `toolu_${index.toString(36).padStart(8, "0")}`, + timestamp: 1_700_000_000_000 + index, + startedAt: 1_700_000_000_000 + index, + finishedAt: 1_700_000_000_120 + index, + durationMs: 120, + })); +} + +function agent(index: number, entries: TranscriptEntry[]): AgentRecord { + return { + index, + callId: `call-${index}`, + label: `review:file-${index}`, + phase: "Review", + state: "done", + model: "claude-opus-5", + contextWindow: 400_000, + startedAt: 1_700_000_000_000 + index, + finishedAt: 1_700_000_050_000 + index, + preview: "reviewed", + usage: emptyUsage(), + resultArtifact: `agent-results/agent-${String(index).padStart(4, "0")}.json`, + transcript: entries, + }; +} + +function details(agents: AgentRecord[]): WorkflowDetails { + return { + runId: "wf_deadbeef", + sessionId: "sess-1", + name: "review-changes", + background: true, + status: "completed", + startedAt: 1_700_000_000_000, + finishedAt: 1_700_000_900_000, + phases: [{ title: "Review" }], + agents, + delivery: { + id: "wf_deadbeef:1", + ownerSessionId: "sess-1", + ownerEpoch: 1, + state: "pending", + attempts: 0, + updatedAt: 1_700_000_900_000, + }, + result: { ok: true }, + }; +} + +function withRunDir(fn: (dir: string) => T): T { + const dir = mkdtempSync(join(tmpdir(), "wf-node-budget-")); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function transcriptKeys(dir: string) { + const raw = JSON.parse( + readFileSync(join(dir, "transcripts.json"), "utf8"), + ) as Record; + return Object.keys(raw).filter((key) => /^\d+$/.test(key)); +} + +test("transcripts.json keeps every agent for a default-scale run (issue #558)", () => { + // 12 agents x 202 entries used to stop at 11 agents (20_000-node default), + // at ~28% of the byte cap, with no record that anything was dropped. + withRunDir((dir) => { + const source = details( + Array.from({ length: 12 }, (_, index) => agent(index, transcript())), + ); + persistWorkflowJson(dir, source); + + const keys = transcriptKeys(dir); + assert.equal(keys.length, 12, "all 12 agent transcripts must be written"); + + const parsed = JSON.parse( + readFileSync(join(dir, "transcripts.json"), "utf8"), + ); + for (let index = 0; index < 12; index++) { + assert.equal( + (parsed as Record)[String(index)]?.length, + TRANSCRIPT_ENTRIES, + `agent ${index} must keep all ${TRANSCRIPT_ENTRIES} entries`, + ); + } + + const back = normalizePersistedWorkflowDetails( + "wf_deadbeef", + JSON.parse(readFileSync(join(dir, "workflow.json"), "utf8")), + ); + assert.equal(back?.status, "completed"); + assert.equal(back?.agents.length, 12); + assert.equal(back?.delivery?.state, "pending"); + assert.equal(back?.transcriptArtifact, "transcripts.json"); + assert.equal(back?.transcriptsOmitted, undefined); + }); +}); + +test("transcripts.json keeps whole agents and reports the dropped tail on overflow", () => { + // Enough full transcripts to exceed the 2 MiB byte budget so the byte cap, + // not the node budget, becomes the binding limit. + withRunDir((dir) => { + const total = 80; + const source = details( + Array.from({ length: total }, (_, index) => agent(index, transcript())), + ); + persistWorkflowJson(dir, source); + + const keys = transcriptKeys(dir); + assert.ok(keys.length > 0, "at least one agent must survive"); + assert.ok( + keys.length < total, + "the tail must be dropped past the byte cap", + ); + + // Whole agents only: every written key holds a full transcript. + const parsed = JSON.parse( + readFileSync(join(dir, "transcripts.json"), "utf8"), + ) as Record; + for (const key of keys) { + assert.equal(parsed[key]?.length, TRANSCRIPT_ENTRIES); + } + // The file stays within its byte cap. + assert.ok( + Buffer.byteLength(readFileSync(join(dir, "transcripts.json"), "utf8")) <= + 2 * 1024 * 1024, + ); + + const back = normalizePersistedWorkflowDetails( + "wf_deadbeef", + JSON.parse(readFileSync(join(dir, "workflow.json"), "utf8")), + ); + const omitted = back?.transcriptsOmitted; + assert.ok(omitted, "transcriptsOmitted must be reported"); + assert.equal(omitted?.agents, total - keys.length); + assert.equal(omitted?.entries, (total - keys.length) * TRANSCRIPT_ENTRIES); + + const report = buildWorkflowReport(back!); + assert.match( + report, + /agent transcript\(s\).*omitted from transcripts\.json/, + ); + }); +}); + +test("boundedTranscriptsArtifact keeps whole agents in index order and counts the rest", () => { + const agents = Array.from({ length: 40 }, (_, index) => + agent(index, transcript()), + ); + const bounded = boundedTranscriptsArtifact(agents, { + maxBytes: 2 * 1024 * 1024, + }); + const parsed = JSON.parse(bounded.content) as Record; + const keptKeys = Object.keys(parsed) + .map(Number) + .sort((a, b) => a - b); + + // Kept keys are a contiguous prefix by index (0..K-1); the tail is omitted. + assert.deepEqual( + keptKeys, + Array.from({ length: keptKeys.length }, (_, i) => i), + ); + assert.equal(bounded.omitted.agents, 40 - keptKeys.length); + assert.equal( + bounded.omitted.entries, + (40 - keptKeys.length) * TRANSCRIPT_ENTRIES, + ); + + const empty = boundedTranscriptsArtifact([]); + assert.deepEqual(empty.omitted, { agents: 0, entries: 0 }); + assert.deepEqual(JSON.parse(empty.content), {}); +}); + +test("workflow.json manifest survives the hard agent-call cap without losing terminal fields", () => { + // At 500-1024 agents the 20_000-node default used to drop delivery / status / + // transcriptArtifact from the tail of the manifest object. + withRunDir((dir) => { + const source = details( + Array.from({ length: 1024 }, (_, index) => agent(index, [])), + ); + persistWorkflowJson(dir, source); + + const raw = readFileSync(join(dir, "workflow.json"), "utf8"); + assert.equal( + raw.includes("[truncated: node limit]"), + false, + "manifest must not be node-truncated within the valid agent range", + ); + const back = normalizePersistedWorkflowDetails( + "wf_deadbeef", + JSON.parse(raw), + ); + assert.equal(back?.status, "completed"); + assert.equal(back?.agents.length, 1024); + assert.equal(back?.delivery?.state, "pending"); + assert.equal(back?.resultArtifact, "result.json"); + assert.equal(back?.transcriptArtifact, "transcripts.json"); + }); +}); + +test("result.json overflow is reported honestly, not silently lossy", () => { + withRunDir((dir) => { + // Many mid-sized strings: each stays under maxStringBytes and the node + // count stays far under the budget, so the *byte* cap is what binds — the + // path the node-budget fix routes overflow through. + const huge = { + items: Array.from({ length: 50 }, (_, index) => ({ + id: index, + blob: "x".repeat(40 * 1024), + })), + }; + const source = details([agent(0, [])]); + source.result = huge; + persistWorkflowJson(dir, source); + + const parsed = JSON.parse(readFileSync(join(dir, "result.json"), "utf8")); + // The honest byte-cap fallback: a visible truncation marker, never a + // valid-looking object missing its tail. + assert.equal(parsed.truncated, true); + assert.match(String(parsed.reason ?? ""), /exceeded/); + }); +}); From b63a3a7629b6a6fedfd035729ddeb86ee42d442e Mon Sep 17 00:00:00 2001 From: yukachen Date: Sun, 20 Sep 2026 15:17:40 +0800 Subject: [PATCH 2/4] fix(workflows): carry transcriptsOmitted through settled and completion projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review: the omission evidence stopped at the disk read-back and never reached the in-memory projections. The settled projection (projectWorkflowDetails) rebuilds WorkflowDetails from a field whitelist that dropped transcriptsOmitted, and completionEnvelope() builds its envelope from that projection — so a restored or evicted run lost the notice, and the completion alert never fired. - retention.ts: pass transcriptsOmitted through makeProjection's candidate (like logsDropped), and add it to the byte-pressure drop list so the bound still converges under an extreme budget. - completion-projection.ts: surface it in the expanded operator report next to the transcripts artifact (the collapsed alert was already added). - tests: assert it survives projectWorkflowDetails and appears in both the alerts and expanded evidence of buildWorkflowCompletionDisplay. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/workflows/completion-projection.ts | 5 +++ extensions/workflows/retention.ts | 4 ++ .../workflows/artifact-node-budget.test.ts | 39 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/extensions/workflows/completion-projection.ts b/extensions/workflows/completion-projection.ts index ca8f784f..b3b549cb 100644 --- a/extensions/workflows/completion-projection.ts +++ b/extensions/workflows/completion-projection.ts @@ -176,6 +176,11 @@ function buildOperatorReport( : undefined, ].filter((entry): entry is string => entry !== undefined); if (artifacts.length > 0) lines.push("", "Artifacts:", ...artifacts); + if (details.transcriptsOmitted) { + lines.push( + ` (${details.transcriptsOmitted.agents} agent transcript(s), ${details.transcriptsOmitted.entries} entr${details.transcriptsOmitted.entries === 1 ? "y" : "ies"} omitted from transcripts.json to stay within its byte budget)`, + ); + } const replayed = details.agents.filter((agent) => agent.replayed).length; if (details.resumedFrom) { diff --git a/extensions/workflows/retention.ts b/extensions/workflows/retention.ts index 82123a76..957a26d8 100644 --- a/extensions/workflows/retention.ts +++ b/extensions/workflows/retention.ts @@ -233,6 +233,9 @@ function makeProjection( agents, ...(logs.length > 0 ? { logs } : {}), ...(details.logsDropped ? { logsDropped: details.logsDropped } : {}), + ...(details.transcriptsOmitted + ? { transcriptsOmitted: details.transcriptsOmitted } + : {}), ...(details.delivery ? { delivery: { @@ -349,6 +352,7 @@ export function projectWorkflowDetails( "name", "sessionId", "logsDropped", + "transcriptsOmitted", "logs", "graphOmitted", "result", diff --git a/tests/extensions/workflows/artifact-node-budget.test.ts b/tests/extensions/workflows/artifact-node-budget.test.ts index 0e1fbb20..efb80134 100644 --- a/tests/extensions/workflows/artifact-node-budget.test.ts +++ b/tests/extensions/workflows/artifact-node-budget.test.ts @@ -7,6 +7,7 @@ import { boundedTranscriptsArtifact, persistWorkflowJson, } from "../../../extensions/workflows/artifacts.ts"; +import { buildWorkflowCompletionDisplay } from "../../../extensions/workflows/completion-projection.ts"; import { buildWorkflowReport, normalizePersistedWorkflowDetails, @@ -17,6 +18,7 @@ import { type TranscriptEntry, type WorkflowDetails, } from "../../../extensions/workflows/model.ts"; +import { projectWorkflowDetails } from "../../../extensions/workflows/retention.ts"; // The upstream ceiling for a single agent's transcript. A run of ordinary // review agents each reaching this is what surfaced the silent node-budget @@ -252,3 +254,40 @@ test("result.json overflow is reported honestly, not silently lossy", () => { assert.match(String(parsed.reason ?? ""), /exceeded/); }); }); + +test("transcriptsOmitted survives the settled in-memory projection", () => { + const source = details( + Array.from({ length: 3 }, (_, index) => agent(index, [])), + ); + source.transcriptsOmitted = { agents: 4, entries: 800 }; + + const projection = projectWorkflowDetails(source, 1_000_000); + assert.ok(projection, "a generous budget must retain a projection"); + assert.deepEqual(projection?.transcriptsOmitted, { + agents: 4, + entries: 800, + }); +}); + +test("transcriptsOmitted surfaces through the completion display projection", () => { + const source = details( + Array.from({ length: 2 }, (_, index) => agent(index, [])), + ); + source.transcriptsOmitted = { agents: 5, entries: 1010 }; + + const display = buildWorkflowCompletionDisplay([ + { + deliveryId: "wf_deadbeef:1", + details: source, + runDir: "/tmp/wf_deadbeef", + }, + ]); + const entry = display.entries[0]; + assert.ok(entry, "the display must contain the run"); + // Collapsed evidence (alerts) and expanded operator report both carry it. + assert.ok( + entry.alerts.some((alert) => /agent transcript\(s\) omitted/.test(alert)), + "an alert must report the omitted transcripts", + ); + assert.match(entry.expanded, /omitted from transcripts\.json/); +}); From 6e17db90bbc4e2ab53142443b17e90615cfdd7ef Mon Sep 17 00:00:00 2001 From: yukachen Date: Tue, 22 Sep 2026 13:08:52 +0800 Subject: [PATCH 3/4] fix(workflows): surface transcriptsOmitted on the live /workflows detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The omission evidence only reached saved report.md (buildWorkflowReport), the completion alert, and disk. The interactive detail page (renderDetail) never read the field, so after transcripts.json overflows its byte budget a user opening a dropped agent still saw an empty transcript labeled "this run predates transcript capture" — the exact misdirection issue #558 targets. - renderDetail now shows a run-level omission notice, reserving its row so the exact-height layout is preserved. - The transcript view's emptyText is run-aware: a dropped agent reads as "omitted ... to stay within its byte budget" instead of "predates capture". - Regression test drives WorkflowDashboard.render() through both surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/workflows/dashboard.ts | 19 ++++- tests/extensions/workflows/dashboard.test.ts | 88 ++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 3606c0f0..f5ab643e 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -1408,8 +1408,9 @@ export class WorkflowDashboard { formatElapsed(agent.startedAt, agent.finishedAt), ], errorText: agent.error, - emptyText: - "transcript unavailable (this run predates transcript capture)", + emptyText: details.transcriptsOmitted + ? "transcript unavailable (omitted from transcripts.json to stay within its byte budget; full data on disk)" + : "transcript unavailable (this run predates transcript capture)", }; }, close: () => { @@ -1586,6 +1587,17 @@ export class WorkflowDashboard { ), ); + // Run-level notice mirroring the saved report and completion alert: without + // it, opening a dropped agent's empty transcript reads as "predates capture" + // with no on-screen evidence anything was omitted (issue #558). + const omissionNotice = d.transcriptsOmitted + ? theme.fg( + "warning", + ` ${d.transcriptsOmitted.agents} of ${d.agents.length} agent transcript(s) omitted from transcripts.json (byte budget); full data on disk.`, + ) + : undefined; + if (omissionNotice) lines.push(omissionNotice); + const groups = this.groups(); this.phaseIndex = Math.min(this.phaseIndex, Math.max(0, groups.length - 1)); const selectedGroup = groups[this.phaseIndex]; @@ -1596,7 +1608,8 @@ export class WorkflowDashboard { // the agent list is what the view exists for. const logBudget = Math.max(0, Math.min(3, height - 12)); const recentLogs = (d.logs ?? []).slice(-logBudget); - const panelHeight = height - 3 - recentLogs.length; + const panelHeight = + height - 3 - recentLogs.length - (omissionNotice ? 1 : 0); const bodyHeight = Math.max(0, panelHeight - 2); // Left: phases sidebar. diff --git a/tests/extensions/workflows/dashboard.test.ts b/tests/extensions/workflows/dashboard.test.ts index 209cebea..4624fcb6 100644 --- a/tests/extensions/workflows/dashboard.test.ts +++ b/tests/extensions/workflows/dashboard.test.ts @@ -1253,6 +1253,94 @@ test("direct workflow navigation drills right and returns left through every lev } }); +test("detail view surfaces transcriptsOmitted and the transcript view stops lying about it", () => { + // A dropped agent hydrates as an empty transcript. Without a run-level notice + // the detail page shows no evidence, and the transcript view's default + // "predates transcript capture" text is actively wrong (issue #558). + const details: WorkflowDetails = { + runId: "wf_0b17ed0123", + sessionId: SESSION, + name: "byte-budget", + description: "Exercise the omission notice", + background: true, + status: "completed", + startedAt: Date.now() - 2_000, + finishedAt: Date.now() - 1_000, + phases: [{ title: "Review" }], + agents: [ + { + index: 12, + label: "review:tail", + phase: "Review", + state: "done", + startedAt: Date.now() - 1_900, + finishedAt: Date.now() - 1_100, + preview: "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + transcript: [], + }, + ], + transcriptArtifact: "transcripts.json", + transcriptsOmitted: { agents: 3, entries: 606 }, + }; + const tui = { + terminal: { rows: 30 }, + requestRender() {}, + } as unknown as TUI; + const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme; + const keys = { + matches(data: string, binding: string) { + return data === binding.replace("tui.editor.cursor", "").toLowerCase(); + }, + getKeys(binding: string) { + const key = binding.split(".").at(-1) ?? binding; + return [key.toLowerCase()]; + }, + } as unknown as KeybindingsManager; + const dashboard = new WorkflowDashboard( + tui, + theme, + keys, + () => new Map([[details.runId, details]]), + SESSION, + new Set(), + 0, + () => {}, + details.runId, + ); + + try { + const detail = dashboard.render(120); + // The run-level notice is on the live detail page, not only in report.md. + assert.match( + detail.join("\n"), + /3 of 1 agent transcript\(s\) omitted from transcripts\.json/, + ); + // Reserving the notice row must not break the exact-height layout. + assert.equal(detail.length, 29); + + dashboard.handleInput("right"); + dashboard.handleInput("right"); + const transcript = stripVTControlCharacters( + dashboard.render(120).join("\n"), + ); + assert.match(transcript, /stay within its byte budget/); + assert.doesNotMatch(transcript, /predates transcript capture/); + } finally { + dashboard.dispose(); + } +}); + test("Workflow transcript follows, pauses on its top row, and resumes", () => { const mouseModes: string[] = []; const transcript = Array.from({ length: 40 }, (_, index) => ({ From e5373b988cb26f5c1f628d8106f376c17cc6da7b Mon Sep 17 00:00:00 2001 From: yukachen Date: Tue, 22 Sep 2026 15:25:08 +0800 Subject: [PATCH 4/4] fix(workflows): clarify transcript omission notices --- extensions/workflows/dashboard.ts | 4 ++-- tests/extensions/workflows/dashboard.test.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index f5ab643e..d9029fbd 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -1409,7 +1409,7 @@ export class WorkflowDashboard { ], errorText: agent.error, emptyText: details.transcriptsOmitted - ? "transcript unavailable (omitted from transcripts.json to stay within its byte budget; full data on disk)" + ? `transcript unavailable (this run omitted ${details.transcriptsOmitted.agents} agent transcript(s) from transcripts.json to stay within its byte budget)` : "transcript unavailable (this run predates transcript capture)", }; }, @@ -1593,7 +1593,7 @@ export class WorkflowDashboard { const omissionNotice = d.transcriptsOmitted ? theme.fg( "warning", - ` ${d.transcriptsOmitted.agents} of ${d.agents.length} agent transcript(s) omitted from transcripts.json (byte budget); full data on disk.`, + ` ${d.transcriptsOmitted.agents} agent transcript(s) (${d.transcriptsOmitted.entries} entries) omitted from transcripts.json (byte budget).`, ) : undefined; if (omissionNotice) lines.push(omissionNotice); diff --git a/tests/extensions/workflows/dashboard.test.ts b/tests/extensions/workflows/dashboard.test.ts index 4624fcb6..96e4a282 100644 --- a/tests/extensions/workflows/dashboard.test.ts +++ b/tests/extensions/workflows/dashboard.test.ts @@ -1324,8 +1324,9 @@ test("detail view surfaces transcriptsOmitted and the transcript view stops lyin // The run-level notice is on the live detail page, not only in report.md. assert.match( detail.join("\n"), - /3 of 1 agent transcript\(s\) omitted from transcripts\.json/, + /3 agent transcript\(s\) \(606 entries\) omitted from transcripts\.json/, ); + assert.doesNotMatch(detail.join("\n"), /full data on disk/); // Reserving the notice row must not break the exact-height layout. assert.equal(detail.length, 29); @@ -1334,7 +1335,11 @@ test("detail view surfaces transcriptsOmitted and the transcript view stops lyin const transcript = stripVTControlCharacters( dashboard.render(120).join("\n"), ); - assert.match(transcript, /stay within its byte budget/); + assert.match( + transcript, + /this run omitted 3 agent transcript\(s\).*stay within its byte budget/, + ); + assert.doesNotMatch(transcript, /full data on disk/); assert.doesNotMatch(transcript, /predates transcript capture/); } finally { dashboard.dispose();