@aaroncql/pim-agent 0.0.1 → 0.1.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 (60) hide show
  1. package/README.md +19 -8
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/bash/capture.test.ts +0 -126
  6. package/src/extensions/bash/format.test.ts +0 -240
  7. package/src/extensions/bash/run.test.ts +0 -262
  8. package/src/extensions/command-picker/ranker.test.ts +0 -46
  9. package/src/extensions/edit/edit.test.ts +0 -285
  10. package/src/extensions/file-picker/catalog.test.ts +0 -263
  11. package/src/extensions/file-picker/index.test.ts +0 -168
  12. package/src/extensions/file-picker/ranker.test.ts +0 -94
  13. package/src/extensions/footer/git.test.ts +0 -76
  14. package/src/extensions/footer/index.test.ts +0 -161
  15. package/src/extensions/footer/segments.test.ts +0 -164
  16. package/src/extensions/glob/glob.test.ts +0 -171
  17. package/src/extensions/glob/index.test.ts +0 -68
  18. package/src/extensions/glob/render.test.ts +0 -126
  19. package/src/extensions/grep/grep.test.ts +0 -387
  20. package/src/extensions/grep/index.test.ts +0 -68
  21. package/src/extensions/grep/render.test.ts +0 -269
  22. package/src/extensions/read/read.test.ts +0 -177
  23. package/src/extensions/read/render.test.ts +0 -61
  24. package/src/extensions/subagent/index.test.ts +0 -44
  25. package/src/extensions/subagent/render.test.ts +0 -292
  26. package/src/extensions/subagent/subagent.test.ts +0 -315
  27. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  28. package/src/extensions/todo/index.test.ts +0 -244
  29. package/src/extensions/todo/render.test.ts +0 -180
  30. package/src/extensions/todo/todo.test.ts +0 -222
  31. package/src/extensions/tps/index.test.ts +0 -254
  32. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  33. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  34. package/src/extensions/web-fetch/render.test.ts +0 -56
  35. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  36. package/src/extensions/web-search/render.test.ts +0 -21
  37. package/src/extensions/web-search/search.test.ts +0 -53
  38. package/src/extensions/working-indicator/index.test.ts +0 -21
  39. package/src/extensions/write/render.test.ts +0 -64
  40. package/src/extensions/write/write.test.ts +0 -108
  41. package/src/shared/DiffLines.test.ts +0 -193
  42. package/src/shared/DiffRenderer.test.ts +0 -206
  43. package/src/shared/EditMatcher.test.ts +0 -123
  44. package/src/shared/FileScanner.test.ts +0 -158
  45. package/src/shared/FuzzyMatcher.test.ts +0 -114
  46. package/src/shared/GitignoreFilter.test.ts +0 -64
  47. package/src/shared/Lines.test.ts +0 -25
  48. package/src/shared/McpClient.test.ts +0 -235
  49. package/src/shared/OutputBudget.test.ts +0 -99
  50. package/src/shared/Paths.test.ts +0 -51
  51. package/src/shared/PimSettings.test.ts +0 -90
  52. package/src/shared/Renderer.test.ts +0 -190
  53. package/src/shared/SpillCache.test.ts +0 -94
  54. package/src/shared/Tools.test.ts +0 -392
  55. package/src/telegram/Config.test.ts +0 -275
  56. package/src/telegram/Markdown.test.ts +0 -143
  57. package/src/telegram/Renderer.test.ts +0 -216
  58. package/src/telegram/SessionRegistry.test.ts +0 -89
  59. package/src/telegram/TaskScheduler.test.ts +0 -278
  60. package/src/telegram/TaskTool.test.ts +0 -179
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  _**Pim is to Pi what Vim is to Vi.**_
5
5
 
6
- An opinionated yet minimal, Bun-native extension pack for [Pi](https://pi.dev/): revamped tools, ANSI-compatible themes, fzf-style autocompletions, Telegram bot, and more. Scores up to [41.6% on Terminal-Bench 2.0](#terminal-bench-20) with locally hosted Qwen3.6-35B, matching Claude Code + Sonnet 4.5.
6
+ A Bun-native extension pack for [Pi](https://pi.dev/): web access, subagents, revamped core tools, ANSI-compatible themes, fzf-style completions, Telegram mode, and more. Preliminary score of [37.8% on Terminal-Bench 2.0](#terminal-bench-20) with locally hosted Qwen3.6-35B, rivalling Claude Code + Sonnet 4.5.
7
7
 
8
8
  - [Quick Start](#quick-start)
9
9
  - [API Keys (Optional)](#api-keys-optional)
@@ -19,6 +19,8 @@ An opinionated yet minimal, Bun-native extension pack for [Pi](https://pi.dev/):
19
19
  - [Terminal-Bench 2.0](#terminal-bench-20)
20
20
  - [Developing](#developing)
21
21
 
22
+ ![Pim Demo](https://raw.githubusercontent.com/AaronCQL/pim-agent/refs/heads/main/assets/demo.webp)
23
+
22
24
  ## Quick Start
23
25
 
24
26
  > [!IMPORTANT]
@@ -37,6 +39,12 @@ pim
37
39
 
38
40
  The `pim` command is a [thin Bun launcher](./bin/pim.ts) that wraps around `pi` so that Bun-specific tooling and APIs can be used. Other Pi extensions should continue to work normally.
39
41
 
42
+ If `pim` cannot locate Pi, make sure `pi` is on your `PATH`, or set:
43
+
44
+ ```sh
45
+ PIM_PI_CLI=/path/to/pi/dist/cli.js pim
46
+ ```
47
+
40
48
  ### API Keys (Optional)
41
49
 
42
50
  Pim's web tools use [Exa](https://exa.ai) for searching the web and [Jina](https://jina.ai/reader/) for fetching websites as Markdown. Without API keys, the tools are subject to the following rate limits (as of May 2026):
@@ -96,7 +104,7 @@ Pim also ships with quality of life improvements for the TUI:
96
104
  - **fzf-style autocomplete** - `@path` file picker and `/command` picker with fuzzy search
97
105
  - **Git-aware powerline footer** - current dir, git branch and states, context usage, model and session cost (toggle with `/powerline`)
98
106
  - **TPS reporting** - per-cycle decode/prefill rate, TTFT, and cache read tokens (toggle with `/tps`)
99
- - **Concise tool headers** - minimal one-liner title across all tool calls, `Ctrl+O` to toggle full details
107
+ - **Concise tool UI** - minimal one-liner title across all tool calls, `Ctrl+O` to toggle full details
100
108
 
101
109
  ## Telegram Bot
102
110
 
@@ -155,22 +163,25 @@ For development, run standalone with `pim --mode telegram` instead.
155
163
 
156
164
  ## Why Pim?
157
165
 
166
+ Pim's philosophy is **opinionated but minimal**. Its goal is to improve the out-of-the-box experience for both users and agents, without sacrificing composability with other Pi extensions.
167
+
158
168
  ### Harness Design
159
169
 
160
- Pim overrides Pi's default tools (`bash`, `read`, `write`, `edit`) so that all tools produce consistent, structured output for the model, cross-reference each other where useful, and render uniformly in the TUI.
170
+ Pim overrides Pi's default tools (`bash`, `read`, `write`, `edit`) so that all tools produce consistent behaviour and output for the model, cross-reference each other where useful, and render uniformly in the TUI.
161
171
 
162
172
  The system prompt is also kept as minimal as possible: at just ~3K tokens despite having 10+ tools (vs OpenCode's ~10K, Hermes' ~16K), with tool descriptions focusing on _how_ to use each tool instead of prescribing _when_. The rationale is that models already appear to internally encode when tools are needed, and prompting them to call tools can [suppress both necessary and unnecessary calls](https://arxiv.org/abs/2605.09252).
163
173
 
164
174
  ### Terminal-Bench 2.0
165
175
 
166
- Preliminary results from two full runs:
167
-
168
176
  | ID | Pim Version | LLM / Model | Results |
169
177
  | --- | --- | --- | --- |
170
178
  | [r1](./benchmarks/terminal_bench_2/results/r1/) | [`21d084d1`](https://github.com/AaronCQL/pim-agent/tree/21d084d1) | `Qwen3.6-35B-A3B-UD-Q6_K_XL.gguf` | **41.6%** (37/89) |
171
179
  | [r2](./benchmarks/terminal_bench_2/results/r2/) | [`bfd792cf`](https://github.com/AaronCQL/pim-agent/tree/bfd792cf) | `Qwen3.6-35B-A3B-UD-Q6_K_XL.gguf` | **36.0%** (32/89) |
180
+ | [r3](./benchmarks/terminal_bench_2/results/r3/) | [`cd52f3a4`](https://github.com/AaronCQL/pim-agent/tree/cd52f3a4) | `Qwen3.6-35B-A3B-UD-Q6_K_XL.gguf` | **36.0%** (32/89) |
181
+
182
+ Preliminary aggregate score of **37.8%** from 3 independent runs. Each ran on an incremental build of pim, though changes between runs were minor and none were tuned to the benchmark.
172
183
 
173
- Comparing against the same Qwen3.6-35B model, Pim solves up to **70% more tasks** than [little-coder](https://github.com/itayinbarr/little-coder) (41.6% vs 24.6%). This puts Pim, with a locally hosted model, in a similar tier to Claude Code + Sonnet 4.5 (40.1%) and above Codex + GPT-5-Mini (31.9%).
184
+ On average, Pim solves **~54% more tasks** than [little-coder](https://github.com/itayinbarr/little-coder) with the same Qwen3.6-35B model (37.8% vs 24.6%). This also places Pim in a similar tier to Claude Code + Sonnet 4.5 (40.1%), and above Codex + GPT-5-Mini (31.9%).
174
185
 
175
186
  The Qwen3.6-35B model is hosted via llama.cpp on an M4 Pro 48GB MacBook, with the following config:
176
187
 
@@ -194,11 +205,11 @@ llama-server \
194
205
  -np 1
195
206
  ```
196
207
 
197
- _Note 1_: results are preliminary as only 2 independent full runs were conducted; Terminal-Bench 2.0 requires 5 independent full runs for an official score.
208
+ _Note 1_: results are preliminary as only 3 independent full runs were conducted; Terminal-Bench 2.0 requires 5 independent full runs under a fixed configuration for an official score.
198
209
 
199
210
  _Note 2_: the gap with little-coder may be partly explained by different inference configs (128K context vs 32K, Q6_K_XL vs Q4_K_M, higher thinking budget, etc.).
200
211
 
201
- _Note 3_: in r1, the recorded result is actually 1 higher at 38/89. However, the `code-from-image` trial was excluded because Qwen autonomously searched for the answer online after 27 legitimate turns (see line 822 in [trajectory.json](benchmarks/terminal_bench_2/results/r1/code-from-image/trajectory.json)).
212
+ _Note 3_: in r1 and r3, the `code-from-image` trial was counted as non-passing because Qwen autonomously searched for the answer online after legitimately trying for a while.
202
213
 
203
214
  _Note 4_: see the [`benchmarks/terminal_bench_2`](./benchmarks/terminal_bench_2/) dir for breakdown of results and reproduction steps.
204
215
 
package/bin/pim.ts CHANGED
@@ -1,9 +1,20 @@
1
1
  #!/usr/bin/env bun
2
+ import { realpath } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
 
4
5
  const PI_PACKAGE = "@earendil-works/pi-coding-agent";
5
6
 
6
- function findPiCli(): string {
7
+ async function findPiCli(): Promise<string> {
8
+ const envCli = await resolveEnvPiCli();
9
+ if (envCli) {
10
+ return envCli;
11
+ }
12
+
13
+ const pathCli = await resolvePathPiCli();
14
+ if (pathCli) {
15
+ return pathCli;
16
+ }
17
+
7
18
  const globalCli = resolveGlobalPiCli();
8
19
  if (globalCli) {
9
20
  return globalCli;
@@ -15,11 +26,52 @@ function findPiCli(): string {
15
26
  } catch {
16
27
  throw new Error(
17
28
  `Pim could not locate ${PI_PACKAGE}.\n` +
18
- `Install it globally under Bun: bun install -g ${PI_PACKAGE}`
29
+ `Install Pi from https://pi.dev/docs/latest/quickstart, or set PIM_PI_CLI=/path/to/cli.js`
19
30
  );
20
31
  }
21
32
  }
22
33
 
34
+ async function resolveEnvPiCli(): Promise<string | null> {
35
+ const candidate = process.env["PIM_PI_CLI"]?.trim();
36
+ if (!candidate) {
37
+ return null;
38
+ }
39
+ return (await isFile(candidate)) ? candidate : null;
40
+ }
41
+
42
+ async function resolvePathPiCli(): Promise<string | null> {
43
+ const piBin = Bun.which("pi");
44
+ if (!piBin) {
45
+ return null;
46
+ }
47
+
48
+ const cliPath = await resolveRealPath(piBin);
49
+ const pkgPath = join(dirname(cliPath), "..", "package.json");
50
+
51
+ try {
52
+ const pkg = (await Bun.file(pkgPath).json()) as { readonly name?: string };
53
+ return pkg.name === PI_PACKAGE ? cliPath : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ async function resolveRealPath(path: string): Promise<string> {
60
+ try {
61
+ return await realpath(path);
62
+ } catch {
63
+ return path;
64
+ }
65
+ }
66
+
67
+ async function isFile(path: string): Promise<boolean> {
68
+ try {
69
+ return (await Bun.file(path).stat()).isFile();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
23
75
  function resolveGlobalPiCli(): string | null {
24
76
  const result = Bun.spawnSync({ cmd: ["bun", "pm", "-g", "bin"] });
25
77
  if (result.exitCode !== 0) {
@@ -78,7 +130,7 @@ if (mode === "telegram") {
78
130
  process.exit(0);
79
131
  }
80
132
 
81
- const piCli = findPiCli();
133
+ const piCli = await findPiCli();
82
134
  const proc = Bun.spawn({
83
135
  cmd: [process.execPath, piCli, ...cliArgs],
84
136
  stdio: [
package/package.json CHANGED
@@ -1,14 +1,28 @@
1
1
  {
2
2
  "name": "@aaroncql/pim-agent",
3
- "version": "0.0.1",
4
- "description": "Pim is to Pi what Vim is to Vi.",
3
+ "version": "0.1.0",
4
+ "description": "A Bun-native extension pack for Pi: web access, subagents, revamped core tools, ANSI-compatible themes, fzf-style completions, Telegram mode, and more.",
5
+ "license": "MIT",
5
6
  "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "agent"
10
+ ],
11
+ "homepage": "https://github.com/AaronCQL/pim-agent#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/AaronCQL/pim-agent/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/AaronCQL/pim-agent.git"
18
+ },
6
19
  "bin": {
7
20
  "pim": "bin/pim.ts"
8
21
  },
9
22
  "files": [
10
23
  "bin/",
11
- "src/"
24
+ "src/",
25
+ "!src/**/*.test.ts"
12
26
  ],
13
27
  "engines": {
14
28
  "bun": ">=1.2.7"
@@ -19,14 +33,15 @@
19
33
  ],
20
34
  "themes": [
21
35
  "./src/themes"
22
- ]
36
+ ],
37
+ "image": "https://raw.githubusercontent.com/AaronCQL/pim-agent/refs/heads/main/assets/demo.webp"
23
38
  },
24
39
  "scripts": {
25
40
  "dev": "bun link && pim",
26
41
  "typecheck": "tsgo --noEmit",
27
42
  "test": "bun test src --only-failures",
28
43
  "lint": "oxlint . --fix",
29
- "format": "prettier --write --list-different package.json tsconfig.json .oxlintrc.json .prettierrc.json \"src/**/*.ts\" \"bin/**/*.ts\" \"**/*.md\"",
44
+ "format": "prettier --write --list-different package.json tsconfig.json .oxlintrc.json .prettierrc.json \"src/**/*.ts\" \"bin/**/*.ts\"",
30
45
  "check": "bun run typecheck && bun run test && bun run lint && bun run format"
31
46
  },
32
47
  "peerDependencies": {
@@ -21,8 +21,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
21
21
  if (typeof Bun === "undefined") {
22
22
  throw new Error(
23
23
  "Pim requires the Bun runtime.\n" +
24
- "Install pi via bun: bun install -g @earendil-works/pi-coding-agent\n" +
25
- "Then run: pim"
24
+ "Install the Pim launcher: bun install -g @aaroncql/pim-agent\n" +
25
+ "Then run: pim\n" +
26
+ "If pim cannot locate Pi, ensure `pi` is on PATH or set PIM_PI_CLI=/path/to/cli.js"
26
27
  );
27
28
  }
28
29
 
@@ -1,126 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { concat, StreamCapture } from "./capture";
3
- import { STREAM_HEAD_BYTES, STREAM_TAIL_BYTES } from "./schema";
4
-
5
- const enc = new TextEncoder();
6
- const u8 = (s: string) => enc.encode(s);
7
-
8
- describe("concat", () => {
9
- test("merges multiple chunks in order", () => {
10
- const out = concat([u8("foo"), u8("bar")], 6);
11
- expect(new TextDecoder().decode(out)).toBe("foobar");
12
- });
13
-
14
- test("returns empty array when total is 0", () => {
15
- expect(concat([], 0).byteLength).toBe(0);
16
- });
17
- });
18
-
19
- describe("StreamCapture", () => {
20
- test("empty capture", () => {
21
- const c = new StreamCapture();
22
- expect(c.snapshot()).toEqual({
23
- text: "",
24
- totalBytes: 0,
25
- truncated: false,
26
- path: null,
27
- nextStart: null,
28
- });
29
- });
30
-
31
- test("ignores zero-byte chunks", () => {
32
- const c = new StreamCapture();
33
- c.push(new Uint8Array(0));
34
- c.push(u8("hi"));
35
- expect(c.snapshot()).toEqual({
36
- text: "hi",
37
- totalBytes: 2,
38
- truncated: false,
39
- path: null,
40
- nextStart: null,
41
- });
42
- });
43
-
44
- test("does not truncate when within head+tail budget", () => {
45
- const c = new StreamCapture();
46
- c.push(u8("hello"));
47
- c.push(u8(" world"));
48
- expect(c.snapshot()).toEqual({
49
- text: "hello world",
50
- totalBytes: 11,
51
- truncated: false,
52
- path: null,
53
- nextStart: null,
54
- });
55
- });
56
-
57
- test("truncates middle when over budget", () => {
58
- const c = new StreamCapture();
59
- const headChunk = "A".repeat(STREAM_HEAD_BYTES);
60
- const middleChunk = "X".repeat(1000);
61
- const tailChunk = "B".repeat(STREAM_TAIL_BYTES);
62
- c.push(u8(headChunk));
63
- c.push(u8(middleChunk));
64
- c.push(u8(tailChunk));
65
-
66
- const snap = c.snapshot();
67
- expect(snap.truncated).toBe(true);
68
- expect(snap.totalBytes).toBe(STREAM_HEAD_BYTES + 1000 + STREAM_TAIL_BYTES);
69
- expect(snap.text.startsWith(headChunk)).toBe(true);
70
- expect(snap.text.endsWith(tailChunk)).toBe(true);
71
- expect(snap.text).toContain(`... ${1000} bytes truncated ...`);
72
- expect(snap.nextStart).toBe(1);
73
- });
74
-
75
- test("reports the resume line at the end of the head when truncated", () => {
76
- const c = new StreamCapture();
77
- const lineBytes = STREAM_HEAD_BYTES / 4;
78
- const headChunk = `${"A".repeat(lineBytes - 1)}\n`.repeat(4);
79
- c.push(u8(headChunk));
80
- c.push(u8("X".repeat(1000)));
81
- c.push(u8("B".repeat(STREAM_TAIL_BYTES)));
82
-
83
- const snap = c.snapshot();
84
- expect(snap.truncated).toBe(true);
85
- // Head holds exactly 4 newline-terminated lines, so reading resumes at 5.
86
- expect(snap.nextStart).toBe(5);
87
- });
88
-
89
- test("splits a single chunk between head and tail when needed", () => {
90
- const c = new StreamCapture();
91
- const big = "Z".repeat(STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 500);
92
- c.push(u8(big));
93
-
94
- const snap = c.snapshot();
95
- expect(snap.truncated).toBe(true);
96
- expect(snap.totalBytes).toBe(big.length);
97
- expect(snap.text.startsWith("Z".repeat(STREAM_HEAD_BYTES))).toBe(true);
98
- expect(snap.text.endsWith("Z".repeat(STREAM_TAIL_BYTES))).toBe(true);
99
- expect(snap.text).toContain(`... ${500} bytes truncated ...`);
100
- });
101
-
102
- test("keeps head and final tail when many middle chunks arrive", () => {
103
- const c = new StreamCapture();
104
- const HEAD_FILL = "A".repeat(STREAM_HEAD_BYTES);
105
- c.push(u8(HEAD_FILL));
106
- for (let i = 0; i < 100; i++) {
107
- c.push(u8("M".repeat(STREAM_TAIL_BYTES)));
108
- }
109
- const finalTail = "B".repeat(STREAM_TAIL_BYTES);
110
- c.push(u8(finalTail));
111
-
112
- const snap = c.snapshot();
113
- expect(snap.truncated).toBe(true);
114
- expect(snap.text.startsWith(HEAD_FILL)).toBe(true);
115
- expect(snap.text.endsWith(finalTail)).toBe(true);
116
- });
117
-
118
- test("at exact head+tail boundary is not truncated", () => {
119
- const c = new StreamCapture();
120
- c.push(u8("A".repeat(STREAM_HEAD_BYTES)));
121
- c.push(u8("B".repeat(STREAM_TAIL_BYTES)));
122
- const snap = c.snapshot();
123
- expect(snap.truncated).toBe(false);
124
- expect(snap.totalBytes).toBe(STREAM_HEAD_BYTES + STREAM_TAIL_BYTES);
125
- });
126
- });
@@ -1,240 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- detailsOf,
4
- formatResult,
5
- formatTruncationAffordance,
6
- isErrorResult,
7
- stripTrailingNewline,
8
- } from "./format";
9
- import {
10
- type BashCommandResult,
11
- STREAM_HEAD_BYTES,
12
- STREAM_TAIL_BYTES,
13
- } from "./schema";
14
-
15
- function makeResult(
16
- overrides: Partial<BashCommandResult> = {}
17
- ): BashCommandResult {
18
- return {
19
- exitCode: 0,
20
- signal: null,
21
- stdout: {
22
- text: "",
23
- totalBytes: 0,
24
- truncated: false,
25
- path: null,
26
- nextStart: null,
27
- },
28
- stderr: {
29
- text: "",
30
- totalBytes: 0,
31
- truncated: false,
32
- path: null,
33
- nextStart: null,
34
- },
35
- timedOut: false,
36
- aborted: false,
37
- durationMs: 1,
38
- ...overrides,
39
- };
40
- }
41
-
42
- describe("stripTrailingNewline", () => {
43
- test("removes one trailing newline", () => {
44
- expect(stripTrailingNewline("foo\n")).toBe("foo");
45
- });
46
- test("leaves no-newline strings alone", () => {
47
- expect(stripTrailingNewline("foo")).toBe("foo");
48
- });
49
- test("only strips one", () => {
50
- expect(stripTrailingNewline("foo\n\n")).toBe("foo\n");
51
- });
52
- });
53
-
54
- describe("formatTruncationAffordance", () => {
55
- test("emits bracketed affordance with byte counts and next-step", () => {
56
- const out = formatTruncationAffordance("stderr", {
57
- text: "x",
58
- totalBytes: 12345,
59
- truncated: true,
60
- path: null,
61
- nextStart: 1,
62
- });
63
- expect(out.startsWith("[bash tool:")).toBe(true);
64
- expect(out.endsWith("]")).toBe(true);
65
- expect(out).toContain("stderr showing first");
66
- expect(out).toContain(`first ${STREAM_HEAD_BYTES} bytes`);
67
- expect(out).toContain(`last ${STREAM_TAIL_BYTES} bytes`);
68
- expect(out).toContain("of 12345");
69
- expect(out).toContain("redirect to a file");
70
- expect(out).toContain("read");
71
- });
72
-
73
- test("points to spill path with a resume line when one is provided", () => {
74
- const out = formatTruncationAffordance("stdout", {
75
- text: "x",
76
- totalBytes: 99999,
77
- truncated: true,
78
- path: "/tmp/pim-bash-abc.out",
79
- nextStart: 42,
80
- });
81
- expect(out).toContain(
82
- "use read with path=/tmp/pim-bash-abc.out and start=42 for the rest."
83
- );
84
- expect(out).not.toContain("redirect to a file");
85
- });
86
- });
87
-
88
- describe("formatResult", () => {
89
- test("happy path with stdout only", () => {
90
- const out = formatResult(
91
- makeResult({
92
- stdout: {
93
- text: "hello\n",
94
- totalBytes: 6,
95
- truncated: false,
96
- path: null,
97
- nextStart: null,
98
- },
99
- }),
100
- 30_000
101
- );
102
- expect(out).toBe("Exit code: 0\nstdout:\nhello");
103
- });
104
-
105
- test("includes signal line when signal present", () => {
106
- const out = formatResult(
107
- makeResult({ exitCode: null, signal: "SIGTERM" }),
108
- 30_000
109
- );
110
- expect(out).toContain("Exit code: none");
111
- expect(out).toContain("Signal: SIGTERM");
112
- });
113
-
114
- test("aborted overrides timed out message", () => {
115
- const out = formatResult(
116
- makeResult({ aborted: true, timedOut: true }),
117
- 30_000
118
- );
119
- expect(out).toContain("Aborted.");
120
- expect(out).not.toContain("Timed out");
121
- });
122
-
123
- test("timed out adds duration message", () => {
124
- const out = formatResult(makeResult({ timedOut: true }), 5000);
125
- expect(out).toContain("Timed out after 5000 ms.");
126
- });
127
-
128
- test("includes both stdout and stderr when both have bytes", () => {
129
- const out = formatResult(
130
- makeResult({
131
- exitCode: 1,
132
- stdout: {
133
- text: "out",
134
- totalBytes: 3,
135
- truncated: false,
136
- path: null,
137
- nextStart: null,
138
- },
139
- stderr: {
140
- text: "err",
141
- totalBytes: 3,
142
- truncated: false,
143
- path: null,
144
- nextStart: null,
145
- },
146
- }),
147
- 30_000
148
- );
149
- expect(out).toBe("Exit code: 1\nstdout:\nout\nstderr:\nerr");
150
- });
151
-
152
- test("appends bracket affordance after a truncated stream body", () => {
153
- const out = formatResult(
154
- makeResult({
155
- stdout: {
156
- text: "head…tail",
157
- totalBytes: 99999,
158
- truncated: true,
159
- path: null,
160
- nextStart: 1,
161
- },
162
- }),
163
- 30_000
164
- );
165
- const lines = out.split("\n");
166
- expect(lines[0]).toBe("Exit code: 0");
167
- expect(lines[1]).toBe("stdout:");
168
- expect(lines[2]).toBe("head…tail");
169
- expect(lines[3]?.startsWith("[bash tool: stdout showing first")).toBe(true);
170
- expect(lines[3]?.endsWith("]")).toBe(true);
171
- });
172
-
173
- test("does not append affordance when stream is not truncated", () => {
174
- const out = formatResult(
175
- makeResult({
176
- stdout: {
177
- text: "ok",
178
- totalBytes: 2,
179
- truncated: false,
180
- path: null,
181
- nextStart: null,
182
- },
183
- }),
184
- 30_000
185
- );
186
- expect(out).not.toContain("[bash tool:");
187
- });
188
- });
189
-
190
- describe("detailsOf", () => {
191
- test("mirrors per-stream truncation and byte counts", () => {
192
- const details = detailsOf(
193
- makeResult({
194
- exitCode: 1,
195
- durationMs: 42,
196
- stdout: {
197
- text: "x",
198
- totalBytes: 99999,
199
- truncated: true,
200
- path: null,
201
- nextStart: 1,
202
- },
203
- stderr: {
204
- text: "y",
205
- totalBytes: 5,
206
- truncated: false,
207
- path: null,
208
- nextStart: null,
209
- },
210
- })
211
- );
212
- expect(details).toEqual({
213
- exitCode: 1,
214
- signal: null,
215
- durationMs: 42,
216
- timedOut: false,
217
- aborted: false,
218
- stdout: { totalBytes: 99999, truncated: true, path: null },
219
- stderr: { totalBytes: 5, truncated: false, path: null },
220
- });
221
- });
222
- });
223
-
224
- describe("isErrorResult", () => {
225
- test("zero exit code is not an error", () => {
226
- expect(isErrorResult(makeResult({ exitCode: 0 }))).toBe(false);
227
- });
228
- test("non-zero exit code is an error", () => {
229
- expect(isErrorResult(makeResult({ exitCode: 1 }))).toBe(true);
230
- });
231
- test("null exit code is an error", () => {
232
- expect(isErrorResult(makeResult({ exitCode: null }))).toBe(true);
233
- });
234
- test("aborted is an error", () => {
235
- expect(isErrorResult(makeResult({ aborted: true }))).toBe(true);
236
- });
237
- test("timed out is an error", () => {
238
- expect(isErrorResult(makeResult({ timedOut: true }))).toBe(true);
239
- });
240
- });