@pi-archimedes/subagent 2.10.4 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +472 -0
  16. package/src/expanded.ts +202 -82
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +566 -0
  25. package/src/index.ts +169 -16
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +244 -0
  28. package/src/row-block.ts +209 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.test.ts +154 -0
  40. package/src/tool-schema.ts +106 -4
  41. package/src/tool-validation.test.ts +321 -0
  42. package/src/tool-validation.ts +262 -0
  43. package/src/types.ts +86 -0
  44. package/src/usage.test.ts +95 -0
  45. package/src/usage.ts +87 -0
  46. package/src/cost.test.ts +0 -62
  47. package/src/cost.ts +0 -8
@@ -0,0 +1,472 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { buildExpandedText, buildProgressExpandedText, renderExpanded, renderProgressExpanded } from "./expanded.js";
4
+ import { RowBlock, type RowSegment, RESET } from "./row-block.js";
5
+ import type { RunOutcome, SubagentProgress, SubagentResult } from "./types.js";
6
+
7
+ // The expanded row is what the user reads after `ctrl+o`; the compact row is
8
+ // what they see while it runs. Both have to carry the *Run outcome* vocabulary
9
+ // (CONTEXT.md) rather than collapsing it into done/failed.
10
+
11
+ const theme = { fg: (token: string, text: string) => `[${token}]${text}`, bold: (t: string) => t };
12
+
13
+ /** The content these segments carry, ignoring how each is laid out at render time. */
14
+ const drawnText = (segments: RowSegment[]): string => segments.map((s) => s.text).join("\n");
15
+
16
+ function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
17
+ return {
18
+ agent: "reviewer",
19
+ task: "review the diff",
20
+ exitCode: 0,
21
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 1 },
22
+ model: "anthropic/claude-opus-4.5",
23
+ finalOutput: "the answer",
24
+ error: undefined,
25
+ progress: undefined,
26
+ progressSummary: { toolCount: 2, tokens: 15, durationMs: 1000 },
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe("expanded view: run outcomes", () => {
32
+ it("still says Done for a completed run", () => {
33
+ expect(drawnText(buildExpandedText(makeResult({ outcome: "completed" }), undefined, theme))).toContain("✓ Done");
34
+ });
35
+
36
+ it("says Truncated — and not Failed — for a cut-off answer", () => {
37
+ const out = drawnText(buildExpandedText(
38
+ makeResult({ outcome: "truncated", exitCode: 1, finalOutput: "partial" }),
39
+ undefined,
40
+ theme,
41
+ ));
42
+ expect(out).toMatch(/truncated/i);
43
+ expect(out).not.toMatch(/Failed/);
44
+ });
45
+
46
+ it("says Aborted for a cancelled run", () => {
47
+ const out = drawnText(buildExpandedText(
48
+ makeResult({ outcome: "aborted", exitCode: 143, error: undefined }),
49
+ undefined,
50
+ theme,
51
+ ));
52
+ expect(out).toMatch(/aborted/i);
53
+ expect(out).not.toMatch(/Failed/);
54
+ });
55
+
56
+ it("keeps reporting a real error as an error", () => {
57
+ const failed = makeResult({ exitCode: 1, error: "401 unauthorized" });
58
+ delete failed.outcome; // a failed run claims no outcome
59
+ const out = drawnText(buildExpandedText(failed, undefined, theme));
60
+ expect(out).toContain("401 unauthorized");
61
+ });
62
+ });
63
+
64
+ // ── A multi-line error, read in full (issue #112) ───────────────────────────
65
+
66
+ /**
67
+ * What a failed child actually reports: a command line, a compiler diagnostic, a
68
+ * stack frame, and the exit status on the last line. The last line is the one
69
+ * whoever reads it wants (`rc=1` says what happened), and it is exactly what the
70
+ * row cut threw away — so every assertion below keys off it.
71
+ */
72
+ const MULTI_LINE_ERROR = [
73
+ "Command failed: pnpm -r exec -- tsc --noEmit",
74
+ "packages/subagent/src/index.ts:42:7 - error TS2345: Argument of type 'string' is not assignable",
75
+ " at runChild (/home/daniel/Coding/Javascript/pi-archimedes/packages/subagent/src/spawn.ts:88:12)",
76
+ "error: subagent exited with rc=1",
77
+ ].join("\n");
78
+ const ERROR_LINES = MULTI_LINE_ERROR.split("\n");
79
+ /** Everything after the head — the part that moved from the row into the block. */
80
+ const TAIL_ERROR_LINES = ERROR_LINES.slice(1);
81
+ const FIRST_ERROR_LINE = ERROR_LINES[0]!;
82
+ const LAST_ERROR_LINE = ERROR_LINES[ERROR_LINES.length - 1]!;
83
+
84
+ /** `theme.fg` for real SGR bytes, so the rendered lines carry an ANSI state. */
85
+ const ERROR_SGR = "\x1b[31m";
86
+ const sgrTheme = {
87
+ fg: (token: string, text: string) =>
88
+ `${token === "error" ? ERROR_SGR : "\x1b[2m"}${text}\x1b[0m`,
89
+ bold: (t: string) => `\x1b[1m${t}\x1b[0m`,
90
+ };
91
+
92
+ /** A `RowBlock` that remembers the segments it was last given, without faking the row layer. */
93
+ class CapturingRowBlock extends RowBlock {
94
+ /** Named apart from `RowBlock`'s own private field of the same name. */
95
+ lastSegments: RowSegment[] | undefined = undefined;
96
+ /** A builder that handed its content over `setText` instead would land here. */
97
+ lastSetText: string | undefined = undefined;
98
+
99
+ override setSegments(segments: RowSegment[]): void {
100
+ this.lastSegments = segments;
101
+ super.setSegments(segments);
102
+ }
103
+
104
+ override setText(text: string): void {
105
+ this.lastSetText = text;
106
+ super.setText(text);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * What the builder handed the row layer — asserted rather than assumed. Hooking
112
+ * `setText` and checking it here means a builder that switched to `setText`
113
+ * fails as a readable assertion instead of a `TypeError` on `lastSegments!`.
114
+ */
115
+ function captured(rows: CapturingRowBlock): RowSegment[] {
116
+ expect(rows.lastSetText, "the builder handed its content over setText, so nothing below reads it")
117
+ .toBeUndefined();
118
+ expect(rows.lastSegments, "setSegments was never called, so nothing below reads the view").toBeDefined();
119
+ return rows.lastSegments!;
120
+ }
121
+
122
+ /** Strip this file's fake `theme.fg` markers, line by line — a block carries one per line. */
123
+ const plainLines = (text: string): string[] => text.split("\n").map((l) => l.replace(/^\[[a-z]+\]/, ""));
124
+
125
+ const content = (line: string): string => stripTerminalSequences(line).replace(/\s+$/, "");
126
+
127
+ function makeProgress(overrides: Partial<SubagentProgress> = {}): SubagentProgress {
128
+ return {
129
+ agent: "reviewer",
130
+ status: "failed",
131
+ task: "review the diff",
132
+ currentTool: undefined,
133
+ currentToolArgs: undefined,
134
+ currentToolStartedAt: undefined,
135
+ toolCount: 2,
136
+ inputTokens: 10,
137
+ outputTokens: 5,
138
+ tokens: 15,
139
+ cost: 0.01,
140
+ durationMs: 1000,
141
+ error: MULTI_LINE_ERROR,
142
+ model: "anthropic/claude-opus-4.5",
143
+ output: undefined,
144
+ recentOutput: undefined,
145
+ toolCalls: undefined,
146
+ ...overrides,
147
+ };
148
+ }
149
+
150
+ /** A failed run: an error and no outcome, which is what `stream.ts` reports. */
151
+ function failedResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
152
+ const result = makeResult({ exitCode: 1, error: MULTI_LINE_ERROR, ...overrides });
153
+ // A failed run claims no outcome — `stream.ts` leaves the field off entirely.
154
+ if (!("outcome" in overrides)) delete result.outcome;
155
+ return result;
156
+ }
157
+
158
+ describe("expanded view: a multi-line error is readable in full (issue #112)", () => {
159
+ /**
160
+ * The guarantee, asserted the same way for all three builders: the error's last
161
+ * line is present, and a `block` carries it. `block` is this repo's mechanism
162
+ * for "read this in full" (`row-block.ts`; issue #112), so the assertion is on
163
+ * the segment kind rather than on the joined text — a change that pushes the
164
+ * error back through `row()` fails here even though the joined text would still
165
+ * contain the line.
166
+ *
167
+ * Order and grouping are asserted too, because a builder that emitted one block
168
+ * per line, or emitted the tail bottom-up, would satisfy the checks above: the
169
+ * tail is exactly one block and its lines are in the order the child wrote them.
170
+ */
171
+ const revealsTheWholeError = (segments: RowSegment[]): void => {
172
+ expect(drawnText(segments)).toContain(LAST_ERROR_LINE);
173
+ expect(
174
+ segments.filter((s) => s.text.includes(LAST_ERROR_LINE)).map((s) => s.kind),
175
+ "the error's last line is inside a row again",
176
+ ).toEqual(["block"]);
177
+ const heads = segments
178
+ .map((s, i) => ({ s, i }))
179
+ .filter(({ s }) => s.kind === "row" && s.text.includes(FIRST_ERROR_LINE));
180
+ expect(heads, "the error's first line is no longer the row it was").toHaveLength(1);
181
+ const tails = segments.filter(
182
+ (s) => s.kind === "block" && TAIL_ERROR_LINES.some((line) => s.text.includes(line)),
183
+ );
184
+ // One block, not one per line: `RowBlock` renders a block by wrapping it, so
185
+ // N blocks for N lines would draw the tail the same at a wide frame and hide
186
+ // the difference at a narrow one (where each block re-wraps on its own).
187
+ expect(tails, "the error's tail is not carried by exactly one block").toHaveLength(1);
188
+ const [tail] = tails;
189
+ expect(
190
+ tail === undefined ? [] : plainLines(tail.text),
191
+ "the error's tail block does not carry its lines in order",
192
+ ).toEqual(TAIL_ERROR_LINES);
193
+ expect(
194
+ segments.indexOf(tail!),
195
+ "the tail block was emitted before the row head, so the error reads bottom-up",
196
+ ).toBeGreaterThan(heads[0]!.i);
197
+ };
198
+
199
+ it("shows every line of a finished run's error, the last one as a block", () => {
200
+ const segments = buildExpandedText(failedResult(), undefined, theme);
201
+ revealsTheWholeError(segments);
202
+ // The marker convention survives: the first line is still the outcome row,
203
+ // with the ✗ error marker in front of it. Pinned exactly, because that row is
204
+ // what the collapsed view draws too — only the tail moves into a block.
205
+ const heads = segments.filter((s) => s.kind === "row" && s.text.includes("✗ "));
206
+ expect(heads).toEqual([{ kind: "row", text: "[error]✗ " + FIRST_ERROR_LINE }]);
207
+ });
208
+
209
+ it("shows every line of a streaming run's error, the last one as a block", () => {
210
+ const rows = new CapturingRowBlock();
211
+ renderProgressExpanded(rows, makeProgress({ status: "failed" }), theme);
212
+ revealsTheWholeError(captured(rows));
213
+ });
214
+
215
+ it("shows every line of a streaming parallel run's error, the last one as a block", () => {
216
+ revealsTheWholeError(buildProgressExpandedText(makeProgress({ status: "failed" }), theme));
217
+ });
218
+
219
+ // Both streaming builders choose this branch on `status === "completed" ||
220
+ // outcome`, and still pass `progress.error` down, where `outcomeSegments` reads
221
+ // `error` before `outcome` — mirroring `buildOutcomeLine` (`format.ts:101`).
222
+ // `stream.ts` only ever sets `error` for a failed run today, but `progress` is
223
+ // also data read back out of a persisted session, and the branch's own contract
224
+ // is "state or outcome, plus whatever error came with it" — so the error has to
225
+ // be revealed here too. Until now it was the one place the finished view's fix
226
+ // had not reached: same run, full error in `buildExpandedText`, one line here.
227
+ it("shows every line of a *completed* streaming run's error, not just a failed one's", () => {
228
+ const rows = new CapturingRowBlock();
229
+ renderProgressExpanded(rows, makeProgress({ status: "completed" }), theme);
230
+ revealsTheWholeError(captured(rows));
231
+ });
232
+
233
+ it("shows every line of a *completed* parallel streaming run's error", () => {
234
+ revealsTheWholeError(buildProgressExpandedText(makeProgress({ status: "completed" }), theme));
235
+ });
236
+
237
+ it("reveals the tail for a still-running progress that already carries an outcome", () => {
238
+ // The `|| progress.outcome` half of the same condition.
239
+ const rows = new CapturingRowBlock();
240
+ renderProgressExpanded(rows, makeProgress({ status: "running", outcome: "truncated" }), theme);
241
+ revealsTheWholeError(captured(rows));
242
+ });
243
+
244
+ it("draws the whole error on screen at a narrow width as well as a wide one", () => {
245
+ // Through the real row component, so this is about what lands on screen and
246
+ // not about the intermediate segments (the style of the width audit).
247
+ const [headLine, ...carriedLines] = MULTI_LINE_ERROR.split("\n");
248
+ for (const width of [200, 120, 80, 40]) {
249
+ const rows = new CapturingRowBlock();
250
+ renderExpanded(rows, failedResult(), undefined, sgrTheme);
251
+ const rendered = rows.render(width);
252
+ const joined = rendered.map(content).join("").replace(/\s+/g, "");
253
+ // Every line the block carries survives the wrap, whole. (The first line
254
+ // stays a row — cut at the frame like every other row, which is why it is
255
+ // not in this list.)
256
+ for (const line of carriedLines) {
257
+ // A wrapped line may break a long line across rows, so compare with the
258
+ // whitespace removed, exactly like the width audit does.
259
+ expect(joined, `line lost at ${width}: ${JSON.stringify(rendered.map(content))}`)
260
+ .toContain(line.replace(/\s+/g, ""));
261
+ }
262
+ // The last line — the one the old cut discarded outright — is on a row of
263
+ // its own rather than smeared across a wrap boundary.
264
+ expect(
265
+ rendered.map(content).some((l) => l.includes(carriedLines[carriedLines.length - 1]!)),
266
+ `last error line missing at ${width}: ${JSON.stringify(rendered.map(content))}`,
267
+ ).toBe(true);
268
+ // And the head is still there, cut the way a row cuts, marker and all.
269
+ expect(rendered.map(content)[0]).toContain("reviewer");
270
+ expect(
271
+ rendered.map(content).some((l) => l.includes("✗ " + headLine!.slice(0, 10))),
272
+ `error head missing at ${width}: ${JSON.stringify(rendered.map(content))}`,
273
+ ).toBe(true);
274
+ for (const line of rendered) {
275
+ expect(visibleWidth(line), `row over the frame at ${width}`).toBeLessThanOrEqual(width);
276
+ }
277
+ }
278
+ });
279
+
280
+ it("closes the error colour on every line it drew, so colour does not bleed", () => {
281
+ // Wide enough that nothing wraps: every logical line is one rendered line, so
282
+ // each one has to carry its own complete SGR pair. Handing `block` the whole
283
+ // `theme.fg("error", "✗ " + error)` string instead would open one span over
284
+ // every line and land the single reset on the last one only — the padding of
285
+ // the earlier lines then sits inside an open span.
286
+ const rows = new CapturingRowBlock();
287
+ renderExpanded(rows, failedResult(), undefined, sgrTheme);
288
+ const drawn = rows.render(200);
289
+ const errorLines = drawn.filter((l) => l.includes(ERROR_SGR));
290
+ expect(errorLines.length).toBe(MULTI_LINE_ERROR.split("\n").length);
291
+ for (const line of errorLines) {
292
+ expect(line.startsWith(ERROR_SGR), `line does not open its own span: ${JSON.stringify(line)}`)
293
+ .toBe(true);
294
+ expect(
295
+ line.trimEnd().endsWith(RESET),
296
+ `line leaves its span open across the padding: ${JSON.stringify(line)}`,
297
+ ).toBe(true);
298
+ }
299
+ });
300
+
301
+ it("leaves a single-line error exactly as it was rendered before", () => {
302
+ // Byte-identical: only the multi-line case changes, so every existing
303
+ // assertion about the outcome row keeps its meaning.
304
+ const failed = makeResult({ exitCode: 1, error: "401 unauthorized" });
305
+ delete failed.outcome;
306
+ const segments = buildExpandedText(failed, undefined, theme);
307
+ const outcome = segments.filter((s) => s.text.includes("401 unauthorized"));
308
+ expect(outcome).toEqual([{ kind: "row", text: "[error]✗ 401 unauthorized" }]);
309
+ // Counted as segments, because a segment's text is already multi-line: the
310
+ // claim is "the error reached the row layer exactly once", which a duplicate
311
+ // (the same line in a row *and* in a block) would fail.
312
+ expect(segments.filter((s) => s.text.includes("401"))).toHaveLength(1);
313
+ });
314
+
315
+ it("draws no blank row for an error that merely ends in a newline", () => {
316
+ // A trailing newline is not a line of content, and opening a block for it
317
+ // would add a blank row to the bottom of every failed run.
318
+ const failed = makeResult({ exitCode: 1, error: "401 unauthorized\n" });
319
+ delete failed.outcome;
320
+ const segments = buildExpandedText(failed, undefined, theme);
321
+ expect(segments.filter((s) => s.kind === "block" && s.text.trim() === "")).toEqual([]);
322
+ // And through the real row component, because the segment layer cannot see
323
+ // the failure this guards against: an emitted `row("")` is invisible in the
324
+ // list above yet `RowBlock` would draw it as a blank bottom row.
325
+ const rows = new CapturingRowBlock();
326
+ renderExpanded(rows, failed, undefined, theme);
327
+ const drawn = rows.render(200).map(content);
328
+ expect(drawn.filter((l) => l.includes("401"))).toHaveLength(1);
329
+ expect(
330
+ drawn[drawn.length - 1],
331
+ `a blank row was drawn under the outcome: ${JSON.stringify(drawn)}`,
332
+ ).toContain("401 unauthorized");
333
+ });
334
+
335
+ it("still lets the error win over the outcome, even when the error is multi-line", () => {
336
+ // Pinned as-is, not decided here: `outcomeSegments` reads `data.error`
337
+ // before the outcome — the same precedence `buildOutcomeLine` gives it — so an
338
+ // error and an outcome together report the error. Both halves of
339
+ // the text have to survive that precedence now that the error is a block.
340
+ const out = drawnText(
341
+ buildExpandedText(failedResult({ outcome: "truncated", finalOutput: "partial" }), undefined, theme),
342
+ );
343
+ expect(out).toContain(FIRST_ERROR_LINE);
344
+ expect(out).toContain(LAST_ERROR_LINE);
345
+ expect(out).not.toMatch(/Truncated/);
346
+ });
347
+
348
+ // The collapsed half of this asymmetry is pinned where that code lives, not here: `compact.test.ts` ("hands over three row segments carrying whole fields, so newlines reach the row cut") and `tool-row.width-audit.test.ts` ("keeps a multiline task or error to three rows per agent (collapsed)").
349
+
350
+ describe("a child that wrote CRLF line endings (a Windows child)", () => {
351
+ // `stream.ts` reads stderr as `Buffer.concat(...).toString("utf-8").trim()` —
352
+ // no newline normalisation — so `\r\n` arrives intact from a Windows child.
353
+ // `pi-tui`'s `wrapTextWithAnsi` splits on `/\r\n|\r|\n/` but `truncateRow`
354
+ // splits on `"\n"`, so a `\r` surviving the split rides inside a `row` and
355
+ // gets drawn: pi-tui does not strip it, and a terminal interpreting a bare CR
356
+ // rewinds the cursor to column 0 and paints the row over itself.
357
+ const CRLF_ERROR = ERROR_LINES.join("\r\n");
358
+
359
+ it("keeps every carriage return out of the segments and off the screen", () => {
360
+ const builders: Array<[string, () => RowSegment[]]> = [
361
+ ["buildExpandedText", () => buildExpandedText(failedResult({ error: CRLF_ERROR }), undefined, theme)],
362
+ ["renderProgressExpanded", () => {
363
+ const rows = new CapturingRowBlock();
364
+ renderProgressExpanded(rows, makeProgress({ status: "completed", error: CRLF_ERROR }), theme);
365
+ return captured(rows);
366
+ }],
367
+ ["buildProgressExpandedText", () => buildProgressExpandedText(makeProgress({ status: "failed", error: CRLF_ERROR }), theme)],
368
+ ];
369
+ for (const [name, build] of builders) {
370
+ const segments = build();
371
+ expect(
372
+ segments.filter((s) => s.text.includes("\r")).map((s) => s.text),
373
+ `${name} left a carriage return inside a segment`,
374
+ ).toEqual([]);
375
+ }
376
+ // Asserted on the RAW rendered lines: `content()` trims trailing whitespace,
377
+ // so it would swallow the very byte this is about.
378
+ for (const width of [200, 80]) {
379
+ const rows = new CapturingRowBlock();
380
+ renderExpanded(rows, failedResult({ error: CRLF_ERROR }), undefined, sgrTheme);
381
+ const rendered = rows.render(width);
382
+ for (const line of rendered) {
383
+ expect(JSON.stringify(line), `a bare CR was drawn at width ${width}`).not.toContain("\\r");
384
+ }
385
+ // Nothing was lost by the split either — every line still reaches the frame.
386
+ const joined = rendered.map(content).join("").replace(/\s+/g, "");
387
+ for (const line of ERROR_LINES) {
388
+ expect(joined, `line lost at ${width}: ${JSON.stringify(rendered.map(content))}`)
389
+ .toContain(line.replace(/\s+/g, ""));
390
+ }
391
+ }
392
+ });
393
+ });
394
+ });
395
+
396
+ // ── The fallback each builder keeps (the legacy-session shape) ──────────────
397
+
398
+ describe("expanded view: a run with nothing to report falls back the way its own builder did", () => {
399
+ // `outcome` is optional (`types.ts:38,70,134`) and only populated from 8cf0d12
400
+ // (#103) on, so both shapes below reach the empty case: a session JSON persisted
401
+ // before it renders the finished view with `status:"completed"` and no outcome,
402
+ // and a progress that reports a terminal state and no outcome renders the two
403
+ // streaming views that way. Each site's fallback is load-bearing there, and they
404
+ // are *not* the same fallback: the finished view says `✗ Failed`, the two
405
+ // streaming views say `✓ Done`. Relabelling a completed run failed is how a
406
+ // useful answer gets thrown away by whoever reads it.
407
+ const saysDoneAndNeverFailed = (text: string): void => {
408
+ expect(text).toContain("✓ Done");
409
+ expect(text).not.toMatch(/Failed/);
410
+ };
411
+
412
+ it("says Done for a finished result that predates the outcome field", () => {
413
+ const legacy = makeResult();
414
+ delete legacy.outcome;
415
+ saysDoneAndNeverFailed(drawnText(buildExpandedText(legacy, undefined, theme)));
416
+ });
417
+
418
+ it("says Done for streaming progress that predates the outcome field", () => {
419
+ const rows = new CapturingRowBlock();
420
+ renderProgressExpanded(rows, makeProgress({ status: "completed", error: undefined }), theme);
421
+ saysDoneAndNeverFailed(drawnText(captured(rows)));
422
+ });
423
+
424
+ it("says Done for parallel streaming progress that predates the outcome field", () => {
425
+ saysDoneAndNeverFailed(
426
+ drawnText(buildProgressExpandedText(makeProgress({ status: "completed", error: undefined }), theme)),
427
+ );
428
+ });
429
+
430
+ it("still names a truncated or aborted streaming run by its outcome, not as failed", () => {
431
+ // The same sites, one step on from the legacy shape: a real outcome has to win
432
+ // over the caller's fallback, and `✓ Done` must not flatten it either.
433
+ for (const outcome of ["truncated", "aborted"] as const) {
434
+ const expected = outcome === "truncated" ? /truncated/i : /aborted/i;
435
+ const rows = new CapturingRowBlock();
436
+ renderProgressExpanded(rows, makeProgress({ status: "completed", outcome, error: undefined }), theme);
437
+ const streaming = drawnText(captured(rows));
438
+ expect(streaming, `renderProgressExpanded flattened ${outcome}`).toMatch(expected);
439
+ expect(streaming).not.toMatch(/Failed|✓ Done/);
440
+ const parallel = drawnText(
441
+ buildProgressExpandedText(makeProgress({ status: "completed", outcome, error: undefined }), theme),
442
+ );
443
+ expect(parallel, `buildProgressExpandedText flattened ${outcome}`).toMatch(expected);
444
+ expect(parallel).not.toMatch(/Failed|✓ Done/);
445
+ }
446
+ });
447
+
448
+ it("falls back to Failed on the finished view when a result reports nothing at all", () => {
449
+ // One door onto the finished view's bottom row: a result with neither an
450
+ // outcome nor an error and a nonzero exit — `buildExpandedText`'s own `else`,
451
+ // which keeps saying Failed because such a run has no success to claim.
452
+ const nothing = makeResult({ exitCode: 1 });
453
+ delete nothing.outcome;
454
+ const out = drawnText(buildExpandedText(nothing, undefined, theme));
455
+ expect(out).toContain("✗ Failed");
456
+ expect(out).not.toMatch(/✓ Done/);
457
+ });
458
+
459
+ it("falls back to Failed — not Done — for an outcome this build cannot name", () => {
460
+ // The other door, and the one that only exists now the fallback is the
461
+ // caller's: `outcome` set to something `buildOutcomeLine` has no case for (a
462
+ // session written by a newer build, or a widened *Run outcome* axis) leaves it
463
+ // returning `undefined`, and the segment `buildExpandedText` passes in is what
464
+ // gets drawn. It stays Failed — a run whose ending this code cannot read is
465
+ // not a success this code may claim — which is the byte this site rendered
466
+ // before the fallback moved, so moving it changed nothing here.
467
+ const unknown = makeResult({ exitCode: 1, outcome: "detached" as RunOutcome });
468
+ const out = drawnText(buildExpandedText(unknown, undefined, theme));
469
+ expect(out).toContain("✗ Failed");
470
+ expect(out).not.toMatch(/✓ Done/);
471
+ });
472
+ });