@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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the row primitives the subagent rows are drawn with.
|
|
3
|
+
*
|
|
4
|
+
* The two properties worth pinning down, both learned by measuring pi-tui:
|
|
5
|
+
*
|
|
6
|
+
* * `truncateToWidth` puts the ellipsis **after** a colour reset
|
|
7
|
+
* (`…\x1b[0m…` → `\x1b[0m...\x1b[0m`), so a truncated span loses the colour
|
|
8
|
+
* of the span it belongs to. That is why the rows do not use it for the
|
|
9
|
+
* ellipsis — see the "why not truncateToWidth" test, which pins the measured
|
|
10
|
+
* bytes so the claim cannot quietly rot.
|
|
11
|
+
* * `sliceByColumn` cuts on display cells (wide glyphs and emoji included) and
|
|
12
|
+
* leaves the ANSI state open at the cut, so a plain `"..."` appended after
|
|
13
|
+
* it inherits the style that was active where the line was cut.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from "vitest";
|
|
17
|
+
import { Box, Text, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { RowBlock, block, row, truncateRow, RESET } from "./row-block.js";
|
|
19
|
+
|
|
20
|
+
const dim = (t: string) => `\x1b[2m${t}${RESET}`;
|
|
21
|
+
const bold = (t: string) => `\x1b[1m${t}${RESET}`;
|
|
22
|
+
|
|
23
|
+
const drawn = (line: string) => visibleWidth(stripTerminalSequences(line).replace(/\s+$/, ""));
|
|
24
|
+
|
|
25
|
+
const LONG =
|
|
26
|
+
"Handle the review cycle for PR #107 in /home/daniel/Coding/Javascript/pi-archimedes and report every finding with a file:line reference so the maintainer can act on each one without re-reading the diff herself ";
|
|
27
|
+
|
|
28
|
+
describe("truncateRow", () => {
|
|
29
|
+
it("leaves a row that fits untouched, byte for byte", () => {
|
|
30
|
+
const line = bold("general") + dim(": short task");
|
|
31
|
+
expect(truncateRow(line, 80)).toBe(line);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("cuts on display cells and fills the frame exactly", () => {
|
|
35
|
+
for (const width of [200, 120, 80, 60, 40, 20]) {
|
|
36
|
+
const truncated = truncateRow(dim(LONG), width);
|
|
37
|
+
expect(drawn(truncated), `width ${width}`).toBe(width);
|
|
38
|
+
expect(stripTerminalSequences(truncated).endsWith("..."), `width ${width}`).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps the ellipsis inside the styled span (byte-exact)", () => {
|
|
43
|
+
// 10 columns, 3 of them the ellipsis: 7 visible characters of the dim text,
|
|
44
|
+
// then the dots — still inside the open `\x1b[2m`, then closed for us.
|
|
45
|
+
expect(truncateRow(dim("abcdefghijklmnop"), 10)).toBe(`\x1b[2mabcdefg...${RESET}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("why not truncateToWidth: its ellipsis lands after the colour reset", () => {
|
|
49
|
+
// Measured against pi-tui 1.2.0. The dots are outside the dim span, which is
|
|
50
|
+
// the exact defect the old character-based `truncLine` existed to avoid.
|
|
51
|
+
expect(truncateToWidth(dim("abcdefghijklmnop"), 10)).toBe(
|
|
52
|
+
"\x1b[2mabcdefg\x1b[0m...\x1b[0m",
|
|
53
|
+
);
|
|
54
|
+
// Ours keeps the colour: nothing resets between the span's own colour and
|
|
55
|
+
// the dots.
|
|
56
|
+
const ours = truncateRow(dim("abcdefghijklmnop"), 10);
|
|
57
|
+
const beforeDots = ours.slice(0, ours.indexOf("..."));
|
|
58
|
+
expect(ours.slice(0, -RESET.length).endsWith("...")).toBe(true);
|
|
59
|
+
expect(beforeDots.startsWith("\x1b[2m")).toBe(true);
|
|
60
|
+
expect(beforeDots.includes(RESET)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("closes the style it left open, so padding is not painted", () => {
|
|
64
|
+
expect(truncateRow(dim(LONG), 30).endsWith(RESET)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("cuts at the first newline instead of bleeding into the next row", () => {
|
|
68
|
+
// The rule the old truncLine enforced: a newline is a hard boundary.
|
|
69
|
+
expect(stripTerminalSequences(truncateRow("line one\nline two\nline three", 40))).toBe("line one...");
|
|
70
|
+
expect(stripTerminalSequences(truncateRow("a\n\n\nb", 40))).toBe("a...");
|
|
71
|
+
expect(stripTerminalSequences(truncateRow("\nhello", 40))).toBe("...");
|
|
72
|
+
// Including when the first line is short enough to fit the frame.
|
|
73
|
+
expect(stripTerminalSequences(truncateRow(dim("short\nrest"), 40))).toBe("short...");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("never splits a wide glyph, at any width", () => {
|
|
77
|
+
const cjk = "検証サイクルをPR107で実行して結果を報告".repeat(3);
|
|
78
|
+
for (const width of [40, 30, 21, 11, 5]) {
|
|
79
|
+
const truncated = truncateRow(cjk, width);
|
|
80
|
+
const cells = visibleWidth(truncated);
|
|
81
|
+
expect(cells, `cjk width ${width}`).toBeLessThanOrEqual(width);
|
|
82
|
+
// One cell of slack is the most a dropped double-width glyph can cost.
|
|
83
|
+
expect(cells, `cjk width ${width}`).toBeGreaterThanOrEqual(Math.min(width, 3) - 1);
|
|
84
|
+
}
|
|
85
|
+
expect(stripTerminalSequences(truncateRow(cjk, 40)).endsWith("...")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("never splits an emoji cluster, at any width", () => {
|
|
89
|
+
const emoji = ("🚀 ship 🔥 the ✅ change 🧪 with 📦 care 🎯 now 🏁 ship 🧭 ").repeat(3);
|
|
90
|
+
for (const width of [40, 27, 13, 7]) {
|
|
91
|
+
expect(visibleWidth(truncateRow(emoji, width)), `emoji width ${width}`).toBeLessThanOrEqual(width);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("never emits a full SGR reset (\\x1b[0m) that would kill a container Box background", () => {
|
|
96
|
+
// Both when cut on width and when cut on a newline
|
|
97
|
+
const cutOnWidth = truncateRow(dim(LONG), 40);
|
|
98
|
+
expect(cutOnWidth.includes("\x1b[0m")).toBe(false);
|
|
99
|
+
expect(cutOnWidth.includes("\x1b[m")).toBe(false);
|
|
100
|
+
|
|
101
|
+
const cutOnNewline = truncateRow("general: Check status\nsecond line", 80);
|
|
102
|
+
expect(cutOnNewline.includes("\x1b[0m")).toBe(false);
|
|
103
|
+
expect(cutOnNewline.includes("\x1b[m")).toBe(false);
|
|
104
|
+
|
|
105
|
+
// Embedded \x1b[0m is safely converted to style reset
|
|
106
|
+
const embedded = truncateRow("\x1b[31merror\x1b[0m: details", 80);
|
|
107
|
+
expect(embedded.includes("\x1b[0m")).toBe(false);
|
|
108
|
+
expect(embedded.includes("\x1b[m")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("preserves an enclosing Box background across the entire row including padding", () => {
|
|
112
|
+
const bgFn = (t: string) => `\x1b[48;2;33;34;44m${t}\x1b[49m`;
|
|
113
|
+
const box = new Box(1, 1, bgFn);
|
|
114
|
+
const rows = new RowBlock();
|
|
115
|
+
rows.setSegments([
|
|
116
|
+
row("general: Check the status of PR #23 on danielcherubini/archimedes:\nsomething else"),
|
|
117
|
+
]);
|
|
118
|
+
box.addChild(rows);
|
|
119
|
+
const lines = box.render(100);
|
|
120
|
+
// Line 1 is the content row
|
|
121
|
+
const contentLine = lines[1]!;
|
|
122
|
+
expect(contentLine).toBeDefined();
|
|
123
|
+
// Background escape was applied at the start and closed at the end
|
|
124
|
+
expect(contentLine.startsWith("\x1b[48;2;33;34;44m")).toBe(true);
|
|
125
|
+
expect(contentLine.endsWith("\x1b[49m")).toBe(true);
|
|
126
|
+
// No full reset in the middle that would kill the background before the padding
|
|
127
|
+
expect(contentLine.includes("\x1b[0m")).toBe(false);
|
|
128
|
+
expect(contentLine.includes("\x1b[m")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("survives widths with no room for an ellipsis", () => {
|
|
132
|
+
expect(truncateRow(LONG, 3)).toBe("...");
|
|
133
|
+
expect(truncateRow(LONG, 2)).toBe("..");
|
|
134
|
+
expect(truncateRow(LONG, 1)).toBe(".");
|
|
135
|
+
expect(truncateRow(LONG, 0)).toBe("");
|
|
136
|
+
expect(truncateRow(LONG, -5)).toBe("");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("RowBlock", () => {
|
|
141
|
+
const padTo = (line: string, width: number) =>
|
|
142
|
+
line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
143
|
+
|
|
144
|
+
const gamma = () => "gamma: " + LONG;
|
|
145
|
+
|
|
146
|
+
it("renders every logical line of setText as one row, padded to the frame", () => {
|
|
147
|
+
const rows = new RowBlock();
|
|
148
|
+
rows.setText(["alpha", "beta", gamma()].join("\n"));
|
|
149
|
+
const lines = rows.render(40);
|
|
150
|
+
expect(lines.length).toBe(3);
|
|
151
|
+
for (const line of lines) expect(visibleWidth(line)).toBe(40);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("matches pi-tui's Text exactly while nothing needs truncating", () => {
|
|
155
|
+
// The rows used to be a plain Text; for short content the replacement must be
|
|
156
|
+
// byte-identical, or the fix silently restyles every row.
|
|
157
|
+
const text = ["alpha", "beta", "gamma"].join("\n");
|
|
158
|
+
const plain = new Text(text, 0, 0).render(80);
|
|
159
|
+
const rows = new RowBlock();
|
|
160
|
+
rows.setText(text);
|
|
161
|
+
expect(rows.render(80)).toEqual(plain);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("truncates a long row to one line instead of wrapping it", () => {
|
|
165
|
+
const rows = new RowBlock();
|
|
166
|
+
rows.setText(bold("general") + dim(": " + LONG));
|
|
167
|
+
for (const width of [200, 120, 80, 60, 40]) {
|
|
168
|
+
const lines = rows.render(width);
|
|
169
|
+
expect(lines.length, `width ${width}`).toBe(1);
|
|
170
|
+
expect(drawn(lines[0]!), `width ${width}`).toBeGreaterThanOrEqual(width - 1);
|
|
171
|
+
expect(stripTerminalSequences(lines[0]!.replace(/\s+$/, "")).endsWith("..."), `width ${width}`).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("keeps wrapped blocks wrapped, so multi-line content is not lost", () => {
|
|
176
|
+
const rows = new RowBlock();
|
|
177
|
+
rows.setSegments([row(bold("general") + dim(": " + LONG)), block(LONG)]);
|
|
178
|
+
const lines = rows.render(40);
|
|
179
|
+
// One row + a wrapped block: the block costs more than one line, the row not.
|
|
180
|
+
expect(lines.length).toBeGreaterThan(2);
|
|
181
|
+
expect(stripTerminalSequences(lines[0]!).trimEnd().endsWith("...")).toBe(true);
|
|
182
|
+
for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(40);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("re-renders for a new width instead of serving a stale frame", () => {
|
|
186
|
+
const rows = new RowBlock();
|
|
187
|
+
rows.setText(bold("general") + dim(": " + LONG));
|
|
188
|
+
const narrow = rows.render(40);
|
|
189
|
+
const wide = rows.render(120);
|
|
190
|
+
expect(narrow).not.toEqual(wide);
|
|
191
|
+
for (const [width, lines] of [
|
|
192
|
+
[40, narrow],
|
|
193
|
+
[120, wide],
|
|
194
|
+
] as const) {
|
|
195
|
+
expect(lines.length).toBe(1);
|
|
196
|
+
expect(drawn(lines[0]!)).toBeGreaterThanOrEqual(width - 1);
|
|
197
|
+
}
|
|
198
|
+
// …and the same width again is stable (the cache is keyed by content + width).
|
|
199
|
+
expect(rows.render(120)).toEqual(wide);
|
|
200
|
+
// …and new content invalidates it, rather than serving the cached frame.
|
|
201
|
+
rows.setText(bold("general") + dim(": now in a hurry"));
|
|
202
|
+
const changed = rows.render(120);
|
|
203
|
+
expect(changed).not.toEqual(wide);
|
|
204
|
+
expect(stripTerminalSequences(changed[0]!).trimEnd()).toBe("general: now in a hurry");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("renders nothing for blank content, like Text", () => {
|
|
208
|
+
const rows = new RowBlock();
|
|
209
|
+
rows.setText("");
|
|
210
|
+
expect(rows.render(80)).toEqual([]);
|
|
211
|
+
rows.setSegments([]);
|
|
212
|
+
expect(rows.render(80)).toEqual([]);
|
|
213
|
+
const blank = new RowBlock();
|
|
214
|
+
blank.setText(" ");
|
|
215
|
+
expect(blank.render(80)).toEqual(new Text(" ", 0, 0).render(80));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("normalizes tabs to three spaces, like Text", () => {
|
|
219
|
+
const rows = new RowBlock();
|
|
220
|
+
rows.setText("a\tb");
|
|
221
|
+
expect(rows.render(80)).toEqual(new Text("a\tb", 0, 0).render(80));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("pads every produced row to the frame", () => {
|
|
225
|
+
const rows = new RowBlock();
|
|
226
|
+
rows.setSegments([row("short"), block(LONG), row(dim(LONG))]);
|
|
227
|
+
const lines = rows.render(60);
|
|
228
|
+
for (const line of lines) expect(padTo(line, 60)).toBe(line);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("sanitizes embedded \\x1b[0m in block segments to RESET when rendered and padded", () => {
|
|
232
|
+
const rows = new RowBlock();
|
|
233
|
+
rows.setSegments([block("\x1b[31merror message\x1b[0m: line one\nsecond line with \x1b[32msuccess\x1b[m details")]);
|
|
234
|
+
const lines = rows.render(80);
|
|
235
|
+
expect(lines.length).toBe(2);
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
expect(line.includes("\x1b[0m")).toBe(false);
|
|
238
|
+
expect(line.includes("\x1b[m")).toBe(false);
|
|
239
|
+
expect(visibleWidth(line)).toBe(80);
|
|
240
|
+
}
|
|
241
|
+
expect(lines[0]!).toContain(`\x1b[31merror message${RESET}: line one`);
|
|
242
|
+
expect(lines[1]!).toContain(`second line with \x1b[32msuccess${RESET} details`);
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/row-block.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
/**
|
|
31
|
+
* SGR reset for styled rows: resets foreground color and font styles (bold,
|
|
32
|
+
* dim, italic, underline, reverse video, strikethrough), but never resets the
|
|
33
|
+
* background color (SGR 49).
|
|
34
|
+
*
|
|
35
|
+
* Inside pi's tool execution component, rows are rendered inside a Box that
|
|
36
|
+
* applies a panel background (e.g. toolSuccessBg / toolPendingBg) across the
|
|
37
|
+
* entire row: `theme.bg(token, paddedLine)`. A full SGR reset (`\x1b[0m` or
|
|
38
|
+
* `\x1b[m`) cancels the Box-painted background mid-line, causing all subsequent
|
|
39
|
+
* padding spaces and trailing content to fall through to the terminal's default
|
|
40
|
+
* background (appearing as an unsightly mid-grey or mismatched bar after
|
|
41
|
+
* truncated text or early newlines).
|
|
42
|
+
*/
|
|
43
|
+
export const RESET = "\x1b[22;23;24;27;29;39m";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Replace any full SGR resets (\x1b[0m or \x1b[m) with a style-and-foreground reset
|
|
47
|
+
* so the line never kills an enclosing container's background color mid-line.
|
|
48
|
+
*/
|
|
49
|
+
export function sanitizeRowResets(line: string): string {
|
|
50
|
+
return line.replace(/\x1b\[0?m/g, RESET);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How `Text` renders a tab; kept identical so short rows stay byte-identical. */
|
|
54
|
+
const TAB_WIDTH = 3;
|
|
55
|
+
|
|
56
|
+
/** `row`: one line, truncated to the frame. `block`: wrapped, never cut. */
|
|
57
|
+
export type RowKind = "row" | "block";
|
|
58
|
+
|
|
59
|
+
export interface RowSegment {
|
|
60
|
+
readonly kind: RowKind;
|
|
61
|
+
readonly text: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One logical line that must stay one line at any width. */
|
|
65
|
+
export function row(text: string): RowSegment {
|
|
66
|
+
return { kind: "row", text };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What the row builders draw into: the two ways content reaches a `RowBlock`.
|
|
71
|
+
* Kept as a projection of `RowBlock` so a builder cannot ask for anything a row
|
|
72
|
+
* does not do (no `tui`, no width, no renderer — see ADR 0027).
|
|
73
|
+
*/
|
|
74
|
+
export type RowSink = Pick<RowBlock, "setText" | "setSegments">;
|
|
75
|
+
|
|
76
|
+
/** Multi-line content that keeps pi-tui's wrapping. */
|
|
77
|
+
export function block(text: string): RowSegment {
|
|
78
|
+
return { kind: "block", text };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Treat every logical line of `text` as a row (the compact view is all rows). */
|
|
82
|
+
export function rowsFromText(text: string): RowSegment[] {
|
|
83
|
+
return text.split("\n").map(row);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cut one styled row to `width` display cells.
|
|
88
|
+
*
|
|
89
|
+
* The ellipsis stays inside the styled span — the property the old character-based
|
|
90
|
+
* `truncLine` existed to keep, and the reason `truncateToWidth` is not used for
|
|
91
|
+
* the cut: it emits `…\x1b[0m…`, i.e. the dots land *after* a colour reset, so a
|
|
92
|
+
* truncated span loses its colour (`row-block.test.ts` pins those bytes).
|
|
93
|
+
* `sliceByColumn` cuts on display cells instead — wide glyphs and emoji included —
|
|
94
|
+
* and leaves the ANSI state open where it cuts, so a plain `"..."` appended after
|
|
95
|
+
* it inherits whatever colour the row was drawing at that point. The trailing
|
|
96
|
+
* reset then closes what was left open, so the row's padding is not painted.
|
|
97
|
+
*
|
|
98
|
+
* A newline is a hard boundary: the row stops at the first one rather than
|
|
99
|
+
* bleeding into the next row, and says so with the ellipsis.
|
|
100
|
+
*/
|
|
101
|
+
export function truncateRow(line: string, width: number): string {
|
|
102
|
+
if (width <= 0) return "";
|
|
103
|
+
const nlIdx = line.indexOf("\n");
|
|
104
|
+
const firstLine = nlIdx === -1 ? line : line.slice(0, nlIdx);
|
|
105
|
+
if (nlIdx === -1 && visibleWidth(firstLine) <= width) {
|
|
106
|
+
return sanitizeRowResets(firstLine);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const budget = width - ROW_ELLIPSIS.length;
|
|
110
|
+
// No room for both content and dots: show as many dots as the frame holds, so
|
|
111
|
+
// the row still reads as "cut" rather than as "the whole task".
|
|
112
|
+
if (budget <= 0) return sliceByColumn(ROW_ELLIPSIS, 0, width, true);
|
|
113
|
+
const sliced = sliceByColumn(firstLine, 0, budget, true);
|
|
114
|
+
return sanitizeRowResets(sliced + ROW_ELLIPSIS) + RESET;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pad a rendered line out to the frame, the way `Text` does. */
|
|
118
|
+
function padToWidth(line: string, width: number): string {
|
|
119
|
+
const used = visibleWidth(line);
|
|
120
|
+
return used >= width ? line : line + " ".repeat(width - used);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The rows of one subagent result, truncated at the width pi renders them at.
|
|
125
|
+
*
|
|
126
|
+
* A `Text` subclass, because everything that already knows how to hold a row —
|
|
127
|
+
* pi's own tool shell, and this package's `setText`-shaped renderers — expects
|
|
128
|
+
* one. `setText` treats every logical line as a row; `setSegments` is for the
|
|
129
|
+
* views that mix rows with wrapped blocks (expanded).
|
|
130
|
+
*/
|
|
131
|
+
export class RowBlock extends Text {
|
|
132
|
+
private segments: RowSegment[];
|
|
133
|
+
private content: string;
|
|
134
|
+
private cacheKey: string | undefined;
|
|
135
|
+
private cacheWidth: number | undefined;
|
|
136
|
+
private cacheLines: string[] | undefined;
|
|
137
|
+
|
|
138
|
+
constructor(text = "") {
|
|
139
|
+
// Edge-to-edge: pi's tool shell supplies the row's inset (a Box with its own
|
|
140
|
+
// padding), exactly as it did for the `Text` this replaces.
|
|
141
|
+
super(text, 0, 0);
|
|
142
|
+
this.segments = rowsFromText(text);
|
|
143
|
+
this.content = text;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every logical line of `text` is a row. */
|
|
147
|
+
override setText(text: string): void {
|
|
148
|
+
super.setText(text);
|
|
149
|
+
this.content = text;
|
|
150
|
+
this.segments = rowsFromText(text);
|
|
151
|
+
this.cacheKey = undefined;
|
|
152
|
+
this.cacheWidth = undefined;
|
|
153
|
+
this.cacheLines = undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Mixed content: rows truncated to the frame, blocks wrapped. */
|
|
157
|
+
setSegments(segments: RowSegment[]): void {
|
|
158
|
+
const text = segments.map((segment) => segment.text).join("\n");
|
|
159
|
+
super.setText(text);
|
|
160
|
+
this.content = text;
|
|
161
|
+
this.segments = segments;
|
|
162
|
+
this.cacheKey = undefined;
|
|
163
|
+
this.cacheWidth = undefined;
|
|
164
|
+
this.cacheLines = undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
override invalidate(): void {
|
|
168
|
+
super.invalidate();
|
|
169
|
+
this.cacheKey = undefined;
|
|
170
|
+
this.cacheWidth = undefined;
|
|
171
|
+
this.cacheLines = undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override render(width: number): string[] {
|
|
175
|
+
// Cached across frames exactly the way `Text` caches: by content and width,
|
|
176
|
+
// because the duration in a running row changes the content between frames.
|
|
177
|
+
if (
|
|
178
|
+
this.cacheLines !== undefined &&
|
|
179
|
+
this.cacheKey === this.content &&
|
|
180
|
+
this.cacheWidth === width
|
|
181
|
+
) {
|
|
182
|
+
return this.cacheLines;
|
|
183
|
+
}
|
|
184
|
+
const lines = this.layout(Math.max(1, width));
|
|
185
|
+
this.cacheKey = this.content;
|
|
186
|
+
this.cacheWidth = width;
|
|
187
|
+
this.cacheLines = lines;
|
|
188
|
+
return lines;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private layout(width: number): string[] {
|
|
192
|
+
// Nothing but blanks to draw: render no lines at all, matching `Text`.
|
|
193
|
+
if (this.segments.length === 0 || this.segments.every((s) => s.text.trim() === "")) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const lines: string[] = [];
|
|
197
|
+
for (const segment of this.segments) {
|
|
198
|
+
const text = segment.text.replace(/\t/g, " ".repeat(TAB_WIDTH));
|
|
199
|
+
if (segment.kind === "block") {
|
|
200
|
+
for (const wrapped of wrapTextWithAnsi(text, width)) {
|
|
201
|
+
lines.push(padToWidth(sanitizeRowResets(wrapped), width));
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
lines.push(padToWidth(truncateRow(text, width), width));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -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
|
+
}
|