@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,720 @@
1
+ /**
2
+ * Row-width audit for the `subagent` **tool rows** (the transcript rows, not the
3
+ * `/agents` overlay — that one is `agent-panel.width-audit.test.ts`).
4
+ *
5
+ * The defect this audits: a `bash` row runs to the right edge of the terminal,
6
+ * because pi truncates at the width it is actually rendering at
7
+ * (`VisualLinePreview` / `truncateToVisualLines`, which take `render(width)`'s
8
+ * width). Our rows ran through a hardcoded character cap — `truncLine(task, 60)`
9
+ * — so on an 200-column terminal the row died at about a third of the frame.
10
+ *
11
+ * The invariant, at every width pi can hand a component:
12
+ *
13
+ * 1. one logical line per row — a long task must not become a wrapped block
14
+ * (pi-tui's `Text.render(width)` wraps, which is why the rows cannot just
15
+ * be left uncapped inside a `Text`);
16
+ * 2. a truncated row **fills the frame**: its drawn width is the render width
17
+ * (± one cell: a wide glyph that would straddle the boundary is dropped by
18
+ * `sliceByColumn`'s strict mode, so a CJK/emoji row can land one cell short);
19
+ * 3. no rendered row is wider than the frame, whatever the content — ASCII,
20
+ * CJK-heavy or emoji-heavy;
21
+ * 4. and the row is *not* capped at 60: at a 200-column width the content is
22
+ * far longer than the old cap.
23
+ *
24
+ * Paths exercised (all reached through the registered tool definition, so the
25
+ * audit walks the real render path rather than a helper it hopes is on it):
26
+ *
27
+ * renderCall — the call row
28
+ * handleToolStart → currentToolArgs — the args a row is drawn from
29
+ * renderResult → renderSubagentResult
30
+ * ├─ results[0] → compact single (3 rows)
31
+ * ├─ progress[0] → compact progress (3 rows, streaming)
32
+ * ├─ results[0..n] → compact parallel (3 rows per agent)
33
+ * ├─ progress[0..n] → compact parallel progress
34
+ * ├─ expanded: results[0] → expanded single (rows + wrapped blocks)
35
+ * ├─ expanded: progress[0] → expanded progress
36
+ * └─ expanded: results/progress → expanded parallel
37
+ */
38
+
39
+ import { describe, it, expect, vi, beforeEach } from "vitest";
40
+ import * as fc from "fast-check";
41
+ import { Box, Container, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
42
+ import { handleToolStart } from "./handlers.js";
43
+ import type { StreamState } from "./types.js";
44
+
45
+ const discoverAgents = vi.hoisted(() => vi.fn());
46
+ const executeSubagent = vi.hoisted(() => vi.fn());
47
+ const executeParallel = vi.hoisted(() => vi.fn());
48
+
49
+ vi.mock("./agents.js", async (importOriginal) => ({
50
+ ...((await importOriginal()) as Record<string, unknown>),
51
+ discoverAgents,
52
+ }));
53
+ vi.mock("./execute.js", () => ({ executeSubagent, executeParallel }));
54
+
55
+ const { registerSubagent } = await import("./index.js");
56
+
57
+ // ── Fixtures ────────────────────────────────────────────────────────────────
58
+
59
+ /** The task from the maintainer's screenshot: long, and ASCII. */
60
+ const ASCII_TASK =
61
+ ("Handle the review cycle for PR #107 in /home/daniel/Coding/Javascript/pi-archimedes and report every finding with a file:line reference. ").repeat(2);
62
+ /** East-asian text: every glyph is two cells, so a character cap is meaningless. */
63
+ const CJK_TASK = "検証サイクルをPR107で実行して結果を報告".repeat(8);
64
+ /** Emoji: vary in width and carry joiners, the classic over-flow case. */
65
+ const EMOJI_TASK = "🚀 ship 🔥 the ✅ change 🧪 with 📦 care 🎯 now ".repeat(6);
66
+
67
+ const TASKS: Array<{ label: string; task: string; slack: number }> = [
68
+ // `slack` is how many cells short of the frame a truncated row may legitimately
69
+ // land: 0 for ASCII, 1 when a double-width glyph sits on the boundary.
70
+ { label: "ascii", task: ASCII_TASK, slack: 0 },
71
+ { label: "cjk", task: CJK_TASK, slack: 1 },
72
+ { label: "emoji", task: EMOJI_TASK, slack: 1 },
73
+ ];
74
+
75
+ const WIDTHS = [200, 120, 80, 60, 40];
76
+
77
+ /** A theme that emits real SGR sequences, so measured rows carry ANSI. */
78
+ const TOKEN_SGR: Record<string, string> = {
79
+ dim: "\x1b[2m",
80
+ muted: "\x1b[90m",
81
+ error: "\x1b[31m",
82
+ success: "\x1b[32m",
83
+ warning: "\x1b[33m",
84
+ accent: "\x1b[36m",
85
+ toolTitle: "\x1b[34m",
86
+ };
87
+ const theme = {
88
+ fg: (token: string, text: string) => `${TOKEN_SGR[token] ?? "\x1b[37m"}${text}\x1b[0m`,
89
+ bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
90
+ };
91
+
92
+ // ── The registered tool, with its real renderers ────────────────────────────
93
+
94
+ interface ToolDef {
95
+ name: string;
96
+ renderCall: (args: unknown, theme: unknown, ctx: unknown) => RowComponent;
97
+ renderResult: (
98
+ result: unknown,
99
+ options: unknown,
100
+ theme: unknown,
101
+ context: unknown,
102
+ ) => RowComponent;
103
+ }
104
+
105
+ interface RowComponent {
106
+ render(width: number): string[];
107
+ invalidate(): void;
108
+ }
109
+
110
+ function registerTool(): ToolDef {
111
+ const tools: ToolDef[] = [];
112
+ const pi = {
113
+ registerTool: (def: ToolDef) => tools.push(def),
114
+ on: () => {},
115
+ getAllTools: () => [{ name: "read" }, { name: "bash" }],
116
+ };
117
+ registerSubagent(pi as never);
118
+ const def = tools.find((t) => t.name === "subagent");
119
+ if (!def) throw new Error("subagent tool was not registered");
120
+ return def;
121
+ }
122
+
123
+ function makeResult(overrides: Record<string, unknown> = {}) {
124
+ return {
125
+ agent: "general",
126
+ task: ASCII_TASK,
127
+ exitCode: 0,
128
+ usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 3 },
129
+ model: "anthropic/claude-opus-4.5",
130
+ finalOutput: undefined,
131
+ error: undefined,
132
+ progress: undefined,
133
+ progressSummary: { toolCount: 5, tokens: 1500, durationMs: 3000 },
134
+ outcome: "completed",
135
+ ...overrides,
136
+ };
137
+ }
138
+
139
+ function makeProgress(overrides: Record<string, unknown> = {}) {
140
+ return {
141
+ agent: "general",
142
+ status: "running",
143
+ task: ASCII_TASK,
144
+ currentTool: "bash",
145
+ currentToolArgs: `git diff HEAD~5 --stat ${"-v".repeat(40)}`,
146
+ currentToolStartedAt: Date.now() - 2000,
147
+ toolCount: 2,
148
+ inputTokens: 50,
149
+ outputTokens: 30,
150
+ tokens: 80,
151
+ cost: 0.005,
152
+ durationMs: 1000,
153
+ error: undefined,
154
+ model: "anthropic/claude-opus-4.5",
155
+ output: undefined,
156
+ recentOutput: undefined,
157
+ toolCalls: [
158
+ { name: "read", argsPreview: `packages/subagent/src/${"agent".repeat(12)}.ts`, error: false },
159
+ { name: "bash", argsPreview: `pnpm -r exec -- tsc --noEmit ${"—".repeat(30)}`, error: true },
160
+ ],
161
+ ...overrides,
162
+ };
163
+ }
164
+
165
+ /** Every way the tool can be asked to draw a row family. */
166
+ function rowComponents(task: string): Array<{ path: string; component: () => RowComponent }> {
167
+ const tool = registerTool();
168
+ const result = makeResult({ task });
169
+ const progress = makeProgress({ task });
170
+ const parallel = {
171
+ mode: "parallel",
172
+ results: [makeResult({ task }), makeResult({ agent: "reviewer", task, exitCode: 1, error: `spawn failed: ${"x".repeat(120)}` })],
173
+ progress: undefined,
174
+ };
175
+ const parallelProgress = {
176
+ mode: "parallel",
177
+ results: [],
178
+ progress: [makeProgress({ task }), makeProgress({ agent: "reviewer", task, currentTool: undefined })],
179
+ };
180
+ const streaming = { mode: "single", results: [], progress: [makeProgress({ task })] };
181
+ const ctx = () => ({ state: {}, invalidate: () => {} });
182
+
183
+ return [
184
+ {
185
+ path: "renderCall",
186
+ component: () => tool.renderCall({ agent: "general", tasks: undefined }, theme, ctx()),
187
+ },
188
+ {
189
+ path: "compact:single",
190
+ component: () =>
191
+ tool.renderResult(
192
+ { content: [], details: { mode: "single", results: [result], progress: undefined }, isError: false },
193
+ { expanded: false },
194
+ theme,
195
+ ctx(),
196
+ ),
197
+ },
198
+ {
199
+ path: "compact:progress",
200
+ component: () =>
201
+ tool.renderResult(
202
+ { content: [], details: streaming, isError: false },
203
+ { expanded: false },
204
+ theme,
205
+ ctx(),
206
+ ),
207
+ },
208
+ {
209
+ path: "compact:parallel",
210
+ component: () =>
211
+ tool.renderResult(
212
+ { content: [], details: parallel, isError: false },
213
+ { expanded: false },
214
+ theme,
215
+ ctx(),
216
+ ),
217
+ },
218
+ {
219
+ path: "compact:parallel-progress",
220
+ component: () =>
221
+ tool.renderResult(
222
+ { content: [], details: parallelProgress, isError: false },
223
+ { expanded: false },
224
+ theme,
225
+ ctx(),
226
+ ),
227
+ },
228
+ {
229
+ path: "expanded:single",
230
+ component: () =>
231
+ tool.renderResult(
232
+ { content: [], details: { mode: "single", results: [result], progress: [makeProgress({ task })] }, isError: false },
233
+ { expanded: true },
234
+ theme,
235
+ ctx(),
236
+ ),
237
+ },
238
+ {
239
+ path: "expanded:progress",
240
+ component: () =>
241
+ tool.renderResult(
242
+ { content: [], details: streaming, isError: false },
243
+ { expanded: true },
244
+ theme,
245
+ ctx(),
246
+ ),
247
+ },
248
+ {
249
+ path: "expanded:parallel",
250
+ component: () =>
251
+ tool.renderResult(
252
+ { content: [], details: parallel, isError: false },
253
+ { expanded: true },
254
+ theme,
255
+ ctx(),
256
+ ),
257
+ },
258
+ ];
259
+ }
260
+
261
+ // ── Measurement ─────────────────────────────────────────────────────────────
262
+
263
+ /** The drawn text of a rendered line, with trailing padding removed. */
264
+ function content(line: string): string {
265
+ return stripTerminalSequences(line).replace(/\s+$/, "");
266
+ }
267
+
268
+ /**
269
+ * A row is *full width* when the text it draws reaches the right edge — the
270
+ * property the maintainer asked for ("notice how the bash tool goes full
271
+ * width"). Padding spaces do not count: they are removed by `content()`.
272
+ */
273
+ function isFullWidth(line: string, width: number, slack: number): boolean {
274
+ const drawn = visibleWidth(content(line));
275
+ return drawn >= width - slack && drawn <= width;
276
+ }
277
+
278
+ interface RowReport {
279
+ path: string;
280
+ task: string;
281
+ width: number;
282
+ index: number;
283
+ line: string;
284
+ drawn: number;
285
+ }
286
+
287
+ function rowsOver(width: number): RowReport[] {
288
+ const over: RowReport[] = [];
289
+ for (const fixture of TASKS) {
290
+ for (const entry of rowComponents(fixture.task)) {
291
+ const lines = entry.component().render(width);
292
+ lines.forEach((line, index) => {
293
+ const drawn = visibleWidth(stripTerminalSequences(line));
294
+ if (drawn > width) over.push({ path: entry.path, task: fixture.label, width, index, line, drawn });
295
+ });
296
+ }
297
+ }
298
+ return over;
299
+ }
300
+
301
+ const summarize = (rows: RowReport[]) =>
302
+ rows
303
+ .slice(0, 4)
304
+ .map((r) => `${r.path}/${r.task}@${r.width}:row${r.index}=${r.drawn} ${JSON.stringify(content(r.line).slice(0, 40))}`)
305
+ .join("\n ");
306
+
307
+ // ── The audit ───────────────────────────────────────────────────────────────
308
+
309
+ describe("subagent tool rows: width audit", () => {
310
+ beforeEach(() => {
311
+ discoverAgents.mockReturnValue([]);
312
+ });
313
+
314
+ it("never draws a row wider than the frame, at any width or content", () => {
315
+ const over: RowReport[] = [];
316
+ for (const width of WIDTHS) over.push(...rowsOver(width));
317
+ expect(over, `${over.length} over-frame row(s):\n ${summarize(over)}`).toEqual([]);
318
+ });
319
+
320
+ it("renders the task row as ONE row that fills the frame, at every width", () => {
321
+ const failures: string[] = [];
322
+ for (const width of WIDTHS) {
323
+ for (const fixture of TASKS) {
324
+ const compact = rowComponents(fixture.task).find((e) => e.path === "compact:single")!;
325
+ const lines = compact.component().render(width);
326
+ const label = lines[0] ?? "";
327
+ const drawn = visibleWidth(content(label));
328
+ if (lines.length !== 3) {
329
+ failures.push(`${fixture.label}@${width}: compact block is ${lines.length} rows, expected 3 (one per logical line):\n ${JSON.stringify(lines.map(content))}`);
330
+ } else if (!isFullWidth(label, width, fixture.slack)) {
331
+ failures.push(
332
+ `${fixture.label}@${width}: task row draws ${drawn} of ${width} columns (slack ${fixture.slack}): ${JSON.stringify(content(label))}`,
333
+ );
334
+ } else if (!content(label).endsWith("...")) {
335
+ failures.push(`${fixture.label}@${width}: truncated task row lost its ellipsis: ${JSON.stringify(content(label))}`);
336
+ }
337
+ }
338
+ }
339
+ expect(failures, failures.join("\n ")).toEqual([]);
340
+ });
341
+
342
+ it("is not capped at 60 characters: a wide frame carries a long task", () => {
343
+ for (const width of WIDTHS) {
344
+ for (const fixture of TASKS) {
345
+ const lines = rowComponents(fixture.task)
346
+ .find((e) => e.path === "compact:single")!
347
+ .component()
348
+ .render(width);
349
+ const drawn = visibleWidth(content(lines[0] ?? ""));
350
+ // A 60-character cap tops out around 69 columns however wide the frame
351
+ // is, so anything at (or near) the frame cannot be the old cap.
352
+ expect(
353
+ drawn,
354
+ `task row capped early at ${width} columns: ${JSON.stringify(content(lines[0] ?? ""))}`,
355
+ ).toBeGreaterThanOrEqual(width - fixture.slack);
356
+ }
357
+ }
358
+ });
359
+
360
+ it("fills the frame on the other row family members too (args, error, activity)", () => {
361
+ const longError = `spawn failed: ${"command not found".repeat(20)}`;
362
+ const cases: Array<{ path: string; details: unknown; row: number }> = [
363
+ // Activity row carries the current tool's args while streaming.
364
+ { path: "compact:progress", details: { mode: "single", results: [], progress: [makeProgress({ currentToolArgs: longError })] }, row: 2 },
365
+ // Activity row carries the error for a failed run.
366
+ {
367
+ path: "compact:single",
368
+ details: { mode: "single", results: [makeResult({ outcome: undefined, exitCode: 1, error: longError })], progress: undefined },
369
+ row: 2,
370
+ },
371
+ // Activity row carries the last completed call's args.
372
+ {
373
+ path: "compact:progress",
374
+ details: {
375
+ mode: "single",
376
+ results: [],
377
+ progress: [makeProgress({ currentTool: undefined, currentToolArgs: undefined, toolCalls: ["x".repeat(400)] })],
378
+ },
379
+ row: 2,
380
+ },
381
+ // The expanded view's own label row.
382
+ {
383
+ path: "expanded:single",
384
+ details: { mode: "single", results: [makeResult()], progress: undefined },
385
+ row: 0,
386
+ },
387
+ ];
388
+
389
+ for (const width of WIDTHS) {
390
+ for (const c of cases) {
391
+ const component = registerTool().renderResult(
392
+ { content: [], details: c.details, isError: false },
393
+ { expanded: c.path.startsWith("expanded") },
394
+ theme,
395
+ { state: {}, invalidate: () => {} },
396
+ );
397
+ const lines = component.render(width);
398
+ const drawn = visibleWidth(content(lines[c.row] ?? ""));
399
+ expect(
400
+ drawn,
401
+ `${c.path} row ${c.row} @${width} draws ${drawn} of ${width}: ${JSON.stringify(content(lines[c.row] ?? ""))}`,
402
+ ).toBeGreaterThanOrEqual(width - 1);
403
+ }
404
+ }
405
+ });
406
+
407
+ // A task is free text and an error is usually stderr, so both arrive carrying
408
+ // newlines. The collapsed view is a fixed three rows per agent; if a newline
409
+ // inside one field became a row of its own, a failed child's stack trace would
410
+ // push the collapsed block open — the old `truncLine` treated a newline as a
411
+ // hard boundary for exactly this reason. `RowBlock.setText` splits every
412
+ // newline, so the compact builders have to hand over one `row` segment per
413
+ // field they mean to be one row.
414
+ it("keeps a multiline task or error to three rows per agent (collapsed)", () => {
415
+ const multilineTask = "Review the change\nRun the full suite\nReport every finding " + "x".repeat(200);
416
+ const multilineError = "spawn failed\ncommand not found: pnpm\nsee the log " + "y".repeat(200);
417
+ const hidden = ["Run the full suite", "command not found: pnpm"];
418
+
419
+ const taskProgress = makeProgress({ task: multilineTask });
420
+ const cases: Array<{ label: string; details: unknown; rows: number }> = [
421
+ {
422
+ label: "compact:single/task",
423
+ details: { mode: "single", results: [makeResult({ task: multilineTask })], progress: undefined },
424
+ rows: 3,
425
+ },
426
+ {
427
+ label: "compact:single/error",
428
+ details: {
429
+ mode: "single",
430
+ results: [makeResult({ task: multilineTask, outcome: undefined, exitCode: 1, error: multilineError })],
431
+ progress: undefined,
432
+ },
433
+ rows: 3,
434
+ },
435
+ { label: "compact:progress", details: { mode: "single", results: [], progress: [taskProgress] }, rows: 3 },
436
+ {
437
+ label: "compact:parallel",
438
+ details: {
439
+ mode: "parallel",
440
+ results: [
441
+ makeResult({ task: multilineTask }),
442
+ makeResult({ agent: "reviewer", task: multilineTask, outcome: undefined, exitCode: 1, error: multilineError }),
443
+ ],
444
+ progress: undefined,
445
+ },
446
+ rows: 6,
447
+ },
448
+ {
449
+ label: "compact:parallel-progress",
450
+ details: {
451
+ mode: "parallel",
452
+ results: [],
453
+ progress: [taskProgress, makeProgress({ agent: "reviewer", task: multilineTask })],
454
+ },
455
+ rows: 6,
456
+ },
457
+ ];
458
+
459
+ const failures: string[] = [];
460
+ for (const width of WIDTHS) {
461
+ for (const c of cases) {
462
+ const lines = registerTool()
463
+ .renderResult(
464
+ { content: [], details: c.details, isError: false },
465
+ { expanded: false },
466
+ theme,
467
+ { state: {}, invalidate: () => {} },
468
+ )
469
+ .render(width);
470
+ const drawn = lines.map(content);
471
+ if (lines.length !== c.rows) {
472
+ failures.push(`${c.label}@${width}: ${lines.length} rows, expected ${c.rows}:\n ${JSON.stringify(drawn)}`);
473
+ }
474
+ for (const text of hidden) {
475
+ if (drawn.some((d) => d.includes(text))) {
476
+ failures.push(`${c.label}@${width}: ${JSON.stringify(text)} leaked into a row of its own:\n ${JSON.stringify(drawn)}`);
477
+ }
478
+ }
479
+ lines.forEach((line, index) => {
480
+ if (visibleWidth(stripTerminalSequences(line)) > width) {
481
+ failures.push(`${c.label}@${width}: row ${index} over the frame`);
482
+ }
483
+ });
484
+ // The row was cut at the newline, and says so.
485
+ if (!(drawn[0] ?? "").endsWith("...")) {
486
+ failures.push(`${c.label}@${width}: label row lost its cut marker: ${JSON.stringify(drawn[0])}`);
487
+ }
488
+ }
489
+ }
490
+ expect(failures, failures.join("\n ")).toEqual([]);
491
+ });
492
+
493
+ // The row layer can only draw what it is handed. `currentToolArgs` is produced
494
+ // by the streaming handler, and a preview cap there is a second, invisible
495
+ // character cap: pi's own bash row passes the command through whole and cuts
496
+ // it at the render width (`formatShellCall`), so anything shorter than the
497
+ // frame has to come from the frame.
498
+ it("fills the frame with args that arrive through handleToolStart", () => {
499
+ const longCommand = `pnpm exec vitest run packages/subagent ${"--reporter=verbose ".repeat(15)}`;
500
+ const state: StreamState = {
501
+ toolCount: 0,
502
+ turnCount: 0,
503
+ totalInput: 0,
504
+ totalOutput: 0,
505
+ totalCacheRead: 0,
506
+ totalCacheWrite: 0,
507
+ totalCost: 0,
508
+ currentTool: undefined,
509
+ currentToolArgs: undefined,
510
+ currentToolStartedAt: undefined,
511
+ model: undefined,
512
+ accumulatedOutput: [],
513
+ recentOutput: [],
514
+ toolCalls: [],
515
+ finalOutput: undefined,
516
+ };
517
+ handleToolStart(state, { type: "tool_execution_start", toolName: "bash", args: { command: longCommand } });
518
+ expect(state.currentToolArgs?.length, "handler threw away args the frame could draw")
519
+ .toBeGreaterThanOrEqual(longCommand.length);
520
+
521
+ const details = {
522
+ mode: "single",
523
+ results: [],
524
+ progress: [makeProgress({ currentToolArgs: state.currentToolArgs, currentToolStartedAt: undefined })],
525
+ };
526
+ for (const width of WIDTHS) {
527
+ const lines = registerTool()
528
+ .renderResult({ content: [], details, isError: false }, { expanded: false }, theme, {
529
+ state: {},
530
+ invalidate: () => {},
531
+ })
532
+ .render(width);
533
+ const drawn = visibleWidth(content(lines[2] ?? ""));
534
+ expect(
535
+ drawn,
536
+ `args row @${width} draws ${drawn} of ${width}: ${JSON.stringify(content(lines[2] ?? ""))}`,
537
+ ).toBeGreaterThanOrEqual(width - 1);
538
+ }
539
+ });
540
+
541
+ // pi keeps the component a tool returned (`ToolExecutionComponent`
542
+ // .resultRendererComponent) and hands it back as `context.lastComponent` on
543
+ // the next frame, then calls `render(width)` whenever it draws. Two frames can
544
+ // therefore differ only in width (a terminal resize) or only in content (a
545
+ // streaming tick at the same width), and the component must not answer either
546
+ // from the frame before.
547
+ it("keeps a reused component honest across a resize and a content change", () => {
548
+ const tool = registerTool();
549
+ const context: Record<string, unknown> = { state: {}, invalidate: () => {} };
550
+ const draw = (task: string, expanded = false) =>
551
+ tool.renderResult(
552
+ { content: [], details: { mode: "single", results: [makeResult({ task })], progress: undefined }, isError: false },
553
+ { expanded },
554
+ theme,
555
+ context,
556
+ );
557
+
558
+ // Same instance across frames — otherwise this test proves nothing.
559
+ draw("first task ".repeat(30));
560
+ const first = context.lastComponent;
561
+ draw("first task ".repeat(30));
562
+ expect(context.lastComponent).toBe(first);
563
+
564
+ // A resize: pi does not re-run the renderer when the terminal changes width,
565
+ // it just calls `render(newWidth)` on the component it kept. So the frames
566
+ // below come from one draw, and only the cache separates them.
567
+ for (const [wide, narrow] of [[200, 40], [120, 60]] as const) {
568
+ const component = draw("resize probe ".repeat(30));
569
+ const wideLines = component.render(wide);
570
+ const narrowLines = component.render(narrow);
571
+ expect(wideLines.length, `no rows at ${wide}`).toBeGreaterThan(0);
572
+ expect(narrowLines, `a resize served the cached ${wide}-column frame`)
573
+ .not.toEqual(wideLines);
574
+ for (const [width, lines] of [[wide, wideLines], [narrow, narrowLines]] as const) {
575
+ expect(visibleWidth(content(lines[0] ?? "")), `row not cut at ${width}`)
576
+ .toBeGreaterThanOrEqual(width - 1);
577
+ }
578
+ // And the same width again is stable, not re-laid out into something else.
579
+ expect(component.render(wide)).toEqual(wideLines);
580
+ }
581
+
582
+ // A streaming tick: new content, same width — the cached frame must go.
583
+ draw("the first run's task text ".repeat(10));
584
+ const before = draw("the first run's task text ".repeat(10)).render(120);
585
+ const after = draw("a second run's task text ".repeat(10)).render(120);
586
+ expect(after).not.toEqual(before);
587
+ expect(stripTerminalSequences(after[0] ?? "")).toContain("second run");
588
+
589
+ // invalidate() is part of the component contract (pi calls it on a theme
590
+ // change through `Tui.invalidate()`); it must not serve the old frame.
591
+ const kept = first as { render(width: number): string[]; invalidate(): void };
592
+ kept.invalidate();
593
+ expect(kept.render(120)).toEqual(after);
594
+ });
595
+
596
+ // The collapsed view is a summary: one row per line, cut at the frame. The
597
+ // expanded view is where the run is meant to be read in full (ADR 0020), so a
598
+ // line that carries content and no second copy of it — the stats line: turns,
599
+ // tools, tokens, duration, cost — has to keep wrapping there. As a row it is
600
+ // cut at the frame, and below ~64 columns the tail (tokens, duration, cost)
601
+ // disappeared with nothing else showing it, which is content #108 would have
602
+ // made unreachable.
603
+ it("loses no stat in the expanded view, however narrow the frame", () => {
604
+ const stats = { toolCount: 5, tokens: 1500, durationMs: 3200 };
605
+ const details = { mode: "single", results: [makeResult({ progressSummary: stats })], progress: undefined };
606
+ // What the stats line carries. Compared with whitespace removed, because a
607
+ // wrapped row may break a stat across rows.
608
+ const carried = ["⟳3", "5tools", "2ktok", "3s", "$0.01"];
609
+ const failures: string[] = [];
610
+
611
+ for (const width of WIDTHS) {
612
+ const lines = registerTool()
613
+ .renderResult({ content: [], details, isError: false }, { expanded: true }, theme, {
614
+ state: {},
615
+ invalidate: () => {},
616
+ })
617
+ .render(width);
618
+ const drawn = lines.map(content).join("").replace(/\s+/g, "");
619
+ for (const stat of carried) {
620
+ if (!drawn.includes(stat)) {
621
+ failures.push(`@${width}: expanded view lost ${stat}:\n ${JSON.stringify(lines.map(content))}`);
622
+ }
623
+ }
624
+ for (const line of lines) {
625
+ if (visibleWidth(stripTerminalSequences(line)) > width) failures.push(`@${width}: row over the frame`);
626
+ }
627
+ }
628
+ // The collapsed view stays a summary: the same content is cut there, on
629
+ // purpose — this asserts the two views did not converge on the same shape.
630
+ const compact = registerTool()
631
+ .renderResult({ content: [], details, isError: false }, { expanded: false }, theme, {
632
+ state: {},
633
+ invalidate: () => {},
634
+ })
635
+ .render(40);
636
+ expect(compact).toHaveLength(3);
637
+ expect(content(compact[1] ?? "")).toContain("...");
638
+
639
+ expect(failures, failures.join("\n ")).toEqual([]);
640
+ });
641
+
642
+ // A loaded runner, not the content, is what costs time here. Across 20 full-suite
643
+ // runs this test measured ~3.5 s idle, but 4.4–12.9 s *uncapped* while the repo's
644
+ // own type-check looped over it (3–5 `tsc` workers on 8 cores), and the five runs
645
+ // vitest cut at its 5 s default recorded 6.1 / 6.7 / 10.0 / 10.8 / 14.9 s — it
646
+ // failed 5 of 5 pre-fix loaded runs, which is issue #131. 30 s clears the worst
647
+ // episode measured (12932 ms) with room to spare while still bounding a hang.
648
+ // `numRuns` is deliberately untouched: shrinking the generator's coverage is a
649
+ // separate decision from giving a slow-but-correct test room to finish.
650
+ it("holds the invariant for arbitrary content (fast-check)", { timeout: 30_000 }, () => {
651
+ const widths = [200, 120, 80, 60, 40];
652
+ // Wide-character and emoji content, plus whatever else the generator likes.
653
+ // Long by construction: a short string is never truncated, so a generator
654
+ // that mostly produces short ones proves nothing about the cut.
655
+ const wideChars = fc.constantFrom(...["\u3040", "\u30ff", "\u4e00", "\u65e5", "\u884c", "\uff5e", "🚀", "🔥", "✅", "🧪", "📦", "a", " ", "z"]);
656
+ const wideTask = fc.array(wideChars, { minLength: 60, maxLength: 200 }).map((parts) => parts.join(""));
657
+ fc.assert(
658
+ fc.property(wideTask, (task) => {
659
+ expect(visibleWidth(task)).toBeGreaterThanOrEqual(60); // long enough to cut
660
+ for (const width of widths) {
661
+ for (const line of rowComponents(task)
662
+ .filter((e) => e.path !== "renderCall")
663
+ .flatMap((e) => e.component().render(width))) {
664
+ if (visibleWidth(stripTerminalSequences(line)) > width) return false;
665
+ }
666
+ }
667
+ return true;
668
+ }),
669
+ { numRuns: 50 },
670
+ );
671
+
672
+ fc.assert(
673
+ fc.property(fc.string({ minLength: 1, maxLength: 400 }), (error) => {
674
+ for (const width of widths) {
675
+ const details = {
676
+ mode: "single",
677
+ results: [makeResult({ outcome: undefined, exitCode: 1, error })],
678
+ progress: undefined,
679
+ };
680
+ const component = registerTool().renderResult(
681
+ { content: [], details, isError: true },
682
+ { expanded: false },
683
+ theme,
684
+ { state: {}, invalidate: () => {} },
685
+ );
686
+ for (const line of component.render(width)) {
687
+ if (visibleWidth(stripTerminalSequences(line)) > width) return false;
688
+ }
689
+ }
690
+ return true;
691
+ }),
692
+ { numRuns: 50 },
693
+ );
694
+ });
695
+
696
+ it("still fits inside pi's own tool shell (Container → Box padding)", () => {
697
+ for (const width of WIDTHS) {
698
+ const shell = new Container();
699
+ const box = new Box(1, 1, (t) => `\x1b[40m${t}\x1b[0m`);
700
+ box.addChild(rowComponents(ASCII_TASK).find((e) => e.path === "compact:single")!.component());
701
+ shell.addChild(box);
702
+ const lines = shell.render(width);
703
+ const tooWide: RowReport[] = lines
704
+ .map((line, index) => ({
705
+ path: "shell",
706
+ task: "ascii",
707
+ width,
708
+ index,
709
+ line,
710
+ drawn: visibleWidth(stripTerminalSequences(line)),
711
+ }))
712
+ .filter((r) => r.drawn > width);
713
+ expect(
714
+ tooWide,
715
+ `rows overflow pi's shell at ${width}:\n ${summarize(tooWide)}`,
716
+ ).toEqual([]);
717
+ expect(lines.length).toBeGreaterThan(0);
718
+ }
719
+ });
720
+ });