@pi-archimedes/subagent 2.10.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { truncLine, formatParallelResults } from "./format.js";
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
- // ── truncLine ───────────────────────────────────────────────────────────────
20
+ // ── Row truncation: moved ─────────────────────────────────────────────────
21
21
 
22
- describe("truncLine", () => {
23
- it("returns text unchanged when within limit", () => {
24
- expect(truncLine("hello", 10)).toBe("hello");
25
- });
26
-
27
- it("truncates with '...' when exceeding limit", () => {
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 over error when both are set", () => {
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("✓ test-agent 5 tools · 0k tok · 3s\nout");
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
+ });
package/src/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SubagentResult } from "./types.js";
1
+ import type { RunOutcome, SubagentResult } from "./types.js";
2
2
 
3
3
  export function formatTokens(n: number): string {
4
4
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
@@ -22,27 +22,6 @@ export function formatCost(cost: number): string {
22
22
  return "$" + cost.toFixed(2);
23
23
  }
24
24
 
25
- /**
26
- * Truncate plain text to a max character length, appending "..." if truncated.
27
- *
28
- * Unlike truncateToWidth from pi-tui, this does not handle wide characters or
29
- * measure ANSI display width. The benefit: the "..." is plain text so it
30
- * inherits any ANSI color wrapper applied around the result (pi-tui's
31
- * truncateToWidth appends "..." after closing color codes, leaving it unstyled).
32
- *
33
- * If the text contains newlines, truncates at the first newline boundary
34
- * rather than bleeding into the next line.
35
- */
36
- export function truncLine(text: string, maxLen: number): string {
37
- // Check for newline first — it's a hard boundary regardless of length
38
- const nlIdx = text.indexOf("\n");
39
- if (nlIdx !== -1) {
40
- return text.slice(0, nlIdx).slice(0, maxLen - 3) + "...";
41
- }
42
- if (text.length <= maxLen) return text;
43
- return text.slice(0, maxLen - 3) + "...";
44
- }
45
-
46
25
  /**
47
26
  * Renders one section per parallel result. The header line is intentionally
48
27
  * byte-identical to the pre-#55 metrics summary (a contract, not an
@@ -50,20 +29,84 @@ export function truncLine(text: string, maxLen: number): string {
50
29
  * (finalOutput ?? error ?? "completed"), `trimEnd()`-ed so sections stay
51
30
  * separated by exactly one blank line, and is deliberately untruncated by
52
31
  * design (see docs/decisions/0020-subagent-parallel-full-output.md).
32
+ *
33
+ * A run that merely completed keeps that header exactly as it was. A truncated
34
+ * or aborted one says so on the row, because a "✓" the calling model has no way
35
+ * to check would be the worse kind of lie.
36
+ *
37
+ * The failure check reads `error`, not the exit code, and comes before the
38
+ * success marker: a `--mode json` child whose last response errored **exits 0**
39
+ * (`stream.ts` turns that into `error` with no `outcome`), and `index.ts` calls
40
+ * the same result `isError`. A header keyed off `exitCode` alone told the model
41
+ * the opposite of the flag on the tool result it was reading.
53
42
  */
54
43
  export function formatParallelResults(results: SubagentResult[]): string {
55
44
  const sections = results.map((r) => {
56
- const status = r.exitCode === 0 ? "✓" : "✗";
45
+ const status =
46
+ r.outcome === "truncated"
47
+ ? "⚠ truncated"
48
+ : r.outcome === "aborted"
49
+ ? "⚠ aborted"
50
+ : r.error || r.exitCode !== 0
51
+ ? "✗"
52
+ : "✓";
57
53
  const summary = r.progressSummary
58
54
  ? `${r.progressSummary.toolCount} tools · ${Math.round(r.progressSummary.tokens / 1000)}k tok · ${Math.round(r.progressSummary.durationMs / 1000)}s`
59
55
  : "";
60
56
  const header = `${status} ${r.agent}${summary ? " " + summary : ""}`;
61
- const body = (r.finalOutput ?? r.error ?? "completed").trimEnd();
57
+ const body = (r.finalOutput ?? r.error ?? fallbackBody(r)).trimEnd();
62
58
  return `${header}\n${body}`;
63
59
  });
64
60
  return sections.join("\n\n");
65
61
  }
66
62
 
63
+ /** What to say when a result has neither output nor an error to show. */
64
+ function fallbackBody(result: SubagentResult): string {
65
+ return result.outcome ? "(no output)" : "completed";
66
+ }
67
+
68
+ /**
69
+ * The single-run result text the calling model reads.
70
+ *
71
+ * The outcome is spelled out in words as well as carried in
72
+ * `details.results[].outcome`, because `content` is the half of the contract the
73
+ * model is guaranteed to see — and "this answer was cut off" is not something it
74
+ * can recover from the answer itself.
75
+ */
76
+ export function formatSingleResult(result: SubagentResult): string {
77
+ const body = result.finalOutput ?? result.error ?? fallbackBody(result);
78
+ const note =
79
+ result.outcome === "truncated"
80
+ ? "[truncated: the subagent reached its output token limit — the answer above is partial]"
81
+ : result.outcome === "aborted"
82
+ ? "[aborted: the subagent run was cancelled — anything above is partial]"
83
+ : undefined;
84
+ return note ? `${body}\n\n${note}` : body;
85
+ }
86
+
87
+ /**
88
+ * The finished-run row, shared by the compact and expanded views.
89
+ *
90
+ * `undefined` means "nothing to report yet" (the run is still going), which is
91
+ * what leaves the callers' running / tool-call rendering untouched.
92
+ *
93
+ * The error text is not capped here: rows are cut at the width pi renders them
94
+ * at, by `RowBlock` (see `row-block.ts`). A fixed character cap is what made
95
+ * these rows die at a third of the frame on a wide terminal.
96
+ */
97
+ export function buildOutcomeLine(
98
+ data: { outcome?: RunOutcome | undefined; error: string | undefined },
99
+ theme: { fg: (token: string, text: string) => string },
100
+ ): string | undefined {
101
+ if (data.error) return theme.fg("error", "✗ " + data.error);
102
+ if (data.outcome === "truncated") {
103
+ return theme.fg("warning", "⚠ Truncated — hit the output token limit, answer is partial");
104
+ }
105
+ if (data.outcome === "aborted") return theme.fg("warning", "⚠ Aborted");
106
+ if (data.outcome === "completed") return theme.fg("success", "✓ Done");
107
+ return undefined;
108
+ }
109
+
67
110
  export interface StatsData {
68
111
  turns: number | undefined;
69
112
  toolCount: number | undefined;
@@ -96,9 +139,9 @@ export function buildStatsLine(
96
139
  return parts.map(p => theme.fg("dim", "· " + p)).join(" ");
97
140
  }
98
141
 
99
- // Build an agent label for display: "agentName: truncated task preview".
100
- // Truncation width of 60 keeps compact rows readable on standard terminal widths
101
- // while still showing meaningful task context.
142
+ // Build an agent label for display: "agentName: task preview".
143
+ // The task is passed through whole: the row it lands on is cut at the render
144
+ // width by `RowBlock`, so the preview fills whatever frame pi draws it in.
102
145
  export function buildAgentLabel(
103
146
  agent: string,
104
147
  task: string | undefined,
@@ -106,7 +149,7 @@ export function buildAgentLabel(
106
149
  ): string {
107
150
  const name = theme.bold(agent);
108
151
  if (task) {
109
- return name + theme.fg("dim", ": " + truncLine(task, 60));
152
+ return name + theme.fg("dim", ": " + task);
110
153
  }
111
154
  return name;
112
155
  }
@@ -1,6 +1,13 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import * as fc from "fast-check";
3
3
  import { validateAgentName, serializeAgent, AGENT_NAME_REGEX } from "./frontmatter-io.js";
4
+ // Imported here rather than inside the tests below: nothing in this file mocks
5
+ // `parseFrontmatter`, so a dynamic `await import()` in a test body only served
6
+ // to charge whichever test ran first with loading the whole pi-coding-agent
7
+ // package — measured at 2.6s alone, 6-7.8s on a loaded machine, against a 5s
8
+ // per-test timeout. That is the "Test timed out in 5000ms" flake, and it lands
9
+ // on collection instead, where no test budget is spent.
10
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
4
11
  import type { AgentConfig } from "./agents.js";
5
12
 
6
13
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -117,3 +124,58 @@ describe("serializeAgent", () => {
117
124
  );
118
125
  });
119
126
  });
127
+
128
+ // ── mcp round-trip (the writer's half of ADR 0028) ──────────────────────────
129
+
130
+ describe("serializeAgent: mcp opt-in", () => {
131
+ it("writes the mcp key when the agent opts in", () => {
132
+ const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
133
+ expect(output).toContain("mcp: true");
134
+ });
135
+
136
+ it("writes no mcp key when the field is absent or false (closed is the default)", () => {
137
+ expect(serializeAgent(makeAgent({ tools: ["read"] }))).not.toContain("mcp:");
138
+ expect(serializeAgent(makeAgent({ tools: ["read"], mcp: false }))).not.toContain("mcp:");
139
+ });
140
+
141
+ it("survives a write → read round trip as a real boolean, not a string", () => {
142
+ const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
143
+ const { frontmatter } = parseFrontmatter(output);
144
+ expect(frontmatter.mcp).toBe(true);
145
+ });
146
+
147
+ it("keeps the tools list intact alongside the mcp key", () => {
148
+ const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
149
+ expect(output).toContain("tools: read, bash");
150
+ expect(output).toContain("mcp: true");
151
+ });
152
+ });
153
+
154
+ // ── One `tools:` key, whatever the config object looks like ───────────────
155
+
156
+ describe("serializeAgent: never writes two tools keys", () => {
157
+ // The read path preserves an unreadable `tools:` in `extraFields` so a save
158
+ // cannot delete a line the user typed, while `applyToolEntries` owns the real
159
+ // `tools` field. If both are populated the file gets two `tools:` keys and the
160
+ // last one wins on the next read — which is the broken one.
161
+ const toolsKeys = (yaml: string): string[] =>
162
+ yaml.split("\n").filter((line) => /^tools:/.test(line));
163
+
164
+ it("writes the real field and drops the preserved raw value when both exist", () => {
165
+ const output = serializeAgent(
166
+ makeAgent({ tools: ["read"], extraFields: { tools: "read, +web_search", owner: "x" } }),
167
+ );
168
+ expect(toolsKeys(output)).toEqual(["tools: read"]);
169
+ expect(parseFrontmatter(output).frontmatter.tools).toBe("read");
170
+ // The unrelated extra still round-trips.
171
+ expect(output).toContain("owner: x");
172
+ });
173
+
174
+ it("still writes a preserved raw value when there is no parsed list to replace it", () => {
175
+ // This is the preservation the read path promised: an agent whose `tools:`
176
+ // was rejected keeps its line on save, rather than being silently rewritten
177
+ // without it.
178
+ const output = serializeAgent(makeAgent({ extraFields: { tools: "read, +web_search" } }));
179
+ expect(toolsKeys(output)).toEqual(["tools: read, +web_search"]);
180
+ });
181
+ });
@@ -65,8 +65,17 @@ export function serializeAgent(config: AgentConfig): string {
65
65
  lines.push(`description: ${quoteYamlValue(config.description)}`);
66
66
 
67
67
  // Optional known fields
68
- if (config.tools && config.tools.length > 0) {
69
- lines.push(`tools: ${quoteYamlValue(config.tools.join(", "))}`);
68
+ const toolsLine =
69
+ config.tools && config.tools.length > 0
70
+ ? `tools: ${quoteYamlValue(config.tools.join(", "))}`
71
+ : undefined;
72
+ if (toolsLine !== undefined) {
73
+ lines.push(toolsLine);
74
+ }
75
+ // Only the opt-in is written: a closed allowlist is the default, so the
76
+ // absence of the key is the meaningful statement (ADR 0028).
77
+ if (config.mcp === true) {
78
+ lines.push("mcp: true");
70
79
  }
71
80
  if (config.model) {
72
81
  lines.push(`model: ${quoteYamlValue(config.model)}`);
@@ -78,6 +87,11 @@ export function serializeAgent(config: AgentConfig): string {
78
87
  // Extra fields (sorted alphabetically)
79
88
  if (config.extraFields) {
80
89
  for (const key of Object.keys(config.extraFields).sort()) {
90
+ // `extraFields.tools` is the read path's copy of a `tools:` line it could
91
+ // not read, kept so a save cannot delete it. It only gets to speak for
92
+ // `tools:` when there is no parsed list to speak for it — otherwise this
93
+ // loop writes a second `tools:` key and the later one wins on reload.
94
+ if (key === "tools" && toolsLine !== undefined) continue;
81
95
  const value = config.extraFields[key]!;
82
96
  if (typeof value === "string") {
83
97
  lines.push(`${key}: ${quoteYamlValue(value)}`);
@@ -4,9 +4,27 @@ import { extractArgsPreview } from "./handlers.js";
4
4
  // ── extractArgsPreview ──────────────────────────────────────────────────────
5
5
 
6
6
  describe("extractArgsPreview", () => {
7
- it("returns string args truncated", () => {
8
- const long = "a".repeat(200);
9
- expect(extractArgsPreview(long)).toBe("a".repeat(120));
7
+ // The preview feeds a transcript row, and the row is what decides how much of
8
+ // it fits (`RowBlock` cuts at the width pi renders at). A cap here is therefore
9
+ // a second, invisible limit: cut it tighter than any terminal is wide and the
10
+ // row stops short of the frame with no cut marker, which is the defect #108
11
+ // set out to remove. pi's own bash row keeps the command whole for the same
12
+ // reason. It stays bounded, because the preview is carried in the streamed
13
+ // progress object for the life of the run.
14
+ it("keeps enough of a long command for a wide frame to draw", () => {
15
+ const long = "a".repeat(300);
16
+ expect(extractArgsPreview(long)).toBe(long);
17
+ });
18
+
19
+ it("still bounds what it returns, since the preview is streamed", () => {
20
+ const preview = extractArgsPreview("a".repeat(100_000));
21
+ expect(preview.length).toBeLessThan(1_000);
22
+ });
23
+
24
+ it("bounds the serialised form of object args too", () => {
25
+ const preview = extractArgsPreview({ a: "b".repeat(100_000), c: 1 });
26
+ expect(preview.length).toBeLessThan(1_000);
27
+ expect(preview.length).toBeGreaterThanOrEqual(300);
10
28
  });
11
29
 
12
30
  it("replaces newlines in string args", () => {
package/src/handlers.ts CHANGED
@@ -1,7 +1,19 @@
1
1
  import type { StreamState } from "./types.js";
2
2
 
3
3
  // Truncation limits for previews
4
- const ARGS_PREVIEW_MAX = 120;
4
+ //
5
+ // The args preview is drawn on a transcript row, and the row is what decides how
6
+ // much of it fits: `RowBlock` cuts at the width pi hands `render()` (see
7
+ // `row-block.ts`). A cap here is therefore a second, invisible character limit —
8
+ // the very defect #108 removed — because a preview shorter than the frame ends
9
+ // mid-word with no cut marker. pi's own bash row keeps the command whole for the
10
+ // same reason (`formatShellCall`). So this bound sits above anything a terminal
11
+ // can draw, and exists only to keep the preview from growing the streamed
12
+ // progress object without limit (it is carried for the life of the run).
13
+ const ARGS_PREVIEW_MAX = 512;
14
+ // `recentOutput` is carried in the progress object but drawn by no row, so it
15
+ // keeps the old, tighter bound: widening it would only grow what is streamed.
16
+ const OUTPUT_LINE_MAX = 120;
5
17
  const TOOL_CALLS_MAX = 50;
6
18
  const RECENT_OUTPUT_MAX = 50;
7
19
 
@@ -97,13 +109,13 @@ export function handleToolResult(state: StreamState, event: JsonEvent): void {
97
109
 
98
110
  if (typeof content === "string" && content.trim()) {
99
111
  const lines = content.split("\n").filter((l) => l.trim());
100
- state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, ARGS_PREVIEW_MAX)}`);
112
+ state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, OUTPUT_LINE_MAX)}`);
101
113
  } else if (Array.isArray(content)) {
102
114
  for (const part of content) {
103
115
  if (part.type === "text" && (part.text as string)?.trim()) {
104
116
  const text = part.text as string;
105
117
  const lines = text.split("\n").filter((l) => l.trim());
106
- state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, ARGS_PREVIEW_MAX)}`);
118
+ state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, OUTPUT_LINE_MAX)}`);
107
119
  break;
108
120
  }
109
121
  }
@@ -126,6 +138,13 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
126
138
  state.model = message.model as string;
127
139
  }
128
140
 
141
+ // How this response ended. The LAST assistant message's stopReason is what
142
+ // decides the run's outcome (`length` ⇒ truncated, `aborted` ⇒ cancelled,
143
+ // `error` ⇒ failed) — the exit code cannot tell them apart.
144
+ if (typeof message.stopReason === "string") {
145
+ state.lastStopReason = message.stopReason;
146
+ }
147
+
129
148
  // Collect text + thinking output
130
149
  const content = message.content as Array<Record<string, unknown>> | string | undefined;
131
150
  if (typeof content === "string" && content.trim()) {