mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
1188 lines
40 KiB
TypeScript
1188 lines
40 KiB
TypeScript
import { describe, it, beforeEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { setupSessionFlush, remainingFlushTimeoutMs, resolveFlushHandoff } from "../../src/handlers/session-flush.js";
|
|
import { resolveWatchedChildPiInvocation } from "../../src/handlers/pi-child-process.js";
|
|
import { DIRECT_FLUSH_SYSTEM_PROMPT, FLUSH_PROMPT } from "../../src/constants.js";
|
|
import type { MemoryConfig } from "../../src/types.js";
|
|
import type { DirectReviewResult } from "../../src/handlers/review-memory-ops.js";
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
/** Event-name → handler[] registry built by mock pi.on() */
|
|
function createMockPi() {
|
|
const handlers: Record<string, Function[]> = {};
|
|
const execCalls: { args: any[] }[] = [];
|
|
|
|
const pi = {
|
|
on(event: string, handler: Function) {
|
|
handlers[event] = handlers[event] || [];
|
|
handlers[event].push(handler);
|
|
},
|
|
async exec(...args: 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");
|
|
}
|
|
execCalls.push({ args: [command, capturedArgs, options] });
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
},
|
|
registerTool() {},
|
|
registerCommand() {},
|
|
};
|
|
|
|
return { pi: pi as any, handlers, execCalls };
|
|
}
|
|
interface MockSessionFlushPi {
|
|
pi: {
|
|
on(event: string, handler: Function): void;
|
|
exec(...args: unknown[]): Promise<{ code: number; stdout: string; stderr: string }>;
|
|
registerTool(): void;
|
|
registerCommand(): void;
|
|
};
|
|
handlers: Record<string, Function[]>;
|
|
execCalls: { args: unknown[] }[];
|
|
}
|
|
|
|
let directCalls: unknown[][];
|
|
|
|
function makeDirectDeps(
|
|
result: DirectReviewResult | "throw",
|
|
): { runDirectMemoryCompletion: (...args: unknown[]) => Promise<DirectReviewResult> } {
|
|
return {
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
if (result === "throw") throw new Error("injected direct flush failure");
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
|
|
function defaultFlushCtx() {
|
|
return { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
}
|
|
|
|
function flushCtxWithNotify() {
|
|
const notifications: { message: string; kind?: string }[] = [];
|
|
return {
|
|
notifications,
|
|
ctx: {
|
|
sessionManager: { getBranch: () => mockBranch(8) },
|
|
ui: {
|
|
notify(message: string, kind?: string) {
|
|
notifications.push({ message, kind });
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
async function primeFlushReady(handlers: Record<string, Function[]>) {
|
|
await emitUserTurns(handlers, 8);
|
|
}
|
|
|
|
/** Emit session_shutdown and await fire-and-forget flush without wall-clock sleep. */
|
|
async function emitShutdownAndAwaitFlush(
|
|
pi: MockSessionFlushPi,
|
|
handlers: Record<string, Function[]>,
|
|
ctx: { sessionManager: { getBranch: () => unknown[] } },
|
|
) {
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
const originalExec = pi.pi.exec;
|
|
pi.pi.exec = async (...args: unknown[]) => {
|
|
try {
|
|
return await originalExec(...args);
|
|
} finally {
|
|
resolve();
|
|
}
|
|
};
|
|
await emit(handlers, "session_shutdown", { type: "session_shutdown", reason: "quit" }, ctx);
|
|
await promise;
|
|
}
|
|
|
|
|
|
/** Build N messages alternating user/assistant */
|
|
function mockBranch(n: number) {
|
|
return Array.from({ length: n }, (_, i) => ({
|
|
type: "message",
|
|
message: {
|
|
role: i % 2 === 0 ? "user" : "assistant",
|
|
content: [{ type: "text", text: `msg ${i}` }],
|
|
timestamp: i,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function defaultConfig(overrides: Partial<MemoryConfig> = {}): MemoryConfig {
|
|
return {
|
|
memoryMode: "policy-only",
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
projectCharLimit: 5000,
|
|
nudgeInterval: 10,
|
|
reviewRecentMessages: 0,
|
|
reviewEnabled: true,
|
|
flushOnCompact: true,
|
|
flushOnShutdown: true,
|
|
flushMinTurns: 6,
|
|
flushRecentMessages: 0,
|
|
autoConsolidate: true,
|
|
correctionDetection: true,
|
|
failureInjectionEnabled: true,
|
|
failureInjectionMaxAgeDays: 7,
|
|
failureInjectionMaxEntries: 5,
|
|
nudgeToolCalls: 15,
|
|
flushCompactTimeoutMs: 60_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Emit message_end N times (simulates user turns) */
|
|
async function emitUserTurns(handlers: Record<string, Function[]>, count: number) {
|
|
const hs = handlers["message_end"] || [];
|
|
for (let i = 0; i < count; i++) {
|
|
for (const h of hs) {
|
|
await h({ message: { role: "user" } }, {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Emit a single event with optional ctx */
|
|
async function emit(
|
|
handlers: Record<string, Function[]>,
|
|
event: string,
|
|
eventObj: any = {},
|
|
ctx: any = {},
|
|
) {
|
|
const hs = handlers[event] || [];
|
|
for (const h of hs) {
|
|
await h(eventObj, ctx);
|
|
}
|
|
}
|
|
|
|
const mockStore = { getMemoryEntries: () => [], getUserEntries: () => [] } as any;
|
|
|
|
function logicalChildArgs(call: { args: any[] }): string[] {
|
|
const [cmd, args] = call.args;
|
|
const underlying = { command: args[3], args: args.slice(4) };
|
|
const expected = resolveWatchedChildPiInvocation(underlying, Number(args[1]), args[2]);
|
|
assert.equal(cmd, expected.command);
|
|
assert.deepEqual(args, expected.args);
|
|
return underlying.command === "pi" ? underlying.args : underlying.args.slice(1);
|
|
}
|
|
|
|
function flushMessage(call: { args: any[] }): string {
|
|
const args = logicalChildArgs(call);
|
|
return args[args.length - 1];
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
describe("remainingFlushTimeoutMs", () => {
|
|
it("gives the full ceiling when nothing has elapsed", () => {
|
|
assert.equal(remainingFlushTimeoutMs(60_000, 0), 60_000);
|
|
});
|
|
|
|
it("returns leftover after a fast miss, not a second copy of the budget", () => {
|
|
assert.equal(remainingFlushTimeoutMs(60_000, 2_000), 58_000);
|
|
});
|
|
|
|
it("skips when leftover is below the 5s spawn floor", () => {
|
|
assert.equal(remainingFlushTimeoutMs(60_000, 56_000), null);
|
|
});
|
|
|
|
it("keeps leftover when it equals the spawn floor", () => {
|
|
assert.equal(remainingFlushTimeoutMs(60_000, 55_000), 5_000);
|
|
});
|
|
|
|
it("skips when the ceiling is fully spent or overshot", () => {
|
|
assert.equal(remainingFlushTimeoutMs(60_000, 60_000), null);
|
|
assert.equal(remainingFlushTimeoutMs(60_000, 65_000), null);
|
|
});
|
|
|
|
it("floors the spawn threshold by the budget so a tiny knob still runs one shot", () => {
|
|
assert.equal(remainingFlushTimeoutMs(3_000, 0), 3_000);
|
|
});
|
|
|
|
it("does not spawn a doomed child after any spend of a tiny budget", () => {
|
|
assert.equal(remainingFlushTimeoutMs(3_000, 1), null);
|
|
});
|
|
|
|
it("treats a non-positive budget as exhausted", () => {
|
|
assert.equal(remainingFlushTimeoutMs(0, 0), null);
|
|
assert.equal(remainingFlushTimeoutMs(-1, 0), null);
|
|
});
|
|
});
|
|
|
|
describe("resolveFlushHandoff", () => {
|
|
it("skips as session_aborted even when leftover budget remains", () => {
|
|
assert.deepEqual(resolveFlushHandoff(true, 60_000, 0), { skip: "session_aborted" });
|
|
assert.deepEqual(resolveFlushHandoff(true, 0, 0), { skip: "session_aborted" });
|
|
});
|
|
|
|
it("skips as budget_exhausted when leftover is below the spawn floor", () => {
|
|
assert.deepEqual(resolveFlushHandoff(false, 60_000, 56_000), { skip: "budget_exhausted" });
|
|
});
|
|
|
|
it("returns leftover timeoutMs, never a second copy of the budget", () => {
|
|
assert.deepEqual(resolveFlushHandoff(false, 60_000, 2_000), { timeoutMs: 58_000 });
|
|
});
|
|
|
|
it("treats a non-positive budget as immediately exhausted", () => {
|
|
assert.deepEqual(resolveFlushHandoff(false, 0, 0), { skip: "budget_exhausted" });
|
|
assert.deepEqual(resolveFlushHandoff(false, -5, 0), { skip: "budget_exhausted" });
|
|
});
|
|
});
|
|
|
|
|
|
describe("setupSessionFlush", () => {
|
|
let mockPi: MockSessionFlushPi;
|
|
|
|
beforeEach(() => {
|
|
mockPi = createMockPi();
|
|
});
|
|
|
|
// ── Compact flush ───────────────────────────────────────────────────
|
|
|
|
it("session_before_compact triggers flush when flushOnCompact is true", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
// Simulate enough user turns
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(mockPi.execCalls.length, 1, "exec should be called once");
|
|
});
|
|
|
|
it("session_before_compact does NOT trigger when flushOnCompact is false", async () => {
|
|
const config = defaultConfig({ flushOnCompact: false });
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(mockPi.execCalls.length, 0, "exec should NOT be called");
|
|
});
|
|
|
|
// ── Shutdown flush ──────────────────────────────────────────────────
|
|
|
|
it("session_shutdown triggers flush when flushOnShutdown is true", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emitShutdownAndAwaitFlush(mockPi, mockPi.handlers, ctx);
|
|
assert.equal(mockPi.execCalls.length, 1, "exec should be called once");
|
|
});
|
|
|
|
it("awaits the bounded flush before session_shutdown resolves", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const { promise: execStarted, resolve: markExecStarted } = Promise.withResolvers<void>();
|
|
const { promise: releaseExec, resolve: release } = Promise.withResolvers<void>();
|
|
mockPi.pi.exec = async (...args: unknown[]) => {
|
|
markExecStarted();
|
|
await releaseExec;
|
|
return { code: 0, stdout: "", stderr: "" };
|
|
};
|
|
|
|
const ctx = {
|
|
sessionManager: { getBranch: () => mockBranch(8) },
|
|
cwd: "/tmp/active-project",
|
|
model: undefined,
|
|
modelRegistry: {},
|
|
};
|
|
const shutdown = mockPi.handlers["session_shutdown"][0]({}, ctx);
|
|
await execStarted;
|
|
|
|
let settled = false;
|
|
void shutdown.then(() => { settled = true; });
|
|
await Promise.resolve();
|
|
assert.equal(settled, false, "shutdown handler must await the flush");
|
|
|
|
release();
|
|
await shutdown;
|
|
});
|
|
|
|
it("session_shutdown does NOT trigger when flushOnShutdown is false", async () => {
|
|
const config = defaultConfig({ flushOnShutdown: false });
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emit(mockPi.handlers, "session_shutdown", {}, ctx);
|
|
|
|
assert.equal(mockPi.execCalls.length, 0, "exec should NOT be called");
|
|
});
|
|
|
|
// ── Minimum turns gate ──────────────────────────────────────────────
|
|
|
|
it("Flush skips if userTurnCount < flushMinTurns", async () => {
|
|
const config = defaultConfig({ flushMinTurns: 6 });
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
// Only 3 user turns — below threshold
|
|
await emitUserTurns(mockPi.handlers, 3);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(3) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(mockPi.execCalls.length, 0, "exec should NOT be called with too few turns");
|
|
});
|
|
|
|
// ── getBranch usage ─────────────────────────────────────────────────
|
|
|
|
it("Flush builds conversation from sessionManager.getBranch()", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
let branchCalled = false;
|
|
const ctx = {
|
|
sessionManager: {
|
|
getBranch: () => {
|
|
branchCalled = true;
|
|
return mockBranch(8);
|
|
},
|
|
},
|
|
};
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.ok(branchCalled, "getBranch should be called");
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
});
|
|
|
|
// ── Exec args verification ──────────────────────────────────────────
|
|
|
|
it("Flush uses pi.exec with correct args", async () => {
|
|
const clock = { t: 0 };
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config, null, null, { now: () => clock.t });
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const branch = mockBranch(4);
|
|
const ctx = { sessionManager: { getBranch: () => branch } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
|
|
const [, , opts] = mockPi.execCalls[0].args;
|
|
const args = logicalChildArgs(mockPi.execCalls[0]);
|
|
assert.equal(args[0], "-p");
|
|
assert.equal(args[1], "--no-session");
|
|
|
|
// The final logical arg is the flush message containing the prompt + conversation
|
|
const message = args[args.length - 1];
|
|
assert.ok(message.includes(FLUSH_PROMPT), "flush message should contain FLUSH_PROMPT");
|
|
assert.ok(message.includes("[USER]"), "flush message should contain [USER] prefix");
|
|
assert.ok(
|
|
message.includes("[ASSISTANT]"),
|
|
"flush message should contain [ASSISTANT] prefix",
|
|
);
|
|
assert.ok(message.includes("msg 0"), "flush message should contain conversation text");
|
|
|
|
// Options should include timeout
|
|
assert.ok(opts, "options should be passed");
|
|
assert.equal(opts.timeout, 65000);
|
|
});
|
|
|
|
it("forwards active cwd and model to flush subprocesses", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = {
|
|
sessionManager: { getBranch: () => mockBranch(4) },
|
|
cwd: "/tmp/active-project",
|
|
model: { provider: "local-llama", id: "local-9b" },
|
|
modelRegistry: {},
|
|
};
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
const [, , opts] = mockPi.execCalls[0].args;
|
|
assert.equal(opts.cwd, "/tmp/active-project");
|
|
const args = logicalChildArgs(mockPi.execCalls[0]);
|
|
assert.deepStrictEqual(args.slice(0, 4), [
|
|
"-p", "--no-session", "--model", "local-llama/local-9b",
|
|
]);
|
|
});
|
|
|
|
it("passes child LLM override args to flush subprocesses", async () => {
|
|
const config = defaultConfig({
|
|
llmModelOverride: "openrouter/deepseek/deepseek-v4-flash",
|
|
llmThinkingOverride: "low",
|
|
});
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(4) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
const args = logicalChildArgs(mockPi.execCalls[0]);
|
|
assert.deepStrictEqual(
|
|
args.slice(0, 6),
|
|
["-p", "--no-session", "--model", "openrouter/deepseek/deepseek-v4-flash", "--thinking", "low"],
|
|
);
|
|
});
|
|
|
|
it("Flush includes the full conversation by default", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
const message = flushMessage(mockPi.execCalls[0]);
|
|
assert.ok(message.includes("msg 0"), "default should include older messages");
|
|
assert.ok(message.includes("msg 7"), "default should include latest messages");
|
|
});
|
|
|
|
it("Flush limits conversation to recent messages when configured", async () => {
|
|
const config = defaultConfig({ flushRecentMessages: 3 });
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
const message = flushMessage(mockPi.execCalls[0]);
|
|
assert.ok(!message.includes("msg 4"), "window should exclude older messages");
|
|
assert.ok(message.includes("msg 5"));
|
|
assert.ok(message.includes("msg 6"));
|
|
assert.ok(message.includes("msg 7"));
|
|
});
|
|
|
|
it("Flush does not use the review recent-message limit", async () => {
|
|
const config = defaultConfig({ reviewRecentMessages: 2 });
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
const message = flushMessage(mockPi.execCalls[0]);
|
|
assert.ok(message.includes("msg 0"), "review limit must not affect flush");
|
|
});
|
|
|
|
// ── Error resilience ────────────────────────────────────────────────
|
|
|
|
it("Flush failure does NOT prevent compaction", async () => {
|
|
// Make exec throw
|
|
const failingPi = createMockPi();
|
|
failingPi.pi.exec = async () => {
|
|
throw new Error("exec failed");
|
|
};
|
|
|
|
const config = defaultConfig();
|
|
setupSessionFlush(failingPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(failingPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
|
|
// Should not throw — error is swallowed for best-effort flush
|
|
await assert.doesNotReject(async () => {
|
|
await emit(failingPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
});
|
|
});
|
|
|
|
it("Flush failure does NOT prevent shutdown", async () => {
|
|
const failingPi = createMockPi();
|
|
failingPi.pi.exec = async () => {
|
|
throw new Error("exec failed");
|
|
};
|
|
|
|
const config = defaultConfig();
|
|
setupSessionFlush(failingPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(failingPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
|
|
await assert.doesNotReject(async () => {
|
|
await emit(failingPi.handlers, "session_shutdown", {}, ctx);
|
|
});
|
|
});
|
|
|
|
// ── Edge cases ──────────────────────────────────────────────────────
|
|
|
|
it("Handles empty branch (no messages)", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => [] } };
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
// exec is still called (flush message just has no conversation lines)
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
|
|
const message = flushMessage(mockPi.execCalls[0]);
|
|
assert.ok(message.includes(FLUSH_PROMPT));
|
|
// No [USER]/[ASSISTANT] prefixes in empty conversation
|
|
assert.ok(!message.includes("[USER]"), "empty branch should have no [USER]");
|
|
});
|
|
|
|
it("Concurrent compact + shutdown both flush", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
let execCount = 0;
|
|
const originalExec = mockPi.pi.exec;
|
|
mockPi.pi.exec = async (...args: unknown[]) => {
|
|
const result = await originalExec(...args);
|
|
execCount += 1;
|
|
if (execCount === 2) resolve();
|
|
return result;
|
|
};
|
|
|
|
await Promise.all([
|
|
emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx),
|
|
emit(mockPi.handlers, "session_shutdown", {}, ctx),
|
|
]);
|
|
await promise;
|
|
assert.equal(mockPi.execCalls.length, 2, "both events should trigger flush");
|
|
});
|
|
|
|
it("Routes compact cancellation through the watchdog", async () => {
|
|
const config = defaultConfig();
|
|
setupSessionFlush(mockPi.pi, mockStore, null, config);
|
|
|
|
await emitUserTurns(mockPi.handlers, 8);
|
|
|
|
const abortController = new AbortController();
|
|
const signal = abortController.signal;
|
|
const ctx = { sessionManager: { getBranch: () => mockBranch(8) } };
|
|
|
|
await emit(mockPi.handlers, "session_before_compact", { signal }, ctx);
|
|
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
const opts = mockPi.execCalls[0].args[2];
|
|
assert.strictEqual(opts.signal, undefined, "the watchdog, not pi.exec, owns compact cancellation");
|
|
});
|
|
});
|
|
|
|
describe("direct transport", () => {
|
|
let mockPi: MockSessionFlushPi;
|
|
|
|
beforeEach(() => {
|
|
mockPi = createMockPi();
|
|
directCalls = [];
|
|
});
|
|
|
|
it("session_shutdown skips direct and subprocess flush on reload", async () => {
|
|
const config = defaultConfig({ reviewTransport: "direct" });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(
|
|
mockPi.handlers,
|
|
"session_shutdown",
|
|
{ type: "session_shutdown", reason: "reload" },
|
|
defaultFlushCtx(),
|
|
);
|
|
|
|
assert.equal(directCalls.length, 0, "reload must not use direct completion");
|
|
assert.equal(mockPi.execCalls.length, 0, "reload must not spawn a subprocess");
|
|
});
|
|
|
|
it("session_before_compact uses direct transport without subprocess when direct returns ok", async () => {
|
|
const config = defaultConfig({ reviewTransport: "direct" });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: true, appliedCount: 2 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, defaultFlushCtx());
|
|
|
|
assert.equal(directCalls.length, 1, "direct completion should run once");
|
|
assert.equal(mockPi.execCalls.length, 0, "subprocess must not run on successful direct flush");
|
|
|
|
const options = directCalls[0][3] as { systemPrompt: string; userPrompt: string };
|
|
assert.match(options.systemPrompt, /target routing/i);
|
|
assert.match(options.systemPrompt, /use target "memory"/i);
|
|
assert.match(options.systemPrompt, /do not emit target "project"/i);
|
|
assert.match(options.userPrompt, /--- Conversation ---/);
|
|
assert.match(options.userPrompt, /msg 0/);
|
|
});
|
|
|
|
it("routes project facts to project target when a project store is available", async () => {
|
|
const config = defaultConfig({ reviewTransport: "direct" });
|
|
const projectStore = { getMemoryEntries: () => ["existing project fact"] } as unknown as Parameters<typeof setupSessionFlush>[2];
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
projectStore,
|
|
config,
|
|
null,
|
|
"project-a",
|
|
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, defaultFlushCtx());
|
|
|
|
const options = directCalls[0][3] as { systemPrompt: string; userPrompt: string };
|
|
assert.match(options.systemPrompt, /project-specific facts.*target "project"/i);
|
|
assert.match(options.userPrompt, /--- Current Project Memory ---/);
|
|
});
|
|
|
|
it("falls back to subprocess with flush message shape when direct returns ok false", async () => {
|
|
const config = defaultConfig({ reviewTransport: "direct" });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: false, appliedCount: 0, fallbackReason: "no_model" }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, defaultFlushCtx());
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 1, "failed direct result must fall back to subprocess");
|
|
|
|
const message = flushMessage(mockPi.execCalls[0]);
|
|
assert.ok(message.includes(FLUSH_PROMPT), "fallback flush message should contain FLUSH_PROMPT");
|
|
assert.ok(message.includes("--- Conversation ---"));
|
|
assert.ok(message.includes("[USER]"));
|
|
assert.ok(message.includes("msg 0"));
|
|
});
|
|
|
|
it("falls back to subprocess when direct throws without propagating on compact", async () => {
|
|
const config = defaultConfig({ reviewTransport: "direct" });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps("throw"),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, defaultFlushCtx());
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 1, "thrown direct error must fall back to subprocess");
|
|
});
|
|
|
|
it("falls back to subprocess when direct throws without propagating on shutdown", async () => {
|
|
const config = defaultConfig({ reviewTransport: "direct" });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps("throw"),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emitShutdownAndAwaitFlush(mockPi, mockPi.handlers, defaultFlushCtx());
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 1, "shutdown flush must survive direct throw");
|
|
});
|
|
|
|
it("skips direct transport when reviewTransport is subprocess", async () => {
|
|
const config = defaultConfig({ reviewTransport: "subprocess" });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: true, appliedCount: 99 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, defaultFlushCtx());
|
|
|
|
assert.equal(directCalls.length, 0, "subprocess transport must not invoke direct completion");
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
});
|
|
});
|
|
|
|
describe("compact flush budget", () => {
|
|
let mockPi: MockSessionFlushPi;
|
|
|
|
beforeEach(() => {
|
|
mockPi = createMockPi();
|
|
directCalls = [];
|
|
});
|
|
|
|
it("direct ok uses the compact ceiling and does not exec or notify", async () => {
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
const config = defaultConfig({ flushCompactTimeoutMs: 120_000 });
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
config,
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
const options = directCalls[0][3];
|
|
assert.ok(options && typeof options === "object" && "timeoutMs" in options);
|
|
assert.equal(options.timeoutMs, 120_000);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("fast parse_error hands the child leftover, not a second copy of the budget", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
{
|
|
now: () => clock.t,
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
clock.t += 2_000;
|
|
return { ok: false, appliedCount: 0, fallbackReason: "parse_error" };
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
assert.equal(mockPi.execCalls[0].args[2].timeout, 58_000 + 5_000);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("internal timeout at the ceiling skips the child and notifies once", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
{
|
|
now: () => clock.t,
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
clock.t += 60_000;
|
|
return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
assert.equal(notifications.length, 1);
|
|
assert.equal(notifications[0].kind, "warning");
|
|
assert.match(notifications[0].message, /flushCompactTimeoutMs/);
|
|
assert.match(notifications[0].message, /timed out after 60000ms/);
|
|
});
|
|
|
|
it("reports the leftover, not a timeout, when the direct call leaves too little budget", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
{
|
|
now: () => clock.t,
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
clock.t += 58_000;
|
|
return { ok: false, appliedCount: 0, fallbackReason: "parse_error" };
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 0, "2s of leftover is below the spawn floor");
|
|
assert.equal(notifications.length, 1);
|
|
assert.match(notifications[0].message, /only 2000ms left of the 60000ms budget/);
|
|
assert.doesNotMatch(notifications[0].message, /timed out after/);
|
|
});
|
|
|
|
it("Esc during compact skips the child silently", async () => {
|
|
const abort = new AbortController();
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
{
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
abort.abort();
|
|
return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: abort.signal }, ctx);
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("already-aborted session signal skips without an LLM call or notify", async () => {
|
|
const abort = new AbortController();
|
|
abort.abort();
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: false, appliedCount: 0, fallbackReason: "aborted" }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: abort.signal }, ctx);
|
|
|
|
assert.equal(directCalls.length, 0);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("direct throw still falls through with leftover", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
{
|
|
now: () => clock.t,
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
clock.t += 2_000;
|
|
throw new Error("injected direct flush failure");
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
assert.equal(mockPi.execCalls[0].args[2].timeout, 58_000 + 5_000);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("non-positive compact budget skips without an LLM call and stays silent", async () => {
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ flushCompactTimeoutMs: 0 }),
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 0);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("subprocess transport takes one child at the full compact ceiling", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ reviewTransport: "subprocess" }),
|
|
null,
|
|
null,
|
|
{ ...makeDirectDeps({ ok: true, appliedCount: 99 }), now: () => clock.t },
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(directCalls.length, 0);
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
assert.equal(mockPi.execCalls[0].args[2].timeout, 60_000 + 5_000);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("compact exec throw does not reject and notifies once", async () => {
|
|
const failingPi = createMockPi();
|
|
failingPi.pi.exec = async () => {
|
|
throw new Error("exec failed");
|
|
};
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
failingPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ reviewTransport: "subprocess" }),
|
|
);
|
|
|
|
await primeFlushReady(failingPi.handlers);
|
|
await assert.doesNotReject(async () => {
|
|
await emit(failingPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
});
|
|
assert.equal(notifications.length, 1);
|
|
assert.match(notifications[0].message, /flushCompactTimeoutMs/);
|
|
});
|
|
|
|
it("default compact ceiling is 60000, not 30000", async () => {
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
makeDirectDeps({ ok: true, appliedCount: 1 }),
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: undefined }, defaultFlushCtx());
|
|
|
|
const options = directCalls[0][3];
|
|
assert.ok(options && typeof options === "object" && "timeoutMs" in options);
|
|
assert.equal(options.timeoutMs, 60_000);
|
|
});
|
|
it("child killed by the watchdog (non-zero exit) notifies once", async () => {
|
|
const failingPi = createMockPi();
|
|
failingPi.pi.exec = async () => ({ code: 124, killed: true, stdout: "", stderr: "" });
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
failingPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ reviewTransport: "subprocess" }),
|
|
);
|
|
|
|
await primeFlushReady(failingPi.handlers);
|
|
await emit(failingPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(notifications.length, 1);
|
|
assert.match(notifications[0].message, /child exited with code 124/);
|
|
assert.match(notifications[0].message, /flushCompactTimeoutMs/);
|
|
});
|
|
|
|
it("child killed with a zero exit code still notifies", async () => {
|
|
const failingPi = createMockPi();
|
|
// The signal-kill shape pi.exec resolves: Node reports no exit code for a
|
|
// signalled child, so the flag is the only evidence of the kill.
|
|
failingPi.pi.exec = async () => ({ code: 0, killed: true, stdout: "", stderr: "" });
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
failingPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ reviewTransport: "subprocess" }),
|
|
);
|
|
|
|
await primeFlushReady(failingPi.handlers);
|
|
await emit(failingPi.handlers, "session_before_compact", { signal: undefined }, ctx);
|
|
|
|
assert.equal(notifications.length, 1);
|
|
assert.match(notifications[0].message, /child was killed before it saved/);
|
|
assert.match(notifications[0].message, /flushCompactTimeoutMs/);
|
|
});
|
|
|
|
it("direct receives the linked budget signal, not the raw session signal", async () => {
|
|
const { ctx } = flushCtxWithNotify();
|
|
const sessionController = new AbortController();
|
|
const seen: AbortSignal[] = [];
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig(),
|
|
null,
|
|
null,
|
|
{
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
const options = args[3] as { signal?: AbortSignal };
|
|
const linked = options.signal;
|
|
seen.push(linked as AbortSignal);
|
|
const { promise, resolve } = Promise.withResolvers<{ ok: boolean; appliedCount: number; fallbackReason: string }>();
|
|
linked?.addEventListener("abort", () => resolve({ ok: false, appliedCount: 0, fallbackReason: "aborted" }), { once: true });
|
|
sessionController.abort(); // synchronous dispatch forwards through linkBudget
|
|
return promise;
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_before_compact", { signal: sessionController.signal }, ctx);
|
|
|
|
assert.equal(seen.length, 1);
|
|
assert.notEqual(seen[0], sessionController.signal);
|
|
assert.equal(seen[0].aborted, true);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
});
|
|
});
|
|
|
|
|
|
describe("shutdown flush budget", () => {
|
|
let mockPi: MockSessionFlushPi;
|
|
|
|
beforeEach(() => {
|
|
mockPi = createMockPi();
|
|
directCalls = [];
|
|
});
|
|
|
|
it("uses 10s independent of flushCompactTimeoutMs and does not notify", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({
|
|
flushCompactTimeoutMs: 120_000,
|
|
reviewTransport: "subprocess",
|
|
}),
|
|
null,
|
|
null,
|
|
{ now: () => clock.t },
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emitShutdownAndAwaitFlush(mockPi, mockPi.handlers, ctx);
|
|
assert.equal(mockPi.execCalls.length, 1);
|
|
assert.equal(mockPi.execCalls[0].args[2].timeout, 10_000 + 5_000);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("does not double-pay after a spent shutdown budget and stays silent", async () => {
|
|
const clock = { t: 0 };
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
mockPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ flushCompactTimeoutMs: 120_000 }),
|
|
null,
|
|
null,
|
|
{
|
|
now: () => clock.t,
|
|
runDirectMemoryCompletion: async (...args: unknown[]) => {
|
|
directCalls.push(args);
|
|
clock.t += 10_000;
|
|
return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
|
|
},
|
|
},
|
|
);
|
|
|
|
await primeFlushReady(mockPi.handlers);
|
|
await emit(mockPi.handlers, "session_shutdown", { type: "session_shutdown", reason: "quit" }, ctx);
|
|
assert.equal(directCalls.length, 1);
|
|
assert.equal(mockPi.execCalls.length, 0);
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
|
|
it("exec throw on shutdown does not notify", async () => {
|
|
const failingPi = createMockPi();
|
|
failingPi.pi.exec = async () => {
|
|
throw new Error("exec failed");
|
|
};
|
|
const { ctx, notifications } = flushCtxWithNotify();
|
|
setupSessionFlush(
|
|
failingPi.pi,
|
|
mockStore,
|
|
null,
|
|
defaultConfig({ reviewTransport: "subprocess" }),
|
|
);
|
|
|
|
await primeFlushReady(failingPi.handlers);
|
|
await assert.doesNotReject(async () => {
|
|
await emit(failingPi.handlers, "session_shutdown", {}, ctx);
|
|
});
|
|
assert.equal(notifications.length, 0);
|
|
});
|
|
});
|
|
|