9478e3cc5c
- Replaced usage of `ReturnType<typeof setTimeout>` and `ReturnType<typeof setInterval>` with the explicit `Timer` type across the codebase. - Updated several type definitions and function signatures to use concrete types instead of inferred return types for improved clarity and maintainability.
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import * as ai from "@oh-my-pi/pi-ai";
|
|
import { Effort, type Model } from "@oh-my-pi/pi-ai";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import {
|
|
buildMemoryToolDeveloperInstructions,
|
|
getMemoryRoot,
|
|
startMemoryStartupTask,
|
|
} from "@oh-my-pi/pi-coding-agent/memories";
|
|
import * as memoryStorage from "@oh-my-pi/pi-coding-agent/memories/storage";
|
|
import { getAgentDbPath, Snowflake, TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
interface SessionFixture {
|
|
agentDir: string;
|
|
sessionDir: string;
|
|
sessionFile: string;
|
|
settings: Settings;
|
|
session: any;
|
|
modelRegistry: any;
|
|
model: Model;
|
|
whenSettled: Promise<void>;
|
|
}
|
|
|
|
let sharedRoot: TempDir | undefined;
|
|
|
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>(res => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
async function makeTempDir(prefix: string): Promise<string> {
|
|
const base = sharedRoot?.path() ?? os.tmpdir();
|
|
const dir = path.join(base, `${prefix}-${Snowflake.next()}`);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function createModel(id = "test-model"): Model {
|
|
return {
|
|
provider: "openai",
|
|
id,
|
|
name: id,
|
|
contextWindow: 32_000,
|
|
} as Model;
|
|
}
|
|
|
|
function createModelRegistry(model: Model): any {
|
|
return {
|
|
find: vi.fn(() => model),
|
|
getAll: vi.fn(() => [model]),
|
|
getApiKey: vi.fn(async () => "test-api-key"),
|
|
resolver: vi.fn(() => async () => "test-api-key"),
|
|
};
|
|
}
|
|
|
|
async function createFixture(overrides?: Partial<Record<string, unknown>>): Promise<SessionFixture> {
|
|
const agentDir = await makeTempDir("memories-runtime-agent");
|
|
const sessionDir = path.join(agentDir, "sessions");
|
|
await fs.mkdir(sessionDir, { recursive: true });
|
|
const sessionFile = path.join(sessionDir, "current-session.jsonl");
|
|
await fs.writeFile(sessionFile, `${JSON.stringify({ type: "session", id: "current-thread", cwd: agentDir })}\n`);
|
|
|
|
const settings = Settings.isolated({
|
|
"memories.enabled": true,
|
|
"memories.minRolloutIdleHours": 0,
|
|
"memories.maxRolloutsPerStartup": 16,
|
|
"memories.threadScanLimit": 64,
|
|
"memories.phase2HeartbeatSeconds": 1,
|
|
...(overrides ?? {}),
|
|
});
|
|
const model = createModel();
|
|
const modelRegistry = createModelRegistry(model);
|
|
const settled = deferred();
|
|
const refreshBaseSystemPrompt = vi.fn(async () => {
|
|
settled.resolve();
|
|
});
|
|
const session = {
|
|
sessionManager: {
|
|
getSessionFile: () => sessionFile,
|
|
getSessionDir: () => sessionDir,
|
|
getSessionId: () => "current-thread",
|
|
getCwd: () => agentDir,
|
|
},
|
|
settings,
|
|
model,
|
|
modelRegistry,
|
|
refreshBaseSystemPrompt,
|
|
};
|
|
|
|
return { agentDir, sessionDir, sessionFile, settings, session, modelRegistry, model, whenSettled: settled.promise };
|
|
}
|
|
|
|
// Resolve any already-scheduled microtasks/macrotasks without a fixed wall delay.
|
|
const flushAsync = (): Promise<void> => new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
|
|
// Await the pipeline's completion signal (its final `refreshBaseSystemPrompt`)
|
|
// instead of polling, racing a generous timeout so a stalled regression fails
|
|
// loudly rather than hanging.
|
|
async function settle(promise: Promise<void>, label: string, timeoutMs = 3000): Promise<void> {
|
|
let timer: Timer | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs);
|
|
});
|
|
try {
|
|
await Promise.race([promise, timeout]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
sharedRoot = await TempDir.create(`memories-runtime-${Snowflake.next()}`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (sharedRoot) {
|
|
await Bun.sleep(0);
|
|
await sharedRoot.remove();
|
|
}
|
|
sharedRoot = undefined;
|
|
});
|
|
|
|
describe("memories runtime", () => {
|
|
let savedXdgData: string | undefined;
|
|
let savedXdgState: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.restoreAllMocks();
|
|
// Prevent getXdgDataPath/getXdgStatePath from resolving to real user data
|
|
savedXdgData = process.env.XDG_DATA_HOME;
|
|
savedXdgState = process.env.XDG_STATE_HOME;
|
|
process.env.XDG_DATA_HOME = "/nonexistent-xdg-data";
|
|
process.env.XDG_STATE_HOME = "/nonexistent-xdg-state";
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
process.env.XDG_DATA_HOME = savedXdgData;
|
|
process.env.XDG_STATE_HOME = savedXdgState;
|
|
});
|
|
|
|
test("startup gating skips when disabled or subagent depth", async () => {
|
|
const disabled = await createFixture({ "memories.enabled": false });
|
|
const openSpy = vi.spyOn(memoryStorage, "openMemoryDb");
|
|
startMemoryStartupTask({
|
|
session: disabled.session,
|
|
settings: disabled.settings,
|
|
modelRegistry: disabled.modelRegistry,
|
|
agentDir: disabled.agentDir,
|
|
taskDepth: 0,
|
|
});
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
|
|
const subagent = await createFixture({ "memories.enabled": true });
|
|
startMemoryStartupTask({
|
|
session: subagent.session,
|
|
settings: subagent.settings,
|
|
modelRegistry: subagent.modelRegistry,
|
|
agentDir: subagent.agentDir,
|
|
taskDepth: 1,
|
|
});
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("startup gating skips when DB is unavailable", async () => {
|
|
const fx = await createFixture();
|
|
vi.spyOn(memoryStorage, "openMemoryDb").mockImplementation(() => {
|
|
throw new Error("db unavailable");
|
|
});
|
|
const stage1Spy = vi.spyOn(ai, "completeSimple");
|
|
|
|
startMemoryStartupTask({
|
|
session: fx.session,
|
|
settings: fx.settings,
|
|
modelRegistry: fx.modelRegistry,
|
|
agentDir: fx.agentDir,
|
|
taskDepth: 0,
|
|
});
|
|
|
|
await flushAsync();
|
|
expect(stage1Spy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("runs phase1 to phase2 and writes consolidated outputs", async () => {
|
|
const fx = await createFixture();
|
|
const rolloutPath = path.join(fx.sessionDir, "thread-a.jsonl");
|
|
const rolloutRows = [
|
|
{ type: "session", id: "thread-a", cwd: fx.agentDir },
|
|
{ type: "message", message: { role: "user", content: "summarize this rollout" } },
|
|
];
|
|
await fs.writeFile(rolloutPath, `${rolloutRows.map(row => JSON.stringify(row)).join("\n")}\n`);
|
|
|
|
const completeSpy = vi
|
|
.spyOn(ai, "completeSimple")
|
|
.mockResolvedValueOnce({
|
|
stopReason: "end_turn",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
rollout_summary: "Rollout summary A",
|
|
rollout_slug: "thread-a-rollout",
|
|
raw_memory: "Raw memory A",
|
|
}),
|
|
},
|
|
],
|
|
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 },
|
|
} as any)
|
|
.mockResolvedValueOnce({
|
|
stopReason: "end_turn",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
memory_md: "# Memory\n\nConsolidated body",
|
|
memory_summary: "Consolidated summary",
|
|
skills: [{ name: "deploy-playbook", content: "# Deploy\nUse blue/green." }],
|
|
}),
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
startMemoryStartupTask({
|
|
session: fx.session,
|
|
settings: fx.settings,
|
|
modelRegistry: fx.modelRegistry,
|
|
agentDir: fx.agentDir,
|
|
taskDepth: 0,
|
|
});
|
|
|
|
const memoryRoot = getMemoryRoot(fx.agentDir, fx.session.sessionManager.getCwd());
|
|
await settle(fx.whenSettled, "phase1->phase2 pipeline");
|
|
expect((await fs.readFile(path.join(memoryRoot, "MEMORY.md"), "utf8")).trim()).toBe(
|
|
"# Memory\n\nConsolidated body",
|
|
);
|
|
expect((await fs.readFile(path.join(memoryRoot, "memory_summary.md"), "utf8")).trim()).toBe(
|
|
"Consolidated summary",
|
|
);
|
|
expect((await fs.readFile(path.join(memoryRoot, "skills", "deploy-playbook", "SKILL.md"), "utf8")).trim()).toBe(
|
|
"# Deploy\nUse blue/green.",
|
|
);
|
|
expect(fx.session.refreshBaseSystemPrompt).toHaveBeenCalledTimes(1);
|
|
expect(ai.completeSimple).toHaveBeenCalled();
|
|
expect(ai.completeSimple).toHaveBeenCalledTimes(2);
|
|
const phase2Prompt = completeSpy.mock.calls[1]?.[1];
|
|
expect(phase2Prompt?.systemPrompt?.[0]).toContain("memory-stage-two consolidator");
|
|
});
|
|
|
|
test("clamps stage1 and phase2 reasoning effort against the model's supported range", async () => {
|
|
// Regression for #1480: memory pipeline hardcoded `Effort.Low`/`Effort.Medium`,
|
|
// which `requireSupportedEffort` rejects on models whose supported range starts
|
|
// above `low` (e.g. deepseek-v4-pro → [high, xhigh]). The fix routes both call
|
|
// sites through `clampThinkingLevelForModel`, lifting the requested effort to
|
|
// the model's floor instead of throwing.
|
|
const fx = await createFixture();
|
|
const constrainedModel: Model = {
|
|
...fx.model,
|
|
reasoning: true,
|
|
thinking: { mode: "effort", efforts: [Effort.High, Effort.XHigh] },
|
|
};
|
|
fx.session.model = constrainedModel;
|
|
fx.modelRegistry.find = vi.fn(() => constrainedModel);
|
|
fx.modelRegistry.getAll = vi.fn(() => [constrainedModel]);
|
|
|
|
const rolloutPath = path.join(fx.sessionDir, "thread-constrained.jsonl");
|
|
const rolloutRows = [
|
|
{ type: "session", id: "thread-constrained", cwd: fx.agentDir },
|
|
{ type: "message", message: { role: "user", content: "summarize this rollout" } },
|
|
];
|
|
await fs.writeFile(rolloutPath, `${rolloutRows.map(row => JSON.stringify(row)).join("\n")}\n`);
|
|
|
|
const spy = vi
|
|
.spyOn(ai, "completeSimple")
|
|
.mockResolvedValueOnce({
|
|
stopReason: "end_turn",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
rollout_summary: "Rollout summary",
|
|
rollout_slug: "thread-constrained",
|
|
raw_memory: "Raw memory",
|
|
}),
|
|
},
|
|
],
|
|
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2 },
|
|
} as any)
|
|
.mockResolvedValueOnce({
|
|
stopReason: "end_turn",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
memory_md: "# Memory\n\nBody",
|
|
memory_summary: "Summary",
|
|
skills: [],
|
|
}),
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
startMemoryStartupTask({
|
|
session: fx.session,
|
|
settings: fx.settings,
|
|
modelRegistry: fx.modelRegistry,
|
|
agentDir: fx.agentDir,
|
|
taskDepth: 0,
|
|
});
|
|
|
|
const memoryRoot = getMemoryRoot(fx.agentDir, fx.session.sessionManager.getCwd());
|
|
await settle(fx.whenSettled, "effort-clamp pipeline");
|
|
expect((await fs.readFile(path.join(memoryRoot, "MEMORY.md"), "utf8")).trim()).toBe("# Memory\n\nBody");
|
|
|
|
expect(spy).toHaveBeenCalledTimes(2);
|
|
// stage1 requested `low`, phase2 requested `medium`; both must clamp up to the
|
|
// model's floor (`high`) instead of being passed through and throwing.
|
|
expect(spy.mock.calls[0]?.[2]?.reasoning).toBe(Effort.High);
|
|
expect(spy.mock.calls[1]?.[2]?.reasoning).toBe(Effort.High);
|
|
});
|
|
|
|
test("phase2 sync prunes stale summaries and preserves raw memory ordering", async () => {
|
|
const fx = await createFixture();
|
|
vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
|
stopReason: "end_turn",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
memory_md: "# Memory\n\nMerged",
|
|
memory_summary: "Merged summary",
|
|
skills: [{ name: "ops", content: "# Ops\nRunbook" }],
|
|
}),
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
const db = memoryStorage.openMemoryDb(getAgentDbPath(fx.agentDir));
|
|
memoryStorage.upsertThreads(db, [
|
|
{
|
|
id: "thread-a",
|
|
updatedAt: 100,
|
|
rolloutPath: "/tmp/a.jsonl",
|
|
cwd: fx.session.sessionManager.getCwd(),
|
|
sourceKind: "cli",
|
|
},
|
|
{
|
|
id: "thread-b",
|
|
updatedAt: 200,
|
|
rolloutPath: "/tmp/b.jsonl",
|
|
cwd: fx.session.sessionManager.getCwd(),
|
|
sourceKind: "cli",
|
|
},
|
|
]);
|
|
db.prepare(
|
|
"INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, rollout_slug, generated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
).run("thread-a", 100, "raw-a", "summary-a", "alpha", 100);
|
|
db.prepare(
|
|
"INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, rollout_slug, generated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
).run("thread-b", 200, "raw-b", "summary-b", "beta", 200);
|
|
memoryStorage.enqueueGlobalWatermark(db, 200, fx.session.sessionManager.getCwd(), {
|
|
forceDirtyWhenNotAdvanced: true,
|
|
});
|
|
memoryStorage.closeMemoryDb(db);
|
|
|
|
const memoryRoot = getMemoryRoot(fx.agentDir, fx.session.sessionManager.getCwd());
|
|
await fs.mkdir(path.join(memoryRoot, "rollout_summaries"), { recursive: true });
|
|
await fs.writeFile(path.join(memoryRoot, "rollout_summaries", "old.md"), "stale");
|
|
|
|
startMemoryStartupTask({
|
|
session: fx.session,
|
|
settings: fx.settings,
|
|
modelRegistry: fx.modelRegistry,
|
|
agentDir: fx.agentDir,
|
|
taskDepth: 0,
|
|
});
|
|
|
|
await settle(fx.whenSettled, "phase2 sync/prune");
|
|
const files = await fs.readdir(path.join(memoryRoot, "rollout_summaries"));
|
|
expect(files.includes("old.md")).toBe(false);
|
|
expect(files).toEqual(expect.arrayContaining(["thread-a-alpha.md", "thread-b-beta.md"]));
|
|
const raw = await fs.readFile(path.join(memoryRoot, "raw_memories.md"), "utf8");
|
|
expect(raw.indexOf("## thread-b")).toBeLessThan(raw.indexOf("## thread-a"));
|
|
});
|
|
|
|
test("phase2 empty-input cleanup removes consolidated files and skills dir", async () => {
|
|
const fx = await createFixture();
|
|
const memoryRoot = getMemoryRoot(fx.agentDir, fx.session.sessionManager.getCwd());
|
|
await fs.mkdir(path.join(memoryRoot, "skills", "legacy"), { recursive: true });
|
|
await fs.writeFile(path.join(memoryRoot, "MEMORY.md"), "legacy memory");
|
|
await fs.writeFile(path.join(memoryRoot, "memory_summary.md"), "legacy summary");
|
|
await fs.writeFile(path.join(memoryRoot, "skills", "legacy", "SKILL.md"), "legacy skill");
|
|
|
|
const db = memoryStorage.openMemoryDb(getAgentDbPath(fx.agentDir));
|
|
memoryStorage.enqueueGlobalWatermark(db, 300, fx.session.sessionManager.getCwd(), {
|
|
forceDirtyWhenNotAdvanced: true,
|
|
});
|
|
memoryStorage.closeMemoryDb(db);
|
|
|
|
startMemoryStartupTask({
|
|
session: fx.session,
|
|
settings: fx.settings,
|
|
modelRegistry: fx.modelRegistry,
|
|
agentDir: fx.agentDir,
|
|
taskDepth: 0,
|
|
});
|
|
|
|
await settle(fx.whenSettled, "phase2 empty-input cleanup");
|
|
expect(await Bun.file(path.join(memoryRoot, "MEMORY.md")).exists()).toBe(false);
|
|
expect(await Bun.file(path.join(memoryRoot, "memory_summary.md")).exists()).toBe(false);
|
|
expect(await Bun.file(path.join(memoryRoot, "skills")).exists()).toBe(false);
|
|
expect((await fs.readFile(path.join(memoryRoot, "raw_memories.md"), "utf8")).trim()).toBe(
|
|
"# Raw Memories\n\nNo raw memories yet.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildMemoryToolDeveloperInstructions", () => {
|
|
let savedXdgData: string | undefined;
|
|
let savedXdgState: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
savedXdgData = process.env.XDG_DATA_HOME;
|
|
savedXdgState = process.env.XDG_STATE_HOME;
|
|
process.env.XDG_DATA_HOME = "/nonexistent-xdg-data";
|
|
process.env.XDG_STATE_HOME = "/nonexistent-xdg-state";
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
process.env.XDG_DATA_HOME = savedXdgData;
|
|
process.env.XDG_STATE_HOME = savedXdgState;
|
|
});
|
|
|
|
test("returns undefined for missing or empty summaries", async () => {
|
|
const agentDir = await makeTempDir("memories-runtime-instructions");
|
|
const settings = Settings.isolated({ "memory.backend": "local" });
|
|
|
|
expect(await buildMemoryToolDeveloperInstructions(agentDir, settings)).toBeUndefined();
|
|
|
|
const memoryRoot = getMemoryRoot(agentDir, settings.getCwd());
|
|
await fs.mkdir(memoryRoot, { recursive: true });
|
|
await fs.writeFile(path.join(memoryRoot, "memory_summary.md"), " \n\t\n");
|
|
expect(await buildMemoryToolDeveloperInstructions(agentDir, settings)).toBeUndefined();
|
|
});
|
|
|
|
test("renders payload with truncation for non-empty summary", async () => {
|
|
const agentDir = await makeTempDir("memories-runtime-instructions");
|
|
const settings = Settings.isolated({
|
|
"memory.backend": "local",
|
|
"memories.summaryInjectionTokenLimit": 8,
|
|
});
|
|
const memoryRoot = getMemoryRoot(agentDir, settings.getCwd());
|
|
await fs.mkdir(memoryRoot, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(memoryRoot, "memory_summary.md"),
|
|
`${"A".repeat(120)}\n${"B".repeat(120)}\n${"C".repeat(120)}`,
|
|
);
|
|
|
|
const payload = await buildMemoryToolDeveloperInstructions(agentDir, settings);
|
|
expect(payload).toBeDefined();
|
|
expect(payload).toContain("memory://root/memory_summary.md");
|
|
expect(payload).not.toContain(memoryRoot);
|
|
expect(payload).toContain("...[truncated]...");
|
|
});
|
|
});
|