mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import * as assert from "node:assert/strict";
|
|
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
import { Box, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
import {
|
|
createSharedToolResultRenderer,
|
|
type SharedOutputView,
|
|
} from "../../src/tools/shared-output-view.js";
|
|
import {
|
|
memoryResultView,
|
|
searchResultView,
|
|
skillResultView,
|
|
} from "../../src/tools/tool-result-views.js";
|
|
|
|
initTheme();
|
|
|
|
function fakeTheme() {
|
|
return {
|
|
fg: (_color: string, text: string): string => text,
|
|
getBgAnsi: (_color: string): string => "",
|
|
} as any;
|
|
}
|
|
|
|
function stripAnsi(text: string): string {
|
|
return text.replace(/\x1b\[[0-?]*[ -/]*m/g, "");
|
|
}
|
|
|
|
function renderPlain(component: Component, width = 120): string {
|
|
return component.render(width).map((row) => stripAnsi(row).trimEnd()).join("\n");
|
|
}
|
|
|
|
function result(text: string, details: Record<string, unknown> = { success: true }) {
|
|
return { content: [{ type: "text", text }], details };
|
|
}
|
|
|
|
function renderToolResult(
|
|
toolResult: ReturnType<typeof result>,
|
|
expanded: boolean,
|
|
width = 120,
|
|
): string {
|
|
return renderPlain(
|
|
createSharedToolResultRenderer()(
|
|
toolResult,
|
|
{ expanded, isPartial: false },
|
|
fakeTheme(),
|
|
{ isError: false },
|
|
),
|
|
width,
|
|
);
|
|
}
|
|
|
|
function renderView(view: SharedOutputView, expanded: boolean, width = 120): string {
|
|
return renderPlain(
|
|
createSharedToolResultRenderer(() => view)(
|
|
result(view.expandedText),
|
|
{ expanded, isPartial: false },
|
|
fakeTheme(),
|
|
{ isError: false },
|
|
),
|
|
width,
|
|
);
|
|
}
|
|
|
|
function assertRowKeepsBackground(row: string): void {
|
|
let backgroundActive = false;
|
|
for (let index = 0; index < row.length;) {
|
|
const sgr = row.slice(index).match(/^\x1b\[([0-9;]*)m/);
|
|
if (sgr) {
|
|
const parameters = sgr[1];
|
|
if (parameters === "" || parameters === "0" || parameters === "49") {
|
|
backgroundActive = false;
|
|
} else if (parameters.split(";")[0] === "48") {
|
|
backgroundActive = true;
|
|
}
|
|
index += sgr[0].length;
|
|
continue;
|
|
}
|
|
assert.equal(backgroundActive, true, `background cleared before ${JSON.stringify(row.slice(index))}`);
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
describe("shared tool-result view", () => {
|
|
it("collapses to one concise line and expands to the full bounded result", () => {
|
|
const toolResult = result("Found 2 memories matching auth:\n\nfirst\nsecond", {
|
|
success: true,
|
|
count: 2,
|
|
});
|
|
|
|
const collapsed = renderToolResult(toolResult, false);
|
|
assert.equal(collapsed.split("\n").length, 1);
|
|
assert.match(collapsed, /Found 2 memories matching auth/);
|
|
assert.doesNotMatch(collapsed, /second/);
|
|
assert.match(collapsed, /expand/);
|
|
|
|
const expanded = renderToolResult(toolResult, true);
|
|
assert.equal(expanded, toolResult.content[0].text);
|
|
});
|
|
|
|
it("sanitizes only the rendered copy of hostile tool text and failure details", () => {
|
|
const hostileText = [
|
|
"safe 世界 \x1b]52;c;YXR0YWNr\x07 after OSC",
|
|
"styled \x1b[31mred\x1b[0m moved \x1b[2J after CSI",
|
|
"private \x1bPdanger\x1b\\ after DCS",
|
|
"linked \x1b]8;;https://evil.example\x1b\\label\x1b]8;;\x1b\\ after ST",
|
|
"carriage\rreturn binary\x00\x08\x7f end",
|
|
].join("\n");
|
|
const hostileReason = "query \x1b]52;c;YXR0YWNr\x07 is required\x00";
|
|
const toolResult = result(hostileText, {
|
|
success: false,
|
|
message: hostileReason,
|
|
nested: { keep: true },
|
|
});
|
|
const before = structuredClone(toolResult);
|
|
|
|
const collapsed = renderToolResult(toolResult, false);
|
|
const expanded = renderToolResult(toolResult, true);
|
|
|
|
for (const rendered of [collapsed, expanded]) {
|
|
assert.doesNotMatch(rendered, /\x1b|\x07|\x00|\x08|\x7f|\r/);
|
|
}
|
|
assert.match(collapsed, /query .* is required/);
|
|
assert.match(expanded, /safe 世界/);
|
|
assert.match(expanded, /styled red moved after CSI/);
|
|
assert.match(expanded, /private danger\\ after DCS/);
|
|
assert.match(expanded, /linked label after ST/);
|
|
assert.match(expanded, /carriagereturn binary end/);
|
|
assert.deepEqual(toolResult, before);
|
|
|
|
const adapterResult = result("safe", {
|
|
success: true,
|
|
message: "Entry added.",
|
|
target: "memory\x1b[2J",
|
|
usage: "1 entry\x00",
|
|
});
|
|
const adapterBefore = structuredClone(adapterResult);
|
|
const adapterRendered = renderPlain(createSharedToolResultRenderer(memoryResultView)(
|
|
adapterResult,
|
|
{ expanded: false, isPartial: false },
|
|
fakeTheme(),
|
|
{ isError: false },
|
|
));
|
|
assert.doesNotMatch(adapterRendered, /\x1b|\x00/);
|
|
assert.deepEqual(adapterResult, adapterBefore);
|
|
});
|
|
|
|
it("does not mutate model-visible content or details", () => {
|
|
const toolResult = result("immutable\nfull output", {
|
|
success: true,
|
|
nested: { keep: true },
|
|
});
|
|
const before = structuredClone(toolResult);
|
|
|
|
renderToolResult(toolResult, false);
|
|
renderToolResult(toolResult, true);
|
|
|
|
assert.deepEqual(toolResult, before);
|
|
});
|
|
|
|
it("keeps textual failure and warning reasons in collapsed output", () => {
|
|
const failure = renderToolResult(
|
|
result("query is required", { success: false, message: "query is required" }),
|
|
false,
|
|
);
|
|
assert.match(failure, /query is required/);
|
|
|
|
const warningView = memoryResultView(result(JSON.stringify({
|
|
success: true,
|
|
message: "Entry added. Warning: SQLite mirror unavailable",
|
|
warning: "SQLite mirror unavailable",
|
|
warnings: ["SQLite mirror unavailable"],
|
|
target: "memory",
|
|
entry_count: 1,
|
|
}), {
|
|
success: true,
|
|
message: "Entry added. Warning: SQLite mirror unavailable",
|
|
warning: "SQLite mirror unavailable",
|
|
warnings: ["SQLite mirror unavailable"],
|
|
target: "memory",
|
|
entry_count: 1,
|
|
}));
|
|
assert.match(renderView(warningView, false), /SQLite mirror unavailable/);
|
|
});
|
|
|
|
it("fits ANSI, CJK, and partial rows while preserving the outer tool-card background", () => {
|
|
const width = 34;
|
|
const backgroundOpen = "\x1b[48;2;40;50;40m";
|
|
const ansiTheme = {
|
|
fg: (_color: string, text: string): string => `\x1b[38;2;145;148;145m${text}\x1b[39m`,
|
|
getBgAnsi: (_color: string): string => backgroundOpen,
|
|
} as any;
|
|
const box = new Box(1, 1, (text: string) => `${backgroundOpen}${text}\x1b[49m`);
|
|
box.addChild(createSharedToolResultRenderer()(
|
|
result("处理中 世界世界世界世界 \x1b[1mvery long partial output\x1b[0m"),
|
|
{ expanded: false, isPartial: true },
|
|
ansiTheme,
|
|
{ isError: false },
|
|
));
|
|
|
|
const rows = box.render(width);
|
|
assert.equal(rows.length, 3);
|
|
assert.equal(visibleWidth(rows[1]), width);
|
|
assert.match(stripAnsi(rows[1]), /处理中|In progress/);
|
|
|
|
assertRowKeepsBackground(rows[1]);
|
|
});
|
|
|
|
it("keeps the tool-card background after sanitizing expanded ANSI and CJK text", () => {
|
|
const width = 18;
|
|
const backgroundOpen = "\x1b[48;2;40;50;40m";
|
|
const ansiTheme = {
|
|
fg: (_color: string, text: string): string => text,
|
|
getBgAnsi: (_color: string): string => backgroundOpen,
|
|
} as any;
|
|
const expandedText = "第一行 世界世界世界\x1b[0m继续\n第二行 \x1b[49m背景保留";
|
|
const box = new Box(1, 1, (text: string) => `${backgroundOpen}${text}\x1b[49m`);
|
|
box.addChild(createSharedToolResultRenderer()(
|
|
result(expandedText),
|
|
{ expanded: true, isPartial: false },
|
|
ansiTheme,
|
|
{ isError: false },
|
|
));
|
|
|
|
const rows = box.render(width);
|
|
assert.ok(rows.length > 4);
|
|
for (const row of rows) {
|
|
assert.equal(visibleWidth(row), width);
|
|
assertRowKeepsBackground(row);
|
|
}
|
|
const expandedRows = rows.slice(1, -1).map((row) => stripAnsi(row).trim());
|
|
assert.deepEqual(expandedRows, ["第一行 世界世界", "世界继续", "第二行 背景保留"]);
|
|
});
|
|
|
|
it("uses Pi shell state for the card background and logical status for foreground", () => {
|
|
const scenarios = [
|
|
{ partial: true, isError: false, success: false, background: "toolPendingBg", foreground: "warning" },
|
|
{ partial: false, isError: true, success: true, background: "toolErrorBg", foreground: "error" },
|
|
{ partial: false, isError: false, success: true, background: "toolSuccessBg", foreground: "toolOutput" },
|
|
{ partial: false, isError: false, success: false, background: "toolSuccessBg", foreground: "error" },
|
|
] as const;
|
|
|
|
for (const scenario of scenarios) {
|
|
const backgrounds: string[] = [];
|
|
const foregrounds: string[] = [];
|
|
const theme = {
|
|
fg: (color: string, text: string): string => {
|
|
foregrounds.push(color);
|
|
return text;
|
|
},
|
|
getBgAnsi: (color: string): string => {
|
|
backgrounds.push(color);
|
|
return "";
|
|
},
|
|
} as any;
|
|
const message = scenario.success ? "done" : "query is required";
|
|
const toolResult = result(message, { success: scenario.success, message });
|
|
|
|
const renderer = createSharedToolResultRenderer();
|
|
const rendered = renderPlain(renderer(
|
|
toolResult,
|
|
{ expanded: false, isPartial: scenario.partial },
|
|
theme,
|
|
{ isError: scenario.isError },
|
|
));
|
|
renderPlain(renderer(
|
|
toolResult,
|
|
{ expanded: true, isPartial: scenario.partial },
|
|
theme,
|
|
{ isError: scenario.isError },
|
|
));
|
|
|
|
assert.deepEqual(backgrounds, [scenario.background, scenario.background]);
|
|
assert.deepEqual(foregrounds, [scenario.foreground]);
|
|
if (!scenario.success) assert.match(rendered, /query is required/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("tool-specific summaries", () => {
|
|
it("summarizes memory, search/session, and skill results without changing expanded text", () => {
|
|
const memoryText = JSON.stringify({
|
|
success: true,
|
|
message: "Entry added.",
|
|
target: "failure",
|
|
category: "tool-quirk",
|
|
entry_count: 2,
|
|
usage: "120 / 5,000 chars",
|
|
});
|
|
const memory = memoryResultView(result(memoryText, JSON.parse(memoryText)));
|
|
assert.match(memory.summary, /Saved/);
|
|
assert.match(memory.summary, /target: failure/);
|
|
assert.match(memory.summary, /category: tool-quirk/);
|
|
assert.equal(memory.expandedText, memoryText);
|
|
|
|
const searchText = "Found 3 memories matching auth:\n\nfirst\nsecond\nthird";
|
|
const search = searchResultView(result(searchText, { success: true, count: 3 }));
|
|
assert.match(search.summary, /3/);
|
|
assert.equal(search.expandedText, searchText);
|
|
|
|
const skillText = JSON.stringify({ success: true, skillId: "global:deploy", name: "deploy" });
|
|
const skill = skillResultView(result(skillText, JSON.parse(skillText)));
|
|
assert.match(skill.summary, /deploy/i);
|
|
assert.equal(skill.expandedText, skillText);
|
|
});
|
|
|
|
it("renders a real skill-tool JSON failure as an actionable failure", () => {
|
|
const error = "Skill 'global:missing' not found.";
|
|
const skillText = JSON.stringify({ success: false, error });
|
|
const toolResult = result(skillText, {});
|
|
const skill = skillResultView(toolResult);
|
|
|
|
assert.equal(skill.status, "failure");
|
|
assert.equal(skill.summary, `Error · ${error}`);
|
|
assert.equal(skill.expandedText, skillText);
|
|
assert.match(renderPlain(
|
|
createSharedToolResultRenderer(skillResultView)(
|
|
toolResult,
|
|
{ expanded: false, isPartial: false },
|
|
fakeTheme(),
|
|
{ isError: false },
|
|
),
|
|
), new RegExp(error.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
});
|
|
|
|
it("summarizes successful failure memory from the real producer result shape", () => {
|
|
// MemoryResult has no category field; producers encode it only in message.
|
|
const details = {
|
|
success: true,
|
|
target: "failure",
|
|
message: "Failure memory saved: tool-quirk",
|
|
entry_count: 16,
|
|
usage: "92% — 9271/10000 chars",
|
|
};
|
|
const fullText = JSON.stringify(details);
|
|
const toolResult = result(fullText, details);
|
|
const before = structuredClone(toolResult);
|
|
const view = memoryResultView(toolResult);
|
|
|
|
assert.equal(view.status, "success");
|
|
assert.equal(
|
|
view.summary,
|
|
"Saved · target: failure · category: tool-quirk · 16 entries · 92% — 9271/10000 chars",
|
|
);
|
|
assert.doesNotMatch(view.summary, /^failure:/);
|
|
assert.equal(view.expandedText, fullText);
|
|
assert.deepEqual(toolResult, before);
|
|
|
|
const collapsed = renderPlain(
|
|
createSharedToolResultRenderer(memoryResultView)(
|
|
toolResult,
|
|
{ expanded: false, isPartial: false },
|
|
fakeTheme(),
|
|
{ isError: false },
|
|
),
|
|
);
|
|
assert.match(
|
|
collapsed,
|
|
/Saved · target: failure · category: tool-quirk · 16 entries · 92% — 9271\/10000 chars/,
|
|
);
|
|
assert.doesNotMatch(collapsed, /^failure:/);
|
|
});
|
|
});
|