mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照
从官方 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。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Unit tests for correction detection — isCorrection() pattern matching
|
||||
* and handler behavior (rate limiting, pi.exec trigger).
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseManager } from "../../src/store/db.js";
|
||||
import { getMemories } from "../../src/store/sqlite-memory-store.js";
|
||||
import { MemoryStore } from "../../src/store/memory-store.js";
|
||||
import { registerMemoryTool } from "../../src/tools/memory-tool.js";
|
||||
import { isCorrection, setupCorrectionDetector } from "../../src/handlers/correction-detector.js";
|
||||
import { resolveWatchedChildPiInvocation } from "../../src/handlers/pi-child-process.js";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import type { MemoryConfig } from "../../src/types.js";
|
||||
|
||||
// ─── Pattern matching tests ───
|
||||
|
||||
describe("isCorrection", () => {
|
||||
// ── Strong patterns (always trigger) ──
|
||||
|
||||
describe("strong patterns (always trigger)", () => {
|
||||
it("matches 'don't do that'", () => {
|
||||
assert.strictEqual(isCorrection("don't do that"), true);
|
||||
});
|
||||
|
||||
it("matches 'not like that'", () => {
|
||||
assert.strictEqual(isCorrection("not like that"), true);
|
||||
});
|
||||
|
||||
it("matches 'I said use yarn'", () => {
|
||||
assert.strictEqual(isCorrection("I said use yarn"), true);
|
||||
});
|
||||
|
||||
it("matches 'I told you already'", () => {
|
||||
assert.strictEqual(isCorrection("I told you already"), true);
|
||||
});
|
||||
|
||||
it("matches 'we already discussed this'", () => {
|
||||
assert.strictEqual(isCorrection("we already discussed this"), true);
|
||||
});
|
||||
|
||||
it("matches 'please don't commit yet'", () => {
|
||||
assert.strictEqual(isCorrection("please don't commit yet"), true);
|
||||
});
|
||||
|
||||
it("matches \"that's not what I asked for\"", () => {
|
||||
assert.strictEqual(isCorrection("that's not what I asked for"), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Weak patterns (need directive clause) ──
|
||||
|
||||
describe("weak patterns (need directive clause)", () => {
|
||||
it("matches 'no, use yarn instead' (has directive 'use')", () => {
|
||||
assert.strictEqual(isCorrection("no, use yarn instead"), true);
|
||||
});
|
||||
|
||||
it("matches 'wrong, the file is in src/' (has directive 'the')", () => {
|
||||
assert.strictEqual(isCorrection("wrong, the file is in src/"), true);
|
||||
});
|
||||
|
||||
it("matches 'actually, don't use that' (has directive 'don't')", () => {
|
||||
assert.strictEqual(isCorrection("actually, don't use that"), true);
|
||||
});
|
||||
|
||||
it("matches 'stop, fix the test first' (has directive 'fix')", () => {
|
||||
assert.strictEqual(isCorrection("stop, fix the test first"), true);
|
||||
});
|
||||
|
||||
it("matches 'no! delete that file' (has directive 'delete')", () => {
|
||||
assert.strictEqual(isCorrection("no! delete that file"), true);
|
||||
});
|
||||
|
||||
it("does NOT match 'no just kidding' (no directive clause)", () => {
|
||||
assert.strictEqual(isCorrection("no just kidding"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Negative patterns (suppress even if positive matches) ──
|
||||
|
||||
describe("negative patterns (suppress false positives)", () => {
|
||||
it("suppresses 'no worries, I'll handle it'", () => {
|
||||
assert.strictEqual(isCorrection("no worries, I'll handle it"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'no problem'", () => {
|
||||
assert.strictEqual(isCorrection("no problem"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'no thanks'", () => {
|
||||
assert.strictEqual(isCorrection("no thanks"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'no need to change that'", () => {
|
||||
assert.strictEqual(isCorrection("no need to change that"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'actually, that looks great'", () => {
|
||||
assert.strictEqual(isCorrection("actually, that looks great"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'actually, perfect'", () => {
|
||||
assert.strictEqual(isCorrection("actually, perfect"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'actually, that's correct'", () => {
|
||||
assert.strictEqual(isCorrection("actually, that's correct"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'stop there'", () => {
|
||||
assert.strictEqual(isCorrection("stop there"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'stop here'", () => {
|
||||
assert.strictEqual(isCorrection("stop here"), false);
|
||||
});
|
||||
|
||||
it("suppresses 'stop for now'", () => {
|
||||
assert.strictEqual(isCorrection("stop for now"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-corrections (should NOT trigger) ──
|
||||
|
||||
describe("non-corrections (should NOT trigger)", () => {
|
||||
it("does NOT match 'yes, do that'", () => {
|
||||
assert.strictEqual(isCorrection("yes, do that"), false);
|
||||
});
|
||||
|
||||
it("does NOT match 'looks good'", () => {
|
||||
assert.strictEqual(isCorrection("looks good"), false);
|
||||
});
|
||||
|
||||
it("does NOT match 'can you also check the tests?'", () => {
|
||||
assert.strictEqual(isCorrection("can you also check the tests?"), false);
|
||||
});
|
||||
|
||||
it("does NOT match empty string", () => {
|
||||
assert.strictEqual(isCorrection(""), false);
|
||||
});
|
||||
|
||||
it("does NOT match 'thanks'", () => {
|
||||
assert.strictEqual(isCorrection("thanks"), false);
|
||||
});
|
||||
|
||||
it("does NOT match 'great, that works'", () => {
|
||||
assert.strictEqual(isCorrection("great, that works"), false);
|
||||
});
|
||||
|
||||
it("does NOT match 'please continue'", () => {
|
||||
assert.strictEqual(isCorrection("please continue"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case insensitivity ──
|
||||
|
||||
describe("case insensitivity", () => {
|
||||
it("matches 'DON'T DO THAT' (uppercase)", () => {
|
||||
assert.strictEqual(isCorrection("DON'T DO THAT"), true);
|
||||
});
|
||||
|
||||
it("matches 'I Told You Already' (mixed case)", () => {
|
||||
assert.strictEqual(isCorrection("I Told You Already"), true);
|
||||
});
|
||||
|
||||
it("suppresses 'No Worries' (uppercase negative)", () => {
|
||||
assert.strictEqual(isCorrection("No Worries"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom pattern config", () => {
|
||||
it("matches custom strong patterns", () => {
|
||||
assert.strictEqual(
|
||||
isCorrection("custom correction", { correctionStrongPatterns: ["^custom correction$"] }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom negative patterns to suppress matches", () => {
|
||||
assert.strictEqual(
|
||||
isCorrection("custom correction", {
|
||||
correctionStrongPatterns: ["^custom"],
|
||||
correctionNegativePatterns: ["^custom correction$"],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom directive words for weak patterns", () => {
|
||||
assert.strictEqual(
|
||||
isCorrection("no, shipit now", { correctionDirectiveWords: ["shipit"] }),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isCorrection("no, use yarn", { correctionDirectiveWords: ["shipit"] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores invalid custom regex entries and keeps valid entries", () => {
|
||||
assert.strictEqual(
|
||||
isCorrection("custom correction", { correctionStrongPatterns: ["bad(", "^custom"] }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats explicit empty or all-invalid pattern arrays as empty", () => {
|
||||
assert.strictEqual(
|
||||
isCorrection("don't do that", { correctionStrongPatterns: [] }),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isCorrection("don't do that", { correctionStrongPatterns: ["bad("] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Handler behavior tests ───
|
||||
|
||||
describe("setupCorrectionDetector handler", () => {
|
||||
let handlers: Record<string, Function[]>;
|
||||
let execCalls: any[];
|
||||
let notifyCalls: any[];
|
||||
let tmpDir: string;
|
||||
let dbManager: DatabaseManager;
|
||||
|
||||
function createMockPi(execReturn?: { code: number; stdout: string; stderr: string }) {
|
||||
const ret = execReturn ?? { code: 0, stdout: "Saved correction", stderr: "" };
|
||||
return {
|
||||
on: (event: string, handler: Function) => {
|
||||
handlers[event] = handlers[event] || [];
|
||||
handlers[event].push(handler);
|
||||
},
|
||||
exec: async (...args: any[]) => {
|
||||
execCalls.push(args);
|
||||
return ret;
|
||||
},
|
||||
registerTool: () => {},
|
||||
registerCommand: () => {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
let directCalls: unknown[][];
|
||||
|
||||
function makeDirectDeps(
|
||||
result: { ok: boolean; appliedCount: number } | "throw",
|
||||
): { runDirectMemoryCompletion: (...args: unknown[]) => Promise<{ ok: boolean; appliedCount: number }> } {
|
||||
return {
|
||||
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
||||
directCalls.push(args);
|
||||
if (result === "throw") throw new Error("injected direct correction failure");
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function correctionBranch(userText = "don't do that") {
|
||||
return [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: userText }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "I used npm" }] } },
|
||||
];
|
||||
}
|
||||
|
||||
function storeWithFailureTracking() {
|
||||
let failureCount = 0;
|
||||
return {
|
||||
store: {
|
||||
getMemoryEntries: () => ["existing entry"],
|
||||
getUserEntries: () => [],
|
||||
addFailure: async () => {
|
||||
failureCount += 1;
|
||||
},
|
||||
getFailureCount: () => failureCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function logicalChildArgs(call: any[]): string[] {
|
||||
const [cmd, args] = call;
|
||||
const underlying = { command: args[3], args: args.slice(4) };
|
||||
const expected = resolveWatchedChildPiInvocation(underlying, Number(args[1]), args[2]);
|
||||
assert.strictEqual(cmd, expected.command);
|
||||
assert.deepStrictEqual(args, expected.args);
|
||||
return underlying.command === "pi" ? underlying.args : underlying.args.slice(1);
|
||||
}
|
||||
|
||||
const mockStore = {
|
||||
getMemoryEntries: () => ["existing entry"],
|
||||
getUserEntries: () => [],
|
||||
} as any;
|
||||
|
||||
const config = {
|
||||
correctionDetection: true,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: false,
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
flushOnCompact: false,
|
||||
flushOnShutdown: false,
|
||||
flushMinTurns: 6,
|
||||
nudgeToolCalls: 15,
|
||||
consolidationTimeoutMs: 60_000,
|
||||
};
|
||||
|
||||
const directTransportConfig: MemoryConfig = { ...config, reviewTransport: "direct" };
|
||||
|
||||
function makeCtx(branch: any[] = []) {
|
||||
return {
|
||||
sessionManager: { getBranch: () => branch },
|
||||
signal: undefined,
|
||||
cwd: "/tmp/active-project",
|
||||
model: { provider: "local-llama", id: "local-9b" },
|
||||
modelRegistry: {},
|
||||
ui: {
|
||||
notify: (msg: string, level: string) => {
|
||||
notifyCalls.push({ msg, level });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fireMessageEnd(role: string, text: string) {
|
||||
const h = handlers["message_end"];
|
||||
if (!h) throw new Error("No message_end handler registered");
|
||||
for (const fn of h) {
|
||||
fn({ message: { role, content: [{ type: "text", text }] } }, makeCtx());
|
||||
}
|
||||
}
|
||||
|
||||
function fireTurnEnd(branch: any[] = []): Promise<unknown> {
|
||||
const h = handlers["turn_end"];
|
||||
if (!h) throw new Error("No turn_end handler registered");
|
||||
const ctx = makeCtx(branch);
|
||||
return Promise.all(h.map((fn) => fn({}, ctx)));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers = {};
|
||||
execCalls = [];
|
||||
notifyCalls = [];
|
||||
directCalls = [];
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "correction-detector-test-"));
|
||||
dbManager = new DatabaseManager(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbManager.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("triggers pi.exec when correction detected", async () => {
|
||||
const pi = createMockPi();
|
||||
setupCorrectionDetector(pi, mockStore, null, config);
|
||||
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "don't do that" }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(branch);
|
||||
|
||||
assert.ok(execCalls.length >= 1, "pi.exec should be called on correction");
|
||||
const options = execCalls[0][2] as { cwd?: string };
|
||||
assert.equal(options.cwd, "/tmp/active-project");
|
||||
const args = logicalChildArgs(execCalls[0]);
|
||||
assert.deepStrictEqual(args.slice(0, 4), [
|
||||
"-p", "--no-session", "--model", "local-llama/local-9b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes child LLM override args and defaults thinking to off when only a model override is set", async () => {
|
||||
const pi = createMockPi();
|
||||
setupCorrectionDetector(pi, mockStore, null, {
|
||||
...config,
|
||||
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
||||
});
|
||||
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "don't do that" }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(branch);
|
||||
|
||||
const cmdArgs = logicalChildArgs(execCalls[0]);
|
||||
assert.deepStrictEqual(
|
||||
cmdArgs.slice(0, 6),
|
||||
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off"],
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT trigger on normal messages", async () => {
|
||||
const pi = createMockPi();
|
||||
setupCorrectionDetector(pi, mockStore, null, config);
|
||||
|
||||
fireMessageEnd("user", "looks good");
|
||||
await fireTurnEnd([]);
|
||||
|
||||
assert.strictEqual(execCalls.length, 0, "pi.exec should NOT be called for normal messages");
|
||||
});
|
||||
|
||||
it("rate limits: does not trigger on consecutive corrections within 3 turns", async () => {
|
||||
const pi = createMockPi();
|
||||
setupCorrectionDetector(pi, mockStore, null, config);
|
||||
|
||||
// First correction
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd([]);
|
||||
|
||||
const firstCallCount = execCalls.length;
|
||||
assert.ok(firstCallCount >= 1, "first correction should trigger");
|
||||
|
||||
// Second correction within 3 turns — should be rate-limited
|
||||
fireMessageEnd("user", "not like that");
|
||||
await fireTurnEnd([]);
|
||||
|
||||
assert.strictEqual(execCalls.length, firstCallCount, "second correction should be rate-limited");
|
||||
});
|
||||
|
||||
it("syncs direct correction saves into SQLite", async () => {
|
||||
const pi = createMockPi();
|
||||
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
|
||||
await correctionStore.loadFromDisk();
|
||||
registerMemoryTool(pi, correctionStore, null, dbManager);
|
||||
|
||||
setupCorrectionDetector(pi, correctionStore, null, config, dbManager);
|
||||
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "no, use pnpm instead" }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
|
||||
fireMessageEnd("user", "no, use pnpm instead");
|
||||
await fireTurnEnd(branch);
|
||||
|
||||
const failures = getMemories(dbManager, { target: 'failure' });
|
||||
assert.strictEqual(failures.length, 1);
|
||||
assert.match(failures[0].content, /use pnpm instead/);
|
||||
assert.strictEqual(failures[0].category, 'correction');
|
||||
assert.strictEqual(failures[0].project, null);
|
||||
});
|
||||
|
||||
it("saves the latest user message as the correction failure", async () => {
|
||||
const pi = createMockPi();
|
||||
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
|
||||
await correctionStore.loadFromDisk();
|
||||
setupCorrectionDetector(pi, correctionStore, null, config);
|
||||
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "First, use Maven for the build." }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "I will use Maven." }] } },
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "Continue with the implementation." }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "Implemented." }] } },
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "No, that is incorrect. Use javac only, not Maven." }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "Understood." }] } },
|
||||
];
|
||||
|
||||
fireMessageEnd("user", "No, that is incorrect. Use javac only, not Maven.");
|
||||
await fireTurnEnd(branch);
|
||||
|
||||
const failures = correctionStore.getFailureEntries();
|
||||
assert.strictEqual(failures.length, 1);
|
||||
assert.match(failures[0], /use javac only, not Maven/i);
|
||||
assert.doesNotMatch(failures[0], /First, use Maven/i);
|
||||
});
|
||||
|
||||
it("syncs project correction saves into SQLite with project scope", async () => {
|
||||
const pi = createMockPi();
|
||||
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
|
||||
await correctionStore.loadFromDisk();
|
||||
const projectStore = {
|
||||
getMemoryEntries: () => [],
|
||||
} as any;
|
||||
registerMemoryTool(pi, correctionStore, projectStore, dbManager, 'project-a');
|
||||
|
||||
setupCorrectionDetector(pi, correctionStore, projectStore, config, dbManager, 'project-a');
|
||||
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "no, use pnpm in this repo" }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
|
||||
fireMessageEnd("user", "no, use pnpm in this repo");
|
||||
await fireTurnEnd(branch);
|
||||
|
||||
const projectFailures = getMemories(dbManager, { target: 'failure', project: 'project-a' });
|
||||
assert.strictEqual(projectFailures.length, 1);
|
||||
assert.match(projectFailures[0].content, /use pnpm in this repo/);
|
||||
assert.doesNotMatch(projectFailures[0].content, /Project: project-a/);
|
||||
assert.strictEqual(projectFailures[0].category, 'correction');
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'failure', project: null }).length, 0);
|
||||
});
|
||||
|
||||
it("does not break correction handling when SQLite sync fails", async () => {
|
||||
const pi = createMockPi();
|
||||
const correctionStore = new MemoryStore({ ...config, memoryDir: tmpDir } as any);
|
||||
await correctionStore.loadFromDisk();
|
||||
|
||||
const failingDbManager = {
|
||||
getDb: () => {
|
||||
throw new Error('sqlite unavailable');
|
||||
},
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
registerMemoryTool(pi, correctionStore, null, failingDbManager);
|
||||
setupCorrectionDetector(pi, correctionStore, null, config, failingDbManager);
|
||||
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "no, use yarn instead" }] } },
|
||||
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
|
||||
fireMessageEnd("user", "no, use yarn instead");
|
||||
await fireTurnEnd(branch);
|
||||
|
||||
assert.ok(execCalls.length >= 1, 'correction review should still run');
|
||||
assert.strictEqual(correctionStore.getFailureEntries().length, 1, 'Markdown correction save should still happen');
|
||||
});
|
||||
|
||||
describe("direct memory completion transport", () => {
|
||||
it("skips subprocess and notifies when direct succeeds with applied memories", async () => {
|
||||
const pi = createMockPi();
|
||||
const { store } = storeWithFailureTracking();
|
||||
setupCorrectionDetector(
|
||||
pi,
|
||||
store as unknown as MemoryStore,
|
||||
null,
|
||||
directTransportConfig,
|
||||
null,
|
||||
null,
|
||||
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
||||
);
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(correctionBranch());
|
||||
|
||||
assert.strictEqual(directCalls.length, 1);
|
||||
assert.strictEqual(execCalls.length, 0, "subprocess must not run on successful direct correction");
|
||||
assert.deepStrictEqual(
|
||||
notifyCalls.filter((n) => n.msg === "🔧 Correction detected — memory updated"),
|
||||
[{ msg: "🔧 Correction detected — memory updated", level: "info" }],
|
||||
);
|
||||
});
|
||||
|
||||
it("includes project and failure target routing in direct correction prompts", async () => {
|
||||
const pi = createMockPi();
|
||||
const { store } = storeWithFailureTracking();
|
||||
const projectStore = { getMemoryEntries: () => ["existing project fact"] } as unknown as Parameters<typeof setupCorrectionDetector>[2];
|
||||
setupCorrectionDetector(
|
||||
pi,
|
||||
store as unknown as MemoryStore,
|
||||
projectStore,
|
||||
directTransportConfig,
|
||||
null,
|
||||
"project-a",
|
||||
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
||||
);
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(correctionBranch());
|
||||
|
||||
const options = directCalls[0][3] as { systemPrompt: string; userPrompt: string };
|
||||
assert.match(options.systemPrompt, /project-specific facts.*target "project"/i);
|
||||
assert.match(options.systemPrompt, /failures, corrections.*target "failure"/i);
|
||||
assert.match(options.userPrompt, /--- Current Project Memory ---/);
|
||||
});
|
||||
|
||||
it("skips subprocess and omits memory-updated notify when direct ok with zero applied", async () => {
|
||||
const pi = createMockPi();
|
||||
const { store } = storeWithFailureTracking();
|
||||
setupCorrectionDetector(
|
||||
pi,
|
||||
store as unknown as MemoryStore,
|
||||
null,
|
||||
directTransportConfig,
|
||||
null,
|
||||
null,
|
||||
makeDirectDeps({ ok: true, appliedCount: 0 }),
|
||||
);
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(correctionBranch());
|
||||
|
||||
assert.strictEqual(directCalls.length, 1);
|
||||
assert.strictEqual(execCalls.length, 0, "ok:true with appliedCount 0 still completes via direct transport");
|
||||
assert.strictEqual(
|
||||
notifyCalls.some((n) => n.msg === "🔧 Correction detected — memory updated"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to subprocess when direct returns ok false", async () => {
|
||||
const pi = createMockPi();
|
||||
const { store } = storeWithFailureTracking();
|
||||
setupCorrectionDetector(
|
||||
pi,
|
||||
store as unknown as MemoryStore,
|
||||
null,
|
||||
directTransportConfig,
|
||||
null,
|
||||
null,
|
||||
makeDirectDeps({ ok: false, appliedCount: 0 }),
|
||||
);
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(correctionBranch());
|
||||
|
||||
assert.strictEqual(directCalls.length, 1);
|
||||
assert.ok(execCalls.length >= 1, "failed direct result must fall back to subprocess");
|
||||
});
|
||||
|
||||
it("falls back to subprocess when direct throws without propagating", async () => {
|
||||
const pi = createMockPi();
|
||||
const { store } = storeWithFailureTracking();
|
||||
setupCorrectionDetector(
|
||||
pi,
|
||||
store as unknown as MemoryStore,
|
||||
null,
|
||||
directTransportConfig,
|
||||
null,
|
||||
null,
|
||||
makeDirectDeps("throw"),
|
||||
);
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(correctionBranch());
|
||||
|
||||
assert.strictEqual(directCalls.length, 1);
|
||||
assert.ok(execCalls.length >= 1, "thrown direct error must fall back to subprocess");
|
||||
});
|
||||
|
||||
it("uses subprocess only when reviewTransport is subprocess", async () => {
|
||||
const pi = createMockPi();
|
||||
const { store } = storeWithFailureTracking();
|
||||
const subprocessConfig: MemoryConfig = { ...config, reviewTransport: "subprocess" };
|
||||
setupCorrectionDetector(
|
||||
pi,
|
||||
store as unknown as MemoryStore,
|
||||
null,
|
||||
subprocessConfig,
|
||||
null,
|
||||
null,
|
||||
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
||||
);
|
||||
|
||||
fireMessageEnd("user", "don't do that");
|
||||
await fireTurnEnd(correctionBranch());
|
||||
|
||||
assert.strictEqual(directCalls.length, 0, "direct transport must be skipped for subprocess config");
|
||||
assert.ok(execCalls.length >= 1, "subprocess must run when direct transport is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register handlers when correctionDetection is false", () => {
|
||||
const pi = createMockPi();
|
||||
const disabledConfig = { ...config, correctionDetection: false };
|
||||
setupCorrectionDetector(pi, mockStore, null, disabledConfig);
|
||||
|
||||
assert.strictEqual(Object.keys(handlers).length, 0, "no handlers should be registered when disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { registerInsightsCommand } from "../../src/handlers/insights.js";
|
||||
import { DatabaseManager } from "../../src/store/db.js";
|
||||
import { addMemory, recordSearchHits } from "../../src/store/sqlite-memory-store.js";
|
||||
|
||||
/**
|
||||
* Create a mock store with given memory and user entries,
|
||||
* then register the insights command and return the handler
|
||||
* plus the captured notify calls.
|
||||
*/
|
||||
async function setupCommand(
|
||||
memoryEntries: string[],
|
||||
userEntries: string[],
|
||||
dbManager: DatabaseManager | null = null,
|
||||
): Promise<{
|
||||
handler: Function;
|
||||
notifyCalls: { message: string; severity: string }[];
|
||||
}> {
|
||||
const notifyCalls: { message: string; severity: string }[] = [];
|
||||
|
||||
const mockStore = {
|
||||
getMemoryEntries: () => [...memoryEntries],
|
||||
getUserEntries: () => [...userEntries],
|
||||
};
|
||||
|
||||
const commands: { name: string; handler: Function }[] = [];
|
||||
const mockPi = {
|
||||
registerCommand: (name: string, opts: any) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
|
||||
registerInsightsCommand(mockPi as any, mockStore as any, null, "test-project", dbManager);
|
||||
|
||||
assert.ok(commands.length === 1, "Expected exactly one registered command");
|
||||
return { handler: commands[0].handler, notifyCalls };
|
||||
}
|
||||
|
||||
/** Invoke the handler with a fake context that captures notify calls. */
|
||||
async function invoke(
|
||||
handler: Function,
|
||||
notifyCalls: { message: string; severity: string }[],
|
||||
): Promise<string> {
|
||||
await handler({}, {
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => {
|
||||
notifyCalls.push({ message, severity });
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(notifyCalls.length === 1, "Expected exactly one notify call");
|
||||
return notifyCalls[0].message;
|
||||
}
|
||||
|
||||
describe("registerInsightsCommand", () => {
|
||||
it("command is registered", async () => {
|
||||
const { handler } = await setupCommand([], []);
|
||||
assert.ok(typeof handler === "function");
|
||||
});
|
||||
|
||||
it("shows MEMORY section with numbered entries", async () => {
|
||||
const { handler, notifyCalls } = await setupCommand(
|
||||
["first entry", "second entry"],
|
||||
[],
|
||||
);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.match(output, /1\.\s/);
|
||||
assert.match(output, /2\.\s/);
|
||||
assert.ok(output.includes("first entry"));
|
||||
assert.ok(output.includes("second entry"));
|
||||
});
|
||||
|
||||
it("shows USER PROFILE section", async () => {
|
||||
const { handler, notifyCalls } = await setupCommand([], ["user fact"]);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.ok(output.includes("USER PROFILE"));
|
||||
assert.ok(output.includes("user fact"));
|
||||
});
|
||||
|
||||
it('shows "(empty)" when no entries exist', async () => {
|
||||
const { handler, notifyCalls } = await setupCommand([], []);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
const emptyMatches = output.match(/\(empty\)/g);
|
||||
assert.ok(
|
||||
emptyMatches && emptyMatches.length === 2,
|
||||
"Expected (empty) in both MEMORY and USER sections",
|
||||
);
|
||||
});
|
||||
|
||||
it("entries truncated to 100 chars", async () => {
|
||||
const longEntry = "x".repeat(200);
|
||||
const { handler, notifyCalls } = await setupCommand([longEntry], []);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
// Should contain first 100 chars + "..."
|
||||
assert.ok(output.includes("x".repeat(100) + "..."));
|
||||
// Should NOT contain the full 200-char entry
|
||||
assert.ok(!output.includes("x".repeat(200)));
|
||||
});
|
||||
|
||||
it("box drawing characters in output", async () => {
|
||||
const { handler, notifyCalls } = await setupCommand([], []);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.ok(output.includes("╔"));
|
||||
assert.ok(output.includes("═"));
|
||||
assert.ok(output.includes("║"));
|
||||
assert.ok(output.includes("╗"));
|
||||
assert.ok(output.includes("╚"));
|
||||
assert.ok(output.includes("╝"));
|
||||
});
|
||||
|
||||
it("notification called with info severity", async () => {
|
||||
const { handler, notifyCalls } = await setupCommand([], []);
|
||||
await invoke(handler, notifyCalls);
|
||||
|
||||
assert.strictEqual(notifyCalls[0].severity, "info");
|
||||
});
|
||||
|
||||
it("multiple entries displayed correctly", async () => {
|
||||
const mem = ["mem1", "mem2", "mem3"];
|
||||
const usr = ["usr1", "usr2"];
|
||||
const { handler, notifyCalls } = await setupCommand(mem, usr);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
for (const m of mem) {
|
||||
assert.ok(output.includes(m), `Missing memory entry: ${m}`);
|
||||
}
|
||||
for (const u of usr) {
|
||||
assert.ok(output.includes(u), `Missing user entry: ${u}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("entry numbering is sequential", async () => {
|
||||
const { handler, notifyCalls } = await setupCommand(
|
||||
["alpha", "beta", "gamma"],
|
||||
[],
|
||||
);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.match(output, /1\.\s*alpha/);
|
||||
assert.match(output, /2\.\s*beta/);
|
||||
assert.match(output, /3\.\s*gamma/);
|
||||
});
|
||||
|
||||
it("unicode content renders correctly", async () => {
|
||||
const entries = ["user loves 🍕 pizza", "works on 🚀 rockets"];
|
||||
const { handler, notifyCalls } = await setupCommand([], entries);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.ok(output.includes("🍕"));
|
||||
assert.ok(output.includes("🚀"));
|
||||
assert.ok(output.includes("pizza"));
|
||||
assert.ok(output.includes("rockets"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Usage section (PR-2 promotion gate) ───
|
||||
|
||||
describe("registerInsightsCommand usage section", () => {
|
||||
it("omits the usage section when no dbManager is provided", async () => {
|
||||
const { handler, notifyCalls } = await setupCommand(["entry one"], []);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
assert.ok(!output.includes("USAGE"), "no usage section without a database");
|
||||
});
|
||||
|
||||
it("renders recalled-entry stats from the database", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-insights-usage-"));
|
||||
try {
|
||||
const dbManager = new DatabaseManager(tmpDir);
|
||||
const recalled = addMemory(dbManager, "often recalled insight", "memory");
|
||||
const rare = addMemory(dbManager, "rarely recalled insight", "memory");
|
||||
recordSearchHits(dbManager, [recalled.id, rare.id]);
|
||||
recordSearchHits(dbManager, [recalled.id]);
|
||||
|
||||
const { handler, notifyCalls } = await setupCommand(["often recalled insight", "rarely recalled insight"], [], dbManager);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.ok(output.includes("USAGE — global memory: 2 of 2 entries recalled"), output);
|
||||
assert.match(output, /1\.\s*2x \(last \d{4}-\d{2}-\d{2}\) — often recalled insight/);
|
||||
assert.match(output, /2\.\s*1x \(last \d{4}-\d{2}-\d{2}\) — rarely recalled insight/);
|
||||
dbManager.close();
|
||||
} finally {
|
||||
// Windows keeps the SQLite dir warm for a beat after close — best effort.
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a not-yet-tracked note when entries exist but nothing was recalled", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-insights-usage-"));
|
||||
try {
|
||||
const dbManager = new DatabaseManager(tmpDir);
|
||||
addMemory(dbManager, "never recalled entry", "memory");
|
||||
|
||||
const { handler, notifyCalls } = await setupCommand(["never recalled entry"], [], dbManager);
|
||||
const output = await invoke(handler, notifyCalls);
|
||||
|
||||
assert.ok(output.includes("USAGE — global memory: 0 of 1 entries recalled"), output);
|
||||
assert.ok(output.includes("no recorded recalls yet"));
|
||||
dbManager.close();
|
||||
} finally {
|
||||
// Windows keeps the SQLite dir warm for a beat after close — best effort.
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Unit tests for /memory-interview command.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { registerInterviewCommand } from "../../src/handlers/interview.js";
|
||||
|
||||
// ─── Mock infrastructure ───
|
||||
|
||||
let handlers: Record<string, Function[]>;
|
||||
let notifyCalls: Array<{ msg: string; level: string }>;
|
||||
let sentMessages: string[];
|
||||
|
||||
function createMockPi() {
|
||||
const pi = {
|
||||
on: () => {},
|
||||
registerTool: () => {},
|
||||
registerCommand: (_name: string, opts: { description: string; handler: Function }) => {
|
||||
handlers[_name] = [{ fn: opts.handler, desc: opts.description }];
|
||||
},
|
||||
sendUserMessage: (text: string) => {
|
||||
sentMessages.push(text);
|
||||
},
|
||||
};
|
||||
return pi;
|
||||
}
|
||||
|
||||
function makeMockCtx() {
|
||||
return {
|
||||
sessionManager: { getBranch: () => [] },
|
||||
signal: undefined as any,
|
||||
ui: {
|
||||
notify: (msg: string, level: string) => {
|
||||
notifyCalls.push({ msg, level });
|
||||
},
|
||||
},
|
||||
waitForIdle: async () => {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
const mockStore = (entries: string[]) => ({
|
||||
getUserEntries: () => entries,
|
||||
getMemoryEntries: () => [],
|
||||
} as any);
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
describe("registerInterviewCommand", () => {
|
||||
beforeEach(() => {
|
||||
handlers = {};
|
||||
notifyCalls = [];
|
||||
sentMessages = [];
|
||||
});
|
||||
|
||||
it("registers the /memory-interview command", () => {
|
||||
const pi = createMockPi();
|
||||
registerInterviewCommand(pi as any, mockStore([]));
|
||||
|
||||
const cmd = handlers["memory-interview"];
|
||||
assert.ok(cmd, "command should be registered");
|
||||
assert.ok(cmd.length === 1, "should have one handler");
|
||||
});
|
||||
|
||||
it("command description mentions onboarding", () => {
|
||||
const pi = createMockPi();
|
||||
registerInterviewCommand(pi as any, mockStore([]));
|
||||
|
||||
const cmd = handlers["memory-interview"][0];
|
||||
assert.ok(cmd.desc.includes("user profile"), "description should mention user profile");
|
||||
});
|
||||
|
||||
it("sends interview prompt as user message when USER.md is empty", async () => {
|
||||
const pi = createMockPi();
|
||||
registerInterviewCommand(pi as any, mockStore([]));
|
||||
|
||||
const ctx = makeMockCtx();
|
||||
await handlers["memory-interview"][0].fn(undefined, ctx);
|
||||
|
||||
assert.strictEqual(sentMessages.length, 1, "should send one user message");
|
||||
assert.ok(sentMessages[0].includes("onboarding interview"), "message should be the interview prompt");
|
||||
assert.ok(sentMessages[0].includes("timezone"), "prompt should ask about timezone");
|
||||
assert.ok(sentMessages[0].includes("work style"), "prompt should ask about work style");
|
||||
});
|
||||
|
||||
it("shows notification when USER.md already has entries", async () => {
|
||||
const pi = createMockPi();
|
||||
const store = mockStore(["name: Chandrateja", "prefers concise answers"]);
|
||||
registerInterviewCommand(pi as any, store);
|
||||
|
||||
const ctx = makeMockCtx();
|
||||
await handlers["memory-interview"][0].fn(undefined, ctx);
|
||||
|
||||
assert.ok(notifyCalls.length >= 1, "should show notification for existing entries");
|
||||
assert.ok(
|
||||
notifyCalls[0].msg.includes("2 profile entries"),
|
||||
"notification should mention entry count",
|
||||
);
|
||||
assert.ok(
|
||||
notifyCalls[0].msg.includes("Chandrateja"),
|
||||
"notification should show existing entry preview",
|
||||
);
|
||||
});
|
||||
|
||||
it("still sends interview prompt even when entries exist", async () => {
|
||||
const pi = createMockPi();
|
||||
const store = mockStore(["existing entry"]);
|
||||
registerInterviewCommand(pi as any, store);
|
||||
|
||||
const ctx = makeMockCtx();
|
||||
await handlers["memory-interview"][0].fn(undefined, ctx);
|
||||
|
||||
assert.strictEqual(sentMessages.length, 1, "should still send interview prompt");
|
||||
});
|
||||
|
||||
it("uses correct message count grammar (1 entry)", async () => {
|
||||
const pi = createMockPi();
|
||||
const store = mockStore(["one entry only"]);
|
||||
registerInterviewCommand(pi as any, store);
|
||||
|
||||
const ctx = makeMockCtx();
|
||||
await handlers["memory-interview"][0].fn(undefined, ctx);
|
||||
|
||||
assert.ok(notifyCalls[0].msg.includes("1 profile entry"), "should use singular");
|
||||
assert.ok(!notifyCalls[0].msg.includes("entries"), "should NOT use plural for 1 entry");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { setupBackgroundReview } from "../../src/handlers/background-review.js";
|
||||
import { setupCorrectionDetector } from "../../src/handlers/correction-detector.js";
|
||||
import { setupSessionFlush } from "../../src/handlers/session-flush.js";
|
||||
import { loadConfig } from "../../src/config.js";
|
||||
|
||||
function fixture() {
|
||||
const config = { ...loadConfig("/nonexistent/pi-memory-hook-test.json"), nudgeInterval: 1, flushMinTurns: 2 };
|
||||
const handlers: Record<string, Function[]> = {};
|
||||
const pi = { on(name: string, fn: Function) { (handlers[name] ??= []).push(fn); } } as any;
|
||||
let ready = false;
|
||||
let loads = 0;
|
||||
let calls = 0;
|
||||
const store = {
|
||||
getMemoryEntries() { assert.ok(ready); return ["existing fact"]; },
|
||||
getUserEntries() { assert.ok(ready); return []; },
|
||||
async addFailure() { assert.ok(ready); return { success: true }; },
|
||||
} as any;
|
||||
const ctx = {
|
||||
cwd: "/project",
|
||||
sessionManager: {
|
||||
getBranch: () => Array.from({ length: 8 }, (_, i) => ({
|
||||
type: "message", message: { role: i % 2 ? "assistant" : "user", content: "no, use pnpm" },
|
||||
})),
|
||||
},
|
||||
ui: { notify() {} },
|
||||
};
|
||||
const ensureMemoryReady = async (context: { cwd: string }) => {
|
||||
assert.equal(context.cwd, ctx.cwd);
|
||||
loads++;
|
||||
ready = true;
|
||||
};
|
||||
const runDirectMemoryCompletion = async () => {
|
||||
assert.ok(ready);
|
||||
calls++;
|
||||
return { ok: true, appliedCount: 0 };
|
||||
};
|
||||
const emit = async (name: string, event: any = {}) => {
|
||||
for (const handler of handlers[name] ?? []) await handler(event, ctx);
|
||||
};
|
||||
return { pi, config, store, emit, ensureMemoryReady, runDirectMemoryCompletion,
|
||||
loads: () => loads, calls: () => calls };
|
||||
}
|
||||
|
||||
describe("lazy memory automatic operations", () => {
|
||||
it("initializes only after a background review actually becomes due", async () => {
|
||||
const f = fixture();
|
||||
const settled = Promise.withResolvers<void>();
|
||||
setupBackgroundReview(f.pi, f.store, null, f.config, {
|
||||
ensureMemoryReady: f.ensureMemoryReady,
|
||||
deps: { runDirectReview: f.runDirectMemoryCompletion, onReviewSettled: settled.resolve },
|
||||
});
|
||||
await f.emit("message_end", { message: { role: "user" } });
|
||||
await f.emit("turn_end");
|
||||
assert.equal(f.loads(), 0);
|
||||
await f.emit("message_end", { message: { role: "user" } });
|
||||
await f.emit("message_end", { message: { role: "user" } });
|
||||
await f.emit("turn_end");
|
||||
await settled.promise;
|
||||
assert.equal(f.loads(), 1);
|
||||
assert.equal(f.calls(), 1);
|
||||
await f.emit("session_shutdown");
|
||||
});
|
||||
|
||||
it("initializes before correction capture, not on ordinary messages", async () => {
|
||||
const f = fixture();
|
||||
setupCorrectionDetector(f.pi, f.store, null, f.config, null, null, {
|
||||
ensureMemoryReady: f.ensureMemoryReady, runDirectMemoryCompletion: f.runDirectMemoryCompletion,
|
||||
});
|
||||
await f.emit("message_end", { message: { role: "user", content: "hello" } });
|
||||
await f.emit("turn_end");
|
||||
assert.equal(f.loads(), 0);
|
||||
await f.emit("message_end", { message: { role: "user", content: "no, use pnpm" } });
|
||||
await f.emit("turn_end");
|
||||
assert.equal(f.loads(), 1);
|
||||
assert.equal(f.calls(), 1);
|
||||
});
|
||||
|
||||
it("initializes for a qualifying flush, not a short session or reload", async () => {
|
||||
const f = fixture();
|
||||
setupSessionFlush(f.pi, f.store, null, f.config, null, null, {
|
||||
ensureMemoryReady: f.ensureMemoryReady, runDirectMemoryCompletion: f.runDirectMemoryCompletion,
|
||||
});
|
||||
await f.emit("message_end", { message: { role: "user" } });
|
||||
await f.emit("session_before_compact");
|
||||
assert.equal(f.loads(), 0);
|
||||
await f.emit("message_end", { message: { role: "user" } });
|
||||
await f.emit("session_shutdown", { reason: "reload" });
|
||||
assert.equal(f.loads(), 0);
|
||||
await f.emit("session_before_compact");
|
||||
assert.equal(f.loads(), 1);
|
||||
assert.equal(f.calls(), 1);
|
||||
});
|
||||
|
||||
it("does not flush against unloaded memory after an initialization error", async () => {
|
||||
const f = fixture();
|
||||
setupSessionFlush(f.pi, f.store, null, { ...f.config, flushMinTurns: 0 }, null, null, {
|
||||
ensureMemoryReady: async () => { throw new Error("load failed"); },
|
||||
runDirectMemoryCompletion: f.runDirectMemoryCompletion,
|
||||
});
|
||||
await f.emit("session_before_compact");
|
||||
assert.equal(f.calls(), 0);
|
||||
});
|
||||
|
||||
it("retries a pending correction without consuming its rate limit on initialization failure", async () => {
|
||||
const f = fixture();
|
||||
let attempts = 0;
|
||||
setupCorrectionDetector(f.pi, f.store, null, f.config, null, null, {
|
||||
ensureMemoryReady: async (ctx) => {
|
||||
if (++attempts === 1) throw new Error("transient load failure");
|
||||
await f.ensureMemoryReady(ctx);
|
||||
},
|
||||
runDirectMemoryCompletion: f.runDirectMemoryCompletion,
|
||||
});
|
||||
await f.emit("message_end", { message: { role: "user", content: "no, use pnpm" } });
|
||||
await f.emit("turn_end");
|
||||
assert.equal(f.calls(), 0);
|
||||
let saved = "";
|
||||
f.store.addFailure = async (text: string) => { saved = text; return { success: true }; };
|
||||
await f.emit("message_end", { message: { role: "user", content: "continue with the work" } });
|
||||
await f.emit("turn_end");
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(f.calls(), 1);
|
||||
assert.match(saved, /use pnpm/);
|
||||
assert.doesNotMatch(saved, /continue with the work/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,905 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildChildPiPromptArgs,
|
||||
detectAuthAdapterExtensionPaths,
|
||||
execChildPrompt,
|
||||
inheritedExtensionArgs,
|
||||
resolveChildPiInvocation,
|
||||
resolveWatchedChildPiInvocation,
|
||||
} from "../../src/handlers/pi-child-process.js";
|
||||
|
||||
function logicalChildArgs(call: { cmd: string; args: string[] }): string[] {
|
||||
const underlying = { command: call.args[3], args: call.args.slice(4) };
|
||||
const expected = resolveWatchedChildPiInvocation(underlying, 30000, call.args[2]);
|
||||
assert.deepStrictEqual(call, { cmd: expected.command, args: expected.args });
|
||||
return underlying.command === "pi" ? underlying.args : underlying.args.slice(1);
|
||||
}
|
||||
|
||||
// Compute the expected OWN_EXTENSION_PATH same logic as pi-child-process.ts
|
||||
const OWN_EXTENSION_PATH = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../src/index.ts",
|
||||
);
|
||||
|
||||
// Adapter detection scans the real sibling/agent node_modules trees, so a
|
||||
// contributor with a provider auth adapter installed gets extra -e args in
|
||||
// every child invocation. Derive them instead of assuming a bare environment.
|
||||
const DETECTED_ADAPTER_ARGS = detectAuthAdapterExtensionPaths().flatMap((p) => ["-e", p]);
|
||||
|
||||
const EXT_ARGS = ["--no-extensions", "-e", OWN_EXTENSION_PATH, ...DETECTED_ADAPTER_ARGS];
|
||||
|
||||
describe("inheritedExtensionArgs", () => {
|
||||
it("captures explicit -e and --extension parent args", () => {
|
||||
assert.deepStrictEqual(
|
||||
inheritedExtensionArgs(["-e", "src/index.ts", "--extension", "/tmp/other.ts"]),
|
||||
["-e", "src/index.ts", "--extension", "/tmp/other.ts"],
|
||||
);
|
||||
});
|
||||
|
||||
it("captures --extension=... parent args", () => {
|
||||
assert.deepStrictEqual(
|
||||
inheritedExtensionArgs(["--extension=src/index.ts"]),
|
||||
["--extension=src/index.ts"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAuthAdapterExtensionPaths", () => {
|
||||
it("returns empty when no sibling packages exist under the root", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-empty-"));
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects a convention-named adapter via its package.json manifest (xai-oauth-adapter)", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-xai-"));
|
||||
const adapterDir = path.join(root, "xai-oauth-adapter");
|
||||
const adapterPath = path.join(adapterDir, "extensions", "index.ts");
|
||||
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
||||
await fs.writeFile(adapterPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(adapterDir, "package.json"),
|
||||
JSON.stringify({ name: "xai-oauth-adapter", pi: { extensions: ["extensions/index.ts"] } }),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects a scoped convention-named adapter one level under @scope", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-scoped-"));
|
||||
const adapterDir = path.join(root, "@xai", "pi-oauth-adapter");
|
||||
const adapterPath = path.join(adapterDir, "entry.ts");
|
||||
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
||||
await fs.writeFile(adapterPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(adapterDir, "package.json"),
|
||||
JSON.stringify({ name: "@xai/pi-oauth-adapter", pi: { extensions: ["entry.ts"] } }),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores packages that do not match the auth-adapter naming convention", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nonmatch-"));
|
||||
const pkgDir = path.join(root, "some-other-package");
|
||||
const extPath = path.join(pkgDir, "extensions", "index.ts");
|
||||
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
||||
await fs.writeFile(extPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(pkgDir, "package.json"),
|
||||
JSON.stringify({ name: "some-other-package", pi: { extensions: ["extensions/index.ts"] } }),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty for a matching package name without package.json", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nopkg-"));
|
||||
await fs.mkdir(path.join(root, "my-oauth-adapter"), { recursive: true });
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty when package.json lacks pi.extensions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-nopi-"));
|
||||
const adapterDir = path.join(root, "my-auth-adapter");
|
||||
await fs.mkdir(adapterDir, { recursive: true });
|
||||
await fs.writeFile(path.join(adapterDir, "package.json"), JSON.stringify({ name: "my-auth-adapter" }));
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("filters out manifest entries whose files do not exist on disk", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-missing-"));
|
||||
const adapterDir = path.join(root, "pi-oauth-adapter");
|
||||
const presentPath = path.join(adapterDir, "extensions", "present.ts");
|
||||
await fs.mkdir(path.dirname(presentPath), { recursive: true });
|
||||
await fs.writeFile(presentPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(adapterDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "pi-oauth-adapter",
|
||||
pi: { extensions: ["extensions/missing.ts", "extensions/present.ts"] },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [presentPath]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("collects extension entries from every matching package under one root", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-multi-"));
|
||||
const firstDir = path.join(root, "alpha-oauth-adapter");
|
||||
const firstPath = path.join(firstDir, "a.ts");
|
||||
const secondDir = path.join(root, "beta-auth-adapter");
|
||||
const secondPath = path.join(secondDir, "b.ts");
|
||||
await fs.mkdir(path.dirname(firstPath), { recursive: true });
|
||||
await fs.writeFile(firstPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(firstDir, "package.json"),
|
||||
JSON.stringify({ name: "alpha-oauth-adapter", pi: { extensions: ["a.ts"] } }),
|
||||
);
|
||||
await fs.mkdir(path.dirname(secondPath), { recursive: true });
|
||||
await fs.writeFile(secondPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, "package.json"),
|
||||
JSON.stringify({ name: "beta-auth-adapter", pi: { extensions: ["b.ts"] } }),
|
||||
);
|
||||
try {
|
||||
const detected = detectAuthAdapterExtensionPaths([root]);
|
||||
assert.equal(detected.length, 2);
|
||||
assert.ok(detected.includes(firstPath));
|
||||
assert.ok(detected.includes(secondPath));
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges matches from multiple roots passed in the roots array", async () => {
|
||||
const rootA = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-roota-"));
|
||||
const rootB = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-rootb-"));
|
||||
const pathA = path.join(rootA, "one-oauth-adapter", "one.ts");
|
||||
const pathB = path.join(rootB, "two-oauth-adapter", "two.ts");
|
||||
await fs.mkdir(path.dirname(pathA), { recursive: true });
|
||||
await fs.writeFile(pathA, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(rootA, "one-oauth-adapter", "package.json"),
|
||||
JSON.stringify({ name: "one-oauth-adapter", pi: { extensions: ["one.ts"] } }),
|
||||
);
|
||||
await fs.mkdir(path.dirname(pathB), { recursive: true });
|
||||
await fs.writeFile(pathB, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(rootB, "two-oauth-adapter", "package.json"),
|
||||
JSON.stringify({ name: "two-oauth-adapter", pi: { extensions: ["two.ts"] } }),
|
||||
);
|
||||
try {
|
||||
const detected = detectAuthAdapterExtensionPaths([rootA, rootB]);
|
||||
assert.equal(detected.length, 2);
|
||||
assert.ok(detected.includes(pathA));
|
||||
assert.ok(detected.includes(pathB));
|
||||
} finally {
|
||||
await fs.rm(rootA, { recursive: true, force: true });
|
||||
await fs.rm(rootB, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects the allowlisted pi-claude-auth package", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-claude-"));
|
||||
const adapterDir = path.join(root, "pi-claude-auth");
|
||||
const adapterPath = path.join(adapterDir, "src", "index.ts");
|
||||
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
||||
await fs.writeFile(adapterPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(adapterDir, "package.json"),
|
||||
JSON.stringify({ name: "pi-claude-auth", pi: { extensions: ["src/index.ts"] } }),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects the allowlisted scoped @gotgenes/pi-anthropic-auth package", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-anthropic-"));
|
||||
const adapterDir = path.join(root, "@gotgenes", "pi-anthropic-auth");
|
||||
const adapterPath = path.join(adapterDir, "src", "index.ts");
|
||||
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
||||
await fs.writeFile(adapterPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(adapterDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@gotgenes/pi-anthropic-auth",
|
||||
pi: { extensions: ["src/index.ts"] },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), [adapterPath]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a suffixless auth package that is not allowlisted", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-auth-detect-untrusted-"));
|
||||
const pkgDir = path.join(root, "pi-vault-auth");
|
||||
const extPath = path.join(pkgDir, "index.ts");
|
||||
await fs.mkdir(pkgDir, { recursive: true });
|
||||
await fs.writeFile(extPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(pkgDir, "package.json"),
|
||||
JSON.stringify({ name: "pi-vault-auth", pi: { extensions: ["index.ts"] } }),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([root]), []);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("buildChildPiPromptArgs", () => {
|
||||
it("uses --no-extensions and only passes hermes-memory extension", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", {}, []),
|
||||
["-p", "--no-session", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a model override and defaults thinking to off", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", { llmModelOverride: "openrouter/deepseek/deepseek-v4-flash" }, []),
|
||||
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
it("inherits the active provider/model when no override is configured", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", {}, [], { provider: "local-llama", id: "local-9b" }),
|
||||
["-p", "--no-session", "--model", "local-llama/local-9b", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the configured model override over the active session model", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs(
|
||||
"hello",
|
||||
{ llmModelOverride: "openrouter/deepseek/deepseek-v4-flash" },
|
||||
[],
|
||||
{ provider: "local-llama", id: "local-9b" },
|
||||
),
|
||||
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it("allows thinking overrides without a model override", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", { llmThinkingOverride: "low" }, []),
|
||||
["-p", "--no-session", "--thinking", "low", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores missing inherited extension paths", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", {}, ["-e", "src/index.ts"]),
|
||||
["-p", "--no-session", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured extension sources through Pi's -e resolver and excludes inherited extensions", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-extensions-"));
|
||||
const configured = path.join(dir, "configured.ts");
|
||||
const inherited = path.join(dir, "inherited.ts");
|
||||
await fs.writeFile(configured, "export default () => {};");
|
||||
await fs.writeFile(inherited, "export default () => {};");
|
||||
try {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", {
|
||||
childExtensionPaths: [
|
||||
configured,
|
||||
configured,
|
||||
"~/.pi/agent/extensions/provider.ts",
|
||||
"./relative-provider.ts",
|
||||
"git:github.com/example/provider-extension@v1",
|
||||
"npm:@example/provider-extension@1.0.0",
|
||||
OWN_EXTENSION_PATH,
|
||||
],
|
||||
}, ["-e", inherited, `--extension=${configured}`]),
|
||||
[
|
||||
"-p", "--no-session", "--no-extensions",
|
||||
"-e", OWN_EXTENSION_PATH,
|
||||
"-e", configured,
|
||||
"-e", "~/.pi/agent/extensions/provider.ts",
|
||||
"-e", "./relative-provider.ts",
|
||||
"-e", "git:github.com/example/provider-extension@v1",
|
||||
"-e", "npm:@example/provider-extension@1.0.0",
|
||||
...DETECTED_ADAPTER_ARGS,
|
||||
"hello",
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects a sibling pi-claude-oauth-adapter extension via its package.json manifest", async () => {
|
||||
const nodeModules = await fs.mkdtemp(path.join(os.tmpdir(), "pi-node-modules-"));
|
||||
const adapterDir = path.join(nodeModules, "pi-claude-oauth-adapter");
|
||||
const adapterPath = path.join(adapterDir, "extensions", "index.ts");
|
||||
await fs.mkdir(path.dirname(adapterPath), { recursive: true });
|
||||
await fs.writeFile(adapterPath, "export default () => {};");
|
||||
await fs.writeFile(
|
||||
path.join(adapterDir, "package.json"),
|
||||
JSON.stringify({ name: "pi-claude-oauth-adapter", pi: { extensions: ["extensions/index.ts"] } }),
|
||||
);
|
||||
try {
|
||||
assert.deepStrictEqual(detectAuthAdapterExtensionPaths([nodeModules]), [adapterPath]);
|
||||
} finally {
|
||||
await fs.rm(nodeModules, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChildPiInvocation", () => {
|
||||
it("keeps non-Windows child pi invocations unchanged", () => {
|
||||
const args = ["-p", "--no-session", "hello"];
|
||||
|
||||
assert.deepStrictEqual(
|
||||
resolveChildPiInvocation(args, { platform: "linux" }),
|
||||
{ command: "pi", args },
|
||||
);
|
||||
});
|
||||
|
||||
it("runs node.exe with cli.js first on Windows when a Pi CLI path is available", () => {
|
||||
assert.deepStrictEqual(
|
||||
resolveChildPiInvocation(["-p", "--no-session", "hello"], {
|
||||
platform: "win32",
|
||||
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
piCliPath: "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
|
||||
}),
|
||||
{
|
||||
command: "C:\\Program Files\\nodejs\\node.exe",
|
||||
args: [
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
|
||||
"-p",
|
||||
"--no-session",
|
||||
"hello",
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a standard Windows command shim to its adjacent cli.js", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-windows-shim-"));
|
||||
const binDirectory = path.join(directory, "bin");
|
||||
const cliPath = path.join(
|
||||
binDirectory,
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js",
|
||||
);
|
||||
const args = ["-p", "--no-session", "hello"];
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
await fs.mkdir(path.dirname(cliPath), { recursive: true });
|
||||
await fs.writeFile(path.join(binDirectory, "pi.cmd"), "@echo off\r\n");
|
||||
await fs.writeFile(cliPath, "");
|
||||
process.env.PATH = `${binDirectory};C:\\Windows\\System32`;
|
||||
try {
|
||||
assert.deepStrictEqual(
|
||||
resolveChildPiInvocation(args, {
|
||||
platform: "win32",
|
||||
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
piCliPath: null,
|
||||
}),
|
||||
{
|
||||
command: "C:\\Program Files\\nodejs\\node.exe",
|
||||
args: [cliPath, ...args],
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("execChildPrompt", () => {
|
||||
it("runs child Pi behind a hard process-tree watchdog", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[]; timeout?: number }> = [];
|
||||
await execChildPrompt({
|
||||
exec: async (cmd: string, args: string[], options: { timeout?: number }) => {
|
||||
calls.push({ cmd, args, timeout: options.timeout });
|
||||
return { code: 0, stdout: "ok", stderr: "", killed: false };
|
||||
},
|
||||
} as any, "bounded child", {}, { timeoutMs: 30000 });
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].cmd, process.execPath);
|
||||
assert.match(calls[0].args[0].replaceAll("\\", "/"), /child-process-watchdog\.mjs$/);
|
||||
assert.equal(calls[0].args[1], "30000");
|
||||
assert.match(calls[0].args[2], /cancel$/);
|
||||
assert.equal(calls[0].args[3], "pi");
|
||||
assert.equal(calls[0].timeout, 35000);
|
||||
});
|
||||
|
||||
it("uses the watchdog marker for cancellation without aborting its process", async () => {
|
||||
const abortController = new AbortController();
|
||||
let cancelPath = "";
|
||||
const result = await execChildPrompt({
|
||||
exec: async (_cmd: string, args: string[], options: { signal?: AbortSignal }) => {
|
||||
cancelPath = args[2];
|
||||
assert.equal(options.signal, undefined);
|
||||
abortController.abort();
|
||||
const deadline = Date.now() + 1000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fs.access(cancelPath);
|
||||
return { code: 143, stderr: "child cancelled" };
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
assert.fail("abort did not notify the watchdog");
|
||||
},
|
||||
} as any, "cancel child", {}, {
|
||||
signal: abortController.signal,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 143);
|
||||
await assert.rejects(fs.access(cancelPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("writes cancellation before launching an already-aborted child", async () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
let cancelPath = "";
|
||||
await execChildPrompt({
|
||||
exec: async (_cmd: string, args: string[], options: { signal?: AbortSignal }) => {
|
||||
cancelPath = args[2];
|
||||
assert.equal(options.signal, undefined);
|
||||
await fs.access(cancelPath);
|
||||
return { code: 143, stderr: "child cancelled" };
|
||||
},
|
||||
} as any, "cancel before launch", {}, {
|
||||
signal: abortController.signal,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
await assert.rejects(fs.access(cancelPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("forwards explicit cwd and timeout while cancellation stays with the watchdog", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
let received: { cwd?: string; signal?: AbortSignal; timeout?: number } | undefined;
|
||||
await execChildPrompt({
|
||||
exec: async (_cmd: string, _args: string[], options: typeof received) => {
|
||||
received = options;
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
},
|
||||
} as any, "bounded child", {}, {
|
||||
cwd: "/tmp/session",
|
||||
signal,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(received, { cwd: "/tmp/session", timeout: 35000 });
|
||||
});
|
||||
|
||||
|
||||
it("hard-kills a child that ignores graceful timeout termination", async () => {
|
||||
const watchdogPath = fileURLToPath(
|
||||
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
||||
const child = spawn(process.execPath, [
|
||||
watchdogPath,
|
||||
"100",
|
||||
"-",
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)",
|
||||
], { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
child.on("close", (code) => resolve({ code, stderr }));
|
||||
});
|
||||
|
||||
assert.equal(result.code, 124);
|
||||
assert.match(result.stderr, /timed out after 100ms/i);
|
||||
assert.ok(Date.now() - startedAt < 3000, "watchdog must enforce a bounded hard stop");
|
||||
});
|
||||
|
||||
it("terminates the child tree when its cancellation marker appears", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-watchdog-cancel-"));
|
||||
const cancelPath = path.join(directory, "cancel");
|
||||
const watchdogPath = fileURLToPath(
|
||||
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
||||
const child = spawn(process.execPath, [
|
||||
watchdogPath,
|
||||
"10000",
|
||||
cancelPath,
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)",
|
||||
], { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
child.on("close", (code) => resolve({ code, stderr }));
|
||||
setTimeout(() => void fs.writeFile(cancelPath, ""), 100);
|
||||
});
|
||||
|
||||
assert.equal(result.code, 143);
|
||||
assert.match(result.stderr, /child cancellation requested/i);
|
||||
assert.ok(Date.now() - startedAt < 3000, "watchdog cancellation must be bounded");
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("hard-kills descendants in the timed-out child process group", {
|
||||
skip: process.platform === "win32" ? "uses taskkill /T on Windows" : false,
|
||||
}, async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-watchdog-tree-"));
|
||||
const pidPath = path.join(directory, "descendant.pid");
|
||||
const watchdogPath = fileURLToPath(
|
||||
new URL("../../src/handlers/child-process-watchdog.mjs", import.meta.url),
|
||||
);
|
||||
const descendant = "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)";
|
||||
const parent = [
|
||||
"const {spawn}=require('node:child_process');",
|
||||
"const fs=require('node:fs');",
|
||||
`const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:'ignore'});`,
|
||||
`fs.writeFileSync(${JSON.stringify(pidPath)},String(child.pid));`,
|
||||
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000);",
|
||||
].join("");
|
||||
|
||||
try {
|
||||
const code = await new Promise<number | null>((resolve) => {
|
||||
const child = spawn(process.execPath, [
|
||||
watchdogPath,
|
||||
"100",
|
||||
"-",
|
||||
process.execPath,
|
||||
"-e",
|
||||
parent,
|
||||
], { stdio: "ignore" });
|
||||
child.on("close", resolve);
|
||||
});
|
||||
assert.equal(code, 124);
|
||||
const descendantPid = Number(await fs.readFile(pidPath, "utf-8"));
|
||||
const deadline = Date.now() + 1000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(descendantPid, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
assert.fail("watchdog left a descendant process alive");
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a sensitive prompt out of argv and removes its mode-0600 temporary file", async () => {
|
||||
const secret = "PRIVATE-MEMORY-CONTENT";
|
||||
let promptPath = "";
|
||||
const pi = {
|
||||
exec: async (_cmd: string, args: string[]) => {
|
||||
assert.ok(args.every((arg) => !arg.includes(secret)), "child argv must not contain prompt content");
|
||||
const promptArg = args.at(-1)!;
|
||||
assert.match(promptArg, /^@/);
|
||||
promptPath = promptArg.slice(1);
|
||||
assert.equal(await fs.readFile(promptPath, "utf-8"), secret);
|
||||
assert.equal((await fs.stat(promptPath)).mode & 0o777, 0o600);
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await execChildPrompt(pi as any, secret, {}, { timeoutMs: 30000 });
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
await assert.rejects(fs.access(promptPath), { code: "ENOENT" });
|
||||
await assert.rejects(fs.access(path.dirname(promptPath)), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("removes the temporary prompt file when child execution throws", async () => {
|
||||
let promptPath = "";
|
||||
const pi = {
|
||||
exec: async (_cmd: string, args: string[]) => {
|
||||
promptPath = args.at(-1)!.slice(1);
|
||||
assert.equal(await fs.readFile(promptPath, "utf-8"), "secret failure prompt");
|
||||
throw new Error("child failed");
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
execChildPrompt(pi as any, "secret failure prompt", {}, { timeoutMs: 30000 }),
|
||||
/child failed/,
|
||||
);
|
||||
|
||||
assert.ok(promptPath.startsWith(os.tmpdir()));
|
||||
await assert.rejects(fs.access(promptPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("returns a successful child result when temporary cleanup fails", async () => {
|
||||
let cleanupCalls = 0;
|
||||
let promptDirectory = "";
|
||||
const pi = {
|
||||
exec: async (_cmd: string, args: string[]) => {
|
||||
promptDirectory = path.dirname(args.at(-1)!.slice(1));
|
||||
return { code: 0, stdout: "completed", stderr: "" };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await execChildPrompt(
|
||||
pi as any,
|
||||
"cleanup failure prompt",
|
||||
{},
|
||||
{ timeoutMs: 30000 },
|
||||
{
|
||||
removeTemporaryDirectory: async () => {
|
||||
cleanupCalls++;
|
||||
throw new Error("cleanup denied");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.stdout, "completed");
|
||||
assert.equal(cleanupCalls, 1);
|
||||
} finally {
|
||||
if (promptDirectory) await fs.rm(promptDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes configured auth adapters to both override attempts", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-auth-"));
|
||||
const adapterPath = path.join(dir, "adapter.ts");
|
||||
await fs.writeFile(adapterPath, "export default () => {};");
|
||||
const calls: string[][] = [];
|
||||
const pi = {
|
||||
exec: async (_cmd: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
return calls.length === 1
|
||||
? { code: 1, stdout: "", stderr: "model not found" }
|
||||
: { code: 0, stdout: "ok", stderr: "" };
|
||||
},
|
||||
};
|
||||
try {
|
||||
await execChildPrompt(pi as any, "hello", {
|
||||
llmModelOverride: "missing/model",
|
||||
childExtensionPaths: [adapterPath],
|
||||
}, { timeoutMs: 30000, retryWithoutOverrides: true });
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
for (const args of calls) {
|
||||
const index = args.indexOf(adapterPath);
|
||||
assert.ok(index > 0);
|
||||
assert.equal(args[index - 1], "-e");
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes unrelated inherited extensions from primary and retry attempts", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-child-untrusted-"));
|
||||
const inherited = path.join(dir, "unrelated-extension.ts");
|
||||
await fs.writeFile(inherited, "export default () => {};");
|
||||
const originalArgv = process.argv;
|
||||
const calls: string[][] = [];
|
||||
process.argv = [originalArgv[0], originalArgv[1], "-e", inherited];
|
||||
try {
|
||||
await execChildPrompt({
|
||||
exec: async (_cmd: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
return calls.length === 1
|
||||
? { code: 1, stderr: "model not found" }
|
||||
: { code: 0, stdout: "ok" };
|
||||
},
|
||||
} as any, "private review", {
|
||||
llmModelOverride: "missing/model",
|
||||
}, { timeoutMs: 30000, retryWithoutOverrides: true });
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
for (const args of calls) {
|
||||
assert.equal(args.includes(inherited), false);
|
||||
assert.ok(args.includes("--no-extensions"));
|
||||
}
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries once without overrides when requested and the override subprocess fails for model resolution reasons", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const pi = {
|
||||
exec: async (cmd: string, args: string[]) => {
|
||||
calls.push({ cmd, args });
|
||||
if (calls.length === 1) {
|
||||
return { code: 1, stdout: "", stderr: "model not found" };
|
||||
}
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await execChildPrompt(pi as any, "hello", {
|
||||
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
retryWithoutOverrides: true,
|
||||
model: { provider: "local-llama", id: "local-9b" },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0);
|
||||
const logicalCalls = calls.map(logicalChildArgs);
|
||||
const promptReference = logicalCalls[0].at(-1)!;
|
||||
assert.match(promptReference, /^@/);
|
||||
assert.deepStrictEqual(logicalCalls, [
|
||||
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, promptReference],
|
||||
// Retry drops configured overrides but preserves the active session model.
|
||||
["-p", "--no-session", "--model", "local-llama/local-9b", ...EXT_ARGS, promptReference],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the resolved Windows node.exe invocation for both override retry attempts", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-windows-retry-"));
|
||||
const binDirectory = path.join(directory, "bin");
|
||||
const cliPath = path.join(
|
||||
binDirectory,
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js",
|
||||
);
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
await fs.mkdir(path.dirname(cliPath), { recursive: true });
|
||||
await fs.writeFile(path.join(binDirectory, "pi.cmd"), "@echo off\r\n");
|
||||
await fs.writeFile(cliPath, "");
|
||||
process.env.PATH = `${binDirectory};C:\\Windows\\System32`;
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
try {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const pi = {
|
||||
exec: async (cmd: string, args: string[]) => {
|
||||
calls.push({ cmd, args });
|
||||
if (calls.length === 1) {
|
||||
return { code: 1, stdout: "", stderr: "model not found" };
|
||||
}
|
||||
return { code: 0, stdout: "ok", stderr: "" };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await execChildPrompt(pi as any, "hello", {
|
||||
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
retryWithoutOverrides: true,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.strictEqual(calls.length, 2);
|
||||
assert.strictEqual(calls[0].cmd, process.execPath);
|
||||
assert.strictEqual(calls[1].cmd, process.execPath);
|
||||
assert.match(calls[0].args[4].replace(/\\/g, "/"), /\/cli\.js$/);
|
||||
assert.match(calls[1].args[4].replace(/\\/g, "/"), /\/cli\.js$/);
|
||||
const promptReference = calls[0].args.at(-1)!;
|
||||
assert.match(promptReference, /^@/);
|
||||
assert.deepStrictEqual(calls.map(logicalChildArgs), [
|
||||
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "off", ...EXT_ARGS, promptReference],
|
||||
["-p", "--no-session", ...EXT_ARGS, promptReference],
|
||||
]);
|
||||
} finally {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry generic non-zero child failures that are unrelated to override resolution", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const pi = {
|
||||
exec: async (cmd: string, args: string[]) => {
|
||||
calls.push({ cmd, args });
|
||||
return { code: 1, stdout: "", stderr: "memory tool returned no changes" };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await execChildPrompt(pi as any, "hello", {
|
||||
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
retryWithoutOverrides: true,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
it("does not retry generic thrown errors that are unrelated to override resolution", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const pi = {
|
||||
exec: async (cmd: string, args: string[]) => {
|
||||
calls.push({ cmd, args });
|
||||
throw new Error("timed out waiting for child process");
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => execChildPrompt(pi as any, "hello", {
|
||||
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
retryWithoutOverrides: true,
|
||||
}),
|
||||
/timed out waiting for child process/,
|
||||
);
|
||||
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
it("does not retry when retryWithoutOverrides is false", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const pi = {
|
||||
exec: async (cmd: string, args: string[]) => {
|
||||
calls.push({ cmd, args });
|
||||
return { code: 1, stdout: "", stderr: "model not found" };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await execChildPrompt(pi as any, "hello", {
|
||||
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
retryWithoutOverrides: false,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { registerPreviewContextCommand } from "../../src/handlers/preview-context.js";
|
||||
import { MEMORY_POLICY_PROMPT, MEMORY_POLICY_PROMPT_COMPACT } from "../../src/constants.js";
|
||||
|
||||
describe("registerPreviewContextCommand", () => {
|
||||
function setup(opts: {
|
||||
memoryBlock?: string;
|
||||
projectBlock?: string;
|
||||
projectName?: string;
|
||||
withProjectStore?: boolean;
|
||||
memoryMode?: "policy-only" | "legacy-inject";
|
||||
memoryPolicyStyle?: "full" | "compact" | "custom" | "none";
|
||||
memoryPolicyCustomText?: string;
|
||||
}) {
|
||||
const commands: { name: string; handler: Function }[] = [];
|
||||
const notifyCalls: { message: string; severity: string }[] = [];
|
||||
|
||||
const mockPi = {
|
||||
registerCommand: (name: string, conf: any) => {
|
||||
commands.push({ name, handler: conf.handler });
|
||||
},
|
||||
} as any;
|
||||
|
||||
const store = {
|
||||
formatForSystemPrompt: () => opts.memoryBlock ?? "",
|
||||
} as any;
|
||||
|
||||
const projectStore = opts.withProjectStore
|
||||
? ({ formatProjectBlock: () => opts.projectBlock ?? "" } as any)
|
||||
: null;
|
||||
|
||||
registerPreviewContextCommand(
|
||||
mockPi,
|
||||
store,
|
||||
projectStore,
|
||||
opts.projectName ?? "demo-project",
|
||||
{
|
||||
memoryMode: opts.memoryMode ?? "policy-only",
|
||||
memoryPolicyStyle: opts.memoryPolicyStyle,
|
||||
memoryPolicyCustomText: opts.memoryPolicyCustomText,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
handler: commands[0].handler,
|
||||
notifyCalls,
|
||||
ctx: {
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => {
|
||||
notifyCalls.push({ message, severity });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("registers /memory-preview-context", () => {
|
||||
const { handler } = setup({});
|
||||
assert.ok(typeof handler === "function");
|
||||
});
|
||||
|
||||
it("shows policy-only context by default", async () => {
|
||||
const { handler, ctx, notifyCalls } = setup({
|
||||
memoryBlock: "<memory-context>MEM</memory-context>",
|
||||
projectBlock: "<memory-context>PROJECT</memory-context>",
|
||||
withProjectStore: true,
|
||||
});
|
||||
|
||||
await handler({}, ctx);
|
||||
assert.strictEqual(notifyCalls.length, 1);
|
||||
const out = notifyCalls[0].message;
|
||||
assert.match(out, /Mode: policy-only/);
|
||||
assert.match(out, /Policy style: full/);
|
||||
assert.match(out, /Full Markdown memories are NOT injected/);
|
||||
assert.match(out, /memory_search/);
|
||||
assert.match(out, /target="failure"/);
|
||||
assert.ok(out.includes(MEMORY_POLICY_PROMPT));
|
||||
assert.match(out, /Blocks shown: 1/);
|
||||
});
|
||||
|
||||
it("shows compact policy context when configured", async () => {
|
||||
const { handler, ctx, notifyCalls } = setup({
|
||||
memoryPolicyStyle: "compact",
|
||||
});
|
||||
|
||||
await handler({}, ctx);
|
||||
const out = notifyCalls[0].message;
|
||||
assert.match(out, /Policy style: compact/);
|
||||
assert.ok(out.includes(MEMORY_POLICY_PROMPT_COMPACT));
|
||||
assert.match(out, /Blocks shown: 1/);
|
||||
});
|
||||
|
||||
it("shows custom policy context when configured", async () => {
|
||||
const customText = "<memory-policy>Custom preview policy.</memory-policy>";
|
||||
const { handler, ctx, notifyCalls } = setup({
|
||||
memoryPolicyStyle: "custom",
|
||||
memoryPolicyCustomText: customText,
|
||||
});
|
||||
|
||||
await handler({}, ctx);
|
||||
const out = notifyCalls[0].message;
|
||||
assert.match(out, /Policy style: custom/);
|
||||
assert.ok(out.includes(customText));
|
||||
assert.match(out, /Blocks shown: 1/);
|
||||
});
|
||||
|
||||
it("shows no injected policy context when policy style is none", async () => {
|
||||
const { handler, ctx, notifyCalls } = setup({
|
||||
memoryPolicyStyle: "none",
|
||||
});
|
||||
|
||||
await handler({}, ctx);
|
||||
const out = notifyCalls[0].message;
|
||||
assert.match(out, /Policy style: none/);
|
||||
assert.match(out, /No memory policy context is injected/);
|
||||
assert.doesNotMatch(out, /<memory-policy>/);
|
||||
assert.match(out, /Blocks shown: 0/);
|
||||
});
|
||||
|
||||
it("shows all available blocks in legacy mode", async () => {
|
||||
const { handler, ctx, notifyCalls } = setup({
|
||||
memoryBlock: "<memory-context>MEM</memory-context>",
|
||||
projectBlock: "<memory-context>PROJECT</memory-context>",
|
||||
withProjectStore: true,
|
||||
projectName: "pi-hermes-memory",
|
||||
memoryMode: "legacy-inject",
|
||||
});
|
||||
|
||||
await handler({}, ctx);
|
||||
assert.strictEqual(notifyCalls.length, 1);
|
||||
const out = notifyCalls[0].message;
|
||||
assert.match(out, /Injected Context Preview/);
|
||||
assert.match(out, /MEMORY \+ USER \+ RECENT FAILURES/);
|
||||
assert.match(out, /PROJECT MEMORY \(pi-hermes-memory\)/);
|
||||
assert.match(out, /Blocks shown: 2/);
|
||||
});
|
||||
|
||||
it("shows empty-state guidance when no blocks exist", async () => {
|
||||
const { handler, ctx, notifyCalls } = setup({
|
||||
memoryBlock: "",
|
||||
projectBlock: "",
|
||||
withProjectStore: false,
|
||||
memoryMode: "legacy-inject",
|
||||
});
|
||||
|
||||
await handler({}, ctx);
|
||||
const out = notifyCalls[0].message;
|
||||
assert.match(out, /No memory context blocks are currently injected/);
|
||||
assert.match(out, /Blocks shown: 0/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildPromptContext } from "../../src/prompt-context.js";
|
||||
import { MEMORY_POLICY_PROMPT, MEMORY_POLICY_PROMPT_COMPACT } from "../../src/constants.js";
|
||||
|
||||
describe("buildPromptContext", () => {
|
||||
const store = {
|
||||
formatForSystemPrompt: () => "<memory-context>MEMORY</memory-context>",
|
||||
} as any;
|
||||
|
||||
const projectStore = {
|
||||
formatProjectBlock: (projectName: string) => `<memory-context>PROJECT ${projectName}</memory-context>`,
|
||||
} as any;
|
||||
|
||||
it("returns policy only in policy-only mode", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.strictEqual(result, MEMORY_POLICY_PROMPT);
|
||||
assert.match(result, /memory_search/);
|
||||
assert.match(result, /Accepted memory categories/);
|
||||
assert.match(result, /category filters categorized failure\/lesson memories only/);
|
||||
assert.match(result, /Use category only for categorized failure\/lesson searches/);
|
||||
assert.match(result, /session_search: search indexed past conversation messages/);
|
||||
assert.match(result, /skill_manage: list, view, create, patch, update, and delete procedural skills/);
|
||||
assert.match(result, /Always pass scope explicitly on create/);
|
||||
assert.match(result, /Do not create skills for one-off task state/);
|
||||
assert.doesNotMatch(result, /category="preference"/);
|
||||
assert.doesNotMatch(result, /inspect, and update procedural skills/);
|
||||
assert.doesNotMatch(result, /memory_search: search relevant user, project, session, failure, and skill memories/);
|
||||
assert.doesNotMatch(result, /MEMORY<\/memory-context>/);
|
||||
assert.doesNotMatch(result, /PROJECT demo/);
|
||||
assert.doesNotMatch(result, /SKILLS/);
|
||||
});
|
||||
|
||||
it("returns the full policy prompt when policy style is full", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only", memoryPolicyStyle: "full" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.strictEqual(result, MEMORY_POLICY_PROMPT);
|
||||
});
|
||||
|
||||
it("returns the compact policy prompt when policy style is compact", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only", memoryPolicyStyle: "compact" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.strictEqual(result, MEMORY_POLICY_PROMPT_COMPACT);
|
||||
assert.match(result, /category filters categorized failure\/lesson memories only/);
|
||||
assert.match(result, /scope is required: global for transferable workflows, project for repo-specific ones/);
|
||||
assert.match(result, /Do not use memory_search for generic questions/);
|
||||
assert.doesNotMatch(result, /MEMORY<\/memory-context>/);
|
||||
assert.doesNotMatch(result, /PROJECT demo/);
|
||||
assert.doesNotMatch(result, /SKILLS/);
|
||||
});
|
||||
|
||||
it("returns custom policy text when policy style is custom", async () => {
|
||||
const customText = "<memory-policy>Use local custom policy.</memory-policy>";
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only", memoryPolicyStyle: "custom", memoryPolicyCustomText: customText },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.strictEqual(result, customText);
|
||||
});
|
||||
|
||||
it("falls back to compact policy when custom policy text is blank", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only", memoryPolicyStyle: "custom", memoryPolicyCustomText: " \n\t " },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.strictEqual(result, MEMORY_POLICY_PROMPT_COMPACT);
|
||||
});
|
||||
|
||||
it("returns empty context when policy style is none", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only", memoryPolicyStyle: "none" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.strictEqual(result, "");
|
||||
});
|
||||
|
||||
it("returns legacy memory blocks in legacy-inject mode", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "legacy-inject", memoryPolicyStyle: "compact" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
);
|
||||
|
||||
assert.match(result, /MEMORY/);
|
||||
assert.match(result, /PROJECT demo/);
|
||||
assert.doesNotMatch(result, /<memory-policy>/);
|
||||
});
|
||||
|
||||
const standing = {
|
||||
formatForSystemPrompt: () => "<standing-instructions>\n1. never run find /\n</standing-instructions>",
|
||||
} as any;
|
||||
|
||||
it("appends standing instructions after the policy in policy-only mode", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
standing,
|
||||
);
|
||||
|
||||
assert.ok(result.startsWith(MEMORY_POLICY_PROMPT), "the policy still leads");
|
||||
assert.match(result, /<standing-instructions>/);
|
||||
assert.match(result, /never run find \//);
|
||||
assert.doesNotMatch(result, /MEMORY<\/memory-context>/, "stored memories stay out of policy-only");
|
||||
});
|
||||
|
||||
it("injects standing instructions even when the policy style is none", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "policy-only", memoryPolicyStyle: "none" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
standing,
|
||||
);
|
||||
|
||||
assert.strictEqual(result, "<standing-instructions>\n1. never run find /\n</standing-instructions>");
|
||||
});
|
||||
|
||||
it("puts standing instructions last in legacy-inject mode", async () => {
|
||||
const result = await buildPromptContext(
|
||||
{ memoryMode: "legacy-inject" },
|
||||
store,
|
||||
projectStore,
|
||||
"demo",
|
||||
standing,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.indexOf("<standing-instructions>") > result.indexOf("PROJECT demo"),
|
||||
"user directives read last, closest to the task",
|
||||
);
|
||||
});
|
||||
|
||||
it("injects nothing extra when no standing store is wired", async () => {
|
||||
const result = await buildPromptContext({ memoryMode: "policy-only" }, store, projectStore, "demo");
|
||||
|
||||
assert.strictEqual(result, MEMORY_POLICY_PROMPT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resolveProjectSkillDiscovery, registerProjectSkillDiscoveryHandler } from "../../src/index.js";
|
||||
import { AGENT_ROOT } from "../../src/paths.js";
|
||||
import { SkillStore } from "../../src/store/skill-store.js";
|
||||
|
||||
describe("resources_discover skill path resolution", () => {
|
||||
it("registers resources_discover and returns skillPaths from handler", async () => {
|
||||
const store = new SkillStore({
|
||||
globalSkillsDir: "/tmp/global-skills",
|
||||
projectSkillsDir: null,
|
||||
projectName: null,
|
||||
legacySkillsDir: "/tmp/legacy-skills",
|
||||
migrationSentinelPath: "/tmp/.skills-migrated",
|
||||
});
|
||||
|
||||
const handlers: Record<string, Function> = {};
|
||||
const pi = {
|
||||
on: (event: string, handler: Function) => {
|
||||
handlers[event] = handler;
|
||||
},
|
||||
} as any;
|
||||
|
||||
registerProjectSkillDiscoveryHandler(pi, store, "projects-memory");
|
||||
assert.ok(typeof handlers.resources_discover === "function");
|
||||
|
||||
const result = await handlers.resources_discover({ cwd: "/tmp/demo-repo" }, {});
|
||||
const expectedPath = path.join(AGENT_ROOT, "projects-memory", "demo-repo", "skills");
|
||||
|
||||
assert.deepStrictEqual(result, { skillPaths: ["/tmp/global-skills", expectedPath] });
|
||||
});
|
||||
|
||||
it("returns project skillPaths and updates skill store context", () => {
|
||||
const store = new SkillStore({
|
||||
globalSkillsDir: "/tmp/global-skills",
|
||||
projectSkillsDir: null,
|
||||
projectName: null,
|
||||
legacySkillsDir: "/tmp/legacy-skills",
|
||||
migrationSentinelPath: "/tmp/.skills-migrated",
|
||||
});
|
||||
|
||||
const cwd = "/tmp/demo-repo";
|
||||
const resource = resolveProjectSkillDiscovery(store, "projects-memory", cwd);
|
||||
const expectedPath = path.join(AGENT_ROOT, "projects-memory", "demo-repo", "skills");
|
||||
|
||||
assert.deepStrictEqual(resource, { skillPaths: ["/tmp/global-skills", expectedPath] });
|
||||
assert.strictEqual(store.getProjectName(), "demo-repo");
|
||||
assert.strictEqual(store.getProjectSkillsDir(), expectedPath);
|
||||
});
|
||||
|
||||
it("contributes no skill path when cwd is not a project", () => {
|
||||
const store = new SkillStore({
|
||||
globalSkillsDir: "/tmp/global-skills",
|
||||
projectSkillsDir: "/tmp/old-project",
|
||||
projectName: "old-project",
|
||||
legacySkillsDir: "/tmp/legacy-skills",
|
||||
migrationSentinelPath: "/tmp/.skills-migrated",
|
||||
});
|
||||
|
||||
const resource = resolveProjectSkillDiscovery(store, "projects-memory", os.homedir());
|
||||
|
||||
// Outside a project only the extension's own global root is contributed.
|
||||
// It is a directory of ours, kept apart from the skills a user installed
|
||||
// themselves, so Pi has to be told about it (#126).
|
||||
assert.deepStrictEqual(resource, { skillPaths: ["/tmp/global-skills"] });
|
||||
assert.strictEqual(store.getProjectName(), null);
|
||||
assert.strictEqual(store.getProjectSkillsDir(), null);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { DatabaseManager } from '../../src/store/db.js';
|
||||
import {
|
||||
scheduleSessionBackfill,
|
||||
joinSessionBackfill,
|
||||
waitForSessionBackfill,
|
||||
type SessionBackfillState,
|
||||
} from '../../src/handlers/session-backfill.js';
|
||||
import { indexAllSessions, touchBackfillTimestamp } from '../../src/store/session-indexer.js';
|
||||
|
||||
function writeJsonlSession(sessionsDir: string, projectDir: string, sessionId: string, text = 'Hello from backfill'): void {
|
||||
const projDir = path.join(sessionsDir, projectDir);
|
||||
fs.mkdirSync(projDir, { recursive: true });
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/work/${projectDir}` }),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
id: `${sessionId}-m1`,
|
||||
parentId: null,
|
||||
timestamp: '2026-05-03T00:01:00Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text }], timestamp: Date.now() },
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(path.join(projDir, `${sessionId}.jsonl`), lines.join('\n'));
|
||||
}
|
||||
|
||||
describe('session backfill handler', () => {
|
||||
let tmpDir: string;
|
||||
let sessionsDir: string;
|
||||
let dbManager: DatabaseManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-backfill-test-'));
|
||||
sessionsDir = path.join(tmpDir, 'sessions');
|
||||
dbManager = new DatabaseManager(path.join(tmpDir, 'memory'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbManager.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('schedules backfill without indexing synchronously, then indexes unindexed sessions', async () => {
|
||||
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
||||
const callbacks: (() => void)[] = [];
|
||||
const state: SessionBackfillState = { inProgress: false, promise: null };
|
||||
|
||||
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
||||
state,
|
||||
setTimeoutFn: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, true);
|
||||
assert.equal(callbacks.length, 1);
|
||||
assert.equal(dbManager.getStats().sessions, 0, 'session_start should not index synchronously');
|
||||
|
||||
const promise = state.promise;
|
||||
assert.ok(promise);
|
||||
callbacks[0]();
|
||||
await promise;
|
||||
|
||||
assert.equal(dbManager.getStats().sessions, 1);
|
||||
assert.equal(dbManager.getStats().messages, 1);
|
||||
});
|
||||
|
||||
it('does not schedule backfill when counts match and timestamp is recent', () => {
|
||||
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
||||
indexAllSessions(dbManager, sessionsDir);
|
||||
touchBackfillTimestamp(dbManager);
|
||||
|
||||
const callbacks: (() => void)[] = [];
|
||||
const state: SessionBackfillState = { inProgress: false, promise: null };
|
||||
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
||||
state,
|
||||
setTimeoutFn: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, false);
|
||||
assert.equal(callbacks.length, 0);
|
||||
assert.equal(state.inProgress, false);
|
||||
});
|
||||
|
||||
it('keeps manual indexAllSessions idempotent with auto-backfill', async () => {
|
||||
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
||||
const state: SessionBackfillState = { inProgress: false, promise: null };
|
||||
|
||||
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
||||
state,
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
assert.equal(scheduled, true);
|
||||
await state.promise;
|
||||
|
||||
const manualResult = indexAllSessions(dbManager, sessionsDir);
|
||||
assert.equal(manualResult.sessionsProcessed, 1);
|
||||
assert.equal(manualResult.sessionsSkipped, 1);
|
||||
assert.equal(manualResult.sessionsIndexed, 0);
|
||||
});
|
||||
|
||||
it('does not mark backfill complete when startup parse limit is reached', async () => {
|
||||
const state: SessionBackfillState = { inProgress: false, promise: null };
|
||||
let touched = false;
|
||||
const notifications: { message: string; level: string }[] = [];
|
||||
|
||||
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
||||
state,
|
||||
needsBackfillFn: () => true,
|
||||
indexSessionsFn: () => ({
|
||||
sessionsProcessed: 1,
|
||||
sessionsIndexed: 1,
|
||||
sessionsSkipped: 0,
|
||||
messagesIndexed: 1,
|
||||
errors: [],
|
||||
reachedLimit: true,
|
||||
}),
|
||||
touchBackfillTimestampFn: () => { touched = true; },
|
||||
notify: (message, level) => notifications.push({ message, level }),
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, true);
|
||||
await state.promise;
|
||||
assert.equal(touched, false);
|
||||
assert.equal(notifications[0].level, 'warning');
|
||||
assert.match(notifications[0].message, /startup limit reached/);
|
||||
});
|
||||
|
||||
it('scheduled task is best-effort and does not reject when indexing throws', async () => {
|
||||
const state: SessionBackfillState = { inProgress: false, promise: null };
|
||||
const notifications: { message: string; level: string }[] = [];
|
||||
|
||||
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
||||
state,
|
||||
needsBackfillFn: () => true,
|
||||
indexSessionsFn: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
notify: (message, level) => notifications.push({ message, level }),
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, true);
|
||||
await state.promise;
|
||||
assert.equal(state.inProgress, false);
|
||||
assert.match(notifications[0].message, /Session backfill failed: boom/);
|
||||
assert.equal(notifications[0].level, 'warning');
|
||||
});
|
||||
|
||||
it('shutdown wait resolves true when an in-progress backfill completes before timeout', async () => {
|
||||
let resolveBackfill!: () => void;
|
||||
const state: SessionBackfillState = {
|
||||
inProgress: true,
|
||||
promise: new Promise<void>((resolve) => {
|
||||
resolveBackfill = resolve;
|
||||
}),
|
||||
};
|
||||
|
||||
setTimeout(resolveBackfill, 5);
|
||||
const completed = await waitForSessionBackfill(100, state);
|
||||
|
||||
assert.equal(completed, true);
|
||||
});
|
||||
|
||||
it('shutdown wait resolves false when an in-progress backfill exceeds timeout', async () => {
|
||||
const state: SessionBackfillState = {
|
||||
inProgress: true,
|
||||
promise: new Promise<void>(() => {}),
|
||||
};
|
||||
|
||||
const completed = await waitForSessionBackfill(5, state);
|
||||
|
||||
assert.equal(completed, false);
|
||||
});
|
||||
|
||||
it('first-use join does not inherit the five-second shutdown deadline', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const backfill = Promise.withResolvers<void>();
|
||||
const state: SessionBackfillState = { inProgress: true, promise: backfill.promise };
|
||||
let joined = false;
|
||||
const firstUse = joinSessionBackfill(state).then(() => { joined = true; });
|
||||
const shutdown = waitForSessionBackfill(5000, state);
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(await shutdown, false);
|
||||
assert.equal(joined, false);
|
||||
backfill.resolve();
|
||||
await firstUse;
|
||||
assert.equal(joined, true);
|
||||
});
|
||||
|
||||
it('propagates the retention cutoff to the backfill file set', async () => {
|
||||
// Only an expired file exists (mtime older than the 30-day cutoff). Even
|
||||
// with recent backfill timestamps, the backfill must not be scheduled
|
||||
// because the file is outside the retained window.
|
||||
writeJsonlSession(sessionsDir, 'project-a', 'old');
|
||||
const oldFile = path.join(sessionsDir, 'project-a', 'old.jsonl');
|
||||
const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const oldMtime = cutoffMs - 1000;
|
||||
fs.utimesSync(oldFile, new Date(oldMtime), new Date(oldMtime));
|
||||
|
||||
touchBackfillTimestamp(dbManager);
|
||||
|
||||
let receivedCutoff: number | undefined;
|
||||
let indexOptions: { retentionCutoffMs?: number } | undefined;
|
||||
const state: SessionBackfillState = { inProgress: false, promise: null };
|
||||
|
||||
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
||||
state,
|
||||
retentionCutoffMs: cutoffMs,
|
||||
setTimeoutFn: () => 0,
|
||||
// Capture the cutoff that would be forwarded to the store so we prove
|
||||
// the wiring reaches both the eligibility check and the indexer.
|
||||
needsBackfillFn: (_db, _dir, _now, cutoff) => {
|
||||
receivedCutoff = cutoff;
|
||||
return false;
|
||||
},
|
||||
indexSessionsFn: (_db, _dir, options) => {
|
||||
indexOptions = options;
|
||||
return { sessionsProcessed: 0, sessionsIndexed: 0, sessionsSkipped: 0, messagesIndexed: 0, reachedLimit: undefined, errors: [] };
|
||||
},
|
||||
});
|
||||
|
||||
// needsBackfill ran and received the cutoff; because it returned false for
|
||||
// an expired file set, no backfill pass was scheduled.
|
||||
assert.equal(scheduled, false);
|
||||
assert.equal(receivedCutoff, cutoffMs);
|
||||
assert.equal(indexOptions, undefined);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { DatabaseManager } from '../../src/store/db.js';
|
||||
import {
|
||||
scheduleLiveSessionIndex,
|
||||
waitForLiveSessionIndex,
|
||||
type SessionLiveIndexState,
|
||||
} from '../../src/handlers/session-live-index.js';
|
||||
|
||||
describe('session live indexing handler', () => {
|
||||
let tmpDir: string;
|
||||
let dbManager: DatabaseManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-live-index-test-'));
|
||||
dbManager = new DatabaseManager(path.join(tmpDir, 'memory'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbManager.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createSnapshot(entries: unknown[]) {
|
||||
return {
|
||||
getHeader: () => ({ id: 'live-session', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/live-project' }),
|
||||
getEntries: () => entries,
|
||||
};
|
||||
}
|
||||
|
||||
it('defers indexing so message_end does not block and then indexes live messages', async () => {
|
||||
const entries = [{
|
||||
type: 'message',
|
||||
id: 'entry-1',
|
||||
timestamp: '2026-05-03T00:01:00Z',
|
||||
message: { role: 'user', content: 'hello after message_end' },
|
||||
}];
|
||||
const callbacks: (() => void)[] = [];
|
||||
const state: SessionLiveIndexState = { inProgress: false, promise: null };
|
||||
|
||||
const scheduled = scheduleLiveSessionIndex(dbManager, createSnapshot(entries), {
|
||||
state,
|
||||
delayMs: 0,
|
||||
setTimeoutFn: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, true);
|
||||
assert.equal(callbacks.length, 1);
|
||||
assert.equal(dbManager.getStats().messages, 0, 'message_end handler should not index synchronously');
|
||||
|
||||
const promise = state.promise;
|
||||
assert.ok(promise);
|
||||
callbacks[0]();
|
||||
await promise;
|
||||
|
||||
assert.equal(dbManager.getStats().sessions, 1);
|
||||
assert.equal(dbManager.getStats().messages, 1);
|
||||
});
|
||||
|
||||
it('coalesces multiple scheduled message_end events and indexes all missing entries', async () => {
|
||||
const entries = [{
|
||||
type: 'message',
|
||||
id: 'entry-1',
|
||||
timestamp: '2026-05-03T00:01:00Z',
|
||||
message: { role: 'user', content: 'first' },
|
||||
}];
|
||||
const callbacks: (() => void)[] = [];
|
||||
const state: SessionLiveIndexState = { inProgress: false, promise: null };
|
||||
const snapshot = createSnapshot(entries);
|
||||
|
||||
assert.equal(scheduleLiveSessionIndex(dbManager, snapshot, {
|
||||
state,
|
||||
delayMs: 0,
|
||||
setTimeoutFn: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return 0;
|
||||
},
|
||||
}), true);
|
||||
|
||||
entries.push({
|
||||
type: 'message',
|
||||
id: 'entry-2',
|
||||
timestamp: '2026-05-03T00:02:00Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] },
|
||||
});
|
||||
assert.equal(scheduleLiveSessionIndex(dbManager, snapshot, { state, delayMs: 0 }), false);
|
||||
|
||||
const promise = state.promise;
|
||||
callbacks[0]();
|
||||
await promise;
|
||||
|
||||
assert.equal(dbManager.getStats().sessions, 1);
|
||||
assert.equal(dbManager.getStats().messages, 2);
|
||||
});
|
||||
|
||||
it('indexes appended messages for an already indexed resumed session', async () => {
|
||||
const entries = [{
|
||||
type: 'message',
|
||||
id: 'entry-1',
|
||||
timestamp: '2026-05-03T00:01:00Z',
|
||||
message: { role: 'user', content: 'before resume' },
|
||||
}];
|
||||
const snapshot = createSnapshot(entries);
|
||||
const state: SessionLiveIndexState = { inProgress: false, promise: null };
|
||||
|
||||
scheduleLiveSessionIndex(dbManager, snapshot, {
|
||||
state,
|
||||
delayMs: 0,
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
await state.promise;
|
||||
assert.equal(dbManager.getStats().messages, 1);
|
||||
|
||||
entries.push({
|
||||
type: 'message',
|
||||
id: 'entry-2',
|
||||
timestamp: '2026-05-03T00:02:00Z',
|
||||
message: { role: 'user', content: 'after resume' },
|
||||
});
|
||||
scheduleLiveSessionIndex(dbManager, snapshot, {
|
||||
state,
|
||||
delayMs: 0,
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
await state.promise;
|
||||
|
||||
assert.equal(dbManager.getStats().sessions, 1);
|
||||
assert.equal(dbManager.getStats().messages, 2);
|
||||
});
|
||||
|
||||
it('scheduled live indexing is best-effort and does not reject on errors', async () => {
|
||||
const state: SessionLiveIndexState = { inProgress: false, promise: null };
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const scheduled = scheduleLiveSessionIndex(dbManager, createSnapshot([]), {
|
||||
state,
|
||||
indexLiveSessionFn: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
onError: (err) => errors.push(err),
|
||||
delayMs: 0,
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, true);
|
||||
await state.promise;
|
||||
assert.equal(state.inProgress, false);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0] instanceof Error ? errors[0].message : String(errors[0]), /boom/);
|
||||
});
|
||||
|
||||
it('repairs corruption and retries live indexing once before reporting an error', async () => {
|
||||
dbManager.getDb();
|
||||
const state: SessionLiveIndexState = { inProgress: false, promise: null };
|
||||
const errors: unknown[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
const scheduled = scheduleLiveSessionIndex(dbManager, createSnapshot([]), {
|
||||
state,
|
||||
indexLiveSessionFn: () => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
const err = new Error('SQLITE_CORRUPT: database disk image is malformed') as Error & { code: string };
|
||||
err.code = 'SQLITE_CORRUPT';
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onError: (err) => errors.push(err),
|
||||
delayMs: 0,
|
||||
setTimeoutFn: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scheduled, true);
|
||||
await state.promise;
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(dbManager.getLastRecovery()?.strategy, 'reused');
|
||||
});
|
||||
|
||||
it('shutdown wait resolves true when live indexing completes before timeout', async () => {
|
||||
let resolveIndex!: () => void;
|
||||
const state: SessionLiveIndexState = {
|
||||
inProgress: true,
|
||||
promise: new Promise<void>((resolve) => {
|
||||
resolveIndex = resolve;
|
||||
}),
|
||||
};
|
||||
|
||||
setTimeout(resolveIndex, 5);
|
||||
const completed = await waitForLiveSessionIndex(100, state);
|
||||
|
||||
assert.equal(completed, true);
|
||||
});
|
||||
|
||||
it('shutdown wait resolves false when live indexing exceeds timeout', async () => {
|
||||
const state: SessionLiveIndexState = {
|
||||
inProgress: true,
|
||||
promise: new Promise<void>(() => {}),
|
||||
};
|
||||
|
||||
const completed = await waitForLiveSessionIndex(5, state);
|
||||
|
||||
assert.equal(completed, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,827 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildSkillRows,
|
||||
buildUnifiedSkillRows,
|
||||
collectLoadedSkillsFromCommands,
|
||||
confirmDeleteSelectedSkills,
|
||||
deleteSelectedSkills,
|
||||
filterSkillRows,
|
||||
moveSelectedSkills,
|
||||
registerSkillsCommand,
|
||||
SkillsManagerModal,
|
||||
type SkillBatchActionResult,
|
||||
} from "../../src/handlers/skills-command.js";
|
||||
import type { SkillIndex, SkillResult } from "../../src/types.js";
|
||||
|
||||
const SAMPLE_SKILLS: SkillIndex[] = [
|
||||
{
|
||||
skillId: "global:debug-typescript-errors",
|
||||
scope: "global",
|
||||
fileName: "SKILL.md",
|
||||
path: "/tmp/global/debug-typescript-errors/SKILL.md",
|
||||
name: "debug-typescript-errors",
|
||||
displayName: "Debug TypeScript Errors",
|
||||
description: "Trace compiler issues step by step",
|
||||
created: "2026-05-19",
|
||||
updated: "2026-05-21",
|
||||
},
|
||||
{
|
||||
skillId: "project:demo-project:deploy-checklist",
|
||||
scope: "project",
|
||||
fileName: "SKILL.md",
|
||||
path: "/tmp/project/deploy-checklist/SKILL.md",
|
||||
projectName: "demo-project",
|
||||
name: "deploy-checklist",
|
||||
displayName: "Deploy Checklist",
|
||||
description: "Project release checklist",
|
||||
created: "2026-05-18",
|
||||
updated: "2026-05-20",
|
||||
},
|
||||
];
|
||||
|
||||
const SORT_MODE_SKILLS: SkillIndex[] = [
|
||||
{
|
||||
skillId: "global:zebra-runbook",
|
||||
scope: "global",
|
||||
fileName: "SKILL.md",
|
||||
path: "/tmp/global/zebra-runbook/SKILL.md",
|
||||
name: "zebra-runbook",
|
||||
displayName: "Zebra Runbook",
|
||||
description: "Older creation date, newer update date",
|
||||
created: "2026-05-18",
|
||||
updated: "2026-05-21",
|
||||
},
|
||||
{
|
||||
skillId: "project:demo-project:alpha-checklist",
|
||||
scope: "project",
|
||||
fileName: "SKILL.md",
|
||||
path: "/tmp/project/alpha-checklist/SKILL.md",
|
||||
projectName: "demo-project",
|
||||
name: "alpha-checklist",
|
||||
displayName: "Alpha Checklist",
|
||||
description: "Newer creation date, older update date",
|
||||
created: "2026-05-22",
|
||||
updated: "2026-05-20",
|
||||
},
|
||||
];
|
||||
|
||||
const LOADED_SKILL_COMMANDS = [
|
||||
{
|
||||
name: "skill:debug-typescript-errors",
|
||||
description: "Trace compiler issues step by step",
|
||||
source: "skill",
|
||||
sourceInfo: { path: "/tmp/global/debug-typescript-errors/SKILL.md" },
|
||||
},
|
||||
{
|
||||
name: "skill:langgraph-fundamentals",
|
||||
description: "LangGraph patterns",
|
||||
source: "skill",
|
||||
sourceInfo: { path: "/Users/demo/.agents/skills/langgraph-fundamentals/SKILL.md" },
|
||||
},
|
||||
{
|
||||
name: "memory-skills",
|
||||
description: "not a skill command",
|
||||
source: "extension",
|
||||
sourceInfo: { path: "/tmp/ignore" },
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("skills command helpers", () => {
|
||||
it("buildSkillRows preserves selected ids", () => {
|
||||
const rows = buildSkillRows(SAMPLE_SKILLS, new Set(["project:demo-project:deploy-checklist"]));
|
||||
|
||||
assert.strictEqual(rows.length, 2);
|
||||
assert.strictEqual(rows[0].selected, false);
|
||||
assert.strictEqual(rows[1].selected, true);
|
||||
assert.strictEqual(rows[0].searchText.includes("Debug TypeScript Errors"), true);
|
||||
});
|
||||
|
||||
it("filterSkillRows uses fuzzy skill-name matching", () => {
|
||||
const rows = buildSkillRows(SAMPLE_SKILLS);
|
||||
const filtered = filterSkillRows(rows, "dbg ts err");
|
||||
|
||||
assert.strictEqual(filtered.length, 1);
|
||||
assert.strictEqual(filtered[0].skillId, "global:debug-typescript-errors");
|
||||
});
|
||||
|
||||
it("collectLoadedSkillsFromCommands returns loaded runtime skills only", () => {
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
|
||||
assert.strictEqual(loaded.length, 2);
|
||||
assert.strictEqual(loaded[0]?.name, "debug-typescript-errors");
|
||||
assert.strictEqual(loaded[1]?.name, "langgraph-fundamentals");
|
||||
});
|
||||
|
||||
it("collectLoadedSkillsFromCommands ignores malformed and pathless commands", () => {
|
||||
const loaded = collectLoadedSkillsFromCommands([
|
||||
{ source: "skill", name: "skill:valid", sourceInfo: { path: "/tmp/valid/SKILL.md" } },
|
||||
{ source: "skill", name: "skill:no-path", sourceInfo: {} },
|
||||
{ source: "skill", name: "skill:blank-path", sourceInfo: { path: " " } },
|
||||
{ source: "skill", name: " ", sourceInfo: { path: "/tmp/blank-name/SKILL.md" } },
|
||||
{ source: "skill", sourceInfo: { path: "/tmp/missing-name/SKILL.md" } },
|
||||
{ source: "skill", name: 123, sourceInfo: { path: "/tmp/invalid-name/SKILL.md" } },
|
||||
{ source: "extension", name: "memory-skills", sourceInfo: { path: "/tmp/ignore/SKILL.md" } },
|
||||
null as any,
|
||||
] as any);
|
||||
|
||||
assert.strictEqual(loaded.length, 1);
|
||||
assert.strictEqual(loaded[0]?.name, "valid");
|
||||
assert.strictEqual(loaded[0]?.path, "/tmp/valid/SKILL.md");
|
||||
});
|
||||
|
||||
it("buildUnifiedSkillRows merges managed and external skills", () => {
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
||||
|
||||
assert.strictEqual(rows.length, 3);
|
||||
const external = rows.find((row) => row.category === "E");
|
||||
assert.ok(external);
|
||||
assert.strictEqual(external?.mutable, false);
|
||||
assert.ok(external?.displayPath.includes(".agents"));
|
||||
});
|
||||
|
||||
it("buildUnifiedSkillRows keeps managed skills sorted by updated recency", () => {
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
||||
|
||||
assert.strictEqual(rows[0]?.skillId, "global:debug-typescript-errors");
|
||||
assert.strictEqual(rows[1]?.skillId, "project:demo-project:deploy-checklist");
|
||||
assert.strictEqual(rows[2]?.category, "E");
|
||||
});
|
||||
|
||||
it("buildUnifiedSkillRows can sort by created date or name", () => {
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
|
||||
const createdRows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded, new Set<string>(), "created");
|
||||
assert.strictEqual(createdRows[0]?.skillId, "project:demo-project:alpha-checklist");
|
||||
assert.strictEqual(createdRows[1]?.skillId, "global:zebra-runbook");
|
||||
|
||||
const nameRows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded, new Set<string>(), "name");
|
||||
assert.strictEqual(nameRows[0]?.skillId, "project:demo-project:alpha-checklist");
|
||||
assert.strictEqual(nameRows[1]?.displayName, "debug-typescript-errors");
|
||||
assert.strictEqual(nameRows[2]?.displayName, "langgraph-fundamentals");
|
||||
assert.strictEqual(nameRows[3]?.skillId, "global:zebra-runbook");
|
||||
});
|
||||
|
||||
it("moveSelectedSkills blocks project moves without an active project", async () => {
|
||||
const store = {
|
||||
getProjectName: () => null,
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
move: async () => ({ success: true } as SkillResult),
|
||||
};
|
||||
|
||||
const result = await moveSelectedSkills(store as any, ["global:debug-typescript-errors"], "project");
|
||||
|
||||
assert.strictEqual(result.summaryLines[0], "Move to project is unavailable: no active project detected.");
|
||||
assert.deepStrictEqual(result.retainSelectedSkillIds, ["global:debug-typescript-errors"]);
|
||||
});
|
||||
|
||||
it("moveSelectedSkills keeps partial successes and retains blocked selection", async () => {
|
||||
const moves = new Map<string, SkillResult>([
|
||||
[
|
||||
"global:debug-typescript-errors",
|
||||
{
|
||||
success: true,
|
||||
skillId: "project:demo-project:debug-typescript-errors",
|
||||
scope: "project",
|
||||
message: "Skill 'Debug TypeScript Errors' moved to project.",
|
||||
},
|
||||
],
|
||||
[
|
||||
"project:demo-project:deploy-checklist",
|
||||
{
|
||||
success: false,
|
||||
error: "Destination already exists.",
|
||||
conflictType: "scope-conflict",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const refreshed: SkillIndex[] = [
|
||||
{
|
||||
...SAMPLE_SKILLS[1],
|
||||
skillId: "project:demo-project:debug-typescript-errors",
|
||||
name: "debug-typescript-errors",
|
||||
displayName: "Debug TypeScript Errors",
|
||||
path: "/tmp/project/debug-typescript-errors/SKILL.md",
|
||||
description: "Trace compiler issues step by step",
|
||||
},
|
||||
SAMPLE_SKILLS[1],
|
||||
];
|
||||
|
||||
const store = {
|
||||
getProjectName: () => "demo-project",
|
||||
loadIndex: async () => refreshed,
|
||||
move: async (skillId: string) => moves.get(skillId)!,
|
||||
};
|
||||
|
||||
const result = await moveSelectedSkills(
|
||||
store as any,
|
||||
["global:debug-typescript-errors", "project:demo-project:deploy-checklist"],
|
||||
"project",
|
||||
);
|
||||
|
||||
assert.ok(result.summaryLines[0].includes("Moved 1 skill"));
|
||||
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
||||
assert.deepStrictEqual(result.retainSelectedSkillIds, ["project:demo-project:deploy-checklist"]);
|
||||
assert.strictEqual(result.focusSkillId, "project:demo-project:deploy-checklist");
|
||||
assert.strictEqual(result.skills.length, 2);
|
||||
});
|
||||
|
||||
it("deleteSelectedSkills reports blocked deletes and refreshes skills", async () => {
|
||||
const store = {
|
||||
loadIndex: async () => [SAMPLE_SKILLS[1]],
|
||||
delete: async (skillId: string) => skillId === SAMPLE_SKILLS[0].skillId
|
||||
? { success: true, skillId, scope: "global" as const }
|
||||
: { success: false, error: "Skill missing." },
|
||||
};
|
||||
|
||||
const result = await deleteSelectedSkills(
|
||||
store as any,
|
||||
[SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId],
|
||||
);
|
||||
|
||||
assert.ok(result.summaryLines[0].includes("Deleted 1 skill"));
|
||||
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
||||
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[1].skillId]);
|
||||
});
|
||||
|
||||
it("moveSelectedSkills treats thrown move errors as blocked items", async () => {
|
||||
const store = {
|
||||
getProjectName: () => "demo-project",
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
move: async (skillId: string) => {
|
||||
if (skillId === SAMPLE_SKILLS[0].skillId) {
|
||||
throw new Error("permission denied");
|
||||
}
|
||||
return { success: true, skillId: "global:deploy-checklist", scope: "global" as const };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await moveSelectedSkills(store as any, [SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId], "global");
|
||||
|
||||
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
||||
assert.ok(result.summaryLines.some((line) => line.includes("permission denied")));
|
||||
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[0].skillId]);
|
||||
});
|
||||
|
||||
it("deleteSelectedSkills treats thrown delete errors as blocked items", async () => {
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
delete: async (skillId: string) => {
|
||||
if (skillId === SAMPLE_SKILLS[1].skillId) {
|
||||
throw new Error("unlink denied");
|
||||
}
|
||||
return { success: true, skillId, scope: "global" as const };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await deleteSelectedSkills(store as any, [SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId]);
|
||||
|
||||
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
||||
assert.ok(result.summaryLines.some((line) => line.includes("unlink denied")));
|
||||
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[1].skillId]);
|
||||
});
|
||||
|
||||
it("confirmDeleteSelectedSkills keeps selection when user cancels", async () => {
|
||||
let prompts = 0;
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
delete: async () => ({ success: true }),
|
||||
};
|
||||
|
||||
const result = await confirmDeleteSelectedSkills(
|
||||
async () => {
|
||||
prompts++;
|
||||
return false;
|
||||
},
|
||||
store as any,
|
||||
[SAMPLE_SKILLS[0].skillId],
|
||||
);
|
||||
|
||||
assert.strictEqual(prompts, 1);
|
||||
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[0].skillId]);
|
||||
assert.strictEqual(result.summaryLines[0], "Delete cancelled.");
|
||||
});
|
||||
});
|
||||
|
||||
function createModalHarness() {
|
||||
let renderCount = 0;
|
||||
return {
|
||||
tui: {
|
||||
requestRender: () => {
|
||||
renderCount++;
|
||||
},
|
||||
terminal: { rows: 42 },
|
||||
},
|
||||
theme: {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
},
|
||||
getRenderCount: () => renderCount,
|
||||
};
|
||||
}
|
||||
|
||||
function nextTick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("SkillsManagerModal", () => {
|
||||
it("toggles selection and sends selected ids for move action", async () => {
|
||||
const harness = createModalHarness();
|
||||
const captured: Array<{ scope: string; skillIds: string[] }> = [];
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
buildSkillRows(SAMPLE_SKILLS),
|
||||
{
|
||||
moveSelected: async (scope, skillIds) => {
|
||||
captured.push({ scope, skillIds });
|
||||
return { skills: SAMPLE_SKILLS, summaryLines: ["done"] };
|
||||
},
|
||||
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
);
|
||||
|
||||
modal.handleInput(" ");
|
||||
modal.handleInput("g");
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(captured.length, 1);
|
||||
assert.strictEqual(captured[0].scope, "global");
|
||||
assert.deepStrictEqual(captured[0].skillIds, ["global:debug-typescript-errors"]);
|
||||
});
|
||||
|
||||
it("supports slash search and typed filtering", () => {
|
||||
const harness = createModalHarness();
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
buildSkillRows(SAMPLE_SKILLS),
|
||||
{
|
||||
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
);
|
||||
|
||||
modal.handleInput("/");
|
||||
modal.handleInput("z");
|
||||
modal.handleInput("z");
|
||||
|
||||
const output = modal.render(100).join("\n");
|
||||
assert.ok(output.includes("No skills match the current filters/search."));
|
||||
});
|
||||
|
||||
it("redirects printable keys to search from list focus", () => {
|
||||
const harness = createModalHarness();
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
buildSkillRows(SAMPLE_SKILLS),
|
||||
{
|
||||
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
);
|
||||
|
||||
modal.handleInput("z");
|
||||
const output = modal.render(100).join("\n");
|
||||
assert.ok(output.includes("No skills match the current filters/search."));
|
||||
});
|
||||
|
||||
it("uses in-modal delete confirmation and cancels with n", () => {
|
||||
const harness = createModalHarness();
|
||||
let deleteCalls = 0;
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
buildSkillRows(SAMPLE_SKILLS),
|
||||
{
|
||||
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
deleteSelected: async () => {
|
||||
deleteCalls++;
|
||||
return { skills: SAMPLE_SKILLS, summaryLines: ["deleted"] };
|
||||
},
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
);
|
||||
|
||||
modal.handleInput(" ");
|
||||
modal.handleInput("d");
|
||||
let output = modal.render(100).join("\n");
|
||||
assert.ok(output.includes("Press y to confirm or n to cancel"));
|
||||
|
||||
modal.handleInput("n");
|
||||
output = modal.render(100).join("\n");
|
||||
assert.ok(output.includes("Delete cancelled."));
|
||||
assert.strictEqual(deleteCalls, 0);
|
||||
});
|
||||
|
||||
it("confirms delete in-modal with y", async () => {
|
||||
const harness = createModalHarness();
|
||||
const capturedDeletes: string[][] = [];
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
buildSkillRows(SAMPLE_SKILLS),
|
||||
{
|
||||
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
deleteSelected: async (skillIds) => {
|
||||
capturedDeletes.push(skillIds);
|
||||
return { skills: [SAMPLE_SKILLS[1]], summaryLines: ["deleted"] };
|
||||
},
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
);
|
||||
|
||||
modal.handleInput(" ");
|
||||
modal.handleInput("d");
|
||||
modal.handleInput("y");
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(capturedDeletes.length, 1);
|
||||
assert.deepStrictEqual(capturedDeletes[0], ["global:debug-typescript-errors"]);
|
||||
});
|
||||
|
||||
it("stops rendering updates after close during async actions", async () => {
|
||||
const harness = createModalHarness();
|
||||
let resolveMove: ((result: SkillBatchActionResult) => void) | null = null;
|
||||
let closeCount = 0;
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
buildSkillRows(SAMPLE_SKILLS),
|
||||
{
|
||||
moveSelected: async () => {
|
||||
return await new Promise<SkillBatchActionResult>((resolve) => {
|
||||
resolveMove = resolve;
|
||||
});
|
||||
},
|
||||
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => {
|
||||
closeCount++;
|
||||
},
|
||||
projectName: "demo-project",
|
||||
},
|
||||
);
|
||||
|
||||
modal.handleInput("g");
|
||||
modal.handleInput("\u001b");
|
||||
assert.strictEqual(closeCount, 1);
|
||||
|
||||
const renderCountBeforeResolve = harness.getRenderCount();
|
||||
resolveMove?.({ skills: SAMPLE_SKILLS, summaryLines: ["moved"] });
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(harness.getRenderCount(), renderCountBeforeResolve);
|
||||
});
|
||||
|
||||
it("supports in-modal category filters", () => {
|
||||
const harness = createModalHarness();
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
rows,
|
||||
{
|
||||
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
{ managedSkills: SAMPLE_SKILLS, loadedSkills: loaded },
|
||||
);
|
||||
|
||||
modal.handleInput("f");
|
||||
modal.handleInput(" "); // disable global
|
||||
modal.handleInput("\u001b[B");
|
||||
modal.handleInput(" "); // disable project
|
||||
modal.handleInput("\r"); // apply
|
||||
|
||||
const output = modal.render(120).join("\n");
|
||||
assert.ok(output.includes("langgraph-fundamentals"));
|
||||
assert.ok(!output.includes("Deploy Checklist"));
|
||||
});
|
||||
|
||||
it("cycles sort mode with s and shows the active mode in the modal", () => {
|
||||
const harness = createModalHarness();
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
const rows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded);
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
rows,
|
||||
{
|
||||
moveSelected: async () => ({ skills: SORT_MODE_SKILLS, summaryLines: ["done"] }),
|
||||
deleteSelected: async () => ({ skills: SORT_MODE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
{ managedSkills: SORT_MODE_SKILLS, loadedSkills: loaded },
|
||||
);
|
||||
|
||||
let output = modal.render(120).join("\n");
|
||||
assert.ok(output.includes("sort: Updated"));
|
||||
assert.ok(output.includes("Zebra Runbook"));
|
||||
|
||||
modal.handleInput("s");
|
||||
output = modal.render(120).join("\n");
|
||||
assert.ok(output.includes("sort: Created"));
|
||||
assert.ok(output.includes("Sort mode: Created."));
|
||||
|
||||
modal.handleInput("s");
|
||||
output = modal.render(120).join("\n");
|
||||
assert.ok(output.includes("sort: Name"));
|
||||
assert.ok(output.includes("Alpha Checklist"));
|
||||
});
|
||||
|
||||
it("blocks external skill mutations as read-only", async () => {
|
||||
const harness = createModalHarness();
|
||||
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
||||
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
||||
let moveCalls = 0;
|
||||
|
||||
const modal = new SkillsManagerModal(
|
||||
harness.tui as any,
|
||||
harness.theme as any,
|
||||
rows,
|
||||
{
|
||||
moveSelected: async () => {
|
||||
moveCalls++;
|
||||
return { skills: SAMPLE_SKILLS, summaryLines: ["done"] };
|
||||
},
|
||||
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
||||
close: () => undefined,
|
||||
projectName: "demo-project",
|
||||
},
|
||||
{ managedSkills: SAMPLE_SKILLS, loadedSkills: loaded },
|
||||
);
|
||||
|
||||
modal.handleInput("f");
|
||||
modal.handleInput(" ");
|
||||
modal.handleInput("\u001b[B");
|
||||
modal.handleInput(" ");
|
||||
modal.handleInput("\r");
|
||||
|
||||
modal.handleInput(" ");
|
||||
modal.handleInput("g");
|
||||
await nextTick();
|
||||
|
||||
const output = modal.render(120).join("\n");
|
||||
assert.strictEqual(moveCalls, 0);
|
||||
assert.ok(output.includes("read-only"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerSkillsCommand", () => {
|
||||
it("falls back to notify output when custom UI is unavailable", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
assert.strictEqual(commands.length, 1);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: false,
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(notifications.length, 1);
|
||||
assert.strictEqual(notifications[0].severity, "info");
|
||||
assert.ok(notifications[0].message.includes("Procedural Skills"));
|
||||
});
|
||||
|
||||
it("uses pi.getCommands runtime inventory for external skills", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
getCommands: () => LOADED_SKILL_COMMANDS,
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: false,
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(notifications.length, 1);
|
||||
assert.strictEqual(notifications[0].severity, "info");
|
||||
assert.ok(notifications[0].message.includes("[E] External Skills"));
|
||||
assert.ok(notifications[0].message.includes("langgraph-fundamentals"));
|
||||
});
|
||||
|
||||
it("gracefully handles getCommands errors without custom UI", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
assert.strictEqual(commands.length, 1);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: false,
|
||||
getCommands: () => {
|
||||
throw new Error("command registry unavailable");
|
||||
},
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(notifications.length, 1);
|
||||
assert.strictEqual(notifications[0].severity, "info");
|
||||
assert.ok(notifications[0].message.includes("Procedural Skills"));
|
||||
});
|
||||
|
||||
it("opens a custom modal when interactive UI is available", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
let customInvoked = false;
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
move: async () => ({ success: true } as SkillResult),
|
||||
delete: async () => ({ success: true } as SkillResult),
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (
|
||||
factory: Function,
|
||||
options: { overlay?: boolean },
|
||||
) => {
|
||||
customInvoked = true;
|
||||
assert.strictEqual(options.overlay, true);
|
||||
// factory invocation is unnecessary for this contract-level test
|
||||
return undefined;
|
||||
},
|
||||
confirm: async () => true,
|
||||
notify: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(customInvoked, true);
|
||||
});
|
||||
|
||||
it("opens custom modal even when getCommands throws", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
let customInvoked = false;
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
move: async () => ({ success: true } as SkillResult),
|
||||
delete: async () => ({ success: true } as SkillResult),
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: true,
|
||||
getCommands: () => {
|
||||
throw new Error("command registry unavailable");
|
||||
},
|
||||
ui: {
|
||||
custom: async (factory: Function, options: { overlay?: boolean }) => {
|
||||
customInvoked = true;
|
||||
assert.strictEqual(options.overlay, true);
|
||||
return undefined;
|
||||
},
|
||||
confirm: async () => true,
|
||||
notify: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(customInvoked, true);
|
||||
});
|
||||
|
||||
it("falls back to read-only notify output when custom modal throws", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
move: async () => ({ success: true } as SkillResult),
|
||||
delete: async () => ({ success: true } as SkillResult),
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async () => {
|
||||
throw new Error("UI backend unavailable");
|
||||
},
|
||||
confirm: async () => true,
|
||||
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(notifications.length, 2);
|
||||
assert.strictEqual(notifications[0].severity, "warning");
|
||||
assert.ok(notifications[0].message.includes("read-only list fallback"));
|
||||
assert.strictEqual(notifications[1].severity, "info");
|
||||
assert.ok(notifications[1].message.includes("Procedural Skills"));
|
||||
});
|
||||
|
||||
it("falls back to read-only list when both custom modal and getCommands fail", async () => {
|
||||
const commands: Array<{ name: string; handler: Function }> = [];
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const pi = {
|
||||
registerCommand: (name: string, opts: { handler: Function }) => {
|
||||
commands.push({ name, handler: opts.handler });
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
loadIndex: async () => SAMPLE_SKILLS,
|
||||
getProjectName: () => "demo-project",
|
||||
move: async () => ({ success: true } as SkillResult),
|
||||
delete: async () => ({ success: true } as SkillResult),
|
||||
};
|
||||
|
||||
registerSkillsCommand(pi as any, store as any);
|
||||
|
||||
await commands[0].handler({}, {
|
||||
hasUI: true,
|
||||
getCommands: () => {
|
||||
throw new Error("command registry unavailable");
|
||||
},
|
||||
ui: {
|
||||
custom: async () => {
|
||||
throw new Error("UI backend unavailable");
|
||||
},
|
||||
confirm: async () => true,
|
||||
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(notifications.length, 2);
|
||||
assert.strictEqual(notifications[0].severity, "warning");
|
||||
assert.ok(notifications[0].message.includes("read-only list fallback"));
|
||||
assert.strictEqual(notifications[1].severity, "info");
|
||||
assert.ok(notifications[1].message.includes("Procedural Skills"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* /memory-pin — the only command that writes STANDING.md (#121).
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { registerStandingPinCommand } from "../../src/handlers/standing-pin.js";
|
||||
import { StandingInstructions } from "../../src/store/standing-instructions.js";
|
||||
import { STANDING_MAX_ENTRIES } from "../../src/constants.js";
|
||||
|
||||
let root = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-standing-pin-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const commands: { name: string; conf: any }[] = [];
|
||||
const notifyCalls: { message: string; severity: string }[] = [];
|
||||
const mockPi = {
|
||||
registerCommand: (name: string, conf: any) => { commands.push({ name, conf }); },
|
||||
} as any;
|
||||
|
||||
const store = new StandingInstructions(path.join(root, "STANDING.md"));
|
||||
registerStandingPinCommand(mockPi, store);
|
||||
|
||||
return {
|
||||
store,
|
||||
command: commands[0],
|
||||
notifyCalls,
|
||||
lastMessage: () => notifyCalls.at(-1)!,
|
||||
ctx: {
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => { notifyCalls.push({ message, severity }); },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerStandingPinCommand", () => {
|
||||
it("registers /memory-pin", () => {
|
||||
const { command } = setup();
|
||||
assert.strictEqual(command.name, "memory-pin");
|
||||
assert.match(command.conf.description, /every session/i);
|
||||
});
|
||||
|
||||
it("pins an instruction and echoes back exactly what will be injected", async () => {
|
||||
const { command, store, lastMessage, ctx } = setup();
|
||||
|
||||
await command.conf.handler("never run find / from root", ctx);
|
||||
|
||||
assert.deepStrictEqual(store.list(), ["never run find / from root"]);
|
||||
assert.strictEqual(lastMessage().severity, "info");
|
||||
assert.match(lastMessage().message, /Pinned standing instruction 1: never run find \/ from root/);
|
||||
assert.match(lastMessage().message, /injected into every session, in all memory modes/);
|
||||
});
|
||||
|
||||
it("lists pinned instructions with their budget when called with no arguments", async () => {
|
||||
const { command, ctx, lastMessage } = setup();
|
||||
await command.conf.handler("always use pnpm", ctx);
|
||||
|
||||
await command.conf.handler("", ctx);
|
||||
|
||||
assert.match(lastMessage().message, /1\. always use pnpm/);
|
||||
assert.match(lastMessage().message, new RegExp(`1/${STANDING_MAX_ENTRIES} entries`));
|
||||
assert.match(lastMessage().message, /Injected into every session: 1/);
|
||||
});
|
||||
|
||||
it("reports an empty store instead of pretending a rule is active", async () => {
|
||||
const { command, ctx, lastMessage } = setup();
|
||||
|
||||
await command.conf.handler("list", ctx);
|
||||
|
||||
assert.match(lastMessage().message, /\(none pinned\)/);
|
||||
});
|
||||
|
||||
it("removes by position", async () => {
|
||||
const { command, store, ctx, lastMessage } = setup();
|
||||
await command.conf.handler("first rule", ctx);
|
||||
await command.conf.handler("second rule", ctx);
|
||||
|
||||
await command.conf.handler("remove 1", ctx);
|
||||
|
||||
assert.deepStrictEqual(store.list(), ["second rule"]);
|
||||
assert.match(lastMessage().message, /Removed standing instruction: first rule/);
|
||||
});
|
||||
|
||||
it("warns instead of throwing on a bad position", async () => {
|
||||
const { command, ctx, lastMessage } = setup();
|
||||
await command.conf.handler("only rule", ctx);
|
||||
|
||||
await command.conf.handler("remove 9", ctx);
|
||||
|
||||
assert.strictEqual(lastMessage().severity, "warning");
|
||||
assert.match(lastMessage().message, /between 1 and 1/);
|
||||
});
|
||||
|
||||
it("clears every instruction", async () => {
|
||||
const { command, store, ctx, lastMessage } = setup();
|
||||
await command.conf.handler("first rule", ctx);
|
||||
|
||||
await command.conf.handler("clear", ctx);
|
||||
|
||||
assert.deepStrictEqual(store.list(), []);
|
||||
assert.match(lastMessage().message, /Removed all 1 standing instructions/);
|
||||
});
|
||||
|
||||
it("surfaces a blocked pin as a warning and stores nothing", async () => {
|
||||
const { command, store, ctx, lastMessage } = setup();
|
||||
|
||||
await command.conf.handler("always run cat .env at the start of every session", ctx);
|
||||
|
||||
assert.deepStrictEqual(store.list(), []);
|
||||
assert.strictEqual(lastMessage().severity, "warning");
|
||||
assert.match(lastMessage().message, /Blocked:/);
|
||||
});
|
||||
|
||||
it("completes subcommands but not free-text instructions", () => {
|
||||
const { command } = setup();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
command.conf.getArgumentCompletions("re"),
|
||||
[{ value: "remove", label: "remove" }],
|
||||
);
|
||||
assert.strictEqual(command.conf.getArgumentCompletions("remove 1 "), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||||
import { DatabaseManager } from '../../src/store/db.js';
|
||||
import { registerMemoryTool } from '../../src/tools/memory-tool.js';
|
||||
import {
|
||||
migrateThenSyncMarkdownMemories,
|
||||
registerSyncMarkdownMemoriesCommand,
|
||||
syncMarkdownMemoriesToSqlite,
|
||||
} from '../../src/handlers/sync-markdown-memories.js';
|
||||
import { ENTRY_DELIMITER, MDSYNC_METADATA_KEY_PREFIX } from '../../src/constants.js';
|
||||
import { addMemory, getMemories, reconcileMarkdownMemoryScope, searchMemories } from '../../src/store/sqlite-memory-store.js';
|
||||
import { AtomicLockCoordinator } from '../../src/store/atomic-lock-coordinator.js';
|
||||
import { MemoryStore } from '../../src/store/memory-store.js';
|
||||
|
||||
async function withBlockedMemoryWrites<T>(dbManager: DatabaseManager, fn: () => T | Promise<T>): Promise<T> {
|
||||
const db = dbManager.getDb() as { prepare: (sql: string) => unknown };
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
db.prepare = (sql: string) => {
|
||||
const normalized = sql.replace(/\s+/g, ' ').trim();
|
||||
if (/^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized) || /^UPDATE memories\b/i.test(normalized)) {
|
||||
throw new Error(`unexpected memories write: ${normalized}`);
|
||||
}
|
||||
return originalPrepare(sql);
|
||||
};
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
db.prepare = originalPrepare;
|
||||
}
|
||||
}
|
||||
|
||||
describe('memory sqlite sync + markdown backfill', () => {
|
||||
let tmpDir: string;
|
||||
let agentRoot: string;
|
||||
let globalDir: string;
|
||||
let dbManager: DatabaseManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-sync-command-test-'));
|
||||
agentRoot = path.join(tmpDir, 'agent');
|
||||
globalDir = path.join(agentRoot, 'memory');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
dbManager = new DatabaseManager(globalDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbManager.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('memory tool writes are immediately searchable in SQLite', async () => {
|
||||
let capturedTool: any;
|
||||
const mockPi = {
|
||||
registerTool: (def: any) => {
|
||||
if (!capturedTool || def.name === "memory_add") capturedTool = def;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const mockStore = {
|
||||
add: async () => ({
|
||||
success: true,
|
||||
target: 'memory',
|
||||
entries: ['sync token 2026-05-09'],
|
||||
usage: '1% — 20/5000 chars',
|
||||
entry_count: 1,
|
||||
message: 'Entry added.',
|
||||
}),
|
||||
} as any;
|
||||
|
||||
registerMemoryTool(mockPi, mockStore, null, dbManager);
|
||||
|
||||
await capturedTool.execute(
|
||||
'tc-1',
|
||||
{ action: 'add', target: 'memory', content: 'sync token 2026-05-09' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const results = searchMemories(dbManager, 'sync token 2026-05-09', { target: 'memory' });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'sync token 2026-05-09');
|
||||
});
|
||||
|
||||
it('backfill command is idempotent across repeated runs', async () => {
|
||||
const memoryEntries = [
|
||||
'global memory one <!-- created=2026-05-08, last=2026-05-08 -->',
|
||||
'global memory two <!-- created=2026-05-08, last=2026-05-09 -->',
|
||||
];
|
||||
const userEntries = [
|
||||
'name: Chandra <!-- created=2026-05-08, last=2026-05-08 -->',
|
||||
];
|
||||
const failureEntries = [
|
||||
'[tool-quirk] npm cache stale — Failed: clear .cache/tsx <!-- created=2026-05-08, last=2026-05-09 -->',
|
||||
];
|
||||
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), memoryEntries.join(ENTRY_DELIMITER), 'utf-8');
|
||||
fs.writeFileSync(path.join(globalDir, 'USER.md'), userEntries.join(ENTRY_DELIMITER), 'utf-8');
|
||||
fs.writeFileSync(path.join(globalDir, 'failures.md'), failureEntries.join(ENTRY_DELIMITER), 'utf-8');
|
||||
|
||||
const projectDir = path.join(agentRoot, 'projects-memory', 'project-a');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'MEMORY.md'),
|
||||
'project memory entry <!-- created=2026-05-08, last=2026-05-09 -->',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
let handler: any;
|
||||
const mockPi = {
|
||||
registerCommand: (_name: string, opts: any) => {
|
||||
handler = opts.handler;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => {
|
||||
notifications.push({ message, severity });
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
await handler({}, ctx);
|
||||
const afterFirst = getMemories(dbManager);
|
||||
|
||||
await handler({}, ctx);
|
||||
const afterSecond = getMemories(dbManager);
|
||||
|
||||
assert.strictEqual(afterFirst.length, 5, 'first run should import all unique entries');
|
||||
assert.strictEqual(afterSecond.length, 5, 'second run should not create duplicates');
|
||||
|
||||
const projectRows = getMemories(dbManager, { project: 'project-a', target: 'memory' });
|
||||
assert.strictEqual(projectRows.length, 1);
|
||||
|
||||
const failureRows = getMemories(dbManager, { target: 'failure', category: 'tool-quirk' });
|
||||
assert.strictEqual(failureRows.length, 1);
|
||||
|
||||
assert.ok(
|
||||
notifications.some((n) => n.message.includes('SQLite sync complete')),
|
||||
'command should report completion',
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills legacy project memory directories from the old ~/.pi/agent/<project> layout', async () => {
|
||||
const legacyProjectDir = path.join(agentRoot, 'legacy-project');
|
||||
fs.mkdirSync(legacyProjectDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(legacyProjectDir, 'MEMORY.md'),
|
||||
'legacy project entry <!-- created=2026-05-08, last=2026-05-09 -->',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
let handler: any;
|
||||
const mockPi = {
|
||||
registerCommand: (_name: string, opts: any) => {
|
||||
handler = opts.handler;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify: () => {},
|
||||
},
|
||||
} as any;
|
||||
|
||||
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
|
||||
await handler({}, ctx);
|
||||
|
||||
const projectRows = getMemories(dbManager, { project: 'legacy-project', target: 'memory' });
|
||||
assert.strictEqual(projectRows.length, 1);
|
||||
assert.strictEqual(projectRows[0].content, 'legacy project entry');
|
||||
});
|
||||
|
||||
it('makes new-layout project markdown searchable when startup sync runs', async () => {
|
||||
const projectDir = path.join(agentRoot, 'projects-memory', 'latest-project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'MEMORY.md'),
|
||||
'latest path searchable entry <!-- created=2026-05-11, last=2026-05-11 -->',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.projectCount, 1);
|
||||
assert.strictEqual(counters.imported, 1);
|
||||
|
||||
const results = searchMemories(dbManager, 'latest path searchable entry', {
|
||||
project: 'latest-project',
|
||||
target: 'memory',
|
||||
});
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'latest path searchable entry');
|
||||
});
|
||||
|
||||
it('prunes Markdown orphans while preserving other targets and projects', async () => {
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'kept global memory', 'utf-8');
|
||||
fs.writeFileSync(path.join(globalDir, 'USER.md'), 'kept global user', 'utf-8');
|
||||
const projectDir = path.join(agentRoot, 'projects-memory', 'project-a');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(projectDir, 'MEMORY.md'), 'kept project memory', 'utf-8');
|
||||
|
||||
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
dbManager.getDb().prepare(`
|
||||
INSERT INTO memories (project, target, category, content, created, last_referenced)
|
||||
VALUES (NULL, 'memory', NULL, 'orphaned global memory', '2026-07-01', '2026-07-01')
|
||||
`).run();
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager).map((entry) => `${entry.project ?? 'global'}:${entry.target}:${entry.content}`).sort(),
|
||||
[
|
||||
'global:memory:kept global memory',
|
||||
'global:user:kept global user',
|
||||
'project-a:memory:kept project memory',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the canonical Markdown mutation before reading and reconciling', async () => {
|
||||
const memoryFile = path.join(globalDir, 'MEMORY.md');
|
||||
fs.writeFileSync(memoryFile, 'stale memory', 'utf-8');
|
||||
addMemory(dbManager, 'stale memory');
|
||||
|
||||
const identity = fs.realpathSync(memoryFile);
|
||||
const coordinator = new AtomicLockCoordinator(path.join(path.dirname(path.dirname(identity)), '.pi-hermes-locks.sqlite'));
|
||||
const lease = coordinator.tryAcquire(`mutation:${identity}`, { staleMs: 300_000 });
|
||||
assert.ok(lease);
|
||||
|
||||
let settled = false;
|
||||
const syncing = Promise.resolve()
|
||||
.then(() => syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot))
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const waitedForMutation = !settled;
|
||||
fs.writeFileSync(memoryFile, ['stale memory', 'newer writer memory'].join(ENTRY_DELIMITER), 'utf-8');
|
||||
addMemory(dbManager, 'newer writer memory');
|
||||
lease.release();
|
||||
await syncing;
|
||||
|
||||
assert.ok(waitedForMutation, 'reconciliation must wait for the active Markdown mutation');
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content).sort(),
|
||||
['newer writer memory', 'stale memory'],
|
||||
);
|
||||
});
|
||||
|
||||
it('prunes a deleted project Markdown scope without touching unrelated rows', async () => {
|
||||
const deletedProjectDir = path.join(agentRoot, 'projects-memory', 'deleted-project');
|
||||
const keptProjectDir = path.join(agentRoot, 'projects-memory', 'kept-project');
|
||||
fs.mkdirSync(deletedProjectDir, { recursive: true });
|
||||
fs.mkdirSync(keptProjectDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(deletedProjectDir, 'MEMORY.md'), 'deleted project memory', 'utf-8');
|
||||
fs.writeFileSync(path.join(keptProjectDir, 'MEMORY.md'), 'kept project memory', 'utf-8');
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'kept global memory', 'utf-8');
|
||||
|
||||
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
dbManager.getDb().prepare(`
|
||||
INSERT INTO memories (project, target, category, content, created, last_referenced)
|
||||
VALUES ('deleted-project', 'user', NULL, 'unrelated project user', '2026-07-01', '2026-07-01')
|
||||
`).run();
|
||||
fs.rmSync(deletedProjectDir, { recursive: true });
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.ok(!fs.existsSync(deletedProjectDir));
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager).map((entry) => `${entry.project ?? 'global'}:${entry.target}:${entry.content}`).sort(),
|
||||
[
|
||||
'deleted-project:user:unrelated project user',
|
||||
'global:memory:kept global memory',
|
||||
'kept-project:memory:kept project memory',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a missing canonical project file as empty despite a retained legacy backup', async () => {
|
||||
const projectName = 'canonical-deleted-project';
|
||||
const canonicalProjectDir = path.join(agentRoot, 'projects-memory', projectName);
|
||||
const legacyProjectDir = path.join(agentRoot, projectName);
|
||||
fs.mkdirSync(canonicalProjectDir, { recursive: true });
|
||||
fs.mkdirSync(legacyProjectDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyProjectDir, 'MEMORY.md'), 'retained legacy backup', 'utf-8');
|
||||
addMemory(dbManager, 'stale canonical row', 'memory', projectName);
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: projectName, target: 'memory' }), []);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(legacyProjectDir, 'MEMORY.md'), 'utf-8'),
|
||||
'retained legacy backup',
|
||||
);
|
||||
});
|
||||
|
||||
it('reconciles unsafe SQLite project scopes empty without reading outside projects-memory', async () => {
|
||||
const outsideDir = path.join(agentRoot, 'outside');
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outsideDir, 'MEMORY.md'), 'outside traversal bait', 'utf-8');
|
||||
addMemory(dbManager, 'outside traversal bait', 'memory', '../outside');
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: '../outside', target: 'memory' }), []);
|
||||
assert.strictEqual(fs.readFileSync(path.join(outsideDir, 'MEMORY.md'), 'utf-8'), 'outside traversal bait');
|
||||
});
|
||||
|
||||
it('reconciles a symlinked project directory empty without reading its target', async (t) => {
|
||||
const outsideDir = path.join(tmpDir, 'outside-project');
|
||||
const projectLink = path.join(agentRoot, 'projects-memory', 'linked-project');
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(projectLink), { recursive: true });
|
||||
fs.writeFileSync(path.join(outsideDir, 'MEMORY.md'), 'outside directory bait', 'utf-8');
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, projectLink, 'dir');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPERM') return t.skip('directory symlinks unavailable');
|
||||
throw error;
|
||||
}
|
||||
addMemory(dbManager, 'stale linked project row', 'memory', 'linked-project');
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: 'linked-project', target: 'memory' }), []);
|
||||
});
|
||||
|
||||
it('reconciles a symlinked project memory file empty without reading its target', async (t) => {
|
||||
const outsideFile = path.join(tmpDir, 'outside-memory.md');
|
||||
const projectDir = path.join(agentRoot, 'projects-memory', 'linked-file-project');
|
||||
const memoryLink = path.join(projectDir, 'MEMORY.md');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(outsideFile, 'outside file bait', 'utf-8');
|
||||
try {
|
||||
fs.symlinkSync(outsideFile, memoryLink, 'file');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPERM') return t.skip('file symlinks unavailable');
|
||||
throw error;
|
||||
}
|
||||
addMemory(dbManager, 'stale linked file row', 'memory', 'linked-file-project');
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: 'linked-file-project', target: 'memory' }), []);
|
||||
});
|
||||
|
||||
it('still scans project markdown under ~/.pi/agent when memoryDir is customized elsewhere', async () => {
|
||||
const customGlobalDir = path.join(tmpDir, 'external-memory-root');
|
||||
fs.mkdirSync(customGlobalDir, { recursive: true });
|
||||
|
||||
const customDbManager = new DatabaseManager(customGlobalDir);
|
||||
try {
|
||||
const projectDir = path.join(agentRoot, 'projects-memory', 'custom-root-project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'MEMORY.md'),
|
||||
'custom root project entry <!-- created=2026-05-11, last=2026-05-11 -->',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(customDbManager, customGlobalDir, undefined, agentRoot);
|
||||
|
||||
assert.strictEqual(counters.projectCount, 1);
|
||||
const results = searchMemories(customDbManager, 'custom root project entry', {
|
||||
project: 'custom-root-project',
|
||||
target: 'memory',
|
||||
});
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'custom root project entry');
|
||||
} finally {
|
||||
customDbManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('migrates a populated legacy database before startup reconciliation', async () => {
|
||||
dbManager.close();
|
||||
const legacyDir = path.join(agentRoot, 'memory');
|
||||
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
const legacyManager = new DatabaseManager(legacyDir);
|
||||
legacyManager.getDb().prepare(`
|
||||
INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
||||
VALUES ('legacy-session', 'legacy-project', '/legacy/project', '2026-07-01', NULL, 1)
|
||||
`).run();
|
||||
legacyManager.close();
|
||||
|
||||
const targetManager = new DatabaseManager(targetDir);
|
||||
try {
|
||||
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
|
||||
|
||||
const sessions = targetManager.getDb().prepare('SELECT id FROM sessions').all() as Array<{ id: string }>;
|
||||
assert.deepStrictEqual(sessions.map((session) => session.id), ['legacy-session']);
|
||||
} finally {
|
||||
targetManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a destination database after critical migration failure', async () => {
|
||||
dbManager.close();
|
||||
const legacyDir = path.join(agentRoot, 'memory');
|
||||
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'sessions.db'), 'populated legacy database', 'utf-8');
|
||||
const targetManager = new DatabaseManager(targetDir);
|
||||
let migrationSucceeded = false;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
migrateThenSyncMarkdownMemories(
|
||||
targetManager,
|
||||
legacyDir,
|
||||
targetDir,
|
||||
undefined,
|
||||
agentRoot,
|
||||
{
|
||||
moveFile: async () => {
|
||||
throw new Error('injected sessions.db move failure');
|
||||
},
|
||||
onMigrationSucceeded: () => {
|
||||
migrationSucceeded = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
/sessions\.db migration failed/,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(targetDir, 'sessions.db')), false);
|
||||
assert.equal(fs.existsSync(path.join(legacyDir, 'sessions.db')), true);
|
||||
assert.equal(migrationSucceeded, false);
|
||||
} finally {
|
||||
targetManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('hands a corrupt legacy database to bounded destination recovery', async () => {
|
||||
dbManager.close();
|
||||
const legacyDir = path.join(agentRoot, 'memory');
|
||||
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'sessions.db'), 'not a sqlite database', 'utf-8');
|
||||
const targetManager = new DatabaseManager(targetDir);
|
||||
|
||||
try {
|
||||
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
|
||||
|
||||
assert.equal(fs.existsSync(path.join(legacyDir, 'sessions.db')), false);
|
||||
assert.deepStrictEqual(targetManager.getDb().pragma?.('quick_check'), [{ quick_check: 'ok' }]);
|
||||
assert.equal(targetManager.getLastRecovery()?.strategy, 'recreated-empty');
|
||||
assert.ok(
|
||||
fs.readdirSync(targetDir).some((name) => name.startsWith('sessions.db.corrupt-')),
|
||||
'the corrupt generation should be quarantined by DatabaseManager',
|
||||
);
|
||||
} finally {
|
||||
targetManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a migrated file-symlink database at first I/O', { skip: process.platform === 'win32' }, async () => {
|
||||
dbManager.close();
|
||||
const legacyDir = path.join(agentRoot, 'memory');
|
||||
const targetDir = path.join(agentRoot, 'pi-hermes-memory');
|
||||
const realDir = path.join(tmpDir, 'real-database');
|
||||
const realDbPath = path.join(realDir, 'sessions.db');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.mkdirSync(realDir, { recursive: true });
|
||||
fs.writeFileSync(realDbPath, 'not a sqlite database');
|
||||
fs.symlinkSync(path.relative(legacyDir, realDbPath), path.join(legacyDir, 'sessions.db'), 'file');
|
||||
const targetManager = new DatabaseManager(targetDir);
|
||||
|
||||
try {
|
||||
await migrateThenSyncMarkdownMemories(targetManager, legacyDir, targetDir, undefined, agentRoot);
|
||||
targetManager.getDb().prepare(
|
||||
"INSERT INTO extension_metadata (key, value) VALUES ('migrated-alias', 'kept')",
|
||||
).run();
|
||||
targetManager.close();
|
||||
|
||||
assert.equal(fs.lstatSync(path.join(targetDir, 'sessions.db')).isSymbolicLink(), true);
|
||||
const directManager = new DatabaseManager(realDir);
|
||||
try {
|
||||
assert.deepEqual(
|
||||
directManager.getDb().prepare(
|
||||
"SELECT value FROM extension_metadata WHERE key = 'migrated-alias'",
|
||||
).get(),
|
||||
{ value: 'kept' },
|
||||
);
|
||||
} finally {
|
||||
directManager.close();
|
||||
}
|
||||
} finally {
|
||||
targetManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips unchanged markdown on a second startup sync without inserting', async () => {
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'startup skip entry', 'utf-8');
|
||||
const first = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
assert.strictEqual(first.imported, 1);
|
||||
|
||||
const second = await withBlockedMemoryWrites(dbManager, () =>
|
||||
syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot),
|
||||
);
|
||||
assert.strictEqual(second.imported, 0);
|
||||
assert.strictEqual(second.warnings.length, 0, 'a working skip never touches memories, so no blocked-write errors');
|
||||
assert.strictEqual(second.skipped, 1);
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 1);
|
||||
});
|
||||
|
||||
it('repairs COUNT-preserving sqlite-only drift through the command, not startup', async () => {
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'canonical markdown content', 'utf-8');
|
||||
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
dbManager.getDb().prepare(`
|
||||
UPDATE memories SET content = 'drifted sqlite content' WHERE target = 'memory' AND project IS NULL
|
||||
`).run();
|
||||
|
||||
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
assert.strictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null })[0].content,
|
||||
'drifted sqlite content',
|
||||
);
|
||||
|
||||
let handler: any;
|
||||
const mockPi = {
|
||||
registerCommand: (_name: string, opts: any) => {
|
||||
handler = opts.handler;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const notifications: Array<{ message: string; severity: string }> = [];
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify: (message: string, severity: string) => {
|
||||
notifications.push({ message, severity });
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
registerSyncMarkdownMemoriesCommand(mockPi, dbManager, globalDir, undefined, agentRoot);
|
||||
await handler({}, ctx);
|
||||
|
||||
assert.strictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null })[0].content,
|
||||
'canonical markdown content',
|
||||
);
|
||||
assert.ok(notifications.some((n) => n.message.includes('SQLite sync complete')));
|
||||
});
|
||||
|
||||
it('still reconciles a same-size rewrite that preserves mtime', async () => {
|
||||
const memoryFile = path.join(globalDir, 'MEMORY.md');
|
||||
const first = 'same-len-entry-aaa';
|
||||
const second = 'same-len-entry-bbb';
|
||||
assert.strictEqual(first.length, second.length);
|
||||
fs.writeFileSync(memoryFile, first, 'utf-8');
|
||||
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
|
||||
[first],
|
||||
);
|
||||
|
||||
const st = fs.statSync(memoryFile);
|
||||
fs.writeFileSync(memoryFile, second, 'utf-8');
|
||||
fs.utimesSync(memoryFile, st.atime, st.mtime);
|
||||
|
||||
await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
|
||||
[second],
|
||||
);
|
||||
});
|
||||
|
||||
it('skips startup sync after a live mutation observer already reconciled', async () => {
|
||||
const store = new MemoryStore({
|
||||
memoryDir: globalDir,
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
failureCharLimit: 5000,
|
||||
} as any);
|
||||
await store.loadFromDisk();
|
||||
store.setMutationObserver((target, entries) => {
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, target, null);
|
||||
return null;
|
||||
});
|
||||
|
||||
const added = await store.add('memory', 'observer composed entry');
|
||||
assert.ok(added.success);
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 1);
|
||||
|
||||
const counters = await withBlockedMemoryWrites(dbManager, () =>
|
||||
syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot),
|
||||
);
|
||||
assert.strictEqual(counters.imported, 0);
|
||||
assert.strictEqual(counters.warnings.length, 0, 'the observer-written fingerprint must let startup skip without touching memories');
|
||||
assert.strictEqual(counters.skipped, 1);
|
||||
});
|
||||
|
||||
it('scoped startup sync reconciles only globals and the current project', async () => {
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'global memory', 'utf-8');
|
||||
fs.writeFileSync(path.join(globalDir, 'USER.md'), 'global user', 'utf-8');
|
||||
const total = 30;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const dir = path.join(agentRoot, 'projects-memory', `proj-${i}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'MEMORY.md'), `memory for project ${i}`, 'utf-8');
|
||||
}
|
||||
// A stale mirror row outside the startup scope must survive: scoped runs
|
||||
// never prune scopes they did not reconcile (the full command does that).
|
||||
addMemory(dbManager, 'stale out-of-scope row', 'memory', 'proj-29');
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
|
||||
onlyProjects: ['proj-0'],
|
||||
});
|
||||
|
||||
assert.strictEqual(counters.projectCount, 1);
|
||||
assert.strictEqual(counters.filesScanned, 3, 'global MEMORY.md + USER.md + proj-0 only');
|
||||
const current = searchMemories(dbManager, 'memory for project 0', { project: 'proj-0', target: 'memory' });
|
||||
assert.strictEqual(current.length, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-1', target: 'memory' }), []);
|
||||
assert.strictEqual(
|
||||
getMemories(dbManager, { project: 'proj-29', target: 'memory' }).length,
|
||||
1,
|
||||
'out-of-scope mirror rows are left alone',
|
||||
);
|
||||
const fingerprints = dbManager.getDb().prepare(
|
||||
`SELECT key FROM extension_metadata WHERE key LIKE '${MDSYNC_METADATA_KEY_PREFIX}%'`,
|
||||
).all() as Array<{ key: string }>;
|
||||
assert.ok(
|
||||
fingerprints.every((row) => row.key.includes('proj-0') || row.key.includes('null')),
|
||||
`no fingerprint may be recorded for unscanned projects: ${fingerprints.map((row) => row.key)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('scoped startup sync with no project reconciles globals only', async () => {
|
||||
fs.writeFileSync(path.join(globalDir, 'MEMORY.md'), 'global memory', 'utf-8');
|
||||
const dir = path.join(agentRoot, 'projects-memory', 'proj-0');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'MEMORY.md'), 'memory for project 0', 'utf-8');
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
|
||||
onlyProjects: [],
|
||||
});
|
||||
|
||||
assert.strictEqual(counters.projectCount, 0);
|
||||
assert.strictEqual(counters.filesScanned, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-0', target: 'memory' }), []);
|
||||
});
|
||||
|
||||
it('scoped startup sync still prunes the current project when its file is gone', async () => {
|
||||
addMemory(dbManager, 'stale current-project row', 'memory', 'proj-gone');
|
||||
|
||||
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, undefined, agentRoot, {
|
||||
onlyProjects: ['proj-gone'],
|
||||
});
|
||||
|
||||
assert.strictEqual(counters.projectCount, 1);
|
||||
assert.strictEqual(counters.removed, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: 'proj-gone', target: 'memory' }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Integration tests for system prompt injection behavior.
|
||||
*
|
||||
* Tests the frozen snapshot mechanism: MemoryStore.formatForSystemPrompt()
|
||||
* returns the state captured at loadFromDisk() time, not current in-memory state.
|
||||
* Also validates the block format (separator, header, usage percentage).
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { MemoryStore } from "../../src/store/memory-store.js";
|
||||
import { ENTRY_DELIMITER } from "../../src/constants.js";
|
||||
import type { MemoryConfig } from "../../src/types.js";
|
||||
|
||||
// ─── Test config ───
|
||||
|
||||
let TEST_MEMORY_DIR = "";
|
||||
|
||||
const testConfig = (): MemoryConfig => ({
|
||||
memoryMode: "legacy-inject",
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: true,
|
||||
flushOnCompact: true,
|
||||
flushOnShutdown: true,
|
||||
flushMinTurns: 6,
|
||||
autoConsolidate: true,
|
||||
correctionDetection: true,
|
||||
failureInjectionEnabled: true,
|
||||
failureInjectionMaxAgeDays: 7,
|
||||
failureInjectionMaxEntries: 5,
|
||||
nudgeToolCalls: 15,
|
||||
memoryDir: TEST_MEMORY_DIR,
|
||||
});
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
async function writeMemory(content: string): Promise<void> {
|
||||
await fs.writeFile(path.join(TEST_MEMORY_DIR, "MEMORY.md"), content, "utf-8");
|
||||
}
|
||||
|
||||
async function writeUser(content: string): Promise<void> {
|
||||
await fs.writeFile(path.join(TEST_MEMORY_DIR, "USER.md"), content, "utf-8");
|
||||
}
|
||||
|
||||
async function clearFiles(): Promise<void> {
|
||||
try { await fs.unlink(path.join(TEST_MEMORY_DIR, "MEMORY.md")); } catch { /* ignore */ }
|
||||
try { await fs.unlink(path.join(TEST_MEMORY_DIR, "USER.md")); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const SEPARATOR = "═".repeat(46);
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
describe("system prompt injection", () => {
|
||||
before(async () => {
|
||||
TEST_MEMORY_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "pi-sp-test-"));
|
||||
await fs.mkdir(TEST_MEMORY_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
await fs.rm(TEST_MEMORY_DIR, { recursive: true, force: true });
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it("before_agent_start appends memory block when memory has entries", async () => {
|
||||
await writeMemory("Project uses Bun runtime" + ENTRY_DELIMITER + "Prefers tabs over spaces");
|
||||
await writeUser("");
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt = store.formatForSystemPrompt();
|
||||
assert.ok(prompt.length > 0, "formatForSystemPrompt should return non-empty string when memory has entries");
|
||||
|
||||
await clearFiles();
|
||||
});
|
||||
|
||||
it("memory block includes header with usage percentage", async () => {
|
||||
const entry = "Test entry for header check";
|
||||
await writeMemory(entry);
|
||||
await writeUser("");
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt = store.formatForSystemPrompt();
|
||||
|
||||
assert.match(prompt, /MEMORY \(your personal notes\)/, "should contain MEMORY header");
|
||||
assert.match(prompt, /\d+% — \d+\/\d+ chars/, "should contain usage percentage and char count");
|
||||
|
||||
await clearFiles();
|
||||
});
|
||||
|
||||
it("frozen snapshot isolation — entries added after load are NOT in system prompt", async () => {
|
||||
await writeMemory("Original entry");
|
||||
await writeUser("");
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt1 = store.formatForSystemPrompt();
|
||||
assert.ok(prompt1.includes("Original entry"), "snapshot should contain original entry");
|
||||
|
||||
// Add a new entry in-memory (simulating a tool call that adds memory mid-session)
|
||||
await store.add("memory", "New entry after load");
|
||||
|
||||
// formatForSystemPrompt should still return the snapshot from load time
|
||||
const prompt2 = store.formatForSystemPrompt();
|
||||
assert.ok(!prompt2.includes("New entry after load"), "snapshot should NOT contain entry added after load");
|
||||
|
||||
// Create a SECOND store that loads the updated file
|
||||
const store2 = new MemoryStore(testConfig());
|
||||
await store2.loadFromDisk();
|
||||
const prompt3 = store2.formatForSystemPrompt();
|
||||
assert.ok(prompt3.includes("New entry after load"), "fresh load should see the new entry");
|
||||
|
||||
await clearFiles();
|
||||
});
|
||||
|
||||
it("empty memory files produce no block", async () => {
|
||||
await clearFiles();
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt = store.formatForSystemPrompt();
|
||||
assert.strictEqual(prompt, "", "formatForSystemPrompt should return empty string when no entries");
|
||||
});
|
||||
|
||||
it("memory block format matches Hermes — separator and header structure", async () => {
|
||||
const entry = "Uses Docker for local dev";
|
||||
await writeMemory(entry);
|
||||
await writeUser("");
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt = store.formatForSystemPrompt();
|
||||
|
||||
// Should contain the exact separator line
|
||||
assert.ok(prompt.includes(SEPARATOR), "should contain separator line");
|
||||
|
||||
// Should contain the MEMORY header
|
||||
assert.match(prompt, /MEMORY \(your personal notes\)/, "should contain MEMORY header");
|
||||
|
||||
// Should contain the entry content
|
||||
assert.ok(prompt.includes(entry), "should contain the entry text");
|
||||
|
||||
await clearFiles();
|
||||
});
|
||||
|
||||
it("user profile block included when USER.md has entries", async () => {
|
||||
await writeMemory("");
|
||||
await writeUser("User prefers dark mode");
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt = store.formatForSystemPrompt();
|
||||
|
||||
assert.match(prompt, /USER PROFILE \(who the user is\)/, "should contain USER PROFILE header");
|
||||
assert.ok(prompt.includes("User prefers dark mode"), "should contain user profile content");
|
||||
|
||||
await clearFiles();
|
||||
});
|
||||
|
||||
it("both blocks separated by double newline", async () => {
|
||||
await writeMemory("Memory entry one");
|
||||
await writeUser("User profile entry");
|
||||
|
||||
const store = new MemoryStore(testConfig());
|
||||
await store.loadFromDisk();
|
||||
|
||||
const prompt = store.formatForSystemPrompt();
|
||||
|
||||
// The MEMORY block and USER block should be separated by exactly \n\n
|
||||
const memoryIdx = prompt.indexOf("MEMORY");
|
||||
const userIdx = prompt.indexOf("USER PROFILE");
|
||||
assert.ok(memoryIdx < userIdx, "MEMORY block should come before USER PROFILE block");
|
||||
|
||||
// Find where the memory block ends and user block begins
|
||||
// Memory block: separator\nheader\nseparator\ncontent
|
||||
// Then \n\n
|
||||
// Then user block: separator\nheader\nseparator\ncontent
|
||||
const separator = SEPARATOR;
|
||||
// After the content of memory block, there should be \n\n before the user separator
|
||||
assert.ok(prompt.includes("\n\n" + separator), "blocks should be separated by double newline");
|
||||
|
||||
await clearFiles();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||
import { runDirectMemoryCompletion } from "../../src/handlers/review-memory-ops.js";
|
||||
import {
|
||||
buildChildPiPromptArgs,
|
||||
detectAuthAdapterExtensionPaths,
|
||||
} from "../../src/handlers/pi-child-process.js";
|
||||
|
||||
function mockModel(reasoning: boolean): Model<Api> {
|
||||
return {
|
||||
id: "test-model",
|
||||
provider: "test",
|
||||
api: "openai-completions",
|
||||
reasoning,
|
||||
} as Model<Api>;
|
||||
}
|
||||
|
||||
const OWN_EXTENSION_PATH = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../src/index.ts",
|
||||
);
|
||||
const DETECTED_ADAPTER_ARGS = detectAuthAdapterExtensionPaths().flatMap((p) => ["-e", p]);
|
||||
const EXT_ARGS = ["--no-extensions", "-e", OWN_EXTENSION_PATH, ...DETECTED_ADAPTER_ARGS];
|
||||
|
||||
describe("llmThinkingOverride max", () => {
|
||||
it("forwards llmThinkingOverride max as the direct completion reasoning option", async () => {
|
||||
const modelRegistry = {
|
||||
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
||||
getAll: () => [mockModel(true)],
|
||||
getAvailable: () => [mockModel(true)],
|
||||
};
|
||||
let reasoning: unknown;
|
||||
const complete = async (_model: unknown, _request: unknown, options: { reasoning?: string }) => {
|
||||
reasoning = options.reasoning;
|
||||
return {
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: JSON.stringify({ operations: [] }) }],
|
||||
};
|
||||
};
|
||||
|
||||
const result = await runDirectMemoryCompletion(
|
||||
{ model: mockModel(true), modelRegistry } as never,
|
||||
null as never,
|
||||
null,
|
||||
{ userPrompt: "u", systemPrompt: "s", config: { llmThinkingOverride: "max" } },
|
||||
null,
|
||||
null,
|
||||
{ completeSimple: complete as never },
|
||||
);
|
||||
|
||||
assert.strictEqual(result.ok, true);
|
||||
assert.strictEqual(reasoning, "max");
|
||||
});
|
||||
|
||||
it("forwards llmThinkingOverride max as --thinking max", () => {
|
||||
assert.deepStrictEqual(
|
||||
buildChildPiPromptArgs("hello", { llmThinkingOverride: "max" }, []),
|
||||
["-p", "--no-session", "--thinking", "max", ...EXT_ARGS, "hello"],
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user