@aaroncql/pim-agent 0.0.1 → 0.2.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 +94 -66
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/apply-patch/coordinator.ts +49 -0
- package/src/extensions/apply-patch/executor.ts +566 -0
- package/src/extensions/apply-patch/index.ts +74 -0
- package/src/extensions/apply-patch/matcher.ts +66 -0
- package/src/extensions/apply-patch/model.ts +34 -0
- package/src/extensions/apply-patch/parser.ts +381 -0
- package/src/extensions/apply-patch/render.ts +261 -0
- package/src/extensions/apply-patch/schema.ts +43 -0
- package/src/extensions/apply-patch/types.ts +30 -0
- package/src/extensions/bash/index.ts +3 -3
- package/src/extensions/edit/index.ts +2 -1
- package/src/extensions/glob/index.ts +3 -1
- package/src/extensions/glob/schema.ts +2 -1
- package/src/extensions/grep/index.ts +3 -1
- package/src/extensions/grep/render.ts +18 -4
- package/src/extensions/grep/schema.ts +1 -1
- package/src/extensions/read/index.ts +36 -9
- package/src/extensions/read/render.ts +31 -3
- package/src/extensions/subagent/index.ts +4 -1
- package/src/extensions/todo/index.ts +4 -3
- package/src/extensions/web-search/index.ts +2 -1
- package/src/extensions/write/index.ts +2 -1
- package/src/shared/PatchSummary.ts +82 -0
- package/src/telegram/Renderer.ts +190 -4
- 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/src/telegram/Renderer.ts
CHANGED
|
@@ -2,8 +2,12 @@ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { GrammyError, type Api } from "grammy";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
|
|
5
|
+
import type { ApplyEntry } from "../extensions/apply-patch/executor";
|
|
5
6
|
import type { SubagentDetails } from "../extensions/subagent/subagent";
|
|
6
7
|
import type { TodoInput } from "../extensions/todo/schema";
|
|
8
|
+
import type { ToolDiff } from "../shared/DiffLines";
|
|
9
|
+
import { DiffView, type DiffStats } from "../shared/DiffView";
|
|
10
|
+
import { type PatchOp, PatchSummary } from "../shared/PatchSummary";
|
|
7
11
|
import type { LogsMode } from "./Config";
|
|
8
12
|
import { Markdown } from "./Markdown";
|
|
9
13
|
import type { Session, SessionId } from "./Session";
|
|
@@ -15,15 +19,30 @@ type TurnState = TurnEndState | "running";
|
|
|
15
19
|
type TrackerEntry = {
|
|
16
20
|
readonly key: string;
|
|
17
21
|
readonly kind: "tool" | "todo" | "thinking" | "narration";
|
|
18
|
-
|
|
22
|
+
emoji: string;
|
|
19
23
|
label: string;
|
|
20
24
|
state: "running" | "ok" | "error";
|
|
25
|
+
// Plaintext "+4/-3" appended after the label once the tool finishes.
|
|
26
|
+
stats?: string;
|
|
21
27
|
};
|
|
22
28
|
|
|
29
|
+
type ApplyOp = {
|
|
30
|
+
readonly emoji: string;
|
|
31
|
+
readonly text: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const EDIT_EMOJI = "✏️";
|
|
35
|
+
const DELETE_EMOJI = "🗑️";
|
|
36
|
+
const ARROW = "➝";
|
|
37
|
+
|
|
38
|
+
// Keys an apply_patch tool call may carry its patch text under (canonical first).
|
|
39
|
+
const PATCH_TEXT_KEYS = ["input", "patch", "patchText", "patch_text"] as const;
|
|
40
|
+
|
|
23
41
|
const TOOL_EMOJI: Record<string, string> = {
|
|
24
42
|
read: "📄",
|
|
25
|
-
edit:
|
|
26
|
-
write:
|
|
43
|
+
edit: EDIT_EMOJI,
|
|
44
|
+
write: EDIT_EMOJI,
|
|
45
|
+
apply_patch: EDIT_EMOJI,
|
|
27
46
|
bash: "⚡️",
|
|
28
47
|
grep: "🔎",
|
|
29
48
|
glob: "🔎",
|
|
@@ -130,6 +149,9 @@ export class Renderer {
|
|
|
130
149
|
return;
|
|
131
150
|
}
|
|
132
151
|
this.updateSubagentLabel(event.toolCallId, event.toolName, event.result);
|
|
152
|
+
if (!event.isError) {
|
|
153
|
+
this.applyDiffStats(event.toolCallId, event.toolName, event.result);
|
|
154
|
+
}
|
|
133
155
|
const idx = this.toolIndex.get(event.toolCallId);
|
|
134
156
|
if (idx !== undefined) {
|
|
135
157
|
this.entries[idx]!.state = event.isError ? "error" : "ok";
|
|
@@ -179,6 +201,21 @@ export class Renderer {
|
|
|
179
201
|
this.scheduleEdit();
|
|
180
202
|
return;
|
|
181
203
|
}
|
|
204
|
+
if (name === "apply_patch") {
|
|
205
|
+
const { emoji, label } = Renderer.buildApplyEntry(
|
|
206
|
+
Renderer.applyOpsFromArgs(args)
|
|
207
|
+
);
|
|
208
|
+
this.entries.push({
|
|
209
|
+
key: toolCallId,
|
|
210
|
+
kind: "tool",
|
|
211
|
+
emoji,
|
|
212
|
+
label,
|
|
213
|
+
state: "running",
|
|
214
|
+
});
|
|
215
|
+
this.toolIndex.set(toolCallId, this.entries.length - 1);
|
|
216
|
+
this.scheduleEdit();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
182
219
|
const emoji = TOOL_EMOJI[name] ?? "⚙️";
|
|
183
220
|
const label = Renderer.toolLabel(toolName, args);
|
|
184
221
|
if (name === "subagent") {
|
|
@@ -234,6 +271,42 @@ export class Renderer {
|
|
|
234
271
|
this.scheduleEdit();
|
|
235
272
|
}
|
|
236
273
|
|
|
274
|
+
private applyDiffStats(
|
|
275
|
+
toolCallId: string,
|
|
276
|
+
toolName: string,
|
|
277
|
+
result: unknown
|
|
278
|
+
): void {
|
|
279
|
+
const idx = this.toolIndex.get(toolCallId);
|
|
280
|
+
if (idx === undefined) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const name = toolName.toLowerCase();
|
|
284
|
+
const details = (result as { readonly details?: unknown } | null)?.details;
|
|
285
|
+
if (name === "edit" || name === "write") {
|
|
286
|
+
const diff = (details as { readonly diff?: ToolDiff } | undefined)?.diff;
|
|
287
|
+
const stats = Renderer.formatPlainStats(DiffView.countStats(diff));
|
|
288
|
+
if (stats) {
|
|
289
|
+
this.entries[idx]!.stats = stats;
|
|
290
|
+
this.scheduleEdit();
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (name === "apply_patch") {
|
|
295
|
+
const entries = (
|
|
296
|
+
details as { readonly entries?: readonly ApplyEntry[] } | undefined
|
|
297
|
+
)?.entries;
|
|
298
|
+
if (!entries) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const built = Renderer.buildApplyEntry(
|
|
302
|
+
Renderer.applyOpsFromEntries(entries)
|
|
303
|
+
);
|
|
304
|
+
this.entries[idx]!.emoji = built.emoji;
|
|
305
|
+
this.entries[idx]!.label = built.label;
|
|
306
|
+
this.scheduleEdit();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
237
310
|
private flushThinking(): void {
|
|
238
311
|
if (this.logsMode !== "verbose") {
|
|
239
312
|
this.thinking = "";
|
|
@@ -370,7 +443,8 @@ export class Renderer {
|
|
|
370
443
|
} else if (state === "running" && isLastEntry) {
|
|
371
444
|
suffix = " 🟡";
|
|
372
445
|
}
|
|
373
|
-
|
|
446
|
+
const stats = entry.stats ? ` ${entry.stats}` : "";
|
|
447
|
+
pieces.push(`${entry.emoji} ${entry.label}${stats}${suffix}`);
|
|
374
448
|
}
|
|
375
449
|
const next = visible[i + 1];
|
|
376
450
|
if (next) {
|
|
@@ -490,6 +564,118 @@ export class Renderer {
|
|
|
490
564
|
}
|
|
491
565
|
}
|
|
492
566
|
|
|
567
|
+
private static buildApplyEntry(ops: readonly ApplyOp[]): {
|
|
568
|
+
readonly emoji: string;
|
|
569
|
+
readonly label: string;
|
|
570
|
+
} {
|
|
571
|
+
const [first, ...rest] = ops;
|
|
572
|
+
if (!first) {
|
|
573
|
+
return { emoji: EDIT_EMOJI, label: "" };
|
|
574
|
+
}
|
|
575
|
+
const label = [
|
|
576
|
+
first.text,
|
|
577
|
+
...rest.map((op) => `${op.emoji} ${op.text}`),
|
|
578
|
+
].join("\n");
|
|
579
|
+
return { emoji: first.emoji, label };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private static applyOpsFromArgs(args: unknown): readonly ApplyOp[] {
|
|
583
|
+
const text = Renderer.patchTextFromArgs(args);
|
|
584
|
+
if (!text) {
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
return PatchSummary.fromText(text).map((op) => Renderer.opFromSummary(op));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private static applyOpsFromEntries(
|
|
591
|
+
entries: readonly ApplyEntry[]
|
|
592
|
+
): readonly ApplyOp[] {
|
|
593
|
+
return entries
|
|
594
|
+
.filter(
|
|
595
|
+
(entry) => !(entry.action.kind === "update" && entry.diff === undefined)
|
|
596
|
+
)
|
|
597
|
+
.map((entry) => Renderer.opFromEntry(entry));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private static opFromSummary(op: PatchOp): ApplyOp {
|
|
601
|
+
const isMove = op.movePath !== undefined && op.movePath !== op.path;
|
|
602
|
+
return Renderer.applyOp({
|
|
603
|
+
kind: isMove ? "move" : op.kind,
|
|
604
|
+
path: op.path,
|
|
605
|
+
movePath: op.movePath,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private static opFromEntry(entry: ApplyEntry): ApplyOp {
|
|
610
|
+
return Renderer.applyOp({
|
|
611
|
+
kind: entry.action.kind,
|
|
612
|
+
path: entry.action.path,
|
|
613
|
+
movePath: entry.action.movePath,
|
|
614
|
+
stats: Renderer.formatPlainStats(DiffView.countStats(entry.diff)),
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private static applyOp(params: {
|
|
619
|
+
readonly kind: "add" | "delete" | "move" | "update";
|
|
620
|
+
readonly path: string;
|
|
621
|
+
readonly movePath?: string;
|
|
622
|
+
readonly stats?: string;
|
|
623
|
+
}): ApplyOp {
|
|
624
|
+
const suffix = params.stats ? ` ${params.stats}` : "";
|
|
625
|
+
if (params.kind === "delete") {
|
|
626
|
+
return {
|
|
627
|
+
emoji: DELETE_EMOJI,
|
|
628
|
+
text: `${Renderer.codeName(params.path)}${suffix}`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
if (params.kind === "move") {
|
|
632
|
+
return {
|
|
633
|
+
emoji: EDIT_EMOJI,
|
|
634
|
+
text: `${Renderer.moveText(params.path, params.movePath ?? params.path)}${suffix}`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
emoji: EDIT_EMOJI,
|
|
639
|
+
text: `${Renderer.codeName(params.path)}${suffix}`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private static moveText(from: string, to: string): string {
|
|
644
|
+
return `${Renderer.codeName(from)} ${ARROW} ${Renderer.codeName(to)}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private static codeName(path: string): string {
|
|
648
|
+
return `<code>${Markdown.escape(basename(path))}</code>`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private static patchTextFromArgs(args: unknown): string | undefined {
|
|
652
|
+
if (typeof args === "string") {
|
|
653
|
+
return args;
|
|
654
|
+
}
|
|
655
|
+
if (!args || typeof args !== "object") {
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
const record = args as Record<string, unknown>;
|
|
659
|
+
for (const key of PATCH_TEXT_KEYS) {
|
|
660
|
+
const value = record[key];
|
|
661
|
+
if (typeof value === "string" && value) {
|
|
662
|
+
return value;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private static formatPlainStats(stats: DiffStats): string {
|
|
669
|
+
const parts: string[] = [];
|
|
670
|
+
if (stats.added > 0) {
|
|
671
|
+
parts.push(`+${stats.added}`);
|
|
672
|
+
}
|
|
673
|
+
if (stats.removed > 0) {
|
|
674
|
+
parts.push(`-${stats.removed}`);
|
|
675
|
+
}
|
|
676
|
+
return parts.join("/");
|
|
677
|
+
}
|
|
678
|
+
|
|
493
679
|
private static toolLabel(toolName: string, args: unknown): string {
|
|
494
680
|
const obj =
|
|
495
681
|
args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
@@ -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
|
-
});
|