mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
1107 lines
35 KiB
TypeScript
1107 lines
35 KiB
TypeScript
import { describe, it, beforeEach } from "node:test";
|
|
import assert from "node:assert";
|
|
import { readFileSync } from "node:fs";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
buildDirectReviewUserPrompt,
|
|
buildSubprocessReviewPrompt,
|
|
setupBackgroundReview,
|
|
type BackgroundReviewDeps,
|
|
} from "../../src/handlers/background-review.js";
|
|
import { resolveWatchedChildPiInvocation } from "../../src/handlers/pi-child-process.js";
|
|
import type { DirectReviewResult } from "../../src/handlers/review-memory-ops.js";
|
|
import type { MemoryConfig } from "../../src/types.js";
|
|
|
|
// ─── Mock infrastructure ───
|
|
|
|
interface CallLog {
|
|
handler: string;
|
|
args: any[];
|
|
}
|
|
|
|
let handlers: Record<string, Function[]>;
|
|
let execCalls: any[];
|
|
let directCalls: any[];
|
|
let notifyCalls: any[];
|
|
|
|
// The turn_end handler intentionally does not await its review work
|
|
// (fire-and-forget, so background review never blocks interactive chat —
|
|
// see #10 in CHANGELOG.md). Tests can't just await fireTurnEnd(), so setup()
|
|
// below wires the production-side onReviewSettled test hook (fired once
|
|
// runReview() and its .finally() cleanup fully complete, regardless of
|
|
// outcome) instead of racing exec-call timing or a fixed sleep.
|
|
let reviewSettledSignal: PromiseWithResolvers<void>;
|
|
|
|
function resetReviewSettledSignal(): void {
|
|
reviewSettledSignal = Promise.withResolvers<void>();
|
|
}
|
|
|
|
function setup(
|
|
pi: ExtensionAPI,
|
|
config: MemoryConfig = defaultConfig as MemoryConfig,
|
|
extraDeps: BackgroundReviewDeps = {},
|
|
): void {
|
|
setupBackgroundReview(pi, mockStore, null, config, {
|
|
deps: { onReviewSettled: () => reviewSettledSignal.resolve(), ...extraDeps },
|
|
});
|
|
}
|
|
|
|
function captureExecArgs(args: any[]): any[] {
|
|
const [command, childArgs, options] = args;
|
|
const capturedArgs = [...childArgs];
|
|
const promptReference = capturedArgs.at(-1);
|
|
if (typeof promptReference === "string" && promptReference.startsWith("@")) {
|
|
capturedArgs[capturedArgs.length - 1] = readFileSync(promptReference.slice(1), "utf-8");
|
|
}
|
|
return [command, capturedArgs, options];
|
|
}
|
|
|
|
function createMockPi(execReturn?: { code: number; stdout: string; stderr: string }) {
|
|
const defaultReturn = { code: 0, stdout: "Saved memory", stderr: "" };
|
|
const ret = execReturn ?? defaultReturn;
|
|
|
|
return {
|
|
on: (event: string, handler: Function) => {
|
|
handlers[event] = handlers[event] || [];
|
|
handlers[event].push(handler);
|
|
},
|
|
exec: async (...args: any[]) => {
|
|
execCalls.push(captureExecArgs(args));
|
|
return ret;
|
|
},
|
|
registerTool: () => {},
|
|
registerCommand: () => {},
|
|
} as any;
|
|
}
|
|
|
|
function makeBranch(numMessages: number) {
|
|
return Array.from({ length: numMessages }, (_, i) => ({
|
|
type: "message",
|
|
message: {
|
|
role: i % 2 === 0 ? "user" : "assistant",
|
|
content: [{ type: "text", text: `Message number ${i} with some real content here` }],
|
|
timestamp: i,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function makeCtx(branch: any[] = [], overrides: Record<string, any> = {}) {
|
|
return {
|
|
sessionManager: { getBranch: () => branch },
|
|
signal: undefined as any,
|
|
ui: {
|
|
notify: (msg: string, level: string) => {
|
|
notifyCalls.push({ msg, level });
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const defaultConfig = {
|
|
reviewEnabled: true,
|
|
reviewTransport: "subprocess" as const,
|
|
nudgeInterval: 10,
|
|
reviewRecentMessages: 0,
|
|
flushMinTurns: 6,
|
|
flushRecentMessages: 0,
|
|
flushOnCompact: true,
|
|
flushOnShutdown: true,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
projectCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
correctionDetection: true,
|
|
failureInjectionEnabled: true,
|
|
failureInjectionMaxAgeDays: 7,
|
|
failureInjectionMaxEntries: 5,
|
|
nudgeToolCalls: 15,
|
|
};
|
|
|
|
const mockStore = {
|
|
getMemoryEntries: () => ["existing memory entry"],
|
|
getUserEntries: () => ["existing user entry"],
|
|
} as any;
|
|
|
|
function fireMessageEnd(role: 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: "hi" }] } }, makeCtx());
|
|
}
|
|
}
|
|
|
|
function fireTurnEnd(branch: any[] = makeBranch(10), ctxOverrides: Record<string, any> = {}) {
|
|
const h = handlers["turn_end"];
|
|
if (!h) throw new Error("No turn_end handler registered");
|
|
const ctx = makeCtx(branch, ctxOverrides);
|
|
// Extract the last assistant message from the branch to pass as event.message
|
|
// (the handler now reads tool calls from event.message, not from the branch)
|
|
let assistantMessage = undefined;
|
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
if (branch[i]?.message?.role === "assistant") {
|
|
assistantMessage = branch[i].message;
|
|
break;
|
|
}
|
|
}
|
|
const event = assistantMessage ? { message: assistantMessage } : {};
|
|
for (const fn of h) {
|
|
fn(event, ctx);
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
// Only for genuinely negative assertions (nothing should have happened),
|
|
// where there is no positive side effect to await instead.
|
|
async function settle(ms = 10): Promise<void> {
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
setTimeout(resolve, ms);
|
|
await promise;
|
|
}
|
|
|
|
function logicalChildArgs(index = execCalls.length - 1): string[] {
|
|
const [cmd, args] = execCalls[index];
|
|
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);
|
|
}
|
|
|
|
function reviewPrompt(index = execCalls.length - 1): string {
|
|
const args = logicalChildArgs(index);
|
|
return args[args.length - 1];
|
|
}
|
|
|
|
// ─── Tests ───
|
|
|
|
describe("setupBackgroundReview", () => {
|
|
beforeEach(() => {
|
|
handlers = {};
|
|
execCalls = [];
|
|
directCalls = [];
|
|
notifyCalls = [];
|
|
resetReviewSettledSignal();
|
|
});
|
|
|
|
function setupWithDirectDeps(
|
|
pi: ExtensionAPI,
|
|
directResult: DirectReviewResult,
|
|
config: MemoryConfig = { ...defaultConfig, reviewTransport: "direct" as const } as MemoryConfig,
|
|
): void {
|
|
setup(pi, config, {
|
|
runDirectReview: async (...args: any[]) => {
|
|
directCalls.push(args);
|
|
return directResult;
|
|
},
|
|
});
|
|
}
|
|
|
|
it("increments user turn count on message_end for user messages", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Verify by checking that 3 user turns is enough to allow review
|
|
// (userTurnCount >= 3 check passes after 3 user message_end events)
|
|
// Fire 10 turn_end events — should trigger review since userTurnCount is 3
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
// exec should have been called since we have 3 user turns and 10 turn_end events
|
|
assert.ok(execCalls.length > 0, "exec should be called with 3 user turns and 10 turn_end events");
|
|
});
|
|
|
|
it("triggers review at nudgeInterval (10) turns", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
// Register 3 user messages first
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Fire 9 turn_end events — not enough
|
|
for (let i = 0; i < 9; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called at 9 turns");
|
|
|
|
// 10th turn_end triggers review
|
|
fireTurnEnd();
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(execCalls.length, 1, "exec should be called once at turn 10");
|
|
// Verify it calls pi.exec with review prompt
|
|
const cmdArgs = logicalChildArgs(0);
|
|
assert.ok(cmdArgs[0] === "-p", "should use -p flag");
|
|
assert.ok(cmdArgs.includes("--no-session"), "should include --no-session");
|
|
const prompt = reviewPrompt(0);
|
|
assert.match(prompt, /Do NOT create or modify skills in this background review/i);
|
|
assert.doesNotMatch(prompt, /save a reusable procedure using the skill tool/i);
|
|
});
|
|
|
|
it("passes child LLM override args to the review subprocess", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, {
|
|
...defaultConfig,
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
llmThinkingOverride: "minimal",
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
const cmdArgs = logicalChildArgs(0);
|
|
assert.deepStrictEqual(
|
|
cmdArgs.slice(0, 6),
|
|
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "minimal"],
|
|
);
|
|
});
|
|
|
|
it("does NOT trigger review when reviewEnabled is false", async () => {
|
|
const config = { ...defaultConfig, reviewEnabled: false };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle();
|
|
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called when reviewEnabled is false");
|
|
});
|
|
|
|
it("does NOT trigger review with fewer than 3 user turns", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
// Only 2 user messages
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle();
|
|
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called with only 2 user turns");
|
|
});
|
|
|
|
it("reviewInProgress guard prevents double-trigger", async () => {
|
|
// Use a slow exec that never resolves to keep reviewInProgress true
|
|
let resolveExec: () => void;
|
|
const slowPi = {
|
|
on: (event: string, handler: Function) => {
|
|
handlers[event] = handlers[event] || [];
|
|
handlers[event].push(handler);
|
|
},
|
|
exec: async (...args: any[]) => {
|
|
execCalls.push(captureExecArgs(args));
|
|
await new Promise<void>((r) => { resolveExec = r; });
|
|
return { code: 0, stdout: "Saved", stderr: "" };
|
|
},
|
|
registerTool: () => {},
|
|
registerCommand: () => {},
|
|
} as any;
|
|
|
|
setup(slowPi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Fire 10 turn_end events — first triggers review (slow, won't resolve)
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle(5);
|
|
|
|
assert.strictEqual(execCalls.length, 1, "exec should be called once for first trigger");
|
|
|
|
// Fire more turn_end events — should be blocked by reviewInProgress
|
|
for (let i = 0; i < 15; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle(5);
|
|
|
|
assert.strictEqual(execCalls.length, 1, "exec should still only be called once — reviewInProgress guard");
|
|
|
|
// Resolve the pending exec to clean up
|
|
resolveExec!();
|
|
await settle();
|
|
});
|
|
|
|
it("does NOT trigger for short conversations (< 4 message parts)", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Branch with only 2 message entries (< 4 parts)
|
|
const shortBranch = [
|
|
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
|
|
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "hello" }] } },
|
|
];
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(shortBranch);
|
|
}
|
|
await settle();
|
|
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called for short conversations");
|
|
});
|
|
|
|
it("uses the full conversation by default", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10));
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
const prompt = reviewPrompt();
|
|
assert.ok(prompt.includes("Message number 0"), "default should include older messages");
|
|
assert.ok(prompt.includes("Message number 9"), "default should include latest messages");
|
|
});
|
|
|
|
it("limits background review to recent messages when configured", async () => {
|
|
const config = { ...defaultConfig, reviewRecentMessages: 3 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10));
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
const prompt = reviewPrompt();
|
|
assert.ok(!prompt.includes("Message number 6"), "window should exclude older messages");
|
|
assert.ok(prompt.includes("Message number 7"));
|
|
assert.ok(prompt.includes("Message number 8"));
|
|
assert.ok(prompt.includes("Message number 9"));
|
|
});
|
|
|
|
it("does not use the flush recent-message limit for background review", async () => {
|
|
const config = { ...defaultConfig, flushRecentMessages: 2 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10));
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.ok(reviewPrompt().includes("Message number 0"), "flush limit must not affect review");
|
|
});
|
|
|
|
it("keeps the short conversation guard based on the full conversation", async () => {
|
|
const config = { ...defaultConfig, reviewRecentMessages: 2 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(4));
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(execCalls.length, 1, "full conversation has enough parts to review");
|
|
const prompt = reviewPrompt();
|
|
assert.ok(!prompt.includes("Message number 0"));
|
|
assert.ok(!prompt.includes("Message number 1"));
|
|
assert.ok(prompt.includes("Message number 2"));
|
|
assert.ok(prompt.includes("Message number 3"));
|
|
});
|
|
|
|
it("resets turn counter after review triggers", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Fire 10 turns — triggers review
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(execCalls.length, 1, "first review triggered");
|
|
|
|
// Fire 10 more turns — should trigger again (counter was reset)
|
|
resetReviewSettledSignal();
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(execCalls.length, 2, "second review should trigger after counter reset");
|
|
});
|
|
|
|
it("shows notification only when review saves something", async () => {
|
|
const pi = createMockPi({ code: 0, stdout: "Saved new memory about user preferences", stderr: "" });
|
|
setup(pi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
// 10 diagnostic notifications + 1 auto-review notification
|
|
const reviewNotify = notifyCalls.find(n => n.msg.includes("Memory auto-reviewed"));
|
|
assert.ok(reviewNotify, "should have a 'Memory auto-reviewed' notification");
|
|
|
|
// Reset and test "nothing to save" case
|
|
handlers = {};
|
|
execCalls = [];
|
|
notifyCalls = [];
|
|
resetReviewSettledSignal();
|
|
|
|
const nothingPi = createMockPi({ code: 0, stdout: "Nothing to save.", stderr: "" });
|
|
setup(nothingPi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
const reviewNotify2 = notifyCalls.find(n => n.msg.includes("Memory auto-reviewed"));
|
|
assert.strictEqual(reviewNotify2, undefined, "no 'Memory auto-reviewed' notification for 'nothing to save'");
|
|
});
|
|
|
|
it("does NOT crash agent when exec throws", async () => {
|
|
const crashPi = {
|
|
on: (event: string, handler: Function) => {
|
|
handlers[event] = handlers[event] || [];
|
|
handlers[event].push(handler);
|
|
},
|
|
exec: async (...args: any[]) => {
|
|
execCalls.push(captureExecArgs(args));
|
|
throw new Error("exec crashed");
|
|
},
|
|
registerTool: () => {},
|
|
registerCommand: () => {},
|
|
} as any;
|
|
|
|
setup(crashPi, defaultConfig);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// This should NOT throw
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(execCalls.length, 1, "exec was attempted");
|
|
// If we get here without an unhandled rejection, the error was caught
|
|
assert.ok(true, "background review failure was caught silently");
|
|
});
|
|
|
|
it("assistant message_end does NOT increment user turn count", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig);
|
|
|
|
// Only assistant messages — userTurnCount stays 0
|
|
fireMessageEnd("assistant");
|
|
fireMessageEnd("assistant");
|
|
fireMessageEnd("assistant");
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle();
|
|
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called — no user messages");
|
|
});
|
|
|
|
// ─── Tool-call-aware nudge tests (Epic 4) ───
|
|
|
|
it("triggers on tool call count threshold even with low turn count", async () => {
|
|
const config = { ...defaultConfig, nudgeToolCalls: 5 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Branch with 5 toolCall blocks (meets tool call threshold)
|
|
const branchWithToolCalls = [
|
|
...makeBranch(4),
|
|
{
|
|
type: "message",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", id: "tc1", name: "read", arguments: {} },
|
|
{ type: "toolCall", id: "tc2", name: "bash", arguments: {} },
|
|
{ type: "toolCall", id: "tc3", name: "edit", arguments: {} },
|
|
{ type: "toolCall", id: "tc4", name: "read", arguments: {} },
|
|
{ type: "toolCall", id: "tc5", name: "bash", arguments: {} },
|
|
],
|
|
timestamp: 1,
|
|
},
|
|
},
|
|
];
|
|
|
|
// Only 2 turn_end events (below turn threshold of 10)
|
|
fireTurnEnd(branchWithToolCalls);
|
|
fireTurnEnd(branchWithToolCalls);
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.ok(execCalls.length >= 1, "exec should be called due to tool call threshold");
|
|
});
|
|
|
|
it("triggers when both thresholds are met", async () => {
|
|
const config = { ...defaultConfig, nudgeToolCalls: 5 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
const branchWithToolCalls = [
|
|
...makeBranch(10),
|
|
{
|
|
type: "message",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", id: "tc1", name: "read", arguments: {} },
|
|
{ type: "toolCall", id: "tc2", name: "bash", arguments: {} },
|
|
],
|
|
timestamp: 1,
|
|
},
|
|
},
|
|
];
|
|
|
|
// Fire 10 turns (meets turn threshold) with tool calls (meets tool threshold)
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(branchWithToolCalls);
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.ok(execCalls.length >= 1, "exec should be called when either threshold is met");
|
|
});
|
|
|
|
it("resets both counters after review", async () => {
|
|
const config = { ...defaultConfig, nudgeToolCalls: 3 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
const branchWithToolCalls = [
|
|
...makeBranch(6),
|
|
{
|
|
type: "message",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", id: "tc1", name: "read", arguments: {} },
|
|
{ type: "toolCall", id: "tc2", name: "bash", arguments: {} },
|
|
{ type: "toolCall", id: "tc3", name: "edit", arguments: {} },
|
|
],
|
|
timestamp: 1,
|
|
},
|
|
},
|
|
];
|
|
|
|
// Trigger first review via tool calls
|
|
fireTurnEnd(branchWithToolCalls);
|
|
await reviewSettledSignal.promise;
|
|
assert.strictEqual(execCalls.length, 1, "first review triggered");
|
|
|
|
// Trigger second review via turn count
|
|
resetReviewSettledSignal();
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10));
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
assert.strictEqual(execCalls.length, 2, "second review should trigger after counter reset");
|
|
});
|
|
|
|
it("does not trigger when neither threshold is met", async () => {
|
|
const config = { ...defaultConfig, nudgeToolCalls: 15 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Only 2 tool calls (below 15 threshold) and 5 turns (below 10 threshold)
|
|
const branchWithFewToolCalls = [
|
|
...makeBranch(4),
|
|
{
|
|
type: "message",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", id: "tc1", name: "read", arguments: {} },
|
|
{ type: "toolCall", id: "tc2", name: "bash", arguments: {} },
|
|
],
|
|
timestamp: 1,
|
|
},
|
|
},
|
|
];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
fireTurnEnd(branchWithFewToolCalls);
|
|
}
|
|
await settle();
|
|
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called when neither threshold met");
|
|
});
|
|
|
|
it("ignores text blocks when counting tool calls", async () => {
|
|
const config = { ...defaultConfig, nudgeToolCalls: 3 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// Branch with text-only messages (no toolCall blocks)
|
|
const branchWithTextOnly = [
|
|
...makeBranch(10),
|
|
];
|
|
|
|
// Fire enough turns but no tool calls
|
|
for (let i = 0; i < 5; i++) {
|
|
fireTurnEnd(branchWithTextOnly);
|
|
}
|
|
await settle();
|
|
|
|
assert.strictEqual(execCalls.length, 0, "exec should NOT be called — no toolCall blocks, turn threshold not met");
|
|
});
|
|
|
|
it("uses direct review by default and does not call subprocess", async () => {
|
|
const pi = createMockPi();
|
|
setupWithDirectDeps(pi, { ok: true, appliedCount: 1 }, {
|
|
...defaultConfig,
|
|
reviewTransport: "direct",
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(directCalls.length, 1, "direct review should run once");
|
|
assert.strictEqual(execCalls.length, 0, "subprocess should not run on successful direct review");
|
|
const directOptions = directCalls[0][3] as { systemPrompt: string };
|
|
assert.match(directOptions.systemPrompt, /target routing/i);
|
|
assert.match(directOptions.systemPrompt, /use target "memory"/i);
|
|
assert.match(directOptions.systemPrompt, /do not emit target "project"/i);
|
|
const reviewNotify = notifyCalls.find((n) => n.msg.includes("Memory auto-reviewed"));
|
|
assert.ok(reviewNotify, "should notify when direct review applies memory");
|
|
});
|
|
|
|
it("falls back to subprocess when direct review cannot run", async () => {
|
|
const pi = createMockPi();
|
|
setupWithDirectDeps(pi, { ok: false, appliedCount: 0, fallbackReason: "no_model" }, {
|
|
...defaultConfig,
|
|
reviewTransport: "direct",
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(directCalls.length, 1, "direct review should be attempted first");
|
|
assert.strictEqual(execCalls.length, 1, "subprocess should run as fallback");
|
|
});
|
|
it("inherits the active session model and cwd without coupling fallback to the agent run signal", async () => {
|
|
const pi = createMockPi();
|
|
setupWithDirectDeps(pi, { ok: false, appliedCount: 0, fallbackReason: "no_auth" }, {
|
|
...defaultConfig,
|
|
reviewTransport: "direct",
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
const controller = new AbortController();
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10), {
|
|
cwd: "/tmp/local-session",
|
|
model: { provider: "local-llama", id: "local-9b" },
|
|
signal: controller.signal,
|
|
});
|
|
}
|
|
controller.abort();
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.deepStrictEqual(logicalChildArgs(0).slice(0, 5), [
|
|
"-p", "--no-session", "--model", "local-llama/local-9b", "--no-extensions",
|
|
]);
|
|
assert.deepStrictEqual(execCalls[0][2], {
|
|
cwd: "/tmp/local-session",
|
|
timeout: 125000,
|
|
});
|
|
});
|
|
|
|
it("surfaces one actionable diagnostic when direct and subprocess review both fail", async () => {
|
|
const pi = createMockPi({ code: 1, stdout: "", stderr: "No API key for local-llama/local-9b" });
|
|
setupWithDirectDeps(pi, {
|
|
ok: false,
|
|
appliedCount: 0,
|
|
fallbackReason: "no_auth",
|
|
error: "No API key for local-llama",
|
|
}, {
|
|
...defaultConfig,
|
|
reviewTransport: "direct",
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10), {
|
|
model: { provider: "local-llama", id: "local-9b" },
|
|
});
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
const failures = notifyCalls.filter((n) => n.level === "warning");
|
|
assert.equal(failures.length, 1);
|
|
assert.match(failures[0].msg, /both transports/i);
|
|
assert.match(failures[0].msg, /no_auth/i);
|
|
assert.match(failures[0].msg, /No API key for local-llama\/local-9b/i);
|
|
assert.match(failures[0].msg, /llmModelOverride/i);
|
|
});
|
|
|
|
|
|
it("falls back to subprocess when direct review throws", async () => {
|
|
const pi = createMockPi();
|
|
setup(pi, { ...defaultConfig, reviewTransport: "direct" } as MemoryConfig, {
|
|
runDirectReview: async (...args: any[]) => {
|
|
directCalls.push(args);
|
|
throw new Error("direct transport exploded");
|
|
},
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.strictEqual(directCalls.length, 1, "direct review should be attempted first");
|
|
assert.strictEqual(execCalls.length, 1, "subprocess should run as fallback when direct review throws");
|
|
});
|
|
|
|
it("does not notify when direct review returns no operations", async () => {
|
|
const pi = createMockPi();
|
|
setupWithDirectDeps(pi, { ok: true, appliedCount: 0, fallbackReason: "empty" }, {
|
|
...defaultConfig,
|
|
reviewTransport: "direct",
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await reviewSettledSignal.promise;
|
|
|
|
const reviewNotify = notifyCalls.find((n) => n.msg.includes("Memory auto-reviewed"));
|
|
assert.strictEqual(reviewNotify, undefined, "empty direct review should not notify");
|
|
assert.strictEqual(execCalls.length, 0, "empty direct review should not fall back");
|
|
});
|
|
|
|
it("includes explicit target routing for an available project store", () => {
|
|
const prompt = buildSubprocessReviewPrompt({
|
|
parts: ["[USER] hello", "[ASSISTANT] hi"],
|
|
currentMemory: "global fact",
|
|
currentUser: "user preference",
|
|
currentProject: "project convention",
|
|
});
|
|
|
|
assert.match(prompt, /project-specific facts.*target "project"/i);
|
|
assert.match(prompt, /global or cross-project facts.*target "memory"/i);
|
|
assert.match(prompt, /failures, corrections.*target "failure"/i);
|
|
});
|
|
|
|
it("keeps project target unavailable when no project store is present", () => {
|
|
const prompt = buildSubprocessReviewPrompt({
|
|
parts: ["[USER] hello", "[ASSISTANT] hi"],
|
|
currentMemory: "global fact",
|
|
currentUser: "user preference",
|
|
currentProject: null,
|
|
});
|
|
|
|
assert.match(prompt, /do not emit target "project"/i);
|
|
assert.match(prompt, /use target "memory"/i);
|
|
assert.match(prompt, /target "failure"/i);
|
|
assert.doesNotMatch(prompt, /--- Current Project Memory ---/i);
|
|
});
|
|
|
|
it("builds separate prompts for direct and subprocess transports", () => {
|
|
const input = {
|
|
parts: ["[USER] hello", "[ASSISTANT] hi"],
|
|
currentMemory: "uses pnpm",
|
|
currentUser: "likes TypeScript",
|
|
currentProject: "monorepo layout",
|
|
};
|
|
|
|
const subprocessPrompt = buildSubprocessReviewPrompt(input);
|
|
const directPrompt = buildDirectReviewUserPrompt(input);
|
|
|
|
assert.match(subprocessPrompt, /save using memory_add/i);
|
|
assert.match(directPrompt, /Conversation to Review/);
|
|
assert.doesNotMatch(directPrompt, /save using memory_add/i);
|
|
assert.ok(subprocessPrompt.includes("uses pnpm"));
|
|
assert.ok(directPrompt.includes("monorepo layout"));
|
|
});
|
|
|
|
it("does not forward the turn abort signal to the child review", async () => {
|
|
let capturedSignal: AbortSignal | undefined;
|
|
const childGate = Promise.withResolvers<void>();
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig, {
|
|
execChildPrompt: async (_api, _prompt, _config, options) => {
|
|
capturedSignal = options.signal;
|
|
await childGate.promise;
|
|
return { code: 0, stdout: "Saved memory", stderr: "" };
|
|
},
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
const turnAbort = new AbortController();
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd(makeBranch(10), { signal: turnAbort.signal });
|
|
}
|
|
await settle(5);
|
|
|
|
assert.ok(capturedSignal, "child review must receive a session signal");
|
|
assert.notStrictEqual(capturedSignal, turnAbort.signal);
|
|
turnAbort.abort();
|
|
await settle(5);
|
|
assert.equal(capturedSignal.aborted, false, "turn abort must not cancel session review");
|
|
|
|
childGate.resolve();
|
|
await reviewSettledSignal.promise;
|
|
});
|
|
|
|
it("session_shutdown aborts the child signal, waits for review, and stays silent", async () => {
|
|
let capturedSignal: AbortSignal | undefined;
|
|
const childGate = Promise.withResolvers<void>();
|
|
let childStarted = 0;
|
|
const pi = createMockPi();
|
|
setup(pi, defaultConfig, {
|
|
execChildPrompt: async (_api, _prompt, _config, options) => {
|
|
childStarted++;
|
|
capturedSignal = options.signal;
|
|
await childGate.promise;
|
|
return { code: 0, stdout: "Saved memory", stderr: "" };
|
|
},
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle(5);
|
|
assert.ok(capturedSignal);
|
|
assert.equal(capturedSignal.aborted, false);
|
|
|
|
const shutdownHandlers = handlers["session_shutdown"];
|
|
assert.ok(shutdownHandlers?.length, "session_shutdown handler must be registered");
|
|
const first = shutdownHandlers[0]({}, makeCtx());
|
|
const second = shutdownHandlers[0]({}, makeCtx());
|
|
assert.strictEqual(first, second, "repeated session_shutdown must reuse the same promise");
|
|
assert.equal(capturedSignal.aborted, true);
|
|
|
|
let shutdownDone = false;
|
|
void Promise.resolve(first).then(() => {
|
|
shutdownDone = true;
|
|
});
|
|
await settle(5);
|
|
assert.equal(shutdownDone, false, "shutdown must wait for the in-flight review");
|
|
|
|
childGate.resolve();
|
|
await first;
|
|
await reviewSettledSignal.promise;
|
|
assert.equal(shutdownDone, true);
|
|
assert.equal(
|
|
notifyCalls.some((n) => n.msg.includes("Memory auto-reviewed") || n.level === "warning"),
|
|
false,
|
|
"cancelled review must not notify success or failure",
|
|
);
|
|
|
|
resetReviewSettledSignal();
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle(10);
|
|
assert.equal(childStarted, 1, "cancelled session must not start another review");
|
|
});
|
|
|
|
it("does not start fallback after session cancellation", async () => {
|
|
const directGate = Promise.withResolvers<void>();
|
|
let childStarted = 0;
|
|
const pi = createMockPi();
|
|
setup(pi, { ...defaultConfig, reviewTransport: "direct" } as MemoryConfig, {
|
|
runDirectReview: async () => {
|
|
await directGate.promise;
|
|
return { ok: true, appliedCount: 1 };
|
|
},
|
|
execChildPrompt: async () => {
|
|
childStarted++;
|
|
return { code: 0, stdout: "Saved memory", stderr: "" };
|
|
},
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle(5);
|
|
|
|
const shutdown = handlers["session_shutdown"][0]({}, makeCtx());
|
|
directGate.resolve();
|
|
await shutdown;
|
|
await reviewSettledSignal.promise;
|
|
|
|
assert.equal(childStarted, 0, "cancelled session must not start subprocess fallback");
|
|
assert.equal(
|
|
notifyCalls.some((n) => n.msg.includes("Memory auto-reviewed") || n.level === "warning"),
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("session_shutdown resolves after the grace bound when review ignores abort", async () => {
|
|
let started = false;
|
|
const pi = createMockPi();
|
|
setup(pi, { ...defaultConfig, reviewTransport: "direct" } as MemoryConfig, {
|
|
shutdownGraceMs: 20,
|
|
runDirectReview: async () => {
|
|
started = true;
|
|
await new Promise(() => {});
|
|
return { ok: true, appliedCount: 1 };
|
|
},
|
|
});
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
for (let i = 0; i < 10; i++) {
|
|
fireTurnEnd();
|
|
}
|
|
await settle(5);
|
|
assert.equal(started, true);
|
|
|
|
const first = handlers["session_shutdown"][0]({}, makeCtx());
|
|
const second = handlers["session_shutdown"][0]({}, makeCtx());
|
|
assert.strictEqual(first, second);
|
|
const startedAt = Date.now();
|
|
await first;
|
|
assert.ok(Date.now() - startedAt < 200, "shutdown must not wait for a review that ignores abort");
|
|
assert.equal(
|
|
notifyCalls.some((n) => n.msg.includes("Memory auto-reviewed") || n.level === "warning"),
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("falls back gracefully if getBranch throws", async () => {
|
|
const config = { ...defaultConfig, nudgeToolCalls: 3 };
|
|
const pi = createMockPi();
|
|
setup(pi, config);
|
|
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
fireMessageEnd("user");
|
|
|
|
// getBranch throws — should not crash
|
|
const crashCtx = {
|
|
sessionManager: { getBranch: () => { throw new Error("session expired"); } },
|
|
signal: undefined as any,
|
|
ui: { notify: () => {} },
|
|
};
|
|
|
|
const h = handlers["turn_end"];
|
|
// Fire 10 turns with crashing getBranch
|
|
for (let i = 0; i < 10; i++) {
|
|
for (const fn of h) {
|
|
fn({}, crashCtx);
|
|
}
|
|
}
|
|
await settle();
|
|
|
|
// Should not throw — we got here = test passed
|
|
assert.ok(true, "no crash when getBranch throws");
|
|
});
|
|
});
|