@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.
- package/README.md +19 -8
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/bash/capture.test.ts +0 -126
- package/src/extensions/bash/format.test.ts +0 -240
- package/src/extensions/bash/run.test.ts +0 -262
- package/src/extensions/command-picker/ranker.test.ts +0 -46
- package/src/extensions/edit/edit.test.ts +0 -285
- package/src/extensions/file-picker/catalog.test.ts +0 -263
- package/src/extensions/file-picker/index.test.ts +0 -168
- package/src/extensions/file-picker/ranker.test.ts +0 -94
- package/src/extensions/footer/git.test.ts +0 -76
- package/src/extensions/footer/index.test.ts +0 -161
- package/src/extensions/footer/segments.test.ts +0 -164
- package/src/extensions/glob/glob.test.ts +0 -171
- package/src/extensions/glob/index.test.ts +0 -68
- package/src/extensions/glob/render.test.ts +0 -126
- package/src/extensions/grep/grep.test.ts +0 -387
- package/src/extensions/grep/index.test.ts +0 -68
- package/src/extensions/grep/render.test.ts +0 -269
- package/src/extensions/read/read.test.ts +0 -177
- package/src/extensions/read/render.test.ts +0 -61
- package/src/extensions/subagent/index.test.ts +0 -44
- package/src/extensions/subagent/render.test.ts +0 -292
- package/src/extensions/subagent/subagent.test.ts +0 -315
- package/src/extensions/system-prompt/prompt.test.ts +0 -64
- package/src/extensions/todo/index.test.ts +0 -244
- package/src/extensions/todo/render.test.ts +0 -180
- package/src/extensions/todo/todo.test.ts +0 -222
- package/src/extensions/tps/index.test.ts +0 -254
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
- package/src/extensions/web-fetch/fetch.test.ts +0 -244
- package/src/extensions/web-fetch/render.test.ts +0 -56
- package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
- package/src/extensions/web-search/render.test.ts +0 -21
- package/src/extensions/web-search/search.test.ts +0 -53
- package/src/extensions/working-indicator/index.test.ts +0 -21
- package/src/extensions/write/render.test.ts +0 -64
- package/src/extensions/write/write.test.ts +0 -108
- package/src/shared/DiffLines.test.ts +0 -193
- package/src/shared/DiffRenderer.test.ts +0 -206
- package/src/shared/EditMatcher.test.ts +0 -123
- package/src/shared/FileScanner.test.ts +0 -158
- package/src/shared/FuzzyMatcher.test.ts +0 -114
- package/src/shared/GitignoreFilter.test.ts +0 -64
- package/src/shared/Lines.test.ts +0 -25
- package/src/shared/McpClient.test.ts +0 -235
- package/src/shared/OutputBudget.test.ts +0 -99
- package/src/shared/Paths.test.ts +0 -51
- package/src/shared/PimSettings.test.ts +0 -90
- package/src/shared/Renderer.test.ts +0 -190
- package/src/shared/SpillCache.test.ts +0 -94
- package/src/shared/Tools.test.ts +0 -392
- package/src/telegram/Config.test.ts +0 -275
- package/src/telegram/Markdown.test.ts +0 -143
- package/src/telegram/Renderer.test.ts +0 -216
- package/src/telegram/SessionRegistry.test.ts +0 -89
- package/src/telegram/TaskScheduler.test.ts +0 -278
- 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
|
-
|
|
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
|
+

|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4
|
-
"description": "
|
|
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\"
|
|
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
|
|
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
|
-
});
|