@pi-archimedes/subagent 2.10.3 → 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.
- package/README.md +1 -1
- 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 +61 -0
- package/src/expanded.ts +96 -80
- 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 +486 -0
- package/src/index.ts +168 -8
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +195 -0
- package/src/row-block.ts +186 -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.ts +85 -0
- 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/row-block.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width-aware rows for the subagent tool's transcript rows.
|
|
3
|
+
*
|
|
4
|
+
* pi draws a tool row at the width it is rendering at: bash truncates inside
|
|
5
|
+
* `VisualLinePreview`/`truncateToVisualLines`, which are handed `render(width)`'s
|
|
6
|
+
* width. That module is not importable (pi's export map is `.` and `./rpc-entry`),
|
|
7
|
+
* so the rows here get the same behaviour from pi-tui's own helpers — the
|
|
8
|
+
* sanctioned route, per ADR 0029 ("width maths goes through pi-tui helpers").
|
|
9
|
+
*
|
|
10
|
+
* Two kinds of content, because the subagent draws two kinds of thing:
|
|
11
|
+
*
|
|
12
|
+
* * a **row** — one logical line: the agent label, the stats line, the activity
|
|
13
|
+
* line, the outcome line. It is cut at the render width, so it fills the
|
|
14
|
+
* frame and truncates there, exactly like the bash row. Leaving it uncapped
|
|
15
|
+
* inside a `Text` is not the fix: `Text.render(width)` *wraps*, so a long task
|
|
16
|
+
* would become a multi-row block instead of one full-width row.
|
|
17
|
+
* * a **block** — multi-line content that is meant to be read in full (the
|
|
18
|
+
* expanded view's output, its task text, its tool-call history). It wraps,
|
|
19
|
+
* as it always has (ADR 0020: expanded output is deliberately untruncated).
|
|
20
|
+
*
|
|
21
|
+
* The width therefore arrives per-render and nothing is cached across renders
|
|
22
|
+
* except keyed by (content, width).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Text, sliceByColumn, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
26
|
+
|
|
27
|
+
/** The marker appended to a row that was cut. Plain text, so it inherits colour. */
|
|
28
|
+
export const ROW_ELLIPSIS = "...";
|
|
29
|
+
|
|
30
|
+
/** SGR reset, appended after a cut so the row's padding is not painted. */
|
|
31
|
+
const RESET = "\x1b[0m";
|
|
32
|
+
|
|
33
|
+
/** How `Text` renders a tab; kept identical so short rows stay byte-identical. */
|
|
34
|
+
const TAB_WIDTH = 3;
|
|
35
|
+
|
|
36
|
+
/** `row`: one line, truncated to the frame. `block`: wrapped, never cut. */
|
|
37
|
+
export type RowKind = "row" | "block";
|
|
38
|
+
|
|
39
|
+
export interface RowSegment {
|
|
40
|
+
readonly kind: RowKind;
|
|
41
|
+
readonly text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One logical line that must stay one line at any width. */
|
|
45
|
+
export function row(text: string): RowSegment {
|
|
46
|
+
return { kind: "row", text };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What the row builders draw into: the two ways content reaches a `RowBlock`.
|
|
51
|
+
* Kept as a projection of `RowBlock` so a builder cannot ask for anything a row
|
|
52
|
+
* does not do (no `tui`, no width, no renderer — see ADR 0027).
|
|
53
|
+
*/
|
|
54
|
+
export type RowSink = Pick<RowBlock, "setText" | "setSegments">;
|
|
55
|
+
|
|
56
|
+
/** Multi-line content that keeps pi-tui's wrapping. */
|
|
57
|
+
export function block(text: string): RowSegment {
|
|
58
|
+
return { kind: "block", text };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Treat every logical line of `text` as a row (the compact view is all rows). */
|
|
62
|
+
export function rowsFromText(text: string): RowSegment[] {
|
|
63
|
+
return text.split("\n").map(row);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cut one styled row to `width` display cells.
|
|
68
|
+
*
|
|
69
|
+
* The ellipsis stays inside the styled span — the property the old character-based
|
|
70
|
+
* `truncLine` existed to keep, and the reason `truncateToWidth` is not used for
|
|
71
|
+
* the cut: it emits `…\x1b[0m…`, i.e. the dots land *after* a colour reset, so a
|
|
72
|
+
* truncated span loses its colour (`row-block.test.ts` pins those bytes).
|
|
73
|
+
* `sliceByColumn` cuts on display cells instead — wide glyphs and emoji included —
|
|
74
|
+
* and leaves the ANSI state open where it cuts, so a plain `"..."` appended after
|
|
75
|
+
* it inherits whatever colour the row was drawing at that point. The trailing
|
|
76
|
+
* reset then closes what was left open, so the row's padding is not painted.
|
|
77
|
+
*
|
|
78
|
+
* A newline is a hard boundary: the row stops at the first one rather than
|
|
79
|
+
* bleeding into the next row, and says so with the ellipsis.
|
|
80
|
+
*/
|
|
81
|
+
export function truncateRow(line: string, width: number): string {
|
|
82
|
+
if (width <= 0) return "";
|
|
83
|
+
const nlIdx = line.indexOf("\n");
|
|
84
|
+
const firstLine = nlIdx === -1 ? line : line.slice(0, nlIdx);
|
|
85
|
+
if (nlIdx === -1 && visibleWidth(firstLine) <= width) return firstLine;
|
|
86
|
+
|
|
87
|
+
const budget = width - ROW_ELLIPSIS.length;
|
|
88
|
+
// No room for both content and dots: show as many dots as the frame holds, so
|
|
89
|
+
// the row still reads as "cut" rather than as "the whole task".
|
|
90
|
+
if (budget <= 0) return sliceByColumn(ROW_ELLIPSIS, 0, width, true);
|
|
91
|
+
return sliceByColumn(firstLine, 0, budget, true) + ROW_ELLIPSIS + RESET;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pad a rendered line out to the frame, the way `Text` does. */
|
|
95
|
+
function padToWidth(line: string, width: number): string {
|
|
96
|
+
const used = visibleWidth(line);
|
|
97
|
+
return used >= width ? line : line + " ".repeat(width - used);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The rows of one subagent result, truncated at the width pi renders them at.
|
|
102
|
+
*
|
|
103
|
+
* A `Text` subclass, because everything that already knows how to hold a row —
|
|
104
|
+
* pi's own tool shell, and this package's `setText`-shaped renderers — expects
|
|
105
|
+
* one. `setText` treats every logical line as a row; `setSegments` is for the
|
|
106
|
+
* views that mix rows with wrapped blocks (expanded).
|
|
107
|
+
*/
|
|
108
|
+
export class RowBlock extends Text {
|
|
109
|
+
private segments: RowSegment[];
|
|
110
|
+
private content: string;
|
|
111
|
+
private cacheKey: string | undefined;
|
|
112
|
+
private cacheWidth: number | undefined;
|
|
113
|
+
private cacheLines: string[] | undefined;
|
|
114
|
+
|
|
115
|
+
constructor(text = "") {
|
|
116
|
+
// Edge-to-edge: pi's tool shell supplies the row's inset (a Box with its own
|
|
117
|
+
// padding), exactly as it did for the `Text` this replaces.
|
|
118
|
+
super(text, 0, 0);
|
|
119
|
+
this.segments = rowsFromText(text);
|
|
120
|
+
this.content = text;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every logical line of `text` is a row. */
|
|
124
|
+
override setText(text: string): void {
|
|
125
|
+
super.setText(text);
|
|
126
|
+
this.content = text;
|
|
127
|
+
this.segments = rowsFromText(text);
|
|
128
|
+
this.cacheKey = undefined;
|
|
129
|
+
this.cacheWidth = undefined;
|
|
130
|
+
this.cacheLines = undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Mixed content: rows truncated to the frame, blocks wrapped. */
|
|
134
|
+
setSegments(segments: RowSegment[]): void {
|
|
135
|
+
const text = segments.map((segment) => segment.text).join("\n");
|
|
136
|
+
super.setText(text);
|
|
137
|
+
this.content = text;
|
|
138
|
+
this.segments = segments;
|
|
139
|
+
this.cacheKey = undefined;
|
|
140
|
+
this.cacheWidth = undefined;
|
|
141
|
+
this.cacheLines = undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
override invalidate(): void {
|
|
145
|
+
super.invalidate();
|
|
146
|
+
this.cacheKey = undefined;
|
|
147
|
+
this.cacheWidth = undefined;
|
|
148
|
+
this.cacheLines = undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
override render(width: number): string[] {
|
|
152
|
+
// Cached across frames exactly the way `Text` caches: by content and width,
|
|
153
|
+
// because the duration in a running row changes the content between frames.
|
|
154
|
+
if (
|
|
155
|
+
this.cacheLines !== undefined &&
|
|
156
|
+
this.cacheKey === this.content &&
|
|
157
|
+
this.cacheWidth === width
|
|
158
|
+
) {
|
|
159
|
+
return this.cacheLines;
|
|
160
|
+
}
|
|
161
|
+
const lines = this.layout(Math.max(1, width));
|
|
162
|
+
this.cacheKey = this.content;
|
|
163
|
+
this.cacheWidth = width;
|
|
164
|
+
this.cacheLines = lines;
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private layout(width: number): string[] {
|
|
169
|
+
// Nothing but blanks to draw: render no lines at all, matching `Text`.
|
|
170
|
+
if (this.segments.length === 0 || this.segments.every((s) => s.text.trim() === "")) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
for (const segment of this.segments) {
|
|
175
|
+
const text = segment.text.replace(/\t/g, " ".repeat(TAB_WIDTH));
|
|
176
|
+
if (segment.kind === "block") {
|
|
177
|
+
for (const wrapped of wrapTextWithAnsi(text, width)) {
|
|
178
|
+
lines.push(padToWidth(wrapped, width));
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(padToWidth(truncateRow(text, width), width));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { decideRunOutcome } from "./run-outcome.js";
|
|
3
|
+
|
|
4
|
+
// The decision table, pinned where it lives. Every row is a case the child can
|
|
5
|
+
// actually produce on pi 1.2.0 — see the table comment in `run-outcome.ts` for
|
|
6
|
+
// where each one comes from.
|
|
7
|
+
|
|
8
|
+
const SETTLED = { settledAborted: false, stopReason: undefined, exitCode: 0, signal: undefined };
|
|
9
|
+
|
|
10
|
+
describe("decideRunOutcome", () => {
|
|
11
|
+
it("completed: exit 0, clean stopReason, settled not aborted", () => {
|
|
12
|
+
expect(decideRunOutcome({ ...SETTLED, stopReason: "stop" })).toBe("completed");
|
|
13
|
+
expect(decideRunOutcome(SETTLED)).toBe("completed");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("truncated: stopReason length, whether the child exited 0 (json mode) or 1 (print mode)", () => {
|
|
17
|
+
expect(decideRunOutcome({ ...SETTLED, stopReason: "length" })).toBe("truncated");
|
|
18
|
+
expect(decideRunOutcome({ ...SETTLED, stopReason: "length", exitCode: 1 })).toBe("truncated");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("aborted: agent_settled.aborted wins over a clean stopReason and a nonzero exit", () => {
|
|
22
|
+
expect(decideRunOutcome({ ...SETTLED, settledAborted: true })).toBe("aborted");
|
|
23
|
+
expect(decideRunOutcome({ ...SETTLED, settledAborted: true, exitCode: 143 })).toBe("aborted");
|
|
24
|
+
expect(decideRunOutcome({ ...SETTLED, settledAborted: true, stopReason: "length" })).toBe("aborted");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("aborted: the provider aborted the last response", () => {
|
|
28
|
+
expect(decideRunOutcome({ ...SETTLED, stopReason: "aborted" })).toBe("aborted");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("aborted: killed by a cancellation signal before it could settle", () => {
|
|
32
|
+
const killed = { ...SETTLED, exitCode: null };
|
|
33
|
+
expect(decideRunOutcome({ ...killed, signal: "SIGTERM" })).toBe("aborted");
|
|
34
|
+
expect(decideRunOutcome({ ...killed, signal: "SIGKILL" })).toBe("aborted");
|
|
35
|
+
expect(decideRunOutcome({ ...killed, signal: "SIGHUP" })).toBe("aborted");
|
|
36
|
+
expect(decideRunOutcome({ ...killed, signal: "SIGINT" })).toBe("aborted");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("aborted: pi's own signal handlers exit 143/129 instead of dying by the signal", () => {
|
|
40
|
+
// Observed against a real 1.2.0 child SIGTERM'd mid-run: `close` reported
|
|
41
|
+
// code 143 and NO signal, because print mode catches SIGTERM/SIGHUP and
|
|
42
|
+
// calls process.exit(143)/process.exit(129) itself
|
|
43
|
+
// (`dist/modes/print-mode.js`, registerSignalHandlers). Reading only
|
|
44
|
+
// `signal` would call a cancellation a failure.
|
|
45
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 143 })).toBe("aborted");
|
|
46
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 129 })).toBe("aborted");
|
|
47
|
+
// A completed last response does not make a cancelled child un-cancelled.
|
|
48
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 143, stopReason: "stop" })).toBe("aborted");
|
|
49
|
+
// And it outranks the shapes a cancellation can leave behind on the way out.
|
|
50
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 143, stopReason: "error" })).toBe("aborted");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("failed: the child's last response errored, whatever the exit code says", () => {
|
|
54
|
+
expect(decideRunOutcome({ ...SETTLED, stopReason: "error", exitCode: 1 })).toBe("failed");
|
|
55
|
+
expect(decideRunOutcome({ ...SETTLED, stopReason: "error", exitCode: 0 })).toBe("failed");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("failed: a nonzero exit with no run behind it", () => {
|
|
59
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 1 })).toBe("failed");
|
|
60
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 2 })).toBe("failed");
|
|
61
|
+
// 130 (SIGINT) is NOT one of pi's handler exits — SIGINT is uncaught, so it
|
|
62
|
+
// arrives as a signal death above; anything else stays a failure.
|
|
63
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 130 })).toBe("failed");
|
|
64
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: 137 })).toBe("failed");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("failed: a crash on an unexpected signal is not someone cancelling", () => {
|
|
68
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: null, signal: "SIGSEGV" })).toBe("failed");
|
|
69
|
+
expect(decideRunOutcome({ ...SETTLED, exitCode: null, signal: undefined })).toBe("failed");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { RunOutcome } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// ── The *Run outcome* decision table (CONTEXT.md vocabulary, ADR 0028) ──────
|
|
4
|
+
//
|
|
5
|
+
// The child's exit code cannot tell these apart, and after pi 1.2.0 it cannot
|
|
6
|
+
// even be trusted to mean "failed": print mode exits **1** when the final
|
|
7
|
+
// response hit the output token limit, so a useful-but-cut-off answer arrives as
|
|
8
|
+
// the same `1` as a crash. `stream.ts` used to reduce the child to `code ?? 1`,
|
|
9
|
+
// which destroyed that distinction exactly where the plan says it must not.
|
|
10
|
+
//
|
|
11
|
+
// The two signals that do carry it are the child's own `stopReason` (on the last
|
|
12
|
+
// assistant `message_end`) and pi 1.1.0's `agent_settled.aborted`. Note that
|
|
13
|
+
// `--mode json` — what the child actually runs — does *not* map `stopReason` onto
|
|
14
|
+
// the exit code at all (that mapping lives in print mode's text branch), so a
|
|
15
|
+
// json child reports truncation through `stopReason` alone and exits 0. Both
|
|
16
|
+
// shapes are handled: truncation is read from the child, never inferred from
|
|
17
|
+
// the code.
|
|
18
|
+
//
|
|
19
|
+
// | input | outcome |
|
|
20
|
+
// |-------------------------------------------------------------|-------------|
|
|
21
|
+
// | `agent_settled.aborted: true` | aborted |
|
|
22
|
+
// | last assistant `stopReason: "aborted"` | aborted |
|
|
23
|
+
// | killed by a cancellation signal (SIGKILL/SIGINT — uncaught) | aborted |
|
|
24
|
+
// | killed by an unexpected signal (SIGSEGV, …) | failed |
|
|
25
|
+
// | exit 143 / 129 (pi caught SIGTERM / SIGHUP and exited) | aborted |
|
|
26
|
+
// | last assistant `stopReason: "error"` | failed |
|
|
27
|
+
// | last assistant `stopReason: "length"` — exit 0 **or** 1 | truncated |
|
|
28
|
+
// | any other nonzero exit | failed |
|
|
29
|
+
// | exit 0, nothing above matched | completed |
|
|
30
|
+
//
|
|
31
|
+
// `truncated` is not an error and neither is `aborted`; `failed` is, and is what
|
|
32
|
+
// the caller reports as one (the result carries no outcome in that case).
|
|
33
|
+
|
|
34
|
+
/** Signals that mean "someone cancelled this run", as opposed to "it broke". */
|
|
35
|
+
const CANCELLATION_SIGNALS = new Set(["SIGTERM", "SIGKILL", "SIGHUP", "SIGINT"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The same cancellation, arriving as an exit code instead of a signal.
|
|
39
|
+
*
|
|
40
|
+
* print mode catches SIGTERM (and SIGHUP on non-Windows) and exits by itself —
|
|
41
|
+
* `process.exit(signal === "SIGHUP" ? 129 : 143)`
|
|
42
|
+
* (`pi-coding-agent/dist/modes/print-mode.js`, `registerSignalHandlers`) — so a
|
|
43
|
+
* real SIGTERM'd child closes with code 143 and **no** signal, which the table
|
|
44
|
+
* would otherwise read as an ordinary failure. Verified against a live 1.2.0
|
|
45
|
+
* child killed mid-run. SIGINT is not caught, so it still arrives as a signal
|
|
46
|
+
* death; SIGKILL cannot be caught at all. Hence exactly these two codes.
|
|
47
|
+
*/
|
|
48
|
+
const CANCELLATION_EXIT_CODES = new Set([143, 129]);
|
|
49
|
+
|
|
50
|
+
export interface RunOutcomeInput {
|
|
51
|
+
/** `agent_settled.aborted` seen on the child's event stream. */
|
|
52
|
+
settledAborted: boolean;
|
|
53
|
+
/** `stopReason` of the last assistant message the child emitted. */
|
|
54
|
+
stopReason: string | undefined;
|
|
55
|
+
/** Process exit code, or `null` when it was terminated by a signal. */
|
|
56
|
+
exitCode: number | null;
|
|
57
|
+
/** The terminating signal, when there was one. */
|
|
58
|
+
signal: string | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decide the run's terminal state, or `failed` when the run did not reach one.
|
|
63
|
+
*
|
|
64
|
+
* Order is the table above: cancellation first (an aborted run's `stopReason` may
|
|
65
|
+
* be `error` or `length` on the way out), then a signal death — which is decided
|
|
66
|
+
* before `stopReason`, because a killed child's last message says nothing about
|
|
67
|
+
* how it died — then the child's own account of how its last response ended
|
|
68
|
+
* (`error`, then `length`), then the exit code, which is the least informative of
|
|
69
|
+
* the three and therefore last. That last ordering is the whole point of the
|
|
70
|
+
* 1.2.0 change: exit 1 arrives for a crash and for a truncated answer alike, so
|
|
71
|
+
* `length` has to be read before the exit code or the distinction is lost again.
|
|
72
|
+
*/
|
|
73
|
+
export function decideRunOutcome(input: RunOutcomeInput): RunOutcome | "failed" {
|
|
74
|
+
if (input.settledAborted) return "aborted";
|
|
75
|
+
if (input.stopReason === "aborted") return "aborted";
|
|
76
|
+
if (input.exitCode === null) {
|
|
77
|
+
return CANCELLATION_SIGNALS.has(input.signal ?? "") ? "aborted" : "failed";
|
|
78
|
+
}
|
|
79
|
+
if (CANCELLATION_EXIT_CODES.has(input.exitCode)) return "aborted";
|
|
80
|
+
if (input.stopReason === "error") return "failed";
|
|
81
|
+
if (input.stopReason === "length") return "truncated";
|
|
82
|
+
if (input.exitCode !== 0) return "failed";
|
|
83
|
+
return "completed";
|
|
84
|
+
}
|
package/src/spawn.test.ts
CHANGED
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import * as net from "node:net";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import type { ChildProcess } from "node:child_process";
|
|
5
|
+
import { Events } from "@pi-archimedes/core/events";
|
|
6
|
+
import type { AgentConfig } from "./agents.js";
|
|
7
|
+
|
|
8
|
+
// The child's argv is a contract with the `pi` CLI, and CLI strings are invisible
|
|
9
|
+
// to tsc — these tests are the only guard on what the child actually receives.
|
|
10
|
+
// `node:child_process` is mocked so no real pi process is spawned.
|
|
11
|
+
const spawnMock = vi.hoisted(() => vi.fn());
|
|
12
|
+
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
|
|
13
|
+
|
|
14
|
+
const { startAskSocketServer, spawnSubagent } = await import("./spawn.js");
|
|
5
15
|
|
|
6
16
|
// startAskSocketServer is tested directly (not via spawnSubagent, which would
|
|
7
|
-
// spawn a real `pi` process).
|
|
8
|
-
//
|
|
9
|
-
//
|
|
17
|
+
// spawn a real `pi` process). Each test gets its OWN bus stand-in — the host
|
|
18
|
+
// handle is passed in rather than reached for, so there is no process-wide
|
|
19
|
+
// singleton to leak between tests any more.
|
|
20
|
+
|
|
21
|
+
/** Stands in for the host's `createEventBus()` (same emit/on/unsubscribe shape). */
|
|
22
|
+
function fakeBus() {
|
|
23
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
24
|
+
return {
|
|
25
|
+
emit(channel: string, data: unknown): void {
|
|
26
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
27
|
+
},
|
|
28
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
29
|
+
const subs = listeners.get(channel) ?? [];
|
|
30
|
+
subs.push(handler);
|
|
31
|
+
listeners.set(channel, subs);
|
|
32
|
+
return () => {
|
|
33
|
+
const current = listeners.get(channel) ?? [];
|
|
34
|
+
const idx = current.indexOf(handler);
|
|
35
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
10
40
|
|
|
11
41
|
const cleanups: Array<() => void> = [];
|
|
12
|
-
const unsubs: Array<() => void> = [];
|
|
13
42
|
|
|
14
43
|
afterEach(() => {
|
|
15
|
-
unsubs.splice(0).forEach((u) => u());
|
|
16
44
|
cleanups.splice(0).forEach((c) => c());
|
|
17
45
|
});
|
|
18
46
|
|
|
@@ -20,6 +48,83 @@ function track(fn: () => void): void {
|
|
|
20
48
|
cleanups.push(fn);
|
|
21
49
|
}
|
|
22
50
|
|
|
51
|
+
// ── spawnSubagent argv ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** A minimal spawn target: the argv assertions never touch the process. */
|
|
54
|
+
function fakeSpawnResult(): ChildProcess {
|
|
55
|
+
const child = new EventEmitter() as ChildProcess & { pid: number };
|
|
56
|
+
Object.assign(child, { pid: 4242, killed: false, kill: () => true });
|
|
57
|
+
return child;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run spawnSubagent against the mocked spawn and return the argv it received. */
|
|
61
|
+
function argvFor(agent: Partial<AgentConfig> | undefined): string[] {
|
|
62
|
+
spawnMock.mockReset();
|
|
63
|
+
spawnMock.mockReturnValue(fakeSpawnResult());
|
|
64
|
+
const events = fakeBus();
|
|
65
|
+
const socket = startAskSocketServer(agent?.name ?? "general", events);
|
|
66
|
+
track(socket.cleanup);
|
|
67
|
+
spawnSubagent({
|
|
68
|
+
task: "do the thing",
|
|
69
|
+
model: undefined,
|
|
70
|
+
activeModel: undefined,
|
|
71
|
+
cwd: "/tmp",
|
|
72
|
+
signal: undefined,
|
|
73
|
+
events,
|
|
74
|
+
agent: agent
|
|
75
|
+
? { name: agent.name ?? "general", description: "d", systemPrompt: "p", source: "user", filePath: "/tmp/a.md", ...agent }
|
|
76
|
+
: undefined,
|
|
77
|
+
});
|
|
78
|
+
const call = spawnMock.mock.calls[0] as [string, string[]] | undefined;
|
|
79
|
+
if (!call) throw new Error("spawn was not called");
|
|
80
|
+
return call[1];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("spawnSubagent tool allowlist (closed set, ADR 0028)", () => {
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
spawnMock.mockReset();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("a non-empty tools allowlist also passes --no-mcp, so the list stays closed", () => {
|
|
89
|
+
const argv = argvFor({ tools: ["read", "bash"] });
|
|
90
|
+
expect(argv).toContain("--tools");
|
|
91
|
+
expect(argv[argv.indexOf("--tools") + 1]).toBe("read,bash");
|
|
92
|
+
expect(argv).toContain("--no-mcp");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("mcp: true is the only way the allowlist stops being closed", () => {
|
|
96
|
+
const argv = argvFor({ tools: ["read", "bash"], mcp: true });
|
|
97
|
+
expect(argv).toContain("--tools");
|
|
98
|
+
expect(argv).not.toContain("--no-mcp");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("an empty tools list changes neither the tool set nor MCP", () => {
|
|
102
|
+
const argv = argvFor({ tools: [] });
|
|
103
|
+
expect(argv).not.toContain("--tools");
|
|
104
|
+
expect(argv).not.toContain("--no-mcp");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("mcp: true without an allowlist passes neither flag (nothing to close)", () => {
|
|
108
|
+
const argv = argvFor({ mcp: true });
|
|
109
|
+
expect(argv).not.toContain("--tools");
|
|
110
|
+
expect(argv).not.toContain("--no-mcp");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("the recursion guard survives the closed allowlist", () => {
|
|
114
|
+
const argv = argvFor({ tools: ["read"] });
|
|
115
|
+
const excludeIdx = argv.indexOf("--exclude-tools");
|
|
116
|
+
expect(excludeIdx).toBeGreaterThan(-1);
|
|
117
|
+
expect(argv[excludeIdx + 1]).toContain("subagent");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("an agent with no config at all gets neither --tools nor --no-mcp", () => {
|
|
121
|
+
const argv = argvFor(undefined);
|
|
122
|
+
expect(argv).not.toContain("--tools");
|
|
123
|
+
expect(argv).not.toContain("--no-mcp");
|
|
124
|
+
expect(argv).toContain("--exclude-tools");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
23
128
|
function connectClient(socketPath: string): Promise<net.Socket> {
|
|
24
129
|
return new Promise((resolve, reject) => {
|
|
25
130
|
const client = net.connect(socketPath, () => resolve(client));
|
|
@@ -40,12 +145,13 @@ function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: str
|
|
|
40
145
|
|
|
41
146
|
describe("startAskSocketServer", () => {
|
|
42
147
|
it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
|
|
43
|
-
const
|
|
148
|
+
const events = fakeBus();
|
|
149
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
44
150
|
track(cleanup);
|
|
45
151
|
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
46
152
|
|
|
47
153
|
const requests: unknown[] = [];
|
|
48
|
-
|
|
154
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
49
155
|
|
|
50
156
|
const client = await connectClient(socketPath);
|
|
51
157
|
writeAskRequest(client, "req-1", "tool-9");
|
|
@@ -57,9 +163,9 @@ describe("startAskSocketServer", () => {
|
|
|
57
163
|
expect(req.toolCallId).toBe("tool-9");
|
|
58
164
|
expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
|
|
59
165
|
|
|
60
|
-
// Deliver the response (drains pending) before closing so no ASK_CANCEL
|
|
61
|
-
//
|
|
62
|
-
|
|
166
|
+
// Deliver the response (drains pending) before closing so no ASK_CANCEL is
|
|
167
|
+
// signalled for a request that was already answered.
|
|
168
|
+
events.emit(Events.ASK_RESPONSE, {
|
|
63
169
|
requestId: "req-1",
|
|
64
170
|
cancelled: false,
|
|
65
171
|
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
@@ -69,15 +175,60 @@ describe("startAskSocketServer", () => {
|
|
|
69
175
|
await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
|
|
70
176
|
});
|
|
71
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The ask round-trip, over the handle: the child's socket request goes out on
|
|
180
|
+
* the bus, the answer comes back on the SAME handle, and the server writes it
|
|
181
|
+
* back down the socket. Before this migration both halves went through the
|
|
182
|
+
* suite's `globalThis` bus; the transport changed, the socket contract did not.
|
|
183
|
+
*/
|
|
184
|
+
it("round-trip: ask_request in → ASK_REQUEST out on the handle → ASK_RESPONSE in → ask_response back down the socket", async () => {
|
|
185
|
+
const events = fakeBus();
|
|
186
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
187
|
+
track(cleanup);
|
|
188
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
189
|
+
|
|
190
|
+
const frames: unknown[] = [];
|
|
191
|
+
const requests: unknown[] = [];
|
|
192
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
193
|
+
|
|
194
|
+
const client = await connectClient(socketPath);
|
|
195
|
+
client.on("data", (chunk) => {
|
|
196
|
+
for (const line of chunk.toString("utf-8").split("\n")) {
|
|
197
|
+
if (line.trim()) frames.push(JSON.parse(line));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
writeAskRequest(client, "req-rt", "tool-rt");
|
|
202
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
203
|
+
|
|
204
|
+
events.emit(Events.ASK_RESPONSE, {
|
|
205
|
+
requestId: "req-rt",
|
|
206
|
+
cancelled: false,
|
|
207
|
+
results: [{ id: "q1", selectedOptions: ["A"], customInput: undefined }],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await vi.waitFor(() => expect(frames.length).toBe(1));
|
|
211
|
+
expect(frames[0]).toEqual({
|
|
212
|
+
type: "ask_response",
|
|
213
|
+
requestId: "req-rt",
|
|
214
|
+
cancelled: false,
|
|
215
|
+
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
client.destroy();
|
|
219
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
220
|
+
});
|
|
221
|
+
|
|
72
222
|
it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
|
|
73
|
-
const
|
|
223
|
+
const events = fakeBus();
|
|
224
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
74
225
|
track(cleanup);
|
|
75
226
|
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
76
227
|
|
|
77
228
|
const cancels: unknown[] = [];
|
|
78
|
-
|
|
229
|
+
events.on(Events.ASK_CANCEL, (p) => cancels.push(p));
|
|
79
230
|
const requests: unknown[] = [];
|
|
80
|
-
|
|
231
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
81
232
|
|
|
82
233
|
const client = await connectClient(socketPath);
|
|
83
234
|
writeAskRequest(client, "req-2");
|
|
@@ -91,21 +242,22 @@ describe("startAskSocketServer", () => {
|
|
|
91
242
|
});
|
|
92
243
|
|
|
93
244
|
it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
|
|
94
|
-
const
|
|
245
|
+
const events = fakeBus();
|
|
246
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
|
|
95
247
|
track(cleanup);
|
|
96
248
|
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
97
249
|
|
|
98
250
|
const cancels: unknown[] = [];
|
|
99
|
-
|
|
251
|
+
events.on(Events.ASK_CANCEL, (p) => cancels.push(p));
|
|
100
252
|
const requests: unknown[] = [];
|
|
101
|
-
|
|
253
|
+
events.on(Events.ASK_REQUEST, (p) => requests.push(p));
|
|
102
254
|
|
|
103
255
|
const client = await connectClient(socketPath);
|
|
104
256
|
writeAskRequest(client, "req-3");
|
|
105
257
|
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
106
258
|
|
|
107
259
|
// Deliver the response (drains pending) before the socket closes
|
|
108
|
-
|
|
260
|
+
events.emit(Events.ASK_RESPONSE, {
|
|
109
261
|
requestId: "req-3",
|
|
110
262
|
cancelled: false,
|
|
111
263
|
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
@@ -117,3 +269,36 @@ describe("startAskSocketServer", () => {
|
|
|
117
269
|
expect(cancels.length).toBe(0);
|
|
118
270
|
});
|
|
119
271
|
});
|
|
272
|
+
|
|
273
|
+
// ── thinking level (pi warns and drops; we must not feed it one) ───────────
|
|
274
|
+
|
|
275
|
+
describe("spawnSubagent thinking level", () => {
|
|
276
|
+
afterEach(() => {
|
|
277
|
+
spawnMock.mockReset();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("passes a level pi accepts through to --thinking", () => {
|
|
281
|
+
const argv = argvFor({ thinking: "xhigh" });
|
|
282
|
+
const at = argv.indexOf("--thinking");
|
|
283
|
+
expect(at).toBeGreaterThan(-1);
|
|
284
|
+
expect(argv[at + 1]).toBe("xhigh");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("does not pass a level pi would warn about and drop", () => {
|
|
288
|
+
// `--thinking ultra` is a `warning` diagnostic and a dropped flag in pi's
|
|
289
|
+
// own parser (dist/cli/args.js), so the child ran at the default thinking
|
|
290
|
+
// level while its config said otherwise — and in `--mode json` that warning
|
|
291
|
+
// is stderr, which the stream reader only shows for a failed run. Passing
|
|
292
|
+
// nothing is the same outcome, said out loud at dispatch instead.
|
|
293
|
+
const argv = argvFor({ thinking: "ultra" });
|
|
294
|
+
expect(argv).not.toContain("--thinking");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("is case-sensitive, exactly like pi's list", () => {
|
|
298
|
+
expect(argvFor({ thinking: "Medium" })).not.toContain("--thinking");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("passes no --thinking when the config sets none", () => {
|
|
302
|
+
expect(argvFor({})).not.toContain("--thinking");
|
|
303
|
+
});
|
|
304
|
+
});
|