@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.
- package/README.md +2 -2
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +472 -0
- package/src/expanded.ts +202 -82
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
package/src/compact.test.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
33
|
+
getContent(): string {
|
|
34
|
+
return this.content;
|
|
19
35
|
}
|
|
20
|
-
return {
|
|
21
|
-
Text: MockTextInner,
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
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("
|
|
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
|
|
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 =
|
|
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("
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
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:
|
|
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
|
-
|
|
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("
|
|
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:
|
|
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
|
-
|
|
402
|
-
|
|
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("
|
|
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:
|
|
415
|
-
(
|
|
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
|
-
|
|
422
|
-
|
|
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("
|
|
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:
|
|
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
|
|
442
|
-
|
|
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 {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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", "✗ " +
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
"
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
133
|
+
text: RowSink,
|
|
116
134
|
result: SubagentResult,
|
|
117
135
|
progress: SubagentProgress | undefined,
|
|
118
136
|
theme: Theme,
|
|
119
137
|
context: RenderContext,
|
|
120
|
-
):
|
|
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
|
|
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.
|
|
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:
|
|
188
|
+
text: RowSink,
|
|
171
189
|
details: SubagentDetails,
|
|
172
190
|
theme: Theme,
|
|
173
191
|
context: RenderContext,
|
|
174
|
-
):
|
|
175
|
-
const
|
|
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.
|
|
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:
|
|
240
|
+
text: RowSink,
|
|
222
241
|
progress: SubagentProgress,
|
|
223
242
|
theme: Theme,
|
|
224
243
|
context: RenderContext,
|
|
225
|
-
):
|
|
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
|
|
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.
|
|
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:
|
|
292
|
+
text: RowSink,
|
|
273
293
|
details: SubagentDetails,
|
|
274
294
|
theme: Theme,
|
|
275
295
|
context: RenderContext,
|
|
276
|
-
):
|
|
277
|
-
const
|
|
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.
|
|
327
|
+
text.setSegments(blocks.flat());
|
|
308
328
|
return text;
|
|
309
329
|
}
|
package/src/dispatch.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|