@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
package/src/expanded.ts CHANGED
@@ -1,10 +1,24 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall } from "./types.js";
3
- import { formatDuration, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
1
+ import type { RunOutcome, SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall } from "./types.js";
2
+ import { formatDuration, buildStatsLine, buildAgentLabel, buildOutcomeLine } from "./format.js";
3
+ import { block, row, type RowBlock, type RowSegment } from "./row-block.js";
4
4
  import { renderToolCallLine } from "@pi-archimedes/core/tool-render";
5
5
 
6
6
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
7
7
 
8
+ // The expanded view mixes two kinds of line and says which is which (see
9
+ // `row-block.ts`): a **row** (label, activity, the first line of an outcome) stays
10
+ // one line and fills the frame; a **block** (the stats line, the task text, the
11
+ // tool-call history, the output, the lines of an error after the first) keeps
12
+ // wrapping, because the expanded view is where all of it is meant to be readable
13
+ // (issue #112; its counterpart — the collapsed view cutting at the first line — is
14
+ // pinned in `tool-row.width-audit.test.ts`). The stats line is a block and not a
15
+ // row so the view reads on its own, without the reader collapsing back to the row
16
+ // to get the numbers back. They do live elsewhere too — `compact.ts` builds the
17
+ // identical `buildStatsLine`, `formatParallelResults` puts tools / tokens /
18
+ // seconds in the model-facing content, and `usage` reaches pi's `/session` — but
19
+ // every row is cut at the frame, so the tail of the stats (tokens, duration,
20
+ // cost) is exactly what a narrow terminal would drop from this view.
21
+
8
22
  // ── Helpers ─────────────────────────────────────────────────────────────────
9
23
 
10
24
  // A completed tool call renders with a status glyph (✓ ok / ✗ error) and its
@@ -19,17 +33,87 @@ function formatToolCall(call: SubagentToolCall | string, theme: Theme): string {
19
33
  return renderToolCallLine(status, call.name, suffix, theme);
20
34
  }
21
35
 
36
+ // The bottom of an expanded view. The *Run outcome* wins over the done/failed
37
+ // pair: a truncated answer is not a failed run, and labelling it "✗ Failed" is
38
+ // how a useful answer gets thrown away by whoever reads it.
39
+ //
40
+ // An error is often stderr or a thrown Error's message — the second kind never
41
+ // touched a stream (`dispatch.ts`'s `buildFailedResult` carries `err.message`, and
42
+ // `stream.ts` synthesises strings like "subagent failed with exit code N"). Either
43
+ // way it arrives with newlines in it, and as one `row` the view could only ever
44
+ // show its first line: `truncateRow` treats a newline as a hard boundary (see
45
+ // `row-block.ts`), which is right for the collapsed view (the three-rows-per-agent
46
+ // shape is pinned in `tool-row.width-audit.test.ts`) and wrong here: the expanded
47
+ // view is where everything is meant to be readable (issue #112), and a stack
48
+ // trace's last line is usually the informative one. So the first line stays the
49
+ // row it was, and the lines after it are handed over as one `block` — this repo's
50
+ // existing mechanism for "read this in full".
51
+ //
52
+ // The raw error is split *before* anything is coloured, and every line is
53
+ // coloured on its own. `theme.fg("error", "✗ " + error)` wraps the whole
54
+ // multi-line string in one span whose reset lands on the last line only, so
55
+ // every earlier line would be drawn — padding included — inside an open span.
56
+ // Measured against `wrapTextWithAnsi`: it re-opens the active codes at the start
57
+ // of a continuation line but never invents a reset at the end of one.
58
+ function outcomeSegments(
59
+ data: { outcome?: RunOutcome | undefined; error: string | undefined },
60
+ theme: Theme,
61
+ // The segment to draw when there is nothing to report, rendered by the caller
62
+ // rather than decided here, because the five call sites do not agree on it — and
63
+ // they disagree per *branch*, not per builder: the finished view's single call
64
+ // says `✗ Failed` (an `outcome` this build cannot name is not a success this
65
+ // build may claim), while each streaming builder passes `✓ Done` from its
66
+ // completed branch and `✗ Failed` from its failed one. So "nothing to report"
67
+ // means something different at each site, and the `✓ Done` ones are why the
68
+ // answer cannot be baked in here: `outcome` is optional on `SubagentProgress`
69
+ // (`types.ts:38`; `SubagentResult` at `:70`, `SubagentRunReport` at `:134`), so a
70
+ // run can report a terminal status with no outcome at all — every session
71
+ // persisted before #103 / 8cf0d12 added the field — and a helper-wide
72
+ // `✗ Failed` there would relabel a finished run a broken one.
73
+ fallback: RowSegment,
74
+ ): RowSegment[] {
75
+ // Same precedence as `buildOutcomeLine` (`format.ts:101`): `error` is read first,
76
+ // so a run that carries both reports the error and not the outcome. Pinned in
77
+ // `expanded.test.ts` rather than re-decided here. It is also why neither streaming
78
+ // branch may assume it has no error in hand: each opens on a completed *status* or
79
+ // on any reported outcome, and either can arrive together with a multi-line one,
80
+ // so a completed progress's error gets the same head-row-then-block treatment here
81
+ // that the failed branch gives its own (#112).
82
+ if (data.error) {
83
+ // `/\r\n|\r|\n/`, not `"\n"`: `stream.ts` reads stderr as
84
+ // `Buffer.concat(...).toString("utf-8").trim()` with no newline
85
+ // normalisation, so a Windows child's `\r\n` arrives intact. pi-tui's
86
+ // `wrapTextWithAnsi` splits on all three endings but `truncateRow` splits on
87
+ // `"\n"`, so a `\r` left inside a row would be *drawn*, and a terminal that
88
+ // honours a bare CR repaints the row from column 0.
89
+ const [head, ...tail] = data.error.split(/\r\n|\r|\n/);
90
+ const segments: RowSegment[] = [row(theme.fg("error", "✗ " + head))];
91
+ // A tail of nothing but blanks gets no block: a synthesised error, or any
92
+ // producer that does not `trim()` its stderr, can end in a newline, and
93
+ // opening a block for that would add a blank row under every failed run.
94
+ if (tail.some((line) => line.trim() !== "")) {
95
+ segments.push(block(tail.map((line) => (line ? theme.fg("error", line) : "")).join("\n")));
96
+ }
97
+ return segments;
98
+ }
99
+ const line = buildOutcomeLine({ outcome: data.outcome, error: undefined }, theme);
100
+ // The outcome vocabulary stays `buildOutcomeLine`'s and this helper still owns
101
+ // the row decision; only "nothing to report" hands back the segment the caller
102
+ // rendered for itself.
103
+ return [line === undefined ? fallback : row(line)];
104
+ }
105
+
22
106
  // ── Expanded completed result ───────────────────────────────────────────────
23
107
 
24
108
  export function buildExpandedText(
25
109
  result: SubagentResult,
26
110
  progress: SubagentProgress | undefined,
27
111
  theme: Theme,
28
- ): string {
29
- const lines: string[] = [];
112
+ ): RowSegment[] {
113
+ const lines: RowSegment[] = [];
30
114
 
31
- lines.push(buildAgentLabel(result.agent, result.task, theme));
32
- lines.push("");
115
+ lines.push(row(buildAgentLabel(result.agent, result.task, theme)));
116
+ lines.push(row(""));
33
117
 
34
118
  // Stats line (same as compact view)
35
119
  const modelName = progress?.model ?? result.model;
@@ -43,69 +127,91 @@ export function buildExpandedText(
43
127
  }, theme);
44
128
  const statsParts = [modelLabel, statsLine].filter(Boolean);
45
129
  if (statsParts.length > 0) {
46
- lines.push(statsParts.join(" "));
130
+ // A block, not a row: the expanded view has to read on its own, so the
131
+ // numbers cannot depend on the reader collapsing the row to get them back
132
+ // however narrow the frame is.
133
+ lines.push(block(statsParts.join(" ")));
47
134
  }
48
135
 
49
136
  // Task
50
137
  if (result.task) {
51
- lines.push("");
52
- lines.push(theme.fg("dim", "Task: " + result.task));
138
+ lines.push(row(""));
139
+ lines.push(block(theme.fg("dim", "Task: " + result.task)));
53
140
  }
54
141
 
55
142
  // Tool calls history
56
143
  const toolCalls = progress?.toolCalls;
57
144
  if (toolCalls && toolCalls.length > 0) {
58
- lines.push("");
145
+ lines.push(row(""));
59
146
  for (const call of toolCalls) {
60
- lines.push(formatToolCall(call, theme));
147
+ lines.push(block(formatToolCall(call, theme)));
61
148
  }
62
149
  }
63
150
 
64
151
  // Output - show live streaming output or final output
65
152
  const outputText = progress?.output ?? result.finalOutput;
66
153
  if (outputText) {
67
- lines.push("");
68
- lines.push(outputText);
154
+ lines.push(row(""));
155
+ // A block, uncapped: ADR 0020 is the precedent that this package does not cap
156
+ // output where the reader has no other source for it. Read it precisely — ADR
157
+ // 0020 governs the *tool-result content* the model reads, not this view — and
158
+ // the same reasoning carries over: the answer shown here is the child's, and
159
+ // nothing else spells it out for whoever is reading the transcript.
160
+ lines.push(block(outputText));
69
161
  }
70
162
 
71
- // Error
72
- if (result.error) {
73
- lines.push("");
74
- lines.push(theme.fg("error", "✗ " + result.error));
75
- } else if (result.exitCode === 0) {
76
- lines.push("");
77
- lines.push(theme.fg("success", "✓ Done"));
163
+ // Error / outcome at the bottom
164
+ if (result.error || result.outcome || result.exitCode === 0) {
165
+ lines.push(row(""));
166
+ if (result.outcome || result.error) {
167
+ // The fallback covers exactly one case here: an `outcome` this build cannot
168
+ // name, which leaves `buildOutcomeLine` with nothing to return. It is
169
+ // Failed, because a run whose ending this code cannot read is not a success
170
+ // this code may claim. The never-settled legacy shape — no outcome, no
171
+ // error, a non-zero exit — is not this fallback's: the `else` below prints
172
+ // it. The streaming builders' completed branch passes ✓ Done instead (see
173
+ // `outcomeSegments`' third parameter for why it is not decided here).
174
+ lines.push(
175
+ ...outcomeSegments(
176
+ { outcome: result.outcome, error: result.error },
177
+ theme,
178
+ row(theme.fg("error", "✗ Failed")),
179
+ ),
180
+ );
181
+ } else {
182
+ lines.push(row(theme.fg("success", "✓ Done")));
183
+ }
78
184
  } else {
79
- lines.push("");
80
- lines.push(theme.fg("error", "✗ Failed"));
185
+ lines.push(row(""));
186
+ lines.push(row(theme.fg("error", "✗ Failed")));
81
187
  }
82
188
 
83
- return lines.join("\n");
189
+ return lines;
84
190
  }
85
191
 
86
192
  export function renderExpanded(
87
- text: Text,
193
+ rows: RowBlock,
88
194
  result: SubagentResult,
89
195
  progress: SubagentProgress | undefined,
90
196
  theme: Theme,
91
- ): Text {
92
- text.setText(buildExpandedText(result, progress, theme));
93
- return text;
197
+ ): RowBlock {
198
+ rows.setSegments(buildExpandedText(result, progress, theme));
199
+ return rows;
94
200
  }
95
201
 
96
202
  // ── Expanded streaming progress ─────────────────────────────────────────────
97
203
 
98
204
  export function renderProgressExpanded(
99
- text: Text,
205
+ rows: RowBlock,
100
206
  progress: SubagentProgress,
101
207
  theme: Theme,
102
- ): Text {
103
- const lines: string[] = [];
208
+ ): RowBlock {
209
+ const lines: RowSegment[] = [];
104
210
 
105
- lines.push(buildAgentLabel(progress.agent, progress.task, theme));
106
- lines.push("");
211
+ lines.push(row(buildAgentLabel(progress.agent, progress.task, theme)));
212
+ lines.push(row(""));
107
213
 
108
- // Stats line (same as compact view)
214
+ // Stats line (same as compact view) — a block: see buildExpandedText.
109
215
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
110
216
  const statsLine = buildStatsLine({
111
217
  turns: undefined,
@@ -116,51 +222,57 @@ export function renderProgressExpanded(
116
222
  }, theme);
117
223
  const statsParts = [modelLabel, statsLine].filter(Boolean);
118
224
  if (statsParts.length > 0) {
119
- lines.push(statsParts.join(" "));
225
+ lines.push(block(statsParts.join(" ")));
120
226
  }
121
227
 
122
228
  // Task
123
229
  if (progress.task) {
124
- lines.push("");
125
- lines.push(theme.fg("dim", "Task: " + progress.task));
230
+ lines.push(row(""));
231
+ lines.push(block(theme.fg("dim", "Task: " + progress.task)));
126
232
  }
127
233
 
128
234
  // Tool calls history
129
235
  if (progress.toolCalls && progress.toolCalls.length > 0) {
130
- lines.push("");
236
+ lines.push(row(""));
131
237
  for (const call of progress.toolCalls) {
132
- lines.push(formatToolCall(call, theme));
238
+ lines.push(block(formatToolCall(call, theme)));
133
239
  }
134
240
  }
135
241
 
136
242
  // Activity (current tool, marked with the running glyph ▸)
137
243
  if (progress.currentTool) {
138
- lines.push("");
139
- const argsPreview = progress.currentToolArgs
140
- ? truncLine(progress.currentToolArgs, 60)
141
- : "";
244
+ lines.push(row(""));
245
+ const argsPreview = progress.currentToolArgs ?? "";
142
246
  const durationPart = progress.currentToolStartedAt
143
247
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
144
248
  : "";
145
249
  const suffix = (argsPreview ? ": " + argsPreview : "") + durationPart;
146
- lines.push(renderToolCallLine("running", progress.currentTool, suffix, theme));
250
+ lines.push(row(renderToolCallLine("running", progress.currentTool, suffix, theme)));
147
251
  }
148
252
 
149
- // Status at bottom
150
- if (progress.status === "completed") {
151
- lines.push("");
152
- lines.push(theme.fg("success", "✓ Done"));
253
+ // Status at bottom. Which fallback each branch hands the helper, and why the
254
+ // helper is not the one to decide it, is written once above `outcomeSegments`.
255
+ if (progress.status === "completed" || progress.outcome) {
256
+ lines.push(row(""));
257
+ // `✓ Done`: this branch's agent reached a terminal state, so a run with nothing
258
+ // to name its outcome by is finished work here — the ✗ below would bury it.
259
+ lines.push(...outcomeSegments(
260
+ { outcome: progress.outcome, error: progress.error },
261
+ theme,
262
+ row(theme.fg("success", "✓ Done")),
263
+ ));
153
264
  } else if (progress.status === "failed") {
154
- lines.push("");
155
- if (progress.error) {
156
- lines.push(theme.fg("error", "✗ " + progress.error));
157
- } else {
158
- lines.push(theme.fg("error", "✗ Failed"));
159
- }
265
+ lines.push(row(""));
266
+ // The only branch here that may call a run broken, so it passes `✗ Failed`.
267
+ lines.push(...outcomeSegments(
268
+ { outcome: undefined, error: progress.error },
269
+ theme,
270
+ row(theme.fg("error", "✗ Failed")),
271
+ ));
160
272
  }
161
273
 
162
- text.setText(lines.join("\n"));
163
- return text;
274
+ rows.setSegments(lines);
275
+ return rows;
164
276
  }
165
277
 
166
278
  // ── Expanded parallel streaming progress ────────────────────────────────────
@@ -168,13 +280,13 @@ export function renderProgressExpanded(
168
280
  export function buildProgressExpandedText(
169
281
  progress: SubagentProgress,
170
282
  theme: Theme,
171
- ): string {
172
- const lines: string[] = [];
283
+ ): RowSegment[] {
284
+ const lines: RowSegment[] = [];
173
285
 
174
- lines.push(buildAgentLabel(progress.agent, progress.task, theme));
175
- lines.push("");
286
+ lines.push(row(buildAgentLabel(progress.agent, progress.task, theme)));
287
+ lines.push(row(""));
176
288
 
177
- // Stats line (same as compact view)
289
+ // Stats line (same as compact view) — a block: see buildExpandedText.
178
290
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
179
291
  const statsLine = buildStatsLine({
180
292
  turns: undefined,
@@ -185,28 +297,26 @@ export function buildProgressExpandedText(
185
297
  }, theme);
186
298
  const statsParts = [modelLabel, statsLine].filter(Boolean);
187
299
  if (statsParts.length > 0) {
188
- lines.push(statsParts.join(" "));
300
+ lines.push(block(statsParts.join(" ")));
189
301
  }
190
302
 
191
303
  // Task
192
304
  if (progress.task) {
193
- lines.push("");
194
- lines.push(theme.fg("dim", "Task: " + progress.task));
305
+ lines.push(row(""));
306
+ lines.push(block(theme.fg("dim", "Task: " + progress.task)));
195
307
  }
196
308
 
197
309
  // Tool calls history
198
310
  if (progress.toolCalls && progress.toolCalls.length > 0) {
199
- lines.push("");
311
+ lines.push(row(""));
200
312
  for (const call of progress.toolCalls) {
201
- lines.push(formatToolCall(call, theme));
313
+ lines.push(block(formatToolCall(call, theme)));
202
314
  }
203
315
  }
204
316
 
205
317
  // Activity
206
318
  if (progress.currentTool) {
207
- const argsPreview = progress.currentToolArgs
208
- ? truncLine(progress.currentToolArgs, 60)
209
- : "";
319
+ const argsPreview = progress.currentToolArgs ?? "";
210
320
  const durationPart = progress.currentToolStartedAt
211
321
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
212
322
  : "";
@@ -217,22 +327,32 @@ export function buildProgressExpandedText(
217
327
  if (durationPart) {
218
328
  line += theme.fg("dim", durationPart);
219
329
  }
220
- lines.push("");
221
- lines.push(line);
330
+ lines.push(row(""));
331
+ lines.push(row(line));
222
332
  }
223
333
 
224
- // Status at bottom
225
- if (progress.status === "completed") {
226
- lines.push("");
227
- lines.push(theme.fg("success", "✓ Done"));
334
+ // Status at bottom — the same pair of branches `renderProgressExpanded` draws for
335
+ // the single running agent, here as one block of the parallel view. What each
336
+ // branch passes, and why the helper cannot decide it, is written once above
337
+ // `outcomeSegments`.
338
+ if (progress.status === "completed" || progress.outcome) {
339
+ lines.push(row(""));
340
+ // `✓ Done`, and never the failed branch's segment: this block claims its agent
341
+ // finished, and a batch read as failed is a batch whose answers get discarded.
342
+ lines.push(...outcomeSegments(
343
+ { outcome: progress.outcome, error: progress.error },
344
+ theme,
345
+ row(theme.fg("success", "✓ Done")),
346
+ ));
228
347
  } else if (progress.status === "failed") {
229
- lines.push("");
230
- if (progress.error) {
231
- lines.push(theme.fg("error", "✗ " + progress.error));
232
- } else {
233
- lines.push(theme.fg("error", "✗ Failed"));
234
- }
348
+ lines.push(row(""));
349
+ // `✗ Failed`: this branch got a broken run, the only one allowed to say so.
350
+ lines.push(...outcomeSegments(
351
+ { outcome: undefined, error: progress.error },
352
+ theme,
353
+ row(theme.fg("error", "✗ Failed")),
354
+ ));
235
355
  }
236
356
 
237
- return lines.join("\n");
357
+ return lines;
238
358
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { truncLine, formatParallelResults } from "./format.js";
2
+ import { formatParallelResults } from "./format.js";
3
3
  import type { SubagentResult } from "./types.js";
4
4
 
5
5
  function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
@@ -17,33 +17,14 @@ function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
17
17
  };
18
18
  }
19
19
 
20
- // ── truncLine ───────────────────────────────────────────────────────────────
20
+ // ── Row truncation: moved ─────────────────────────────────────────────────
21
21
 
22
- describe("truncLine", () => {
23
- it("returns text unchanged when within limit", () => {
24
- expect(truncLine("hello", 10)).toBe("hello");
25
- });
26
-
27
- it("truncates with '...' when exceeding limit", () => {
28
- expect(truncLine("hello world", 8)).toBe("hello...");
29
- });
30
-
31
- it("stops at newline boundary instead of bleeding into next line", () => {
32
- expect(truncLine("line one\nline two\nline three", 15)).toBe("line one...");
33
- });
34
-
35
- it("truncates first line if it itself exceeds limit", () => {
36
- expect(truncLine("this is a very long first line\nsecond", 12)).toBe("this is a...");
37
- });
38
-
39
- it("handles multiple consecutive newlines", () => {
40
- expect(truncLine("a\n\n\nb", 10)).toBe("a...");
41
- });
42
-
43
- it("handles text starting with newline", () => {
44
- expect(truncLine("\nhello", 10)).toBe("...");
45
- });
46
- });
22
+ // The character cap this section used to pin (`truncLine`, at 60/80 characters)
23
+ // is gone. Rows are now cut at the width pi renders them at, which keeps the two
24
+ // properties the cap existed for — the ellipsis stays inside the styled span, and
25
+ // a newline is a hard boundary — while letting a row fill the frame. Those
26
+ // properties are pinned with measured bytes in `row-block.test.ts`, and the width
27
+ // contract itself in `tool-row.width-audit.test.ts`.
47
28
 
48
29
  describe("formatParallelResults", () => {
49
30
  it("renders header + body per task with exactly one blank line between sections", () => {
@@ -90,9 +71,9 @@ describe("formatParallelResults", () => {
90
71
  expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nspawn failed");
91
72
  });
92
73
 
93
- it("prefers finalOutput over error when both are set", () => {
74
+ it("prefers finalOutput as the body when both are set, and still marks the failure", () => {
94
75
  const r = makeResult({ finalOutput: "out", error: "boom" });
95
- expect(formatParallelResults([r])).toBe("✓ test-agent 5 tools · 0k tok · 3s\nout");
76
+ expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nout");
96
77
  });
97
78
 
98
79
  it("keeps internal newlines in a multi-line body and trims only the tail", () => {
@@ -117,3 +98,64 @@ describe("formatParallelResults", () => {
117
98
  );
118
99
  });
119
100
  });
101
+
102
+ describe("formatParallelResults: run outcomes", () => {
103
+ it("says truncated on the header row of a cut-off result", () => {
104
+ const out = formatParallelResults([
105
+ makeResult({ outcome: "truncated", finalOutput: "partial answer" }),
106
+ ]);
107
+ expect(out.split("\n")[0]).toMatch(/truncated/i);
108
+ expect(out).toContain("partial answer");
109
+ });
110
+
111
+ it("marks a run that failed at exit 0 with ✗, because the exit code is not the signal", () => {
112
+ // `--mode json` — what the child actually runs — never maps `stopReason`
113
+ // onto the exit code (that mapping is print mode's text branch), so a child
114
+ // whose last response errored exits **0**. `streamEvents` reports that as
115
+ // `error` with no `outcome`; a header that read only `exitCode` handed the
116
+ // calling model a ✓ for a run that produced nothing, while `index.ts` set
117
+ // `isError` on the very same result.
118
+ const out = formatParallelResults([
119
+ makeResult({ exitCode: 0, error: "401 unauthorized" }),
120
+ ]);
121
+ expect(out.split("\n")[0]?.startsWith("✗")).toBe(true);
122
+ expect(out).toContain("401 unauthorized");
123
+ });
124
+
125
+ it("marks a run the parent cancelled through the bridge (exit 0, error set)", () => {
126
+ // The bridge contract: a local cancel settles as a FAILED result carrying
127
+ // error "cancelled" and exitCode 0 — same shape, same wrong ✓ without the
128
+ // error check.
129
+ const out = formatParallelResults([
130
+ makeResult({ exitCode: 0, error: "cancelled" }),
131
+ ]);
132
+ expect(out.split("\n")[0]?.startsWith("✗")).toBe(true);
133
+ });
134
+
135
+ it("never shows a success marker for a result the tool reports as an error", () => {
136
+ // The single assertion that ties the two halves together: `index.ts` decides
137
+ // `isError` from `error`, so the header has to agree with it.
138
+ const out = formatParallelResults([
139
+ makeResult({ exitCode: 0, error: "boom" }),
140
+ makeResult({ agent: "ok", exitCode: 0, finalOutput: "fine" }),
141
+ ]);
142
+ const rows = out.split("\n").filter((l) => l.startsWith("✓") || l.startsWith("✗"));
143
+ expect(rows[0]).toContain("✗");
144
+ expect(rows[1]).toContain("✓");
145
+ });
146
+
147
+ it("says aborted on the header row of a cancelled result", () => {
148
+ const out = formatParallelResults([makeResult({ outcome: "aborted" })]);
149
+ expect(out.split("\n")[0]).toMatch(/aborted/i);
150
+ });
151
+
152
+ it("leaves a completed header byte-identical to the pre-outcome contract", () => {
153
+ const r = makeResult({
154
+ outcome: "completed",
155
+ agent: "reviewer",
156
+ progressSummary: { toolCount: 4, tokens: 2000, durationMs: 12000 },
157
+ finalOutput: "Looks good.",
158
+ });
159
+ expect(formatParallelResults([r])).toBe("✓ reviewer 4 tools · 2k tok · 12s\nLooks good.");
160
+ });
161
+ });