@pi-archimedes/subagent 2.10.4 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +472 -0
- package/src/expanded.ts +202 -82
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
package/src/expanded.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import type { RunOutcome, SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall } from "./types.js";
|
|
2
|
+
import { formatDuration, buildStatsLine, buildAgentLabel, buildOutcomeLine } from "./format.js";
|
|
3
|
+
import { block, row, type RowBlock, type RowSegment } from "./row-block.js";
|
|
4
4
|
import { renderToolCallLine } from "@pi-archimedes/core/tool-render";
|
|
5
5
|
|
|
6
6
|
type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
|
|
7
7
|
|
|
8
|
+
// The expanded view mixes two kinds of line and says which is which (see
|
|
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.
|
|
21
|
+
|
|
8
22
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
9
23
|
|
|
10
24
|
// A completed tool call renders with a status glyph (✓ ok / ✗ error) and its
|
|
@@ -19,17 +33,87 @@ function formatToolCall(call: SubagentToolCall | string, theme: Theme): string {
|
|
|
19
33
|
return renderToolCallLine(status, call.name, suffix, theme);
|
|
20
34
|
}
|
|
21
35
|
|
|
36
|
+
// The bottom of an expanded view. The *Run outcome* wins over the done/failed
|
|
37
|
+
// pair: a truncated answer is not a failed run, and labelling it "✗ Failed" is
|
|
38
|
+
// how a useful answer gets thrown away by whoever reads it.
|
|
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(
|
|
59
|
+
data: { outcome?: RunOutcome | undefined; error: string | undefined },
|
|
60
|
+
theme: Theme,
|
|
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)];
|
|
104
|
+
}
|
|
105
|
+
|
|
22
106
|
// ── Expanded completed result ───────────────────────────────────────────────
|
|
23
107
|
|
|
24
108
|
export function buildExpandedText(
|
|
25
109
|
result: SubagentResult,
|
|
26
110
|
progress: SubagentProgress | undefined,
|
|
27
111
|
theme: Theme,
|
|
28
|
-
):
|
|
29
|
-
const lines:
|
|
112
|
+
): RowSegment[] {
|
|
113
|
+
const lines: RowSegment[] = [];
|
|
30
114
|
|
|
31
|
-
lines.push(buildAgentLabel(result.agent, result.task, theme));
|
|
32
|
-
lines.push("");
|
|
115
|
+
lines.push(row(buildAgentLabel(result.agent, result.task, theme)));
|
|
116
|
+
lines.push(row(""));
|
|
33
117
|
|
|
34
118
|
// Stats line (same as compact view)
|
|
35
119
|
const modelName = progress?.model ?? result.model;
|
|
@@ -43,69 +127,91 @@ export function buildExpandedText(
|
|
|
43
127
|
}, theme);
|
|
44
128
|
const statsParts = [modelLabel, statsLine].filter(Boolean);
|
|
45
129
|
if (statsParts.length > 0) {
|
|
46
|
-
|
|
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.
|
|
133
|
+
lines.push(block(statsParts.join(" ")));
|
|
47
134
|
}
|
|
48
135
|
|
|
49
136
|
// Task
|
|
50
137
|
if (result.task) {
|
|
51
|
-
lines.push("");
|
|
52
|
-
lines.push(theme.fg("dim", "Task: " + result.task));
|
|
138
|
+
lines.push(row(""));
|
|
139
|
+
lines.push(block(theme.fg("dim", "Task: " + result.task)));
|
|
53
140
|
}
|
|
54
141
|
|
|
55
142
|
// Tool calls history
|
|
56
143
|
const toolCalls = progress?.toolCalls;
|
|
57
144
|
if (toolCalls && toolCalls.length > 0) {
|
|
58
|
-
lines.push("");
|
|
145
|
+
lines.push(row(""));
|
|
59
146
|
for (const call of toolCalls) {
|
|
60
|
-
lines.push(formatToolCall(call, theme));
|
|
147
|
+
lines.push(block(formatToolCall(call, theme)));
|
|
61
148
|
}
|
|
62
149
|
}
|
|
63
150
|
|
|
64
151
|
// Output - show live streaming output or final output
|
|
65
152
|
const outputText = progress?.output ?? result.finalOutput;
|
|
66
153
|
if (outputText) {
|
|
67
|
-
lines.push("");
|
|
68
|
-
|
|
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.
|
|
160
|
+
lines.push(block(outputText));
|
|
69
161
|
}
|
|
70
162
|
|
|
71
|
-
// Error
|
|
72
|
-
if (result.error) {
|
|
73
|
-
lines.push("");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
163
|
+
// Error / outcome at the bottom
|
|
164
|
+
if (result.error || result.outcome || result.exitCode === 0) {
|
|
165
|
+
lines.push(row(""));
|
|
166
|
+
if (result.outcome || result.error) {
|
|
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
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
lines.push(row(theme.fg("success", "✓ Done")));
|
|
183
|
+
}
|
|
78
184
|
} else {
|
|
79
|
-
lines.push("");
|
|
80
|
-
lines.push(theme.fg("error", "✗ Failed"));
|
|
185
|
+
lines.push(row(""));
|
|
186
|
+
lines.push(row(theme.fg("error", "✗ Failed")));
|
|
81
187
|
}
|
|
82
188
|
|
|
83
|
-
return lines
|
|
189
|
+
return lines;
|
|
84
190
|
}
|
|
85
191
|
|
|
86
192
|
export function renderExpanded(
|
|
87
|
-
|
|
193
|
+
rows: RowBlock,
|
|
88
194
|
result: SubagentResult,
|
|
89
195
|
progress: SubagentProgress | undefined,
|
|
90
196
|
theme: Theme,
|
|
91
|
-
):
|
|
92
|
-
|
|
93
|
-
return
|
|
197
|
+
): RowBlock {
|
|
198
|
+
rows.setSegments(buildExpandedText(result, progress, theme));
|
|
199
|
+
return rows;
|
|
94
200
|
}
|
|
95
201
|
|
|
96
202
|
// ── Expanded streaming progress ─────────────────────────────────────────────
|
|
97
203
|
|
|
98
204
|
export function renderProgressExpanded(
|
|
99
|
-
|
|
205
|
+
rows: RowBlock,
|
|
100
206
|
progress: SubagentProgress,
|
|
101
207
|
theme: Theme,
|
|
102
|
-
):
|
|
103
|
-
const lines:
|
|
208
|
+
): RowBlock {
|
|
209
|
+
const lines: RowSegment[] = [];
|
|
104
210
|
|
|
105
|
-
lines.push(buildAgentLabel(progress.agent, progress.task, theme));
|
|
106
|
-
lines.push("");
|
|
211
|
+
lines.push(row(buildAgentLabel(progress.agent, progress.task, theme)));
|
|
212
|
+
lines.push(row(""));
|
|
107
213
|
|
|
108
|
-
// Stats line (same as compact view)
|
|
214
|
+
// Stats line (same as compact view) — a block: see buildExpandedText.
|
|
109
215
|
const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
|
|
110
216
|
const statsLine = buildStatsLine({
|
|
111
217
|
turns: undefined,
|
|
@@ -116,51 +222,57 @@ export function renderProgressExpanded(
|
|
|
116
222
|
}, theme);
|
|
117
223
|
const statsParts = [modelLabel, statsLine].filter(Boolean);
|
|
118
224
|
if (statsParts.length > 0) {
|
|
119
|
-
lines.push(statsParts.join(" "));
|
|
225
|
+
lines.push(block(statsParts.join(" ")));
|
|
120
226
|
}
|
|
121
227
|
|
|
122
228
|
// Task
|
|
123
229
|
if (progress.task) {
|
|
124
|
-
lines.push("");
|
|
125
|
-
lines.push(theme.fg("dim", "Task: " + progress.task));
|
|
230
|
+
lines.push(row(""));
|
|
231
|
+
lines.push(block(theme.fg("dim", "Task: " + progress.task)));
|
|
126
232
|
}
|
|
127
233
|
|
|
128
234
|
// Tool calls history
|
|
129
235
|
if (progress.toolCalls && progress.toolCalls.length > 0) {
|
|
130
|
-
lines.push("");
|
|
236
|
+
lines.push(row(""));
|
|
131
237
|
for (const call of progress.toolCalls) {
|
|
132
|
-
lines.push(formatToolCall(call, theme));
|
|
238
|
+
lines.push(block(formatToolCall(call, theme)));
|
|
133
239
|
}
|
|
134
240
|
}
|
|
135
241
|
|
|
136
242
|
// Activity (current tool, marked with the running glyph ▸)
|
|
137
243
|
if (progress.currentTool) {
|
|
138
|
-
lines.push("");
|
|
139
|
-
const argsPreview = progress.currentToolArgs
|
|
140
|
-
? truncLine(progress.currentToolArgs, 60)
|
|
141
|
-
: "";
|
|
244
|
+
lines.push(row(""));
|
|
245
|
+
const argsPreview = progress.currentToolArgs ?? "";
|
|
142
246
|
const durationPart = progress.currentToolStartedAt
|
|
143
247
|
? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
|
|
144
248
|
: "";
|
|
145
249
|
const suffix = (argsPreview ? ": " + argsPreview : "") + durationPart;
|
|
146
|
-
lines.push(renderToolCallLine("running", progress.currentTool, suffix, theme));
|
|
250
|
+
lines.push(row(renderToolCallLine("running", progress.currentTool, suffix, theme)));
|
|
147
251
|
}
|
|
148
252
|
|
|
149
|
-
// Status at bottom
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
lines.push(
|
|
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`.
|
|
255
|
+
if (progress.status === "completed" || progress.outcome) {
|
|
256
|
+
lines.push(row(""));
|
|
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
|
+
));
|
|
153
264
|
} else if (progress.status === "failed") {
|
|
154
|
-
lines.push("");
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
265
|
+
lines.push(row(""));
|
|
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
|
+
));
|
|
160
272
|
}
|
|
161
273
|
|
|
162
|
-
|
|
163
|
-
return
|
|
274
|
+
rows.setSegments(lines);
|
|
275
|
+
return rows;
|
|
164
276
|
}
|
|
165
277
|
|
|
166
278
|
// ── Expanded parallel streaming progress ────────────────────────────────────
|
|
@@ -168,13 +280,13 @@ export function renderProgressExpanded(
|
|
|
168
280
|
export function buildProgressExpandedText(
|
|
169
281
|
progress: SubagentProgress,
|
|
170
282
|
theme: Theme,
|
|
171
|
-
):
|
|
172
|
-
const lines:
|
|
283
|
+
): RowSegment[] {
|
|
284
|
+
const lines: RowSegment[] = [];
|
|
173
285
|
|
|
174
|
-
lines.push(buildAgentLabel(progress.agent, progress.task, theme));
|
|
175
|
-
lines.push("");
|
|
286
|
+
lines.push(row(buildAgentLabel(progress.agent, progress.task, theme)));
|
|
287
|
+
lines.push(row(""));
|
|
176
288
|
|
|
177
|
-
// Stats line (same as compact view)
|
|
289
|
+
// Stats line (same as compact view) — a block: see buildExpandedText.
|
|
178
290
|
const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
|
|
179
291
|
const statsLine = buildStatsLine({
|
|
180
292
|
turns: undefined,
|
|
@@ -185,28 +297,26 @@ export function buildProgressExpandedText(
|
|
|
185
297
|
}, theme);
|
|
186
298
|
const statsParts = [modelLabel, statsLine].filter(Boolean);
|
|
187
299
|
if (statsParts.length > 0) {
|
|
188
|
-
lines.push(statsParts.join(" "));
|
|
300
|
+
lines.push(block(statsParts.join(" ")));
|
|
189
301
|
}
|
|
190
302
|
|
|
191
303
|
// Task
|
|
192
304
|
if (progress.task) {
|
|
193
|
-
lines.push("");
|
|
194
|
-
lines.push(theme.fg("dim", "Task: " + progress.task));
|
|
305
|
+
lines.push(row(""));
|
|
306
|
+
lines.push(block(theme.fg("dim", "Task: " + progress.task)));
|
|
195
307
|
}
|
|
196
308
|
|
|
197
309
|
// Tool calls history
|
|
198
310
|
if (progress.toolCalls && progress.toolCalls.length > 0) {
|
|
199
|
-
lines.push("");
|
|
311
|
+
lines.push(row(""));
|
|
200
312
|
for (const call of progress.toolCalls) {
|
|
201
|
-
lines.push(formatToolCall(call, theme));
|
|
313
|
+
lines.push(block(formatToolCall(call, theme)));
|
|
202
314
|
}
|
|
203
315
|
}
|
|
204
316
|
|
|
205
317
|
// Activity
|
|
206
318
|
if (progress.currentTool) {
|
|
207
|
-
const argsPreview = progress.currentToolArgs
|
|
208
|
-
? truncLine(progress.currentToolArgs, 60)
|
|
209
|
-
: "";
|
|
319
|
+
const argsPreview = progress.currentToolArgs ?? "";
|
|
210
320
|
const durationPart = progress.currentToolStartedAt
|
|
211
321
|
? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
|
|
212
322
|
: "";
|
|
@@ -217,22 +327,32 @@ export function buildProgressExpandedText(
|
|
|
217
327
|
if (durationPart) {
|
|
218
328
|
line += theme.fg("dim", durationPart);
|
|
219
329
|
}
|
|
220
|
-
lines.push("");
|
|
221
|
-
lines.push(line);
|
|
330
|
+
lines.push(row(""));
|
|
331
|
+
lines.push(row(line));
|
|
222
332
|
}
|
|
223
333
|
|
|
224
|
-
// Status at bottom
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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`.
|
|
338
|
+
if (progress.status === "completed" || progress.outcome) {
|
|
339
|
+
lines.push(row(""));
|
|
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
|
+
));
|
|
228
347
|
} else if (progress.status === "failed") {
|
|
229
|
-
lines.push("");
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
348
|
+
lines.push(row(""));
|
|
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
|
+
));
|
|
235
355
|
}
|
|
236
356
|
|
|
237
|
-
return lines
|
|
357
|
+
return lines;
|
|
238
358
|
}
|
package/src/format.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { formatParallelResults } from "./format.js";
|
|
3
3
|
import type { SubagentResult } from "./types.js";
|
|
4
4
|
|
|
5
5
|
function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
|
|
@@ -17,33 +17,14 @@ function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// ──
|
|
20
|
+
// ── Row truncation: moved ─────────────────────────────────────────────────
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(truncLine("hello world", 8)).toBe("hello...");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("stops at newline boundary instead of bleeding into next line", () => {
|
|
32
|
-
expect(truncLine("line one\nline two\nline three", 15)).toBe("line one...");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("truncates first line if it itself exceeds limit", () => {
|
|
36
|
-
expect(truncLine("this is a very long first line\nsecond", 12)).toBe("this is a...");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("handles multiple consecutive newlines", () => {
|
|
40
|
-
expect(truncLine("a\n\n\nb", 10)).toBe("a...");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("handles text starting with newline", () => {
|
|
44
|
-
expect(truncLine("\nhello", 10)).toBe("...");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
22
|
+
// The character cap this section used to pin (`truncLine`, at 60/80 characters)
|
|
23
|
+
// is gone. Rows are now cut at the width pi renders them at, which keeps the two
|
|
24
|
+
// properties the cap existed for — the ellipsis stays inside the styled span, and
|
|
25
|
+
// a newline is a hard boundary — while letting a row fill the frame. Those
|
|
26
|
+
// properties are pinned with measured bytes in `row-block.test.ts`, and the width
|
|
27
|
+
// contract itself in `tool-row.width-audit.test.ts`.
|
|
47
28
|
|
|
48
29
|
describe("formatParallelResults", () => {
|
|
49
30
|
it("renders header + body per task with exactly one blank line between sections", () => {
|
|
@@ -90,9 +71,9 @@ describe("formatParallelResults", () => {
|
|
|
90
71
|
expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nspawn failed");
|
|
91
72
|
});
|
|
92
73
|
|
|
93
|
-
it("prefers finalOutput
|
|
74
|
+
it("prefers finalOutput as the body when both are set, and still marks the failure", () => {
|
|
94
75
|
const r = makeResult({ finalOutput: "out", error: "boom" });
|
|
95
|
-
expect(formatParallelResults([r])).toBe("
|
|
76
|
+
expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nout");
|
|
96
77
|
});
|
|
97
78
|
|
|
98
79
|
it("keeps internal newlines in a multi-line body and trims only the tail", () => {
|
|
@@ -117,3 +98,64 @@ describe("formatParallelResults", () => {
|
|
|
117
98
|
);
|
|
118
99
|
});
|
|
119
100
|
});
|
|
101
|
+
|
|
102
|
+
describe("formatParallelResults: run outcomes", () => {
|
|
103
|
+
it("says truncated on the header row of a cut-off result", () => {
|
|
104
|
+
const out = formatParallelResults([
|
|
105
|
+
makeResult({ outcome: "truncated", finalOutput: "partial answer" }),
|
|
106
|
+
]);
|
|
107
|
+
expect(out.split("\n")[0]).toMatch(/truncated/i);
|
|
108
|
+
expect(out).toContain("partial answer");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("marks a run that failed at exit 0 with ✗, because the exit code is not the signal", () => {
|
|
112
|
+
// `--mode json` — what the child actually runs — never maps `stopReason`
|
|
113
|
+
// onto the exit code (that mapping is print mode's text branch), so a child
|
|
114
|
+
// whose last response errored exits **0**. `streamEvents` reports that as
|
|
115
|
+
// `error` with no `outcome`; a header that read only `exitCode` handed the
|
|
116
|
+
// calling model a ✓ for a run that produced nothing, while `index.ts` set
|
|
117
|
+
// `isError` on the very same result.
|
|
118
|
+
const out = formatParallelResults([
|
|
119
|
+
makeResult({ exitCode: 0, error: "401 unauthorized" }),
|
|
120
|
+
]);
|
|
121
|
+
expect(out.split("\n")[0]?.startsWith("✗")).toBe(true);
|
|
122
|
+
expect(out).toContain("401 unauthorized");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("marks a run the parent cancelled through the bridge (exit 0, error set)", () => {
|
|
126
|
+
// The bridge contract: a local cancel settles as a FAILED result carrying
|
|
127
|
+
// error "cancelled" and exitCode 0 — same shape, same wrong ✓ without the
|
|
128
|
+
// error check.
|
|
129
|
+
const out = formatParallelResults([
|
|
130
|
+
makeResult({ exitCode: 0, error: "cancelled" }),
|
|
131
|
+
]);
|
|
132
|
+
expect(out.split("\n")[0]?.startsWith("✗")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("never shows a success marker for a result the tool reports as an error", () => {
|
|
136
|
+
// The single assertion that ties the two halves together: `index.ts` decides
|
|
137
|
+
// `isError` from `error`, so the header has to agree with it.
|
|
138
|
+
const out = formatParallelResults([
|
|
139
|
+
makeResult({ exitCode: 0, error: "boom" }),
|
|
140
|
+
makeResult({ agent: "ok", exitCode: 0, finalOutput: "fine" }),
|
|
141
|
+
]);
|
|
142
|
+
const rows = out.split("\n").filter((l) => l.startsWith("✓") || l.startsWith("✗"));
|
|
143
|
+
expect(rows[0]).toContain("✗");
|
|
144
|
+
expect(rows[1]).toContain("✓");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("says aborted on the header row of a cancelled result", () => {
|
|
148
|
+
const out = formatParallelResults([makeResult({ outcome: "aborted" })]);
|
|
149
|
+
expect(out.split("\n")[0]).toMatch(/aborted/i);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("leaves a completed header byte-identical to the pre-outcome contract", () => {
|
|
153
|
+
const r = makeResult({
|
|
154
|
+
outcome: "completed",
|
|
155
|
+
agent: "reviewer",
|
|
156
|
+
progressSummary: { toolCount: 4, tokens: 2000, durationMs: 12000 },
|
|
157
|
+
finalOutput: "Looks good.",
|
|
158
|
+
});
|
|
159
|
+
expect(formatParallelResults([r])).toBe("✓ reviewer 4 tools · 2k tok · 12s\nLooks good.");
|
|
160
|
+
});
|
|
161
|
+
});
|