mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(pi-overflow): 新增统一的新工具结果展示截断扩展
此前截断分散在多层且互相冲突:Pi bash 保留尾部 50KB,RTK 兜底截断又只留 开头 12000 字符(砍掉报错与完整输出路径),pi-chrome 截开头 30K,web_fetch 各 provider 不一致。新扩展统一负责"新结果给模型看多少": - capture.ts 最先加载暂存原文,index.ts 最后加载(system-prompt 之前) - 超过 24K 字符时原文落盘 <agentDir>/my-pi/pi-overflow/(700/600、原子写), 优先取 bash fullOutputPath 完整文件;模型看到 头 + 中段信号行 + 尾 的有界 视图,行号基于落盘原文、结尾永远可见 - overflow_query:按 id 正则搜索、行区间、jsonPath 只读查询,id 校验防穿越 - 收编:RTK truncate 默认关闭、删除 pi-chrome truncateText、web_fetch 默认 每页 20000(上限 100000)、web_search max_results 限定 1–20 - pi-compaction 遮蔽带 details.overflow.id 的结果时在占位符提示 overflow_query - 登记 tools.jsonc / routing / 权限 allow / tool-search 常驻与分组 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
a6d8416c76
commit
2a6bf8c929
@@ -0,0 +1,314 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import overflowCapture from "../capture.ts";
|
||||
import overflowExtension from "../index.ts";
|
||||
import { getStash } from "../src/stash.ts";
|
||||
import { readOverflowSettings, OVERFLOW_DEFAULTS, type OverflowSettings } from "../settings.ts";
|
||||
|
||||
function harness() {
|
||||
const handlers = new Map<string, Function[]>();
|
||||
const tools = new Map<string, any>();
|
||||
const pi: any = {
|
||||
on(name: string, handler: Function) {
|
||||
const list = handlers.get(name) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(name, list);
|
||||
},
|
||||
registerTool(tool: any) { tools.set(tool.name, tool); },
|
||||
};
|
||||
return { pi, handlers, tools };
|
||||
}
|
||||
|
||||
function ctx() {
|
||||
return { sessionManager: { getSessionId: () => "session-test" } } as any;
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<OverflowSettings> = {}): OverflowSettings {
|
||||
return { ...readOverflowSettings("/nonexistent/pi-overflow.json"), ...overrides };
|
||||
}
|
||||
|
||||
function textEvent(toolCallId: string, text: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
toolCallId,
|
||||
toolName: "bash",
|
||||
input: { command: "run" },
|
||||
content: [{ type: "text", text }],
|
||||
details: {},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function runHandlers(handlers: Map<string, Function[]>, event: any, context: any) {
|
||||
let current = event;
|
||||
for (const handler of handlers.get("tool_result") ?? []) {
|
||||
const result = await handler(current, context);
|
||||
if (result) current = { ...current, ...result };
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function makeStore() {
|
||||
return mkdtemp(join(tmpdir(), "pi-overflow-test-"));
|
||||
}
|
||||
|
||||
test("capture stashes the original before another handler shortens it; bound stage stores original but views current", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const capture = harness();
|
||||
overflowCapture(capture.pi);
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const big = Array.from({ length: 3000 }, (_, i) => `orig ${i}`).join("\n");
|
||||
const event = textEvent("call-1", big);
|
||||
const afterCapture = await runHandlers(capture.handlers, event, ctx());
|
||||
assert.equal(afterCapture, event, "capture must not modify the result");
|
||||
assert.ok(getStash().has("call-1"), "capture must stash the original");
|
||||
|
||||
// Simulate RTK: replace content with a shortened version that is still
|
||||
// over threshold.
|
||||
const rtkShortened = Array.from({ length: 900 }, (_, i) => `rtk ${i}`).join("\n") + "\n" + "z".repeat(30_000);
|
||||
const mid = { ...afterCapture, content: [{ type: "text", text: rtkShortened }] };
|
||||
const final = await runHandlers(bound.handlers, mid, ctx());
|
||||
|
||||
assert.ok(!getStash().has("call-1"), "stash entry must be consumed");
|
||||
const text = final.content[0].text;
|
||||
assert.match(text, /\[pi-overflow id=[0-9a-f]{12} tool=bash/);
|
||||
assert.ok(text.length <= settings().thresholdChars);
|
||||
|
||||
const id = final.details.overflow.id;
|
||||
const stored = await readFile(join(root, `${id}.txt`), "utf8");
|
||||
assert.ok(stored.includes("orig 0"), "store must keep the ORIGINAL, not the RTK-shortened text");
|
||||
assert.ok(stored.includes("orig 2999"));
|
||||
assert.equal(final.details.overflow.truncated, true);
|
||||
});
|
||||
|
||||
test("details.fullOutputPath file is used as the original", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const spill = join(root, "spill.txt");
|
||||
await writeFile(spill, Array.from({ length: 5000 }, (_, i) => `spill ${i}`).join("\n"));
|
||||
const event = textEvent("call-2", "x".repeat(30_000), { details: { fullOutputPath: spill } });
|
||||
const final = await runHandlers(bound.handlers, event, ctx());
|
||||
const stored = await readFile(join(root, `${final.details.overflow.id}.txt`), "utf8");
|
||||
assert.ok(stored.includes("spill 0"));
|
||||
assert.ok(stored.includes("spill 4999"));
|
||||
});
|
||||
|
||||
test("view line numbers track the STORED file, not the current text", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
// bash spill: 5000 lines on disk, but the in-context result only carries
|
||||
// the last ~100 lines (still over threshold via padding).
|
||||
const spill = join(root, "spill-5000.txt");
|
||||
await writeFile(
|
||||
spill,
|
||||
Array.from({ length: 5000 }, (_, i) => `spill line ${i + 1}`).join("\n"),
|
||||
);
|
||||
const current = Array.from({ length: 100 }, (_, i) => `spill line ${4901 + i}`).join("\n") + "\n" + "pad".repeat(10_000);
|
||||
const event = textEvent("call-lines", current, { details: { fullOutputPath: spill } });
|
||||
const final = await runHandlers(bound.handlers, event, ctx());
|
||||
|
||||
const text = final.content[0].text;
|
||||
const id = final.details.overflow.id;
|
||||
assert.match(text, /--- lines 4881-5000 ---/, "tail range must come from the stored 5000-line file");
|
||||
assert.match(text, /spill line 5000/);
|
||||
assert.match(text, /\[pi-overflow id=[0-9a-f]{12} tool=bash 5000 lines/);
|
||||
|
||||
// overflow_query with the view's tail range returns the same lines.
|
||||
const tool = bound.tools.get("overflow_query");
|
||||
const result = await tool.execute("q1", { id, lines: { start: 4881, end: 4883 } });
|
||||
assert.match(result.content[0].text, /L4881: spill line 4881/);
|
||||
assert.match(result.content[0].text, /L4883: spill line 4883/);
|
||||
});
|
||||
|
||||
test("RTK scenario: view line numbers come from the stored original text", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const capture = harness();
|
||||
overflowCapture(capture.pi);
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const original = Array.from({ length: 3000 }, (_, i) => `orig ${i + 1}`).join("\n");
|
||||
await runHandlers(capture.handlers, textEvent("call-rtk", original), ctx());
|
||||
// RTK rewrites the visible text but it stays over threshold.
|
||||
const mid = {
|
||||
...textEvent("call-rtk", ""),
|
||||
content: [{ type: "text", text: "z".repeat(30_000) }],
|
||||
};
|
||||
const final = await runHandlers(bound.handlers, mid, ctx());
|
||||
const text = final.content[0].text;
|
||||
assert.match(text, /--- lines 2881-3000 ---/, "view tail must be the stored original's tail");
|
||||
assert.ok(text.includes("orig 3000"));
|
||||
});
|
||||
|
||||
test("file sources over maxEntryBytes keep real head and tail halves", async () => {
|
||||
const root = await makeStore();
|
||||
const { storeEntry } = await import("../src/store.ts");
|
||||
const src = join(root, "big.bin");
|
||||
const content = "HEAD-".repeat(200) + "MID-".repeat(2000) + "TAIL-".repeat(200);
|
||||
await writeFile(src, content);
|
||||
const maxEntryBytes = 2000;
|
||||
await storeEntry(root, "0123456789ab", { kind: "file", path: src }, { tool: "bash", args: {} }, maxEntryBytes);
|
||||
const stored = await readFile(join(root, "0123456789ab.txt"), "utf8");
|
||||
assert.ok(stored.startsWith("HEAD-"));
|
||||
assert.ok(stored.endsWith("TAIL-"));
|
||||
assert.match(stored, /bytes omitted/);
|
||||
// Head and tail halves must equal the source's real bytes.
|
||||
const half = Math.floor(maxEntryBytes / 2);
|
||||
assert.equal(stored.slice(0, half), content.slice(0, half));
|
||||
assert.equal(stored.slice(-half), content.slice(-half));
|
||||
assert.ok(!stored.includes("\0".repeat(100)), "must not store zeroed content");
|
||||
});
|
||||
|
||||
test("interrupted-write tmp files older than an hour are cleaned", async () => {
|
||||
const root = await makeStore();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const staleTmp = join(root, "deadbeef.txt.tmp-999-zzz");
|
||||
const freshTmp = join(root, "livebeef.txt.tmp-111-aaa");
|
||||
await writeFile(staleTmp, "partial");
|
||||
await writeFile(freshTmp, "partial");
|
||||
const old = new Date(Date.now() - 2 * 3_600_000);
|
||||
const { utimes } = await import("node:fs/promises");
|
||||
await utimes(staleTmp, old, old);
|
||||
|
||||
for (const handler of bound.handlers.get("session_start") ?? []) await handler({}, ctx());
|
||||
const { existsSync } = await import("node:fs");
|
||||
assert.ok(!existsSync(staleTmp), "stale tmp must be removed");
|
||||
assert.ok(existsSync(freshTmp), "fresh tmp must survive");
|
||||
});
|
||||
|
||||
test("current under threshold but original over → one-line marker appended", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const capture = harness();
|
||||
overflowCapture(capture.pi);
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const big = "a".repeat(30_000);
|
||||
const event = textEvent("call-3", big);
|
||||
await runHandlers(capture.handlers, event, ctx());
|
||||
const mid = { ...event, content: [{ type: "text", text: "short summary" }] };
|
||||
const final = await runHandlers(bound.handlers, mid, ctx());
|
||||
|
||||
const text = final.content[0].text;
|
||||
assert.ok(text.startsWith("short summary"));
|
||||
assert.match(text, /\[pi-overflow id=[0-9a-f]{12}: full original output stored \(\d+ lines, .*; use overflow_query/);
|
||||
assert.equal(final.details.overflow.truncated, false);
|
||||
assert.ok(final.details.overflow.id);
|
||||
});
|
||||
|
||||
test("exempt tools and nested calls are ignored", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const big = "x".repeat(30_000);
|
||||
const exempt = await runHandlers(bound.handlers, textEvent("c-ex", big, { toolName: "read" }), ctx());
|
||||
assert.equal(exempt.content[0].text, big, "read results must pass through untouched");
|
||||
|
||||
const nested = await runHandlers(bound.handlers, textEvent("c-nest", big, { parentToolCallId: "parent" }), ctx());
|
||||
assert.equal(nested.content[0].text, big, "nested calls must pass through untouched");
|
||||
});
|
||||
|
||||
test("image parts and structuredContent survive", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const event = textEvent("call-4", "x".repeat(30_000));
|
||||
event.content.push({ type: "image", data: "b64" } as any);
|
||||
(event as any).structuredContent = { rows: 3 };
|
||||
const final = await runHandlers(bound.handlers, event, ctx());
|
||||
assert.deepEqual(final.structuredContent, { rows: 3 });
|
||||
assert.equal(final.content.at(-1).type, "image");
|
||||
assert.equal(final.content.at(-1).data, "b64");
|
||||
});
|
||||
|
||||
test("existing details fields survive the merge", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const event = textEvent("call-5", "x".repeat(30_000), { details: { rtk: { saved: 100 }, fullOutputPath: undefined } });
|
||||
const final = await runHandlers(bound.handlers, event, ctx());
|
||||
assert.deepEqual(final.details.rtk, { saved: 100 });
|
||||
assert.ok(final.details.overflow.id);
|
||||
});
|
||||
|
||||
test("files are 0600 and directories 0700", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings() });
|
||||
|
||||
const final = await runHandlers(bound.handlers, textEvent("call-6", "x".repeat(30_000)), ctx());
|
||||
const id = final.details.overflow.id;
|
||||
const dirMode = (await stat(root)).mode & 0o777;
|
||||
const fileMode = (await stat(join(root, `${id}.txt`))).mode & 0o777;
|
||||
const metaMode = (await stat(join(root, `${id}.json`))).mode & 0o777;
|
||||
assert.equal(dirMode, 0o700);
|
||||
assert.equal(fileMode, 0o600);
|
||||
assert.equal(metaMode, 0o600);
|
||||
});
|
||||
|
||||
test("retention and total-size cleanup remove old entries", async () => {
|
||||
const root = await makeStore();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, {
|
||||
storeRoot: root,
|
||||
settings: settings({ retentionDays: 1, maxStoreBytes: 100 }),
|
||||
});
|
||||
|
||||
// An old entry (mtime in the past) must be deleted by age.
|
||||
const oldId = "aaaaaaaaaaaa";
|
||||
await writeFile(join(root, `${oldId}.txt`), "old");
|
||||
await writeFile(join(root, `${oldId}.json`), "{}");
|
||||
const past = new Date(Date.now() - 10 * 86_400_000);
|
||||
const { utimes } = await import("node:fs/promises");
|
||||
await utimes(join(root, `${oldId}.txt`), past, past);
|
||||
await utimes(join(root, `${oldId}.json`), past, past);
|
||||
|
||||
for (const handler of bound.handlers.get("session_start") ?? []) await handler({}, ctx());
|
||||
const names = await readdir(root);
|
||||
assert.ok(!names.some((n) => n.startsWith(oldId)), "old entry must be deleted");
|
||||
|
||||
// Fresh entries exceeding maxStoreBytes get evicted oldest-first.
|
||||
for (const [i, id] of ["bbbbbbbbbbbb", "cccccccccccc", "dddddddddddd"].entries()) {
|
||||
await writeFile(join(root, `${id}.txt`), "y".repeat(80));
|
||||
const t = new Date(Date.now() - (3 - i) * 60_000);
|
||||
await utimes(join(root, `${id}.txt`), t, t);
|
||||
}
|
||||
for (const handler of bound.handlers.get("session_start") ?? []) await handler({}, ctx());
|
||||
const after = (await readdir(root)).filter((n) => n.endsWith(".txt"));
|
||||
assert.ok(after.length <= 2, `expected eviction under maxStoreBytes, got ${after.join(",")}`);
|
||||
assert.ok(!after.includes("bbbbbbbbbbbb.txt"), "oldest entry must be evicted first");
|
||||
});
|
||||
|
||||
test("disabled setting clears the stash and returns undefined", async () => {
|
||||
const root = await makeStore();
|
||||
getStash().clear();
|
||||
const bound = harness();
|
||||
overflowExtension(bound.pi, { storeRoot: root, settings: settings({ enabled: false }) });
|
||||
getStash().set("c-off", { rawText: "x".repeat(30_000) });
|
||||
const event = textEvent("c-off", "x".repeat(30_000));
|
||||
const result = await runHandlers(bound.handlers, event, ctx());
|
||||
assert.equal(result, event);
|
||||
assert.ok(!getStash().has("c-off"));
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runOverflowQuery } from "../src/query.ts";
|
||||
import { storeEntry } from "../src/store.ts";
|
||||
import { readOverflowSettings, type OverflowSettings } from "../settings.ts";
|
||||
|
||||
function settings(overrides: Partial<OverflowSettings> = {}): OverflowSettings {
|
||||
return { ...readOverflowSettings("/nonexistent/pi-overflow.json"), ...overrides };
|
||||
}
|
||||
|
||||
async function store(text: string, id = "0123456789ab") {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-overflow-query-"));
|
||||
await storeEntry(root, id, { kind: "text", text }, { tool: "bash", args: { command: "run" } }, 1_000_000);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("search returns hits with merged context", async () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => (i === 9 || i === 11 || i === 50 ? `line ${i + 1} FAIL` : `line ${i + 1}`));
|
||||
const root = await store(lines.join("\n"));
|
||||
const result = await runOverflowQuery(root, { id: "0123456789ab", search: "FAIL", context: 1 }, settings());
|
||||
assert.ok(!result.error);
|
||||
assert.match(result.text, /L10: line 10 FAIL/);
|
||||
assert.match(result.text, /L12: line 12 FAIL/);
|
||||
assert.match(result.text, /L51: line 51 FAIL/);
|
||||
// hits at lines 10 and 12 with context 1 merge into one range 9-13
|
||||
assert.match(result.text, /--- lines 9-13 ---/);
|
||||
assert.match(result.text, /--- lines 50-52 ---/);
|
||||
});
|
||||
|
||||
test("line-range retrieval returns numbered lines", async () => {
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `row ${i + 1}`);
|
||||
const root = await store(lines.join("\n"));
|
||||
const result = await runOverflowQuery(root, { id: "0123456789ab", lines: { start: 10, end: 15 } }, settings());
|
||||
assert.match(result.text, /--- lines 10-15 ---/);
|
||||
assert.match(result.text, /L10: row 10/);
|
||||
assert.match(result.text, /L15: row 15/);
|
||||
assert.ok(!result.text.includes("row 16"));
|
||||
});
|
||||
|
||||
test("jsonPath extracts nested values", async () => {
|
||||
const doc = JSON.stringify({ snapshot: { nodes: [{ name: "a" }, { name: "b" }, { name: "c" }] } });
|
||||
const root = await store(doc);
|
||||
const result = await runOverflowQuery(root, { id: "0123456789ab", jsonPath: "snapshot.nodes[2].name" }, settings());
|
||||
assert.match(result.text, /"c"/);
|
||||
});
|
||||
|
||||
test("invalid id, regex, JSON and path return clear errors without throwing", async () => {
|
||||
const root = await store('{"a": {"b": 1}}');
|
||||
assert.ok((await runOverflowQuery(root, { id: "../etc/passwd" }, settings())).error);
|
||||
assert.ok((await runOverflowQuery(root, { id: "zzzzzzzzzzzz" }, settings())).error);
|
||||
assert.ok((await runOverflowQuery(root, { id: "0123456789ab", search: "([" }, settings())).error);
|
||||
const goodPath = await runOverflowQuery(root, { id: "0123456789ab", jsonPath: "a.b" }, settings());
|
||||
assert.ok(!goodPath.error, "valid path on valid JSON must succeed");
|
||||
const badPath = await runOverflowQuery(root, { id: "0123456789ab", jsonPath: "a.b.c!!" }, settings());
|
||||
assert.ok(badPath.error);
|
||||
const missing = await runOverflowQuery(root, { id: "ffffffffffff" }, settings());
|
||||
assert.ok(missing.error);
|
||||
});
|
||||
|
||||
test("non-JSON content + jsonPath reports the parse failure", async () => {
|
||||
const root = await store("not json at all");
|
||||
const result = await runOverflowQuery(root, { id: "0123456789ab", jsonPath: "a" }, settings());
|
||||
assert.ok(result.error);
|
||||
assert.match(result.text, /not valid JSON/);
|
||||
});
|
||||
|
||||
test("output is bounded by thresholdChars and marked truncated", async () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `line ${i + 1} pad ${"p".repeat(40)}`);
|
||||
const root = await store(lines.join("\n"));
|
||||
const result = await runOverflowQuery(
|
||||
root,
|
||||
{ id: "0123456789ab", lines: { start: 1, end: 5000 } },
|
||||
settings({ thresholdChars: 4000 }),
|
||||
);
|
||||
assert.ok(result.text.length <= 4000);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.match(result.text, /truncated/);
|
||||
});
|
||||
|
||||
test("lines and search output clip each line to maxLineChars", async () => {
|
||||
const long = `row ${"q".repeat(2_000)}`;
|
||||
const root = await store(`first\n${long}\n${long} ERROR\nlast`);
|
||||
const s = settings({ maxLineChars: 60 });
|
||||
const lines = await runOverflowQuery(root, { id: "0123456789ab", lines: { start: 2, end: 3 } }, s);
|
||||
assert.ok(lines.text.includes("q".repeat(56) + "…"));
|
||||
assert.ok(!lines.text.includes("q".repeat(61)));
|
||||
const search = await runOverflowQuery(root, { id: "0123456789ab", search: "ERROR", context: 0 }, s);
|
||||
assert.ok(search.text.includes("q".repeat(56) + "…"));
|
||||
assert.ok(!search.text.includes("q".repeat(61)));
|
||||
});
|
||||
|
||||
test("search only scans the first 10000 chars of each line", async () => {
|
||||
const earlyHit = `hit ${"a".repeat(100)} FAIL`;
|
||||
const lateHit = `${"a".repeat(10_500)} FAIL`;
|
||||
const root = await store(`${earlyHit}\n${lateHit}\nplain`);
|
||||
const result = await runOverflowQuery(root, { id: "0123456789ab", search: "FAIL", context: 0 }, settings());
|
||||
assert.match(result.text, /L1: hit /);
|
||||
assert.ok(!result.text.includes("L2:"), "match beyond char 10000 must not be reported");
|
||||
});
|
||||
|
||||
test("default query returns metadata and a first-screen view", async () => {
|
||||
const lines = Array.from({ length: 300 }, (_, i) => `row ${i + 1}`);
|
||||
const root = await store(lines.join("\n"));
|
||||
const result = await runOverflowQuery(root, { id: "0123456789ab" }, settings());
|
||||
assert.ok(!result.error);
|
||||
assert.match(result.text, /\[pi-overflow id=0123456789ab tool=bash \d+ lines \/ \d+ bytes\]/);
|
||||
assert.match(result.text, /--- lines 1-40 ---/);
|
||||
assert.ok(result.text.includes("row 300"), "tail of the stored file must appear");
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildView, collectViewFromText } from "../src/view.ts";
|
||||
import { readOverflowSettings, type OverflowSettings } from "../settings.ts";
|
||||
|
||||
function settings(overrides: Partial<OverflowSettings> = {}): OverflowSettings {
|
||||
return {
|
||||
...readOverflowSettings("/nonexistent/pi-overflow.json"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const meta = { id: "abc123def456", tool: "bash" };
|
||||
|
||||
function view(text: string, s = settings()) {
|
||||
return buildView(collectViewFromText(text, s), s, meta);
|
||||
}
|
||||
|
||||
test("below threshold remains unchanged", () => {
|
||||
const out = view("line one\nline two\nline three");
|
||||
assert.ok(out.includes("line one"));
|
||||
assert.ok(out.includes("line three"));
|
||||
});
|
||||
|
||||
test("above threshold includes header, line ranges, signal lines and tail", () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= 1000; i++) lines.push(`row ${i} ${i === 500 ? "ERROR boom" : "ok"}`);
|
||||
const out = view(lines.join("\n"));
|
||||
assert.match(out, /\[pi-overflow id=abc123def456 tool=bash 1000 lines/);
|
||||
assert.match(out, /--- lines 1-40 ---/);
|
||||
assert.match(out, /L500: row 500 ERROR boom/);
|
||||
assert.match(out, /--- lines 881-1000 ---/);
|
||||
assert.ok(out.includes("row 1000"));
|
||||
});
|
||||
|
||||
test("output never exceeds thresholdChars", () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `line ${i} ${"x".repeat(60)}`);
|
||||
const s = settings({ thresholdChars: 2000 });
|
||||
const out = view(lines.join("\n"), s);
|
||||
assert.ok(out.length <= 2000, `view ${out.length} exceeds budget`);
|
||||
assert.ok(out.includes("line 4999"), "tail must survive");
|
||||
});
|
||||
|
||||
test("few long lines still expose the ending", () => {
|
||||
const lines = ["start", ...Array.from({ length: 5 }, (_, i) => `middle${i} ${"y".repeat(4000)}`), "THE_END_MARKER"];
|
||||
const s = settings({ thresholdChars: 3000, headLines: 40, tailLines: 120 });
|
||||
const out = view(lines.join("\n"), s);
|
||||
assert.ok(out.includes("THE_END_MARKER"));
|
||||
assert.ok(out.length <= 3000 + 100, `view ${out.length} far exceeds budget`);
|
||||
});
|
||||
|
||||
test("same input produces identical output", () => {
|
||||
const lines = Array.from({ length: 800 }, (_, i) => `entry ${i} ${i % 77 === 0 ? "warning" : "fine"}`);
|
||||
const text = lines.join("\n");
|
||||
assert.equal(view(text), view(text));
|
||||
});
|
||||
|
||||
test("a single line over maxLineChars is truncated", () => {
|
||||
const long = "z".repeat(2000);
|
||||
const out = view(`a\n${long}\nb`, settings({ maxLineChars: 50 }));
|
||||
assert.ok(out.includes("z".repeat(50) + "…"));
|
||||
assert.ok(!out.includes("z".repeat(51)));
|
||||
});
|
||||
|
||||
test("signals outrank head and survive when head+tail exceed the budget", () => {
|
||||
// ~150-char lines: head 40 + tail 120 = 160 lines * 150 = ~24K plus
|
||||
// overhead, over budget. Shrink order must keep some signals while head
|
||||
// collapses to 10 lines before the 35/65 phase kicks in.
|
||||
const lines = Array.from({ length: 400 }, (_, i) => {
|
||||
const marker = i === 200 ? "ERROR detected here" : "ok";
|
||||
return `line ${i + 1} ${marker} ${"x".repeat(140)}`;
|
||||
});
|
||||
const out = view(lines.join("\n"));
|
||||
assert.match(out, /--- signals/);
|
||||
assert.match(out, /L201: line 201 ERROR detected here/);
|
||||
// head collapsed below the configured 40 lines
|
||||
assert.match(out, /--- lines 1-(?:[0-9]|10) ---/);
|
||||
assert.ok(out.includes("line 400"));
|
||||
});
|
||||
|
||||
test("signal hits inside the tail region are not duplicated", () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1} ${"y".repeat(40)}`);
|
||||
lines[99] = "line 100 tail-side ERROR";
|
||||
const out = view(lines.join("\n"));
|
||||
assert.ok(!out.includes("L100:"), "tail-region hit must not appear in signals");
|
||||
});
|
||||
Reference in New Issue
Block a user