@pi-archimedes/subagent 2.11.0 → 2.11.2
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 +3 -3
- package/src/expanded.test.ts +414 -3
- package/src/expanded.ts +130 -26
- package/src/index.test.ts +80 -0
- package/src/index.ts +2 -9
- package/src/row-block.test.ts +54 -5
- package/src/row-block.ts +28 -5
- package/src/tool-schema.test.ts +154 -0
- package/src/tool-schema.ts +21 -4
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ The `agent` field is optional — omitting it is not a built-in default agent, a
|
|
|
44
44
|
}
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
**The dispatch waits.** A call — single or parallel — blocks until every task completes and returns the combined results, each optionally carrying the child's `childSessionId`. There is an `async`
|
|
47
|
+
**The dispatch waits.** A call — single or parallel — blocks until every task completes and returns the combined results, each optionally carrying the child's `childSessionId`. There is no async mode: an `async` parameter was deleted in #114 because the schema declared it and the implementation ignored it, and a parameter the model is told about but nothing reads is a false promise. Implementing fire-and-forget is deferred to #113; until then, batch the work into `tasks` and let the call block on all of it. In parallel mode each result section carries the child's final output beneath its metrics line.
|
|
48
48
|
|
|
49
49
|
### Model and thinking resolution
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
],
|
|
16
16
|
"main": "./src/index.ts",
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@pi-archimedes/core": "2.11.
|
|
19
|
-
"@pi-archimedes/todo": "2.11.
|
|
18
|
+
"@pi-archimedes/core": "2.11.2",
|
|
19
|
+
"@pi-archimedes/todo": "2.11.2"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@earendil-works/pi-ai": ">=1.2.0",
|
package/src/expanded.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import type
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { buildExpandedText, buildProgressExpandedText, renderExpanded, renderProgressExpanded } from "./expanded.js";
|
|
4
|
+
import { RowBlock, type RowSegment, RESET } from "./row-block.js";
|
|
5
|
+
import type { RunOutcome, SubagentProgress, SubagentResult } from "./types.js";
|
|
5
6
|
|
|
6
7
|
// The expanded row is what the user reads after `ctrl+o`; the compact row is
|
|
7
8
|
// what they see while it runs. Both have to carry the *Run outcome* vocabulary
|
|
@@ -59,3 +60,413 @@ describe("expanded view: run outcomes", () => {
|
|
|
59
60
|
expect(out).toContain("401 unauthorized");
|
|
60
61
|
});
|
|
61
62
|
});
|
|
63
|
+
|
|
64
|
+
// ── A multi-line error, read in full (issue #112) ───────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a failed child actually reports: a command line, a compiler diagnostic, a
|
|
68
|
+
* stack frame, and the exit status on the last line. The last line is the one
|
|
69
|
+
* whoever reads it wants (`rc=1` says what happened), and it is exactly what the
|
|
70
|
+
* row cut threw away — so every assertion below keys off it.
|
|
71
|
+
*/
|
|
72
|
+
const MULTI_LINE_ERROR = [
|
|
73
|
+
"Command failed: pnpm -r exec -- tsc --noEmit",
|
|
74
|
+
"packages/subagent/src/index.ts:42:7 - error TS2345: Argument of type 'string' is not assignable",
|
|
75
|
+
" at runChild (/home/daniel/Coding/Javascript/pi-archimedes/packages/subagent/src/spawn.ts:88:12)",
|
|
76
|
+
"error: subagent exited with rc=1",
|
|
77
|
+
].join("\n");
|
|
78
|
+
const ERROR_LINES = MULTI_LINE_ERROR.split("\n");
|
|
79
|
+
/** Everything after the head — the part that moved from the row into the block. */
|
|
80
|
+
const TAIL_ERROR_LINES = ERROR_LINES.slice(1);
|
|
81
|
+
const FIRST_ERROR_LINE = ERROR_LINES[0]!;
|
|
82
|
+
const LAST_ERROR_LINE = ERROR_LINES[ERROR_LINES.length - 1]!;
|
|
83
|
+
|
|
84
|
+
/** `theme.fg` for real SGR bytes, so the rendered lines carry an ANSI state. */
|
|
85
|
+
const ERROR_SGR = "\x1b[31m";
|
|
86
|
+
const sgrTheme = {
|
|
87
|
+
fg: (token: string, text: string) =>
|
|
88
|
+
`${token === "error" ? ERROR_SGR : "\x1b[2m"}${text}\x1b[0m`,
|
|
89
|
+
bold: (t: string) => `\x1b[1m${t}\x1b[0m`,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A `RowBlock` that remembers the segments it was last given, without faking the row layer. */
|
|
93
|
+
class CapturingRowBlock extends RowBlock {
|
|
94
|
+
/** Named apart from `RowBlock`'s own private field of the same name. */
|
|
95
|
+
lastSegments: RowSegment[] | undefined = undefined;
|
|
96
|
+
/** A builder that handed its content over `setText` instead would land here. */
|
|
97
|
+
lastSetText: string | undefined = undefined;
|
|
98
|
+
|
|
99
|
+
override setSegments(segments: RowSegment[]): void {
|
|
100
|
+
this.lastSegments = segments;
|
|
101
|
+
super.setSegments(segments);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override setText(text: string): void {
|
|
105
|
+
this.lastSetText = text;
|
|
106
|
+
super.setText(text);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What the builder handed the row layer — asserted rather than assumed. Hooking
|
|
112
|
+
* `setText` and checking it here means a builder that switched to `setText`
|
|
113
|
+
* fails as a readable assertion instead of a `TypeError` on `lastSegments!`.
|
|
114
|
+
*/
|
|
115
|
+
function captured(rows: CapturingRowBlock): RowSegment[] {
|
|
116
|
+
expect(rows.lastSetText, "the builder handed its content over setText, so nothing below reads it")
|
|
117
|
+
.toBeUndefined();
|
|
118
|
+
expect(rows.lastSegments, "setSegments was never called, so nothing below reads the view").toBeDefined();
|
|
119
|
+
return rows.lastSegments!;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Strip this file's fake `theme.fg` markers, line by line — a block carries one per line. */
|
|
123
|
+
const plainLines = (text: string): string[] => text.split("\n").map((l) => l.replace(/^\[[a-z]+\]/, ""));
|
|
124
|
+
|
|
125
|
+
const content = (line: string): string => stripTerminalSequences(line).replace(/\s+$/, "");
|
|
126
|
+
|
|
127
|
+
function makeProgress(overrides: Partial<SubagentProgress> = {}): SubagentProgress {
|
|
128
|
+
return {
|
|
129
|
+
agent: "reviewer",
|
|
130
|
+
status: "failed",
|
|
131
|
+
task: "review the diff",
|
|
132
|
+
currentTool: undefined,
|
|
133
|
+
currentToolArgs: undefined,
|
|
134
|
+
currentToolStartedAt: undefined,
|
|
135
|
+
toolCount: 2,
|
|
136
|
+
inputTokens: 10,
|
|
137
|
+
outputTokens: 5,
|
|
138
|
+
tokens: 15,
|
|
139
|
+
cost: 0.01,
|
|
140
|
+
durationMs: 1000,
|
|
141
|
+
error: MULTI_LINE_ERROR,
|
|
142
|
+
model: "anthropic/claude-opus-4.5",
|
|
143
|
+
output: undefined,
|
|
144
|
+
recentOutput: undefined,
|
|
145
|
+
toolCalls: undefined,
|
|
146
|
+
...overrides,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A failed run: an error and no outcome, which is what `stream.ts` reports. */
|
|
151
|
+
function failedResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
|
|
152
|
+
const result = makeResult({ exitCode: 1, error: MULTI_LINE_ERROR, ...overrides });
|
|
153
|
+
// A failed run claims no outcome — `stream.ts` leaves the field off entirely.
|
|
154
|
+
if (!("outcome" in overrides)) delete result.outcome;
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe("expanded view: a multi-line error is readable in full (issue #112)", () => {
|
|
159
|
+
/**
|
|
160
|
+
* The guarantee, asserted the same way for all three builders: the error's last
|
|
161
|
+
* line is present, and a `block` carries it. `block` is this repo's mechanism
|
|
162
|
+
* for "read this in full" (`row-block.ts`; issue #112), so the assertion is on
|
|
163
|
+
* the segment kind rather than on the joined text — a change that pushes the
|
|
164
|
+
* error back through `row()` fails here even though the joined text would still
|
|
165
|
+
* contain the line.
|
|
166
|
+
*
|
|
167
|
+
* Order and grouping are asserted too, because a builder that emitted one block
|
|
168
|
+
* per line, or emitted the tail bottom-up, would satisfy the checks above: the
|
|
169
|
+
* tail is exactly one block and its lines are in the order the child wrote them.
|
|
170
|
+
*/
|
|
171
|
+
const revealsTheWholeError = (segments: RowSegment[]): void => {
|
|
172
|
+
expect(drawnText(segments)).toContain(LAST_ERROR_LINE);
|
|
173
|
+
expect(
|
|
174
|
+
segments.filter((s) => s.text.includes(LAST_ERROR_LINE)).map((s) => s.kind),
|
|
175
|
+
"the error's last line is inside a row again",
|
|
176
|
+
).toEqual(["block"]);
|
|
177
|
+
const heads = segments
|
|
178
|
+
.map((s, i) => ({ s, i }))
|
|
179
|
+
.filter(({ s }) => s.kind === "row" && s.text.includes(FIRST_ERROR_LINE));
|
|
180
|
+
expect(heads, "the error's first line is no longer the row it was").toHaveLength(1);
|
|
181
|
+
const tails = segments.filter(
|
|
182
|
+
(s) => s.kind === "block" && TAIL_ERROR_LINES.some((line) => s.text.includes(line)),
|
|
183
|
+
);
|
|
184
|
+
// One block, not one per line: `RowBlock` renders a block by wrapping it, so
|
|
185
|
+
// N blocks for N lines would draw the tail the same at a wide frame and hide
|
|
186
|
+
// the difference at a narrow one (where each block re-wraps on its own).
|
|
187
|
+
expect(tails, "the error's tail is not carried by exactly one block").toHaveLength(1);
|
|
188
|
+
const [tail] = tails;
|
|
189
|
+
expect(
|
|
190
|
+
tail === undefined ? [] : plainLines(tail.text),
|
|
191
|
+
"the error's tail block does not carry its lines in order",
|
|
192
|
+
).toEqual(TAIL_ERROR_LINES);
|
|
193
|
+
expect(
|
|
194
|
+
segments.indexOf(tail!),
|
|
195
|
+
"the tail block was emitted before the row head, so the error reads bottom-up",
|
|
196
|
+
).toBeGreaterThan(heads[0]!.i);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
it("shows every line of a finished run's error, the last one as a block", () => {
|
|
200
|
+
const segments = buildExpandedText(failedResult(), undefined, theme);
|
|
201
|
+
revealsTheWholeError(segments);
|
|
202
|
+
// The marker convention survives: the first line is still the outcome row,
|
|
203
|
+
// with the ✗ error marker in front of it. Pinned exactly, because that row is
|
|
204
|
+
// what the collapsed view draws too — only the tail moves into a block.
|
|
205
|
+
const heads = segments.filter((s) => s.kind === "row" && s.text.includes("✗ "));
|
|
206
|
+
expect(heads).toEqual([{ kind: "row", text: "[error]✗ " + FIRST_ERROR_LINE }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("shows every line of a streaming run's error, the last one as a block", () => {
|
|
210
|
+
const rows = new CapturingRowBlock();
|
|
211
|
+
renderProgressExpanded(rows, makeProgress({ status: "failed" }), theme);
|
|
212
|
+
revealsTheWholeError(captured(rows));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("shows every line of a streaming parallel run's error, the last one as a block", () => {
|
|
216
|
+
revealsTheWholeError(buildProgressExpandedText(makeProgress({ status: "failed" }), theme));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Both streaming builders choose this branch on `status === "completed" ||
|
|
220
|
+
// outcome`, and still pass `progress.error` down, where `outcomeSegments` reads
|
|
221
|
+
// `error` before `outcome` — mirroring `buildOutcomeLine` (`format.ts:101`).
|
|
222
|
+
// `stream.ts` only ever sets `error` for a failed run today, but `progress` is
|
|
223
|
+
// also data read back out of a persisted session, and the branch's own contract
|
|
224
|
+
// is "state or outcome, plus whatever error came with it" — so the error has to
|
|
225
|
+
// be revealed here too. Until now it was the one place the finished view's fix
|
|
226
|
+
// had not reached: same run, full error in `buildExpandedText`, one line here.
|
|
227
|
+
it("shows every line of a *completed* streaming run's error, not just a failed one's", () => {
|
|
228
|
+
const rows = new CapturingRowBlock();
|
|
229
|
+
renderProgressExpanded(rows, makeProgress({ status: "completed" }), theme);
|
|
230
|
+
revealsTheWholeError(captured(rows));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("shows every line of a *completed* parallel streaming run's error", () => {
|
|
234
|
+
revealsTheWholeError(buildProgressExpandedText(makeProgress({ status: "completed" }), theme));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("reveals the tail for a still-running progress that already carries an outcome", () => {
|
|
238
|
+
// The `|| progress.outcome` half of the same condition.
|
|
239
|
+
const rows = new CapturingRowBlock();
|
|
240
|
+
renderProgressExpanded(rows, makeProgress({ status: "running", outcome: "truncated" }), theme);
|
|
241
|
+
revealsTheWholeError(captured(rows));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("draws the whole error on screen at a narrow width as well as a wide one", () => {
|
|
245
|
+
// Through the real row component, so this is about what lands on screen and
|
|
246
|
+
// not about the intermediate segments (the style of the width audit).
|
|
247
|
+
const [headLine, ...carriedLines] = MULTI_LINE_ERROR.split("\n");
|
|
248
|
+
for (const width of [200, 120, 80, 40]) {
|
|
249
|
+
const rows = new CapturingRowBlock();
|
|
250
|
+
renderExpanded(rows, failedResult(), undefined, sgrTheme);
|
|
251
|
+
const rendered = rows.render(width);
|
|
252
|
+
const joined = rendered.map(content).join("").replace(/\s+/g, "");
|
|
253
|
+
// Every line the block carries survives the wrap, whole. (The first line
|
|
254
|
+
// stays a row — cut at the frame like every other row, which is why it is
|
|
255
|
+
// not in this list.)
|
|
256
|
+
for (const line of carriedLines) {
|
|
257
|
+
// A wrapped line may break a long line across rows, so compare with the
|
|
258
|
+
// whitespace removed, exactly like the width audit does.
|
|
259
|
+
expect(joined, `line lost at ${width}: ${JSON.stringify(rendered.map(content))}`)
|
|
260
|
+
.toContain(line.replace(/\s+/g, ""));
|
|
261
|
+
}
|
|
262
|
+
// The last line — the one the old cut discarded outright — is on a row of
|
|
263
|
+
// its own rather than smeared across a wrap boundary.
|
|
264
|
+
expect(
|
|
265
|
+
rendered.map(content).some((l) => l.includes(carriedLines[carriedLines.length - 1]!)),
|
|
266
|
+
`last error line missing at ${width}: ${JSON.stringify(rendered.map(content))}`,
|
|
267
|
+
).toBe(true);
|
|
268
|
+
// And the head is still there, cut the way a row cuts, marker and all.
|
|
269
|
+
expect(rendered.map(content)[0]).toContain("reviewer");
|
|
270
|
+
expect(
|
|
271
|
+
rendered.map(content).some((l) => l.includes("✗ " + headLine!.slice(0, 10))),
|
|
272
|
+
`error head missing at ${width}: ${JSON.stringify(rendered.map(content))}`,
|
|
273
|
+
).toBe(true);
|
|
274
|
+
for (const line of rendered) {
|
|
275
|
+
expect(visibleWidth(line), `row over the frame at ${width}`).toBeLessThanOrEqual(width);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("closes the error colour on every line it drew, so colour does not bleed", () => {
|
|
281
|
+
// Wide enough that nothing wraps: every logical line is one rendered line, so
|
|
282
|
+
// each one has to carry its own complete SGR pair. Handing `block` the whole
|
|
283
|
+
// `theme.fg("error", "✗ " + error)` string instead would open one span over
|
|
284
|
+
// every line and land the single reset on the last one only — the padding of
|
|
285
|
+
// the earlier lines then sits inside an open span.
|
|
286
|
+
const rows = new CapturingRowBlock();
|
|
287
|
+
renderExpanded(rows, failedResult(), undefined, sgrTheme);
|
|
288
|
+
const drawn = rows.render(200);
|
|
289
|
+
const errorLines = drawn.filter((l) => l.includes(ERROR_SGR));
|
|
290
|
+
expect(errorLines.length).toBe(MULTI_LINE_ERROR.split("\n").length);
|
|
291
|
+
for (const line of errorLines) {
|
|
292
|
+
expect(line.startsWith(ERROR_SGR), `line does not open its own span: ${JSON.stringify(line)}`)
|
|
293
|
+
.toBe(true);
|
|
294
|
+
expect(
|
|
295
|
+
line.trimEnd().endsWith(RESET),
|
|
296
|
+
`line leaves its span open across the padding: ${JSON.stringify(line)}`,
|
|
297
|
+
).toBe(true);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("leaves a single-line error exactly as it was rendered before", () => {
|
|
302
|
+
// Byte-identical: only the multi-line case changes, so every existing
|
|
303
|
+
// assertion about the outcome row keeps its meaning.
|
|
304
|
+
const failed = makeResult({ exitCode: 1, error: "401 unauthorized" });
|
|
305
|
+
delete failed.outcome;
|
|
306
|
+
const segments = buildExpandedText(failed, undefined, theme);
|
|
307
|
+
const outcome = segments.filter((s) => s.text.includes("401 unauthorized"));
|
|
308
|
+
expect(outcome).toEqual([{ kind: "row", text: "[error]✗ 401 unauthorized" }]);
|
|
309
|
+
// Counted as segments, because a segment's text is already multi-line: the
|
|
310
|
+
// claim is "the error reached the row layer exactly once", which a duplicate
|
|
311
|
+
// (the same line in a row *and* in a block) would fail.
|
|
312
|
+
expect(segments.filter((s) => s.text.includes("401"))).toHaveLength(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("draws no blank row for an error that merely ends in a newline", () => {
|
|
316
|
+
// A trailing newline is not a line of content, and opening a block for it
|
|
317
|
+
// would add a blank row to the bottom of every failed run.
|
|
318
|
+
const failed = makeResult({ exitCode: 1, error: "401 unauthorized\n" });
|
|
319
|
+
delete failed.outcome;
|
|
320
|
+
const segments = buildExpandedText(failed, undefined, theme);
|
|
321
|
+
expect(segments.filter((s) => s.kind === "block" && s.text.trim() === "")).toEqual([]);
|
|
322
|
+
// And through the real row component, because the segment layer cannot see
|
|
323
|
+
// the failure this guards against: an emitted `row("")` is invisible in the
|
|
324
|
+
// list above yet `RowBlock` would draw it as a blank bottom row.
|
|
325
|
+
const rows = new CapturingRowBlock();
|
|
326
|
+
renderExpanded(rows, failed, undefined, theme);
|
|
327
|
+
const drawn = rows.render(200).map(content);
|
|
328
|
+
expect(drawn.filter((l) => l.includes("401"))).toHaveLength(1);
|
|
329
|
+
expect(
|
|
330
|
+
drawn[drawn.length - 1],
|
|
331
|
+
`a blank row was drawn under the outcome: ${JSON.stringify(drawn)}`,
|
|
332
|
+
).toContain("401 unauthorized");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("still lets the error win over the outcome, even when the error is multi-line", () => {
|
|
336
|
+
// Pinned as-is, not decided here: `outcomeSegments` reads `data.error`
|
|
337
|
+
// before the outcome — the same precedence `buildOutcomeLine` gives it — so an
|
|
338
|
+
// error and an outcome together report the error. Both halves of
|
|
339
|
+
// the text have to survive that precedence now that the error is a block.
|
|
340
|
+
const out = drawnText(
|
|
341
|
+
buildExpandedText(failedResult({ outcome: "truncated", finalOutput: "partial" }), undefined, theme),
|
|
342
|
+
);
|
|
343
|
+
expect(out).toContain(FIRST_ERROR_LINE);
|
|
344
|
+
expect(out).toContain(LAST_ERROR_LINE);
|
|
345
|
+
expect(out).not.toMatch(/Truncated/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// The collapsed half of this asymmetry is pinned where that code lives, not here: `compact.test.ts` ("hands over three row segments carrying whole fields, so newlines reach the row cut") and `tool-row.width-audit.test.ts` ("keeps a multiline task or error to three rows per agent (collapsed)").
|
|
349
|
+
|
|
350
|
+
describe("a child that wrote CRLF line endings (a Windows child)", () => {
|
|
351
|
+
// `stream.ts` reads stderr as `Buffer.concat(...).toString("utf-8").trim()` —
|
|
352
|
+
// no newline normalisation — so `\r\n` arrives intact from a Windows child.
|
|
353
|
+
// `pi-tui`'s `wrapTextWithAnsi` splits on `/\r\n|\r|\n/` but `truncateRow`
|
|
354
|
+
// splits on `"\n"`, so a `\r` surviving the split rides inside a `row` and
|
|
355
|
+
// gets drawn: pi-tui does not strip it, and a terminal interpreting a bare CR
|
|
356
|
+
// rewinds the cursor to column 0 and paints the row over itself.
|
|
357
|
+
const CRLF_ERROR = ERROR_LINES.join("\r\n");
|
|
358
|
+
|
|
359
|
+
it("keeps every carriage return out of the segments and off the screen", () => {
|
|
360
|
+
const builders: Array<[string, () => RowSegment[]]> = [
|
|
361
|
+
["buildExpandedText", () => buildExpandedText(failedResult({ error: CRLF_ERROR }), undefined, theme)],
|
|
362
|
+
["renderProgressExpanded", () => {
|
|
363
|
+
const rows = new CapturingRowBlock();
|
|
364
|
+
renderProgressExpanded(rows, makeProgress({ status: "completed", error: CRLF_ERROR }), theme);
|
|
365
|
+
return captured(rows);
|
|
366
|
+
}],
|
|
367
|
+
["buildProgressExpandedText", () => buildProgressExpandedText(makeProgress({ status: "failed", error: CRLF_ERROR }), theme)],
|
|
368
|
+
];
|
|
369
|
+
for (const [name, build] of builders) {
|
|
370
|
+
const segments = build();
|
|
371
|
+
expect(
|
|
372
|
+
segments.filter((s) => s.text.includes("\r")).map((s) => s.text),
|
|
373
|
+
`${name} left a carriage return inside a segment`,
|
|
374
|
+
).toEqual([]);
|
|
375
|
+
}
|
|
376
|
+
// Asserted on the RAW rendered lines: `content()` trims trailing whitespace,
|
|
377
|
+
// so it would swallow the very byte this is about.
|
|
378
|
+
for (const width of [200, 80]) {
|
|
379
|
+
const rows = new CapturingRowBlock();
|
|
380
|
+
renderExpanded(rows, failedResult({ error: CRLF_ERROR }), undefined, sgrTheme);
|
|
381
|
+
const rendered = rows.render(width);
|
|
382
|
+
for (const line of rendered) {
|
|
383
|
+
expect(JSON.stringify(line), `a bare CR was drawn at width ${width}`).not.toContain("\\r");
|
|
384
|
+
}
|
|
385
|
+
// Nothing was lost by the split either — every line still reaches the frame.
|
|
386
|
+
const joined = rendered.map(content).join("").replace(/\s+/g, "");
|
|
387
|
+
for (const line of ERROR_LINES) {
|
|
388
|
+
expect(joined, `line lost at ${width}: ${JSON.stringify(rendered.map(content))}`)
|
|
389
|
+
.toContain(line.replace(/\s+/g, ""));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── The fallback each builder keeps (the legacy-session shape) ──────────────
|
|
397
|
+
|
|
398
|
+
describe("expanded view: a run with nothing to report falls back the way its own builder did", () => {
|
|
399
|
+
// `outcome` is optional (`types.ts:38,70,134`) and only populated from 8cf0d12
|
|
400
|
+
// (#103) on, so both shapes below reach the empty case: a session JSON persisted
|
|
401
|
+
// before it renders the finished view with `status:"completed"` and no outcome,
|
|
402
|
+
// and a progress that reports a terminal state and no outcome renders the two
|
|
403
|
+
// streaming views that way. Each site's fallback is load-bearing there, and they
|
|
404
|
+
// are *not* the same fallback: the finished view says `✗ Failed`, the two
|
|
405
|
+
// streaming views say `✓ Done`. Relabelling a completed run failed is how a
|
|
406
|
+
// useful answer gets thrown away by whoever reads it.
|
|
407
|
+
const saysDoneAndNeverFailed = (text: string): void => {
|
|
408
|
+
expect(text).toContain("✓ Done");
|
|
409
|
+
expect(text).not.toMatch(/Failed/);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
it("says Done for a finished result that predates the outcome field", () => {
|
|
413
|
+
const legacy = makeResult();
|
|
414
|
+
delete legacy.outcome;
|
|
415
|
+
saysDoneAndNeverFailed(drawnText(buildExpandedText(legacy, undefined, theme)));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("says Done for streaming progress that predates the outcome field", () => {
|
|
419
|
+
const rows = new CapturingRowBlock();
|
|
420
|
+
renderProgressExpanded(rows, makeProgress({ status: "completed", error: undefined }), theme);
|
|
421
|
+
saysDoneAndNeverFailed(drawnText(captured(rows)));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("says Done for parallel streaming progress that predates the outcome field", () => {
|
|
425
|
+
saysDoneAndNeverFailed(
|
|
426
|
+
drawnText(buildProgressExpandedText(makeProgress({ status: "completed", error: undefined }), theme)),
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("still names a truncated or aborted streaming run by its outcome, not as failed", () => {
|
|
431
|
+
// The same sites, one step on from the legacy shape: a real outcome has to win
|
|
432
|
+
// over the caller's fallback, and `✓ Done` must not flatten it either.
|
|
433
|
+
for (const outcome of ["truncated", "aborted"] as const) {
|
|
434
|
+
const expected = outcome === "truncated" ? /truncated/i : /aborted/i;
|
|
435
|
+
const rows = new CapturingRowBlock();
|
|
436
|
+
renderProgressExpanded(rows, makeProgress({ status: "completed", outcome, error: undefined }), theme);
|
|
437
|
+
const streaming = drawnText(captured(rows));
|
|
438
|
+
expect(streaming, `renderProgressExpanded flattened ${outcome}`).toMatch(expected);
|
|
439
|
+
expect(streaming).not.toMatch(/Failed|✓ Done/);
|
|
440
|
+
const parallel = drawnText(
|
|
441
|
+
buildProgressExpandedText(makeProgress({ status: "completed", outcome, error: undefined }), theme),
|
|
442
|
+
);
|
|
443
|
+
expect(parallel, `buildProgressExpandedText flattened ${outcome}`).toMatch(expected);
|
|
444
|
+
expect(parallel).not.toMatch(/Failed|✓ Done/);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("falls back to Failed on the finished view when a result reports nothing at all", () => {
|
|
449
|
+
// One door onto the finished view's bottom row: a result with neither an
|
|
450
|
+
// outcome nor an error and a nonzero exit — `buildExpandedText`'s own `else`,
|
|
451
|
+
// which keeps saying Failed because such a run has no success to claim.
|
|
452
|
+
const nothing = makeResult({ exitCode: 1 });
|
|
453
|
+
delete nothing.outcome;
|
|
454
|
+
const out = drawnText(buildExpandedText(nothing, undefined, theme));
|
|
455
|
+
expect(out).toContain("✗ Failed");
|
|
456
|
+
expect(out).not.toMatch(/✓ Done/);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("falls back to Failed — not Done — for an outcome this build cannot name", () => {
|
|
460
|
+
// The other door, and the one that only exists now the fallback is the
|
|
461
|
+
// caller's: `outcome` set to something `buildOutcomeLine` has no case for (a
|
|
462
|
+
// session written by a newer build, or a widened *Run outcome* axis) leaves it
|
|
463
|
+
// returning `undefined`, and the segment `buildExpandedText` passes in is what
|
|
464
|
+
// gets drawn. It stays Failed — a run whose ending this code cannot read is
|
|
465
|
+
// not a success this code may claim — which is the byte this site rendered
|
|
466
|
+
// before the fallback moved, so moving it changed nothing here.
|
|
467
|
+
const unknown = makeResult({ exitCode: 1, outcome: "detached" as RunOutcome });
|
|
468
|
+
const out = drawnText(buildExpandedText(unknown, undefined, theme));
|
|
469
|
+
expect(out).toContain("✗ Failed");
|
|
470
|
+
expect(out).not.toMatch(/✓ Done/);
|
|
471
|
+
});
|
|
472
|
+
});
|
package/src/expanded.ts
CHANGED
|
@@ -6,12 +6,18 @@ import { renderToolCallLine } from "@pi-archimedes/core/tool-render";
|
|
|
6
6
|
type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
|
|
7
7
|
|
|
8
8
|
// The expanded view mixes two kinds of line and says which is which (see
|
|
9
|
-
// `row-block.ts`): a **row** (label, activity,
|
|
10
|
-
// the frame; a **block** (the stats line, the task text, the
|
|
11
|
-
// the output
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// `row-block.ts`): a **row** (label, activity, the first line of an outcome) stays
|
|
10
|
+
// one line and fills the frame; a **block** (the stats line, the task text, the
|
|
11
|
+
// tool-call history, the output, the lines of an error after the first) keeps
|
|
12
|
+
// wrapping, because the expanded view is where all of it is meant to be readable
|
|
13
|
+
// (issue #112; its counterpart — the collapsed view cutting at the first line — is
|
|
14
|
+
// pinned in `tool-row.width-audit.test.ts`). The stats line is a block and not a
|
|
15
|
+
// row so the view reads on its own, without the reader collapsing back to the row
|
|
16
|
+
// to get the numbers back. They do live elsewhere too — `compact.ts` builds the
|
|
17
|
+
// identical `buildStatsLine`, `formatParallelResults` puts tools / tokens /
|
|
18
|
+
// seconds in the model-facing content, and `usage` reaches pi's `/session` — but
|
|
19
|
+
// every row is cut at the frame, so the tail of the stats (tokens, duration,
|
|
20
|
+
// cost) is exactly what a narrow terminal would drop from this view.
|
|
15
21
|
|
|
16
22
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
17
23
|
|
|
@@ -27,18 +33,74 @@ function formatToolCall(call: SubagentToolCall | string, theme: Theme): string {
|
|
|
27
33
|
return renderToolCallLine(status, call.name, suffix, theme);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
// The bottom
|
|
36
|
+
// The bottom of an expanded view. The *Run outcome* wins over the done/failed
|
|
31
37
|
// pair: a truncated answer is not a failed run, and labelling it "✗ Failed" is
|
|
32
38
|
// how a useful answer gets thrown away by whoever reads it.
|
|
33
|
-
|
|
39
|
+
//
|
|
40
|
+
// An error is often stderr or a thrown Error's message — the second kind never
|
|
41
|
+
// touched a stream (`dispatch.ts`'s `buildFailedResult` carries `err.message`, and
|
|
42
|
+
// `stream.ts` synthesises strings like "subagent failed with exit code N"). Either
|
|
43
|
+
// way it arrives with newlines in it, and as one `row` the view could only ever
|
|
44
|
+
// show its first line: `truncateRow` treats a newline as a hard boundary (see
|
|
45
|
+
// `row-block.ts`), which is right for the collapsed view (the three-rows-per-agent
|
|
46
|
+
// shape is pinned in `tool-row.width-audit.test.ts`) and wrong here: the expanded
|
|
47
|
+
// view is where everything is meant to be readable (issue #112), and a stack
|
|
48
|
+
// trace's last line is usually the informative one. So the first line stays the
|
|
49
|
+
// row it was, and the lines after it are handed over as one `block` — this repo's
|
|
50
|
+
// existing mechanism for "read this in full".
|
|
51
|
+
//
|
|
52
|
+
// The raw error is split *before* anything is coloured, and every line is
|
|
53
|
+
// coloured on its own. `theme.fg("error", "✗ " + error)` wraps the whole
|
|
54
|
+
// multi-line string in one span whose reset lands on the last line only, so
|
|
55
|
+
// every earlier line would be drawn — padding included — inside an open span.
|
|
56
|
+
// Measured against `wrapTextWithAnsi`: it re-opens the active codes at the start
|
|
57
|
+
// of a continuation line but never invents a reset at the end of one.
|
|
58
|
+
function outcomeSegments(
|
|
34
59
|
data: { outcome?: RunOutcome | undefined; error: string | undefined },
|
|
35
60
|
theme: Theme,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
// The segment to draw when there is nothing to report, rendered by the caller
|
|
62
|
+
// rather than decided here, because the five call sites do not agree on it — and
|
|
63
|
+
// they disagree per *branch*, not per builder: the finished view's single call
|
|
64
|
+
// says `✗ Failed` (an `outcome` this build cannot name is not a success this
|
|
65
|
+
// build may claim), while each streaming builder passes `✓ Done` from its
|
|
66
|
+
// completed branch and `✗ Failed` from its failed one. So "nothing to report"
|
|
67
|
+
// means something different at each site, and the `✓ Done` ones are why the
|
|
68
|
+
// answer cannot be baked in here: `outcome` is optional on `SubagentProgress`
|
|
69
|
+
// (`types.ts:38`; `SubagentResult` at `:70`, `SubagentRunReport` at `:134`), so a
|
|
70
|
+
// run can report a terminal status with no outcome at all — every session
|
|
71
|
+
// persisted before #103 / 8cf0d12 added the field — and a helper-wide
|
|
72
|
+
// `✗ Failed` there would relabel a finished run a broken one.
|
|
73
|
+
fallback: RowSegment,
|
|
74
|
+
): RowSegment[] {
|
|
75
|
+
// Same precedence as `buildOutcomeLine` (`format.ts:101`): `error` is read first,
|
|
76
|
+
// so a run that carries both reports the error and not the outcome. Pinned in
|
|
77
|
+
// `expanded.test.ts` rather than re-decided here. It is also why neither streaming
|
|
78
|
+
// branch may assume it has no error in hand: each opens on a completed *status* or
|
|
79
|
+
// on any reported outcome, and either can arrive together with a multi-line one,
|
|
80
|
+
// so a completed progress's error gets the same head-row-then-block treatment here
|
|
81
|
+
// that the failed branch gives its own (#112).
|
|
82
|
+
if (data.error) {
|
|
83
|
+
// `/\r\n|\r|\n/`, not `"\n"`: `stream.ts` reads stderr as
|
|
84
|
+
// `Buffer.concat(...).toString("utf-8").trim()` with no newline
|
|
85
|
+
// normalisation, so a Windows child's `\r\n` arrives intact. pi-tui's
|
|
86
|
+
// `wrapTextWithAnsi` splits on all three endings but `truncateRow` splits on
|
|
87
|
+
// `"\n"`, so a `\r` left inside a row would be *drawn*, and a terminal that
|
|
88
|
+
// honours a bare CR repaints the row from column 0.
|
|
89
|
+
const [head, ...tail] = data.error.split(/\r\n|\r|\n/);
|
|
90
|
+
const segments: RowSegment[] = [row(theme.fg("error", "✗ " + head))];
|
|
91
|
+
// A tail of nothing but blanks gets no block: a synthesised error, or any
|
|
92
|
+
// producer that does not `trim()` its stderr, can end in a newline, and
|
|
93
|
+
// opening a block for that would add a blank row under every failed run.
|
|
94
|
+
if (tail.some((line) => line.trim() !== "")) {
|
|
95
|
+
segments.push(block(tail.map((line) => (line ? theme.fg("error", line) : "")).join("\n")));
|
|
96
|
+
}
|
|
97
|
+
return segments;
|
|
98
|
+
}
|
|
99
|
+
const line = buildOutcomeLine({ outcome: data.outcome, error: undefined }, theme);
|
|
100
|
+
// The outcome vocabulary stays `buildOutcomeLine`'s and this helper still owns
|
|
101
|
+
// the row decision; only "nothing to report" hands back the segment the caller
|
|
102
|
+
// rendered for itself.
|
|
103
|
+
return [line === undefined ? fallback : row(line)];
|
|
42
104
|
}
|
|
43
105
|
|
|
44
106
|
// ── Expanded completed result ───────────────────────────────────────────────
|
|
@@ -65,9 +127,9 @@ export function buildExpandedText(
|
|
|
65
127
|
}, theme);
|
|
66
128
|
const statsParts = [modelLabel, statsLine].filter(Boolean);
|
|
67
129
|
if (statsParts.length > 0) {
|
|
68
|
-
// A block, not a row:
|
|
69
|
-
//
|
|
70
|
-
// the frame is
|
|
130
|
+
// A block, not a row: the expanded view has to read on its own, so the
|
|
131
|
+
// numbers cannot depend on the reader collapsing the row to get them back
|
|
132
|
+
// however narrow the frame is.
|
|
71
133
|
lines.push(block(statsParts.join(" ")));
|
|
72
134
|
}
|
|
73
135
|
|
|
@@ -90,6 +152,11 @@ export function buildExpandedText(
|
|
|
90
152
|
const outputText = progress?.output ?? result.finalOutput;
|
|
91
153
|
if (outputText) {
|
|
92
154
|
lines.push(row(""));
|
|
155
|
+
// A block, uncapped: ADR 0020 is the precedent that this package does not cap
|
|
156
|
+
// output where the reader has no other source for it. Read it precisely — ADR
|
|
157
|
+
// 0020 governs the *tool-result content* the model reads, not this view — and
|
|
158
|
+
// the same reasoning carries over: the answer shown here is the child's, and
|
|
159
|
+
// nothing else spells it out for whoever is reading the transcript.
|
|
93
160
|
lines.push(block(outputText));
|
|
94
161
|
}
|
|
95
162
|
|
|
@@ -97,7 +164,20 @@ export function buildExpandedText(
|
|
|
97
164
|
if (result.error || result.outcome || result.exitCode === 0) {
|
|
98
165
|
lines.push(row(""));
|
|
99
166
|
if (result.outcome || result.error) {
|
|
100
|
-
|
|
167
|
+
// The fallback covers exactly one case here: an `outcome` this build cannot
|
|
168
|
+
// name, which leaves `buildOutcomeLine` with nothing to return. It is
|
|
169
|
+
// Failed, because a run whose ending this code cannot read is not a success
|
|
170
|
+
// this code may claim. The never-settled legacy shape — no outcome, no
|
|
171
|
+
// error, a non-zero exit — is not this fallback's: the `else` below prints
|
|
172
|
+
// it. The streaming builders' completed branch passes ✓ Done instead (see
|
|
173
|
+
// `outcomeSegments`' third parameter for why it is not decided here).
|
|
174
|
+
lines.push(
|
|
175
|
+
...outcomeSegments(
|
|
176
|
+
{ outcome: result.outcome, error: result.error },
|
|
177
|
+
theme,
|
|
178
|
+
row(theme.fg("error", "✗ Failed")),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
101
181
|
} else {
|
|
102
182
|
lines.push(row(theme.fg("success", "✓ Done")));
|
|
103
183
|
}
|
|
@@ -170,14 +250,25 @@ export function renderProgressExpanded(
|
|
|
170
250
|
lines.push(row(renderToolCallLine("running", progress.currentTool, suffix, theme)));
|
|
171
251
|
}
|
|
172
252
|
|
|
173
|
-
// Status at bottom
|
|
253
|
+
// Status at bottom. Which fallback each branch hands the helper, and why the
|
|
254
|
+
// helper is not the one to decide it, is written once above `outcomeSegments`.
|
|
174
255
|
if (progress.status === "completed" || progress.outcome) {
|
|
175
256
|
lines.push(row(""));
|
|
176
|
-
|
|
177
|
-
|
|
257
|
+
// `✓ Done`: this branch's agent reached a terminal state, so a run with nothing
|
|
258
|
+
// to name its outcome by is finished work here — the ✗ below would bury it.
|
|
259
|
+
lines.push(...outcomeSegments(
|
|
260
|
+
{ outcome: progress.outcome, error: progress.error },
|
|
261
|
+
theme,
|
|
262
|
+
row(theme.fg("success", "✓ Done")),
|
|
263
|
+
));
|
|
178
264
|
} else if (progress.status === "failed") {
|
|
179
265
|
lines.push(row(""));
|
|
180
|
-
|
|
266
|
+
// The only branch here that may call a run broken, so it passes `✗ Failed`.
|
|
267
|
+
lines.push(...outcomeSegments(
|
|
268
|
+
{ outcome: undefined, error: progress.error },
|
|
269
|
+
theme,
|
|
270
|
+
row(theme.fg("error", "✗ Failed")),
|
|
271
|
+
));
|
|
181
272
|
}
|
|
182
273
|
|
|
183
274
|
rows.setSegments(lines);
|
|
@@ -240,14 +331,27 @@ export function buildProgressExpandedText(
|
|
|
240
331
|
lines.push(row(line));
|
|
241
332
|
}
|
|
242
333
|
|
|
243
|
-
// Status at bottom
|
|
334
|
+
// Status at bottom — the same pair of branches `renderProgressExpanded` draws for
|
|
335
|
+
// the single running agent, here as one block of the parallel view. What each
|
|
336
|
+
// branch passes, and why the helper cannot decide it, is written once above
|
|
337
|
+
// `outcomeSegments`.
|
|
244
338
|
if (progress.status === "completed" || progress.outcome) {
|
|
245
339
|
lines.push(row(""));
|
|
246
|
-
|
|
247
|
-
|
|
340
|
+
// `✓ Done`, and never the failed branch's segment: this block claims its agent
|
|
341
|
+
// finished, and a batch read as failed is a batch whose answers get discarded.
|
|
342
|
+
lines.push(...outcomeSegments(
|
|
343
|
+
{ outcome: progress.outcome, error: progress.error },
|
|
344
|
+
theme,
|
|
345
|
+
row(theme.fg("success", "✓ Done")),
|
|
346
|
+
));
|
|
248
347
|
} else if (progress.status === "failed") {
|
|
249
348
|
lines.push(row(""));
|
|
250
|
-
|
|
349
|
+
// `✗ Failed`: this branch got a broken run, the only one allowed to say so.
|
|
350
|
+
lines.push(...outcomeSegments(
|
|
351
|
+
{ outcome: undefined, error: progress.error },
|
|
352
|
+
theme,
|
|
353
|
+
row(theme.fg("error", "✗ Failed")),
|
|
354
|
+
));
|
|
251
355
|
}
|
|
252
356
|
|
|
253
357
|
return lines;
|
package/src/index.test.ts
CHANGED
|
@@ -484,3 +484,83 @@ describe("dispatch: cost is reported natively, not on the bus", () => {
|
|
|
484
484
|
expect(busEmits.filter(([channel]) => channel === Events.COST_UPDATE)).toEqual([]);
|
|
485
485
|
});
|
|
486
486
|
});
|
|
487
|
+
|
|
488
|
+
// The shape handed to the executor, pinned as data.
|
|
489
|
+
//
|
|
490
|
+
// `index.ts` maps each incoming task into the object `executeParallel` receives
|
|
491
|
+
// (and builds the single equivalent for `executeSubagent`). That map is the only
|
|
492
|
+
// place a per-task `cwd` or `model` reaches the child, and it had no guard: the
|
|
493
|
+
// tests above drive both branches through the mocked executors and assert the
|
|
494
|
+
// outcome, never the argument, so deleting a forwarded key stayed green
|
|
495
|
+
// everywhere. This is the behavioural replacement for the source-text scanner
|
|
496
|
+
// deleted in the same branch: it pins what is actually handed over by running the
|
|
497
|
+
// tool, instead of parsing `index.ts` and risking a red run on an unrelated edit.
|
|
498
|
+
// It is a narrower claim than the scanner's — it says nothing about whether the
|
|
499
|
+
// executor later reads a key it is given — see the header of
|
|
500
|
+
// `tool-schema.test.ts`, which states the residue that leaves uncovered.
|
|
501
|
+
describe("dispatch: the object forwarded to the executor", () => {
|
|
502
|
+
it("forwards the full per-task shape to executeParallel", async () => {
|
|
503
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
504
|
+
await subagent(register()).execute(
|
|
505
|
+
"c",
|
|
506
|
+
{ tasks: [{ agent: "reviewer", task: "a", model: "anthropic/claude-opus-4.5", cwd: "/tmp/work" }] },
|
|
507
|
+
undefined,
|
|
508
|
+
undefined,
|
|
509
|
+
makeCtx(),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
expect(executeParallel).toHaveBeenCalledTimes(1);
|
|
513
|
+
const calls = executeParallel.mock.calls as unknown as Array<[{ tasks: Array<Record<string, unknown>> }]>;
|
|
514
|
+
const forwarded = calls[0]![0].tasks[0]!;
|
|
515
|
+
// Exact and sorted, so it fails in both directions: dropping a forward
|
|
516
|
+
// (`cwd`, `model`, …) silently loses a documented per-task option, and
|
|
517
|
+
// adding one changes the executor's input contract.
|
|
518
|
+
expect(Object.keys(forwarded).sort()).toEqual([
|
|
519
|
+
"activeModel",
|
|
520
|
+
"agent",
|
|
521
|
+
"agentConfig",
|
|
522
|
+
"cwd",
|
|
523
|
+
"model",
|
|
524
|
+
"task",
|
|
525
|
+
"toolCallId",
|
|
526
|
+
]);
|
|
527
|
+
// The key set alone would still pass with the values swapped or dropped to
|
|
528
|
+
// undefined, so the two the #114 discussion cares about are pinned by value.
|
|
529
|
+
expect(forwarded.cwd).toBe("/tmp/work");
|
|
530
|
+
expect(forwarded.model).toBe("anthropic/claude-opus-4.5");
|
|
531
|
+
expect(forwarded.task).toBe("a");
|
|
532
|
+
expect(forwarded.toolCallId).toBe("c");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("forwards the same per-task shape to executeSubagent on the single-run path", async () => {
|
|
536
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
537
|
+
await subagent(register()).execute(
|
|
538
|
+
"c",
|
|
539
|
+
{ agent: "reviewer", task: "t", model: "anthropic/claude-opus-4.5", cwd: "/tmp/work" },
|
|
540
|
+
undefined,
|
|
541
|
+
undefined,
|
|
542
|
+
makeCtx(),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
546
|
+
const calls = executeSubagent.mock.calls as unknown as Array<[Record<string, unknown>]>;
|
|
547
|
+
const forwarded = calls[0]![0];
|
|
548
|
+
// The single path takes its arguments flat rather than nested under `tasks`,
|
|
549
|
+
// so its key set differs from the parallel item's by exactly that: no
|
|
550
|
+
// nesting, and `signal` / `events` / `onUpdate` carried per run.
|
|
551
|
+
expect(Object.keys(forwarded).sort()).toEqual([
|
|
552
|
+
"activeModel",
|
|
553
|
+
"agent",
|
|
554
|
+
"agentConfig",
|
|
555
|
+
"cwd",
|
|
556
|
+
"events",
|
|
557
|
+
"model",
|
|
558
|
+
"onUpdate",
|
|
559
|
+
"signal",
|
|
560
|
+
"task",
|
|
561
|
+
"toolCallId",
|
|
562
|
+
]);
|
|
563
|
+
expect(forwarded.cwd).toBe("/tmp/work");
|
|
564
|
+
expect(forwarded.model).toBe("anthropic/claude-opus-4.5");
|
|
565
|
+
});
|
|
566
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { SUBAGENT_PARAMS_SCHEMA, SUBAGENT_OUTPUT_SCHEMA, subagentStructuredContent } from "./tool-schema.js";
|
|
4
|
+
import { SUBAGENT_PARAMS_SCHEMA, SUBAGENT_OUTPUT_SCHEMA, subagentStructuredContent, type SubagentParams } from "./tool-schema.js";
|
|
5
5
|
import { executeSubagent, executeParallel } from "./execute.js";
|
|
6
6
|
import { formatParallelResults, formatSingleResult } from "./format.js";
|
|
7
7
|
// agent-manager.js lazy-loaded below to keep subagent tool registration fast
|
|
@@ -71,14 +71,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
|
|
|
71
71
|
|
|
72
72
|
async execute(
|
|
73
73
|
_id: string,
|
|
74
|
-
params:
|
|
75
|
-
agent?: string;
|
|
76
|
-
task?: string;
|
|
77
|
-
tasks?: Array<{ agent?: string; task: string; count?: number; model?: string; cwd?: string }>;
|
|
78
|
-
model?: string;
|
|
79
|
-
cwd?: string;
|
|
80
|
-
async?: boolean;
|
|
81
|
-
},
|
|
74
|
+
params: SubagentParams,
|
|
82
75
|
signal: AbortSignal | undefined,
|
|
83
76
|
onUpdate: ((update: SubagentToolResult) => void) | undefined,
|
|
84
77
|
ctx: ExtensionContext,
|
package/src/row-block.test.ts
CHANGED
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect } from "vitest";
|
|
17
|
-
import { Text, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
-
import { RowBlock, block, row, truncateRow } from "./row-block.js";
|
|
17
|
+
import { Box, Text, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { RowBlock, block, row, truncateRow, RESET } from "./row-block.js";
|
|
19
19
|
|
|
20
|
-
const dim = (t: string) => `\x1b[2m${t}
|
|
21
|
-
const bold = (t: string) => `\x1b[1m${t}
|
|
22
|
-
const RESET = "\x1b[0m";
|
|
20
|
+
const dim = (t: string) => `\x1b[2m${t}${RESET}`;
|
|
21
|
+
const bold = (t: string) => `\x1b[1m${t}${RESET}`;
|
|
23
22
|
|
|
24
23
|
const drawn = (line: string) => visibleWidth(stripTerminalSequences(line).replace(/\s+$/, ""));
|
|
25
24
|
|
|
@@ -93,6 +92,42 @@ describe("truncateRow", () => {
|
|
|
93
92
|
}
|
|
94
93
|
});
|
|
95
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
|
+
|
|
96
131
|
it("survives widths with no room for an ellipsis", () => {
|
|
97
132
|
expect(truncateRow(LONG, 3)).toBe("...");
|
|
98
133
|
expect(truncateRow(LONG, 2)).toBe("..");
|
|
@@ -192,4 +227,18 @@ describe("RowBlock", () => {
|
|
|
192
227
|
const lines = rows.render(60);
|
|
193
228
|
for (const line of lines) expect(padTo(line, 60)).toBe(line);
|
|
194
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
|
+
});
|
|
195
244
|
});
|
package/src/row-block.ts
CHANGED
|
@@ -27,8 +27,28 @@ import { Text, sliceByColumn, visibleWidth, wrapTextWithAnsi } from "@earendil-w
|
|
|
27
27
|
/** The marker appended to a row that was cut. Plain text, so it inherits colour. */
|
|
28
28
|
export const ROW_ELLIPSIS = "...";
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
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
|
+
}
|
|
32
52
|
|
|
33
53
|
/** How `Text` renders a tab; kept identical so short rows stay byte-identical. */
|
|
34
54
|
const TAB_WIDTH = 3;
|
|
@@ -82,13 +102,16 @@ export function truncateRow(line: string, width: number): string {
|
|
|
82
102
|
if (width <= 0) return "";
|
|
83
103
|
const nlIdx = line.indexOf("\n");
|
|
84
104
|
const firstLine = nlIdx === -1 ? line : line.slice(0, nlIdx);
|
|
85
|
-
if (nlIdx === -1 && visibleWidth(firstLine) <= width)
|
|
105
|
+
if (nlIdx === -1 && visibleWidth(firstLine) <= width) {
|
|
106
|
+
return sanitizeRowResets(firstLine);
|
|
107
|
+
}
|
|
86
108
|
|
|
87
109
|
const budget = width - ROW_ELLIPSIS.length;
|
|
88
110
|
// No room for both content and dots: show as many dots as the frame holds, so
|
|
89
111
|
// the row still reads as "cut" rather than as "the whole task".
|
|
90
112
|
if (budget <= 0) return sliceByColumn(ROW_ELLIPSIS, 0, width, true);
|
|
91
|
-
|
|
113
|
+
const sliced = sliceByColumn(firstLine, 0, budget, true);
|
|
114
|
+
return sanitizeRowResets(sliced + ROW_ELLIPSIS) + RESET;
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
/** Pad a rendered line out to the frame, the way `Text` does. */
|
|
@@ -175,7 +198,7 @@ export class RowBlock extends Text {
|
|
|
175
198
|
const text = segment.text.replace(/\t/g, " ".repeat(TAB_WIDTH));
|
|
176
199
|
if (segment.kind === "block") {
|
|
177
200
|
for (const wrapped of wrapTextWithAnsi(text, width)) {
|
|
178
|
-
lines.push(padToWidth(wrapped, width));
|
|
201
|
+
lines.push(padToWidth(sanitizeRowResets(wrapped), width));
|
|
179
202
|
}
|
|
180
203
|
} else {
|
|
181
204
|
lines.push(padToWidth(truncateRow(text, width), width));
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #114 — the parameter set `subagent` declares is the whole contract, so
|
|
3
|
+
* every name in it must be a name `execute` actually reads.
|
|
4
|
+
*
|
|
5
|
+
* #114 deleted a declared `async` boolean ("Run asynchronously
|
|
6
|
+
* (fire-and-forget)") that nothing read: a model that passed `async: true` got a
|
|
7
|
+
* fully synchronous run with no warning. That is a false statement in a
|
|
8
|
+
* model-facing contract, and worse than a missing feature, because the contract
|
|
9
|
+
* *is* what the model believes about the tool — it is what gets rendered into
|
|
10
|
+
* the tool list, what the model plans around, what a wrong answer gets blamed
|
|
11
|
+
* on. Deleting the claim is the fix; implementing fire-and-forget is out of
|
|
12
|
+
* scope and blocked on #113 (reaping subagent children across session
|
|
13
|
+
* replacement).
|
|
14
|
+
*
|
|
15
|
+
* The review that followed found a SECOND instance of the same defect class the
|
|
16
|
+
* issue never named: `count?: number` on the parallel task item, read nowhere.
|
|
17
|
+
* It differed from `async` in one instructive way: it lived only in the
|
|
18
|
+
* hand-written `params` type on `execute` in `index.ts`, never in `TaskItem`, so
|
|
19
|
+
* the JSON Schema the model sees never advertised it and a guard that enumerates
|
|
20
|
+
* the schema alone could not have caught it. The root cause of both was one
|
|
21
|
+
* contract stated twice — once for the model, once for TypeScript — with nothing
|
|
22
|
+
* keeping the two in step. So the fix is structural rather than another
|
|
23
|
+
* assertion: `SubagentParams` in `tool-schema.ts` is
|
|
24
|
+
* `Static<typeof SUBAGENT_PARAMS_SCHEMA>` and `execute` takes that, which is the
|
|
25
|
+
* `ask` / `sudo` precedent. There is no second list left to drift on its own, so
|
|
26
|
+
* the only low-friction way to introduce a `count` is to say so at one of the
|
|
27
|
+
* two surfaces a reviewer reads: the schema, or `execute`'s signature.
|
|
28
|
+
*
|
|
29
|
+
* What is guarded where, stated honestly:
|
|
30
|
+
*
|
|
31
|
+
* - the schema's declared sets are pinned below, exactly and enumerated from
|
|
32
|
+
* the schema object rather than restated;
|
|
33
|
+
* - there is no second TS-side list left for a test to police. `tsc` does
|
|
34
|
+
* reject a params type that *contradicts* the schema — `model?: number` at
|
|
35
|
+
* the params position fails `registerTool`'s `execute` with TS2322 — but
|
|
36
|
+
* that check is neither new nor the derivation's doing: `registerTool`
|
|
37
|
+
* infers `TParams` from `parameters` and declares `execute`'s `params` as
|
|
38
|
+
* `Static<TParams>` (`pi-coding-agent` `dist/core/extensions/types.d.ts:514`),
|
|
39
|
+
* so the same TS2322 fires with or without `SubagentParams` (measured against
|
|
40
|
+
* the pre-derivation literal, and it failed there too).
|
|
41
|
+
* - what `tsc` never caught, and still does not, is the phantom itself. An
|
|
42
|
+
* extra *optional* member only widens the params type, and pi declares
|
|
43
|
+
* `execute` as a method, so method parameters are bivariant: `count?: number`
|
|
44
|
+
* in the old literal, in `TaskItem` inside that literal, and spliced on as
|
|
45
|
+
* `params: SubagentParams & { count?: number }` all compile (each measured).
|
|
46
|
+
* So the derivation removes the *place* a phantom hid — it does not add a
|
|
47
|
+
* compiler check that would catch one. Writing a phantom now takes a
|
|
48
|
+
* deliberate `& { … }` at the params position, an edit a reviewer sees, where
|
|
49
|
+
* before it was the default way to state the contract.
|
|
50
|
+
* - the residual gap, stated against what this branch removed rather than
|
|
51
|
+
* against the compiler. The ~370-line source-text scanner deleted in
|
|
52
|
+
* `ca1a328` DID catch a declared-and-unread task field: it parsed
|
|
53
|
+
* `index.ts` as text, derived the task fields the parallel branch reads
|
|
54
|
+
* (`readTaskFields()`) and compared them against
|
|
55
|
+
* `declaredTaskItemParams()`, pinning both directions — declared and not
|
|
56
|
+
* read, and read and not declared. So type erasure was never what defeated
|
|
57
|
+
* that guard: it read the file, and a test over source text does see what
|
|
58
|
+
* the body reads. It was deleted for its false-positive cost, not because it
|
|
59
|
+
* did not work — it could redden on an unrelated `index.ts` edit (Greptile's
|
|
60
|
+
* P2 on PR #143), and that brittleness is why the maintainer took the
|
|
61
|
+
* schema-shared-type option instead. Losing it has a real price: on the
|
|
62
|
+
* task-item surface a schema field that `execute` declares but ignores is
|
|
63
|
+
* once again undetectable here, which is exactly how `async` survived. The
|
|
64
|
+
* cheap, non-brittle replacement is a BEHAVIOURAL pin, not a second parser:
|
|
65
|
+
* `index.test.ts` ("the object forwarded to the executor") now asserts the
|
|
66
|
+
* exact key set handed to `executeParallel` / `executeSubagent`, so dropping
|
|
67
|
+
* a forwarded task field — or adding one nobody declared — fails a test. Its
|
|
68
|
+
* limit, stated rather than sold: it pins the shape handed over, so a schema
|
|
69
|
+
* field that `execute` never forwards at all is invisible to it, and it
|
|
70
|
+
* cannot prove the executor reads a key it is handed. That residue is the
|
|
71
|
+
* phantom class, and it is genuinely uncovered again. What still covers it is
|
|
72
|
+
* the two pins below, a schema small enough to read in one screen — five
|
|
73
|
+
* top-level parameters, four task fields — and review.
|
|
74
|
+
*
|
|
75
|
+
* If a name reappears here it has to arrive together with the behaviour that
|
|
76
|
+
* honours it (#113 first, for `async`) and a consumer that reads it; until then
|
|
77
|
+
* the honest description is the one that does not mention it.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import { describe, it, expect } from "vitest";
|
|
81
|
+
import { Value } from "typebox/value";
|
|
82
|
+
import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
|
|
83
|
+
|
|
84
|
+
/** Read a schema node's declared property names, failing loudly if it has none. */
|
|
85
|
+
function propertyNames(node: unknown, what: string): string[] {
|
|
86
|
+
const properties = (node as { properties?: Record<string, unknown> }).properties;
|
|
87
|
+
if (!properties) throw new Error(`${what} declares no properties`);
|
|
88
|
+
return Object.keys(properties);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The top-level parameters `subagent` declares. Read from the schema, never restated. */
|
|
92
|
+
function declaredParams(): string[] {
|
|
93
|
+
return propertyNames(SUBAGENT_PARAMS_SCHEMA, "SUBAGENT_PARAMS_SCHEMA");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The properties declared for ONE entry of the `tasks` array — `TaskItem`.
|
|
98
|
+
* Reached through the schema rather than exported from `tool-schema.ts`, so the
|
|
99
|
+
* guard depends on the contract the model actually receives and the module
|
|
100
|
+
* keeps its current public surface (`TaskItem` is deliberately unexported).
|
|
101
|
+
*/
|
|
102
|
+
function declaredTaskItemParams(): string[] {
|
|
103
|
+
const tasks = (SUBAGENT_PARAMS_SCHEMA as { properties?: Record<string, unknown> }).properties?.tasks;
|
|
104
|
+
if (!tasks) throw new Error("SUBAGENT_PARAMS_SCHEMA declares no `tasks` property");
|
|
105
|
+
const items = (tasks as { items?: unknown }).items;
|
|
106
|
+
if (!items) throw new Error("`tasks` is not an array schema — no item schema to enumerate");
|
|
107
|
+
return propertyNames(items, "The `tasks` item schema (TaskItem)");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("SUBAGENT_PARAMS_SCHEMA — declared parameter set (issue #114)", () => {
|
|
111
|
+
it("pins the five declared parameters", () => {
|
|
112
|
+
// Exact and sorted, so it fails in BOTH directions: adding a name the pin
|
|
113
|
+
// does not know (`async` would) and deleting one of the five. Read from the
|
|
114
|
+
// schema object, never restated by hand. And because `SubagentParams` is
|
|
115
|
+
// derived, adding a name here is also adding it to what `execute` is handed,
|
|
116
|
+
// so this pin is where a new name has to be justified. What it does NOT
|
|
117
|
+
// check is whether anything reads the name — that cross-surface claim
|
|
118
|
+
// belonged to the scanner described in the header and no longer exists, so
|
|
119
|
+
// the name says only what the body proves.
|
|
120
|
+
expect([...declaredParams()].sort()).toEqual(["agent", "cwd", "model", "task", "tasks"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("TaskItem — declared parallel task item (issue #114, second instance: `count`)", () => {
|
|
125
|
+
it("pins the four declared task-item properties", () => {
|
|
126
|
+
// `count` was the proof this surface needs its own pin: a phantom can be
|
|
127
|
+
// added to the task item while the top-level set above stays green. Like the
|
|
128
|
+
// pin above this one enumerates the SCHEMA only — with the scanner gone
|
|
129
|
+
// nothing here compares the four against what the parallel branch reads, so
|
|
130
|
+
// the name claims the enumeration and no more.
|
|
131
|
+
expect([...declaredTaskItemParams()].sort()).toEqual(["agent", "cwd", "model", "task"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Legacy-caller compatibility check — a real behavioural guarantee, so it is
|
|
136
|
+
// pinned rather than assumed. TypeBox `Type.Object` does not set
|
|
137
|
+
// `additionalProperties: false`, so deleting `async` — and deleting `count` from
|
|
138
|
+
// the TS type, which the schema never advertised anyway — stops promising the
|
|
139
|
+
// option without turning an existing caller's payload into a validation error: a
|
|
140
|
+
// caller still passing `async: true`, or a `count` on a task, keeps validating, it
|
|
141
|
+
// just no longer gets a promise about it. That is what makes both deletions safe
|
|
142
|
+
// to land in a patch release, and it is the one thing here that fails on its own
|
|
143
|
+
// if anyone ever tightens the schema with `additionalProperties: false`.
|
|
144
|
+
describe("SUBAGENT_PARAMS_SCHEMA — legacy callers passing the deleted fields still validate", () => {
|
|
145
|
+
it("accepts a payload carrying `async`", () => {
|
|
146
|
+
expect(Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { task: "x", async: true })).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts a task item carrying `count`", () => {
|
|
150
|
+
expect(
|
|
151
|
+
Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { tasks: [{ task: "x", count: 1 }] }),
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/tool-schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type } from "typebox";
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
2
|
import type { SubagentResult, SubagentRunReport } from "./types.js";
|
|
3
3
|
|
|
4
4
|
// ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
|
|
@@ -25,14 +25,31 @@ export const SUBAGENT_PARAMS_SCHEMA = Type.Object({
|
|
|
25
25
|
model: Type.Optional(Type.String({
|
|
26
26
|
description: "Model override for the subagent",
|
|
27
27
|
})),
|
|
28
|
-
async: Type.Optional(Type.Boolean({
|
|
29
|
-
description: "Run asynchronously (fire-and-forget)",
|
|
30
|
-
})),
|
|
31
28
|
cwd: Type.Optional(Type.String({
|
|
32
29
|
description: "Working directory for the subagent",
|
|
33
30
|
})),
|
|
34
31
|
});
|
|
35
32
|
|
|
33
|
+
/**
|
|
34
|
+
* What `execute` is handed, derived from the schema rather than written out —
|
|
35
|
+
* the `ask` / `sudo` precedent (`AskParams`, `SudoExecInput`).
|
|
36
|
+
*
|
|
37
|
+
* #114's two phantoms were only possible because one contract was stated twice,
|
|
38
|
+
* independently: `async` in this schema, `count?: number` in the hand-written
|
|
39
|
+
* `params` type in `index.ts`. Nothing kept the two in step, and only one of them
|
|
40
|
+
* is what the model sees. Stating the contract once removes the second list: the
|
|
41
|
+
* schema is now the only *default* source of a name in `execute`'s declared
|
|
42
|
+
* params type — see the caveat below, which is the whole difference between
|
|
43
|
+
* "only default" and "only".
|
|
44
|
+
*
|
|
45
|
+
* That is a structural fix, not a check. It does not stop anyone reading a
|
|
46
|
+
* declared field, and it does not make a phantom a compile error — `execute` is
|
|
47
|
+
* declared as a method, so bivariant parameters accept a widening `& { … }` at
|
|
48
|
+
* the params position. See the header of `tool-schema.test.ts` for what is
|
|
49
|
+
* enforced where, measured.
|
|
50
|
+
*/
|
|
51
|
+
export type SubagentParams = Static<typeof SUBAGENT_PARAMS_SCHEMA>;
|
|
52
|
+
|
|
36
53
|
// ── JSON Schema for `structuredContent` ────────────────────────────────
|
|
37
54
|
//
|
|
38
55
|
// One report per run: what ran, how it ended, what it cost, what it answered.
|