Files
oh-my-pi/packages/coding-agent/test/memories-runtime.test.ts
T
汐 2ae1a042d8 feat(utils): full XDG Base Directory support for all path helpers (#407)
* feat(utils): full XDG Base Directory support for all path helpers

Implement XDG-first resolution across all omp path helpers, extend the
migration command to cover every data/state/cache location, and fix
five data-safety issues found in review.

dirs.ts:
- Add getXdgCachePath() helper ($XDG_CACHE_HOME/omp/<subpath>)
- Add isDefaultAgentDir() helper: XDG lookup is only valid when the
  resolved agentDir equals the process default (~/.omp/agent); custom
  profiles set via PI_CODING_AGENT_DIR or setAgentDir() are never
  silently redirected to the global XDG database
- Update 15 functions to XDG-first resolution:
    data:  getPluginsDir, getRemoteDir, getRemoteHostDir, getPythonEnvDir,
           getWorktreeBaseDir
    state: getReportsDir, getSshControlDir, getCrashLogPath, getDebugLogPath
    cache: getPuppeteerDir, getGpuCachePath, getNativesDir
- Guard XDG lookup with isDefaultAgentDir(agentDir ?? getAgentDir()) in
  all 9 agent-subdir helpers so that callers passing the global default
  agentDir still resolve to the migrated XDG location, while callers
  passing a non-default agentDir or running under a custom profile via
  setAgentDir() bypass XDG entirely
- Plugin-derived helpers delegate to getPluginsDir() and follow XDG
  resolution automatically

migrate-xdg.ts:
- Add getXdgCacheHome() helper
- Extend MigrationItem.category to include 'cache'
- Add 12 new migration entries: reports, plugins, remote, ssh-control,
  remote-host, python-env, puppeteer, wt, gpu_cache.json, natives,
  omp-crash.log, omp-debug.log
- Refuse to run when PI_CODING_AGENT_DIR points to a non-default
  profile: migration only makes sense for the default ~/.omp/agent tree
- copyDirectory returns skipped source paths (target existed, non-force)
- verifyIntegrity: remove size-mismatch early-return that masked stale
  targets as successful copies
- executeMigration: delete only entries that were actually copied;
  use rmdir on source dir so it is removed only when empty, preserving
  any skipped files for a subsequent --force run
- executeMigration: rename partial target to <target>.bak on integrity
  failure instead of deleting; preserves pre-existing user data while
  preventing getXdgDataPath from treating the partial tree as
  authoritative; source remains intact for re-copy on next run

test isolation:
- Set XDG_DATA_HOME/XDG_STATE_HOME to non-existent paths in
  memories-runtime.test.ts beforeEach/afterEach to prevent
  getXdgDataPath/getXdgStatePath from resolving to real user data

* fix(utils,coding-agent): fix XDG support issues

dirs.ts:
- Refactor path resolution into DirResolver class. XDG base dirs are
  resolved once at construction from env vars (Linux only, no
  existsSync). setAgentDir creates a fresh instance, naturally
  invalidating all cached paths and recomputing isDefaultProfile.
- getRootSubdir/agentSubdir accept optional XdgCategory parameter;
  when set, the XDG base replaces the config root. Every accessor
  is a one-liner delegate.
- Non-Linux platforms: XDG fields are null, zero overhead. No
  filesystem probing, no string comparisons on the hot path.
- Config-only subdirs (themes, tools, commands, prompts, modules)
  have no XDG category — they stay under the config root.
- Remove `import { env } from 'bun'`, use process.env consistently.
- Restore JSDoc comments to document actual defaults (~/.omp/...).

migrate-xdg.ts:
- Gate migrateToXdg on Linux — exits with clear error on other
  platforms.
- Fix data loss bug: verifyIntegrity now accepts a Set of skipped
  paths and skips verification for files that were intentionally not
  copied (pre-existing at target in non-force mode).
- Fix nested directory source deletion: recursive removeSourceEntries
  walks the tree and only deletes files not in the skipped set.
- Remove dead _sourcePath variable and unused force parameter from
  verifyIntegrity.
- Remove `import { env } from 'bun'`, use process.env consistently.

logger.ts:
- Revert JSDoc to document ~/.omp/logs/ as default.

oauth.ts:
- Replace direct getAgentSubdir call with getTestAuthPath().

CHANGELOG.md:
- Merge duplicate section headers under [Unreleased].
- Add missing blank line before [13.11.1].

---------

Co-authored-by: can1357 <me@can.ac>
2026-03-14 14:52:12 +01:00

382 lines
12 KiB
TypeScript

import { afterEach, 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 type { Model } from "@oh-my-pi/pi-ai";
import * as ai 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 } from "@oh-my-pi/pi-utils";
interface SessionFixture {
agentDir: string;
sessionDir: string;
sessionFile: string;
settings: Settings;
session: any;
modelRegistry: any;
model: Model;
}
const createdDirs = new Set<string>();
async function makeTempDir(prefix: string): Promise<string> {
const dir = path.join(os.tmpdir(), `${prefix}-${Snowflake.next()}`);
await fs.mkdir(dir, { recursive: true });
createdDirs.add(dir);
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"),
};
}
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 refreshBaseSystemPrompt = vi.fn(async () => undefined);
const session = {
sessionManager: {
getSessionFile: () => sessionFile,
getSessionDir: () => sessionDir,
getSessionId: () => "current-thread",
getCwd: () => agentDir,
},
settings,
model,
modelRegistry,
refreshBaseSystemPrompt,
};
return { agentDir, sessionDir, sessionFile, settings, session, modelRegistry, model };
}
async function waitFor(assertion: () => Promise<void> | void, timeoutMs = 3000): Promise<void> {
const start = Date.now();
let lastError: unknown;
while (Date.now() - start < timeoutMs) {
try {
await assertion();
return;
} catch (error) {
lastError = error;
}
await Bun.sleep(20);
}
throw lastError;
}
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;
for (const dir of createdDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
createdDirs.clear();
});
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 Bun.sleep(50);
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`);
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 waitFor(async () => {
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);
});
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 waitFor(async () => {
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 waitFor(async () => {
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;
for (const dir of createdDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
createdDirs.clear();
});
test("returns undefined for missing or empty summaries", async () => {
const agentDir = await makeTempDir("memories-runtime-instructions");
const settings = Settings.isolated({ "memories.enabled": true });
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({
"memories.enabled": true,
"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]...");
});
});