2ae1a042d8
* 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>
382 lines
12 KiB
TypeScript
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]...");
|
|
});
|
|
});
|