@pi-archimedes/subagent 2.10.4 → 2.11.0

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 (46) hide show
  1. package/README.md +1 -1
  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 +61 -0
  16. package/src/expanded.ts +96 -80
  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 +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -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.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
@@ -1,29 +1,43 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
  import * as fc from "fast-check";
3
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
4
  import { buildActivityLine, renderCompactSingle, renderCompactParallel } from "./compact.js";
5
+ import { RowBlock, type RowSegment, type RowSink } from "./row-block.js";
4
6
  import type { SubagentResult, SubagentProgress, SubagentDetails, SubagentToolCall } from "./types.js";
5
7
 
6
- // ── Mocks ───────────────────────────────────────────────────────────────────
7
-
8
- vi.mock("@earendil-works/pi-tui", () => {
9
- class MockTextInner {
10
- private _content = "";
8
+ // ── The row sink the builders draw into ───────────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * A recording `RowSink`, standing in for the `RowBlock` `render.ts` hands these
12
+ * builders. Deliberately not a `RowBlock`, and deliberately no mock of pi-tui's
13
+ * `Text`: `row-block.ts` takes `sliceByColumn` / `wrapTextWithAnsi` /
14
+ * `visibleWidth` from that module, so mocking it to fake a sink would quietly
15
+ * break the row code the tests are about. What the segments draw is asserted
16
+ * with a real `RowBlock` below.
17
+ */
18
+ class RecordingSink implements RowSink {
19
+ private content = "";
20
+ private segments: RowSegment[] | undefined = undefined;
21
+
22
+ setText(content: string): void {
23
+ this.content = content;
24
+ this.segments = undefined;
25
+ }
11
26
 
12
- setText(content: string): void {
13
- this._content = content;
14
- }
27
+ /** Mirrors `RowBlock.setSegments`: the joined text is what a row draws. */
28
+ setSegments(segments: RowSegment[]): void {
29
+ this.segments = segments;
30
+ this.content = segments.map((s) => s.text).join("\n");
31
+ }
15
32
 
16
- getContent(): string {
17
- return this._content;
18
- }
33
+ getContent(): string {
34
+ return this.content;
19
35
  }
20
- return {
21
- Text: MockTextInner,
22
- };
23
- });
24
36
 
25
- // Re-import MockText for use in tests (same class as the mock)
26
- import { Text as MockText } from "@earendil-works/pi-tui";
37
+ getSegments(): RowSegment[] | undefined {
38
+ return this.segments;
39
+ }
40
+ }
27
41
 
28
42
  // Type-safe helper: ActivityData with all optional fields for test convenience
29
43
  // ActivityData is defined in compact.ts — reconstruct the shape here
@@ -236,23 +250,23 @@ describe("renderCompactSingle", () => {
236
250
  };
237
251
  }
238
252
 
239
- it("calls setText with expected output structure for completed agent", () => {
253
+ it("hands the block to the row sink with the expected structure for a completed agent", () => {
240
254
  const { theme } = makeMockTheme();
241
- const text = new MockText();
255
+ const text = new RecordingSink();
242
256
  const result = makeResult({ exitCode: 0 });
243
257
  const context = { state: {}, invalidate: vi.fn() };
244
258
 
245
259
  renderCompactSingle(text, result, undefined, theme, context);
246
260
 
247
- const output = (text as unknown as { getContent(): string }).getContent();
261
+ const output = text.getContent();
248
262
  expect(output).toContain("test-agent");
249
263
  expect(output).toContain("do something");
250
264
  expect(output).toContain("✓ Done");
251
265
  });
252
266
 
253
- it("calls setText with expected output structure for running agent", () => {
267
+ it("hands the block to the row sink with the expected structure for a running agent", () => {
254
268
  const { theme } = makeMockTheme();
255
- const text = new MockText();
269
+ const text = new RecordingSink();
256
270
  const result = makeResult({ exitCode: 1 });
257
271
  const progress: SubagentProgress = {
258
272
  agent: "test-agent",
@@ -277,20 +291,57 @@ describe("renderCompactSingle", () => {
277
291
 
278
292
  renderCompactSingle(text, result, progress, theme, context);
279
293
 
280
- const output = (text as unknown as { getContent(): string }).getContent();
294
+ const output = text.getContent();
281
295
  expect(output).toContain("test-agent");
282
296
  expect(output).toContain("read");
283
297
  });
284
298
 
285
299
  it("returns the text instance", () => {
286
300
  const { theme } = makeMockTheme();
287
- const text = new MockText();
301
+ const text = new RecordingSink();
288
302
  const result = makeResult();
289
303
  const context = { state: {}, invalidate: vi.fn() };
290
304
 
291
305
  const returned = renderCompactSingle(text, result, undefined, theme, context);
292
306
  expect(returned).toBe(text);
293
307
  });
308
+
309
+ // A task is free text and an error is usually stderr, so both arrive with
310
+ // newlines in them. The collapsed view is three rows per agent, so each field
311
+ // has to reach the row layer as its own `row` segment carrying the whole text:
312
+ // the row then cuts itself at the first newline, at the width it is drawn at.
313
+ // Handed over as one joined string, every newline would become a row and a
314
+ // failed child's stderr would open the collapsed block.
315
+ it("hands over three row segments carrying whole fields, so newlines reach the row cut", () => {
316
+ const { theme } = makeMockTheme();
317
+ const text = new RecordingSink();
318
+ const result = makeResult({
319
+ task: "Review the change\nRun the full suite",
320
+ exitCode: 1,
321
+ error: "spawn failed\ncommand not found: pnpm",
322
+ });
323
+
324
+ renderCompactSingle(text, result, undefined, theme, { state: {}, invalidate: vi.fn() });
325
+
326
+ const segments = text.getSegments();
327
+ expect(segments?.map((s) => s.kind)).toEqual(["row", "row", "row"]);
328
+ // Fields arrive uncapped — the row decides how much of each one fits.
329
+ expect(segments?.[0]?.text).toContain("Run the full suite");
330
+ expect(segments?.[2]?.text).toContain("command not found: pnpm");
331
+
332
+ // And drawn by the real row component, that is three rows: the newline is a
333
+ // boundary inside the row, not a new row.
334
+ const rows = new RowBlock();
335
+ rows.setSegments(segments!);
336
+ const drawn = rows.render(80).map((l) => stripTerminalSequences(l).replace(/\s+$/, ""));
337
+ // Three rows, and the two fields that carry newlines end in the cut marker
338
+ // rather than spilling into rows of their own. (The mock theme marks its
339
+ // spans with markers of its own, so match the tail of each row.)
340
+ expect(drawn).toHaveLength(3);
341
+ expect(drawn[0]).toMatch(/: Review the change\.\.\.$/);
342
+ expect(drawn[1]).toContain("gpt-4");
343
+ expect(drawn[2]).toMatch(/✗ spawn failed\.\.\.$/);
344
+ });
294
345
  });
295
346
 
296
347
  // ── renderCompactParallel ──────────────────────────────────────────────────
@@ -330,13 +381,13 @@ describe("renderCompactParallel", () => {
330
381
 
331
382
  it("renders a full 3-line block per agent (parity with single view)", () => {
332
383
  const { theme } = makeMockTheme();
333
- const text = new MockText();
384
+ const text = new RecordingSink();
334
385
  const details = makeDetails();
335
386
  const context = { state: {}, invalidate: vi.fn() };
336
387
 
337
388
  renderCompactParallel(text, details, theme, context);
338
389
 
339
- const output = (text as unknown as { getContent(): string }).getContent();
390
+ const output = text.getContent();
340
391
  expect(output).toContain("agent-a");
341
392
  expect(output).toContain("agent-b");
342
393
  // Each agent is a 3-line block (label / model+stats / activity); with two
@@ -355,7 +406,7 @@ describe("renderCompactParallel", () => {
355
406
 
356
407
  it("returns the text instance", () => {
357
408
  const { theme } = makeMockTheme();
358
- const text = new MockText();
409
+ const text = new RecordingSink();
359
410
  const details = makeDetails();
360
411
  const context = { state: {}, invalidate: vi.fn() };
361
412
 
@@ -366,83 +417,111 @@ describe("renderCompactParallel", () => {
366
417
 
367
418
  // ── Property tests (fast-check) ────────────────────────────────────────────
368
419
 
369
- describe("buildActivityLine property tests", () => {
370
- it("truncated error never exceeds truncLine limit of 80 chars", () => {
420
+ // The cap property tests that used to live here pinned "≤ 60 / ≤ 80 characters".
421
+ // That rule is gone: rows are cut at the width pi renders them at, which is
422
+ // audited against the real renderers in `tool-row.width-audit.test.ts`, and the
423
+ // cutting itself (cells, colour, newlines) in `row-block.test.ts`. What these
424
+ // builders owe that audit is the *whole* content, so the row — not the builder —
425
+ // decides where the line ends.
426
+ describe("buildActivityLine hands its row whole content (no character cap)", () => {
427
+ /** Remove the mock theme's `[token]`/`[/token]` markers (fixtures exclude `[`). */
428
+ const stripMarkers = (s: string) => s.replace(/\[\/?(?:muted|running|success|error|dim|accent)\]/g, "");
429
+
430
+ it("carries an error far longer than the old 80-character limit", () => {
371
431
  fc.assert(
372
432
  fc.property(
373
- fc.string({ minLength: 1, maxLength: 500 }),
433
+ fc.string({ minLength: 81, maxLength: 500 }).filter((s) => !s.includes("[")),
374
434
  (error) => {
375
435
  const { theme } = makeMockTheme();
376
436
  const result = buildActivityLine(
377
437
  activityData({ error, status: "running" }),
378
438
  theme,
379
439
  );
380
- // Strip the "✗ " prefix and ANSI tags to get the actual truncated text
381
- const withoutPrefix = result.replace(/^\[error\]✗ /, "");
382
- // The visible text (without closing ANSI tag) should be <= 80
383
- const visibleText = withoutPrefix.replace(/\[\/error\]$/, "");
384
- expect(visibleText.length).toBeLessThanOrEqual(80);
440
+ expect(stripMarkers(result)).toBe("✗ " + error);
385
441
  },
386
442
  ),
387
443
  );
388
444
  });
389
445
 
390
- it("truncated argsPreview never exceeds truncLine limit of 60 chars", () => {
446
+ it("carries args far longer than the old 60-character limit", () => {
391
447
  fc.assert(
392
448
  fc.property(
393
- fc.string({ minLength: 1, maxLength: 20 }).filter((s) => !s.includes(": ") && !s.includes("\n") && !s.includes("|")),
394
- fc.string({ minLength: 1, maxLength: 500 }).filter((s) => !s.includes(": ") && !s.includes("\n") && !s.includes("|")),
449
+ fc.string({ minLength: 1, maxLength: 20 }).filter((s) => !s.includes(": ") && !s.includes("\n") && !s.includes("|") && !s.includes("[")),
450
+ fc.string({ minLength: 61, maxLength: 500 }).filter((s) => !s.includes(": ") && !s.includes("\n") && !s.includes("|") && !s.includes("[")),
395
451
  (currentTool, currentToolArgs) => {
396
452
  const { theme } = makeMockTheme();
397
453
  const result = buildActivityLine(
398
454
  activityData({ status: "running", currentTool, currentToolArgs }),
399
455
  theme,
400
456
  );
401
- // Extract the args portion after ": "
402
- const argsMatch = result.match(/: (.+?)(?:\s\|.*|\[\/dim\].*)?$/);
403
- expect(argsMatch).not.toBeNull();
404
- const argsText = argsMatch![1]!.replace(/\[\/dim\]$/, "").replace(/\[dim\]/, "");
405
- expect(argsText.length).toBeLessThanOrEqual(60);
457
+ const visible = stripMarkers(result);
458
+ expect(visible.includes(currentToolArgs)).toBe(true);
406
459
  },
407
460
  ),
408
461
  );
409
462
  });
410
463
 
411
- it("truncated finalOutput first line never exceeds truncLine limit of 80 chars", () => {
464
+ it("carries the first line of output whole, and never a second line", () => {
412
465
  fc.assert(
413
466
  fc.property(
414
- fc.string({ minLength: 1, maxLength: 500 }),
415
- (finalOutput) => {
467
+ fc.string({ minLength: 81, maxLength: 500 }).filter((s) => !s.includes("\n") && !s.includes("[")),
468
+ (firstLine) => {
416
469
  const { theme } = makeMockTheme();
417
470
  const result = buildActivityLine(
418
- activityData({ status: "running", finalOutput }),
471
+ activityData({ status: "running", finalOutput: `${firstLine}\nsecond line\nthird` }),
419
472
  theme,
420
473
  );
421
- // Strip ANSI tags to get visible text
422
- const visible = result.replace(/\[muted\]/g, "").replace(/\[\/muted\]/g, "");
423
- // The visible text after the running glyph "▸ " should be <= 80
424
- const afterArrow = visible.replace(/^▸ /, "");
425
- expect(afterArrow.length).toBeLessThanOrEqual(80);
474
+ const visible = stripMarkers(result);
475
+ expect(visible).toBe("▸ " + firstLine);
426
476
  },
427
477
  ),
428
478
  );
429
479
  });
430
480
 
431
- it("truncated string toolCall never exceeds truncLine limit of 60 chars", () => {
481
+ it("carries a string tool call far longer than the old 60-character limit", () => {
432
482
  fc.assert(
433
483
  fc.property(
434
- fc.string({ minLength: 1, maxLength: 500 }),
484
+ fc.string({ minLength: 61, maxLength: 500 }).filter((s) => !s.includes("\n") && !s.includes("[")),
435
485
  (toolCallStr) => {
436
486
  const { theme } = makeMockTheme();
437
487
  const result = buildActivityLine(
438
488
  activityData({ status: "running", toolCalls: [toolCallStr] }),
439
489
  theme,
440
490
  );
441
- const visible = result.replace(/\[dim\]/g, "").replace(/\[\/dim\]/g, "");
442
- const afterArrow = visible.replace(/^▸ /, "");
443
- expect(afterArrow.length).toBeLessThanOrEqual(60);
491
+ const visible = stripMarkers(result);
492
+ expect(visible.includes(toolCallStr)).toBe(true);
444
493
  },
445
494
  ),
446
495
  );
447
496
  });
448
497
  });
498
+
499
+ describe("compact activity line: run outcomes", () => {
500
+ it("shows Truncated for a cut-off answer, in warning colour", () => {
501
+ const { theme } = makeMockTheme();
502
+ const line = buildActivityLine(
503
+ activityData({ status: "completed", outcome: "truncated" } as never),
504
+ theme,
505
+ );
506
+ expect(line).toMatch(/truncated/i);
507
+ expect(line).not.toMatch(/Done/);
508
+ });
509
+
510
+ it("shows Aborted for a cancelled run", () => {
511
+ const { theme } = makeMockTheme();
512
+ const line = buildActivityLine(
513
+ activityData({ status: "completed", outcome: "aborted" } as never),
514
+ theme,
515
+ );
516
+ expect(line).toMatch(/aborted/i);
517
+ });
518
+
519
+ it("shows Done for a completed run, unchanged", () => {
520
+ const { theme } = makeMockTheme();
521
+ const line = buildActivityLine(
522
+ activityData({ status: "completed", outcome: "completed" } as never),
523
+ theme,
524
+ );
525
+ expect(line).toContain("✓ Done");
526
+ });
527
+ });
package/src/compact.ts CHANGED
@@ -1,19 +1,28 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall, SubagentToolResult } from "./types.js";
3
- import { formatDuration, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
1
+ import type { RunOutcome, SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall, SubagentToolResult } from "./types.js";
2
+ import { formatDuration, buildStatsLine, buildAgentLabel, buildOutcomeLine } from "./format.js";
3
+ import { row, type RowSegment, type RowSink } 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
  type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
8
8
 
9
+ // Re-exported so callers and tests describe the sink the same way the builders do.
10
+ export type { RowSink };
11
+
9
12
  // ── Activity line builder ───────────────────────────────────────────────────
10
13
 
14
+ // The builders below hand back whole content, never a fixed-length preview: the
15
+ // row each line lands on is cut at the width pi renders at (`RowBlock`,
16
+ // `row-block.ts`), so a long task or error fills the frame instead of stopping at
17
+ // 60 or 80 characters.
11
18
  interface ActivityData {
12
19
  currentTool: string | undefined;
13
20
  currentToolArgs: string | undefined;
14
21
  currentToolStartedAt: number | undefined;
15
22
  finalOutput: string | undefined;
16
23
  status: "running" | "completed" | "failed" | undefined;
24
+ /** The *Run outcome*, when the run reached one — shown instead of Done/Failed. */
25
+ outcome?: RunOutcome | undefined;
17
26
  error: string | undefined;
18
27
  toolCalls?: (SubagentToolCall | string)[] | undefined;
19
28
  }
@@ -23,7 +32,15 @@ export function buildActivityLine(
23
32
  theme: Theme,
24
33
  ): string {
25
34
  if (data.error) {
26
- return theme.fg("error", "✗ " + truncLine(data.error, 80));
35
+ return theme.fg("error", "✗ " + data.error);
36
+ }
37
+
38
+ // A finished run reports its outcome: Done / Truncated / Aborted. Truncated and
39
+ // Aborted are not the old "Failed" — the answer may be perfectly good, just
40
+ // partial, and a cancelled run was cancelled on purpose.
41
+ if (data.outcome) {
42
+ const line = buildOutcomeLine({ outcome: data.outcome, error: undefined }, theme);
43
+ if (line) return line;
27
44
  }
28
45
 
29
46
  // Finished subagents always show their final status — never a stale
@@ -38,9 +55,7 @@ export function buildActivityLine(
38
55
 
39
56
  // Running: show the current tool with the running glyph ▸ + live duration.
40
57
  if (data.currentTool) {
41
- const argsPreview = data.currentToolArgs
42
- ? truncLine(data.currentToolArgs, 60)
43
- : "";
58
+ const argsPreview = data.currentToolArgs ?? "";
44
59
  const durationPart = data.currentToolStartedAt
45
60
  ? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
46
61
  : "";
@@ -54,12 +69,10 @@ export function buildActivityLine(
54
69
  const lastCall = data.toolCalls[data.toolCalls.length - 1];
55
70
  if (lastCall) {
56
71
  if (typeof lastCall === "string") {
57
- return renderToolCallLine("success", truncLine(lastCall, 60), "", theme);
72
+ return renderToolCallLine("success", lastCall, "", theme);
58
73
  }
59
74
  const status = lastCall.error ? "error" : "success";
60
- const suffix = lastCall.argsPreview
61
- ? ": " + truncLine(lastCall.argsPreview, 60)
62
- : "";
75
+ const suffix = lastCall.argsPreview ? ": " + lastCall.argsPreview : "";
63
76
  return renderToolCallLine(status, lastCall.name, suffix, theme);
64
77
  }
65
78
  }
@@ -67,7 +80,7 @@ export function buildActivityLine(
67
80
  // Running, no tool history: show first line of streamed output (▸, muted).
68
81
  if (data.finalOutput) {
69
82
  const firstLine = data.finalOutput.split("\n")[0] ?? "";
70
- return renderToolCallLine("running", truncLine(firstLine, 80), "", theme);
83
+ return renderToolCallLine("running", firstLine, "", theme);
71
84
  }
72
85
 
73
86
  // Running, no info yet
@@ -96,34 +109,38 @@ type AgentBlockData = {
96
109
  // <agent>: <task>
97
110
  // <model> · <stats>
98
111
  // <activity> (status lives here — "✓ Done" / "✗ Failed" / ▸ tool)
99
- function buildAgentBlock(data: AgentBlockData, theme: Theme): string {
112
+ // Each field is handed over as its own `row` segment, so it stays one row of the
113
+ // rendered frame however wide the frame is. Handing the three over as one string
114
+ // would not do that: a task is free text and an error is usually stderr, so both
115
+ // carry newlines, and `setText` reads every newline as the start of a new row —
116
+ // which would push a failed child's stack trace open across the collapsed block.
117
+ // `truncateRow` cuts the row at the first newline and marks it with the ellipsis,
118
+ // which is the boundary the old character-based `truncLine` enforced.
119
+ function buildAgentBlock(data: AgentBlockData, theme: Theme): RowSegment[] {
100
120
  const statsLine = buildStatsLine(data.statsData, theme);
101
121
  const modelLabel = data.model ? theme.fg("accent", data.model) : "";
102
122
  const activityLine = buildActivityLine(data.activity, theme);
103
123
  const label = buildAgentLabel(data.agentName, data.task, theme);
104
124
 
105
- return (
106
- label +
107
- "\n" +
108
- [modelLabel, statsLine].filter(Boolean).join(" ") +
109
- "\n" +
110
- activityLine
111
- );
125
+ return [
126
+ row(label),
127
+ row([modelLabel, statsLine].filter(Boolean).join(" ")),
128
+ row(activityLine),
129
+ ];
112
130
  }
113
131
 
114
132
  export function renderCompactSingle(
115
- text: Text,
133
+ text: RowSink,
116
134
  result: SubagentResult,
117
135
  progress: SubagentProgress | undefined,
118
136
  theme: Theme,
119
137
  context: RenderContext,
120
- ): Text {
138
+ ): RowSink {
121
139
  // agentName sourced from result; defaults to "subagent"
122
140
  const agentName = result.agent ?? "subagent";
123
141
  const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
124
142
  const isRunning = progress?.status === "running";
125
143
  const status = isRunning ? "running" : (result.exitCode === 0 ? "completed" : "failed");
126
-
127
144
  // Track start time for live duration
128
145
  const timeKey = "_subagentStartTime_" + agentName;
129
146
  if (isRunning && context.state[timeKey] === undefined) {
@@ -135,7 +152,7 @@ export function renderCompactSingle(
135
152
  ? Date.now() - (context.state[timeKey] as number)
136
153
  : summary.durationMs;
137
154
 
138
- const output = buildAgentBlock(
155
+ const block = buildAgentBlock(
139
156
  {
140
157
  agentName,
141
158
  task: result.task,
@@ -153,6 +170,7 @@ export function renderCompactSingle(
153
170
  currentToolStartedAt: progress?.currentToolStartedAt,
154
171
  finalOutput: result.finalOutput,
155
172
  status: isRunning ? "running" : status,
173
+ outcome: isRunning ? undefined : result.outcome,
156
174
  error: result.error,
157
175
  toolCalls: progress?.toolCalls,
158
176
  },
@@ -160,19 +178,19 @@ export function renderCompactSingle(
160
178
  theme,
161
179
  );
162
180
 
163
- text.setText(output);
181
+ text.setSegments(block);
164
182
  return text;
165
183
  }
166
184
 
167
185
  // ── Compact parallel agents ─────────────────────────────────────────────────
168
186
 
169
187
  export function renderCompactParallel(
170
- text: Text,
188
+ text: RowSink,
171
189
  details: SubagentDetails,
172
190
  theme: Theme,
173
191
  context: RenderContext,
174
- ): Text {
175
- const lines = details.results.map((result, i) => {
192
+ ): RowSink {
193
+ const blocks = details.results.map((result, i) => {
176
194
  const progress = details.progress?.[i];
177
195
  const agentName = result.agent ?? "subagent";
178
196
  const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
@@ -203,6 +221,7 @@ export function renderCompactParallel(
203
221
  currentToolStartedAt: progress?.currentToolStartedAt,
204
222
  finalOutput: result.finalOutput,
205
223
  status,
224
+ outcome: result.outcome,
206
225
  error: result.error,
207
226
  toolCalls: progress?.toolCalls,
208
227
  },
@@ -211,18 +230,18 @@ export function renderCompactParallel(
211
230
  );
212
231
  });
213
232
 
214
- text.setText(lines.join("\n"));
233
+ text.setSegments(blocks.flat());
215
234
  return text;
216
235
  }
217
236
 
218
237
  // ── Compact streaming progress ──────────────────────────────────────────────
219
238
 
220
239
  export function renderCompactProgress(
221
- text: Text,
240
+ text: RowSink,
222
241
  progress: SubagentProgress,
223
242
  theme: Theme,
224
243
  context: RenderContext,
225
- ): Text {
244
+ ): RowSink {
226
245
  // agentName sourced from progress; defaults to "subagent"
227
246
  const agentName = progress.agent ?? "subagent";
228
247
  const status = progress.status;
@@ -237,7 +256,7 @@ export function renderCompactProgress(
237
256
  ? Date.now() - (context.state[timeKey] as number)
238
257
  : progress.durationMs;
239
258
 
240
- const output = buildAgentBlock(
259
+ const block = buildAgentBlock(
241
260
  {
242
261
  agentName,
243
262
  task: progress.task,
@@ -255,6 +274,7 @@ export function renderCompactProgress(
255
274
  currentToolStartedAt: progress.currentToolStartedAt,
256
275
  finalOutput: undefined,
257
276
  status,
277
+ outcome: status === "running" ? undefined : progress.outcome,
258
278
  error: progress.error,
259
279
  toolCalls: progress.toolCalls,
260
280
  },
@@ -262,19 +282,19 @@ export function renderCompactProgress(
262
282
  theme,
263
283
  );
264
284
 
265
- text.setText(output);
285
+ text.setSegments(block);
266
286
  return text;
267
287
  }
268
288
 
269
289
  // ── Compact parallel streaming progress ─────────────────────────────────────
270
290
 
271
291
  export function renderCompactParallelProgress(
272
- text: Text,
292
+ text: RowSink,
273
293
  details: SubagentDetails,
274
294
  theme: Theme,
275
295
  context: RenderContext,
276
- ): Text {
277
- const lines = (details.progress ?? []).map((progress) => {
296
+ ): RowSink {
297
+ const blocks = (details.progress ?? []).map((progress) => {
278
298
  const agentName = progress.agent ?? "subagent";
279
299
  const status = progress.status;
280
300
 
@@ -304,6 +324,6 @@ export function renderCompactParallelProgress(
304
324
  );
305
325
  });
306
326
 
307
- text.setText(lines.join("\n"));
327
+ text.setSegments(blocks.flat());
308
328
  return text;
309
329
  }
@@ -42,7 +42,9 @@ function activateBridge(socketPath: string): void {
42
42
  process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
43
43
  // registerBridge evaluates isBridgeMode at session_start — fire the handler.
44
44
  const onSpy = vi.fn();
45
- registerBridge({ on: onSpy } as unknown as ExtensionAPI);
45
+ // The handle the relay subscribes on. Inert here: these tests are about the
46
+ // socket frames the dispatch sends, not about the relay.
47
+ registerBridge({ on: onSpy, events: { emit: () => {}, on: () => () => {} } } as unknown as ExtensionAPI);
46
48
  const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
47
49
  const startCall = calls.find((c) => c[0] === "session_start");
48
50
  expect(startCall).toBeTruthy();
@@ -73,6 +75,9 @@ function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
73
75
  activeModel: "active-model",
74
76
  cwd: undefined,
75
77
  signal: undefined,
78
+ // Inert handle: a bridge-dispatched run never touches the event bus here, but
79
+ // ExecuteOptions carries one because the fork path needs it.
80
+ events: { emit: () => {}, on: () => () => {} },
76
81
  onUpdate: undefined,
77
82
  toolCallId: "tc-1",
78
83
  ...overrides,
@@ -290,3 +295,70 @@ describe("resolveModel", () => {
290
295
  expect(resolveModel({ model: undefined, activeModel: undefined, agent: undefined })).toBeUndefined();
291
296
  });
292
297
  });
298
+
299
+ // ── Every bridge-settled result belongs to the desktop's session for cost ──
300
+
301
+ describe("dispatchViaBridge: cost ownership", () => {
302
+ // The desktop runs the task as a session of its own and books its usage
303
+ // there. `usage` still rides along for display, but the result has to say it
304
+ // was accounted for elsewhere, or the parent folds the same money into its own
305
+ // session totals through the tool result (ADR 0028 counts cost exactly once).
306
+ // Only the `{ fallback: true }` corner is different — there the fork path
307
+ // re-runs the task and the tool result IS the cost report.
308
+ it("marks a successful bridge run", async () => {
309
+ const sockPath = tempSocketPath();
310
+ const server = await startFakeDesktop(sockPath, (msg) => ({
311
+ v: 1,
312
+ type: "response",
313
+ id: msg.id,
314
+ result: { output: "done", metrics: { inputTokens: 1, outputTokens: 2, cost: 0.01, durationMs: 5 } },
315
+ }));
316
+ activateBridge(sockPath);
317
+
318
+ const result = (await dispatchViaBridge(makeOptions(), undefined, "tc-1")) as SubagentResult;
319
+ expect(result.usageAccountedElsewhere).toBe(true);
320
+ // The metrics are still there for the details view.
321
+ expect(result.usage.cost).toBe(0.01);
322
+
323
+ server.close();
324
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
325
+ });
326
+
327
+ it("marks a bridge run the desktop reported as failed", async () => {
328
+ const sockPath = tempSocketPath();
329
+ const server = await startFakeDesktop(sockPath, (msg) => ({
330
+ v: 1,
331
+ type: "response",
332
+ id: msg.id,
333
+ error: "worker runtime unavailable",
334
+ }));
335
+ activateBridge(sockPath);
336
+
337
+ const result = (await dispatchViaBridge(makeOptions(), undefined, "tc-1")) as SubagentResult;
338
+ expect(result.error).toBe("worker runtime unavailable");
339
+ expect(result.usageAccountedElsewhere).toBe(true);
340
+
341
+ server.close();
342
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
343
+ });
344
+
345
+ it("marks a run cancelled locally, and never claims the fork path's ownership", async () => {
346
+ const sockPath = tempSocketPath();
347
+ const server = await startFakeDesktop(sockPath, () => {
348
+ /* accept but never respond */
349
+ });
350
+ activateBridge(sockPath);
351
+
352
+ const controller = new AbortController();
353
+ const pending = dispatchViaBridge(makeOptions(), controller.signal, "tc-1");
354
+ await new Promise((r) => setTimeout(r, 50));
355
+ controller.abort();
356
+
357
+ const result = (await pending) as SubagentResult;
358
+ expect(result.error).toBe("cancelled");
359
+ expect(result.usageAccountedElsewhere).toBe(true);
360
+
361
+ server.close();
362
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
363
+ });
364
+ });