const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const Module = require("node:module"); const DATA_DIR_MODULE = path.resolve(__dirname, "../electron/lib/codex_data_dir.js"); const SESSIONS_MODULE = path.resolve(__dirname, "../electron/lib/sessions.js"); function loadModules(userData) { const originalLoad = Module._load; Module._load = function (request, parent, isMain) { if (request === "electron") return { app: { getPath: () => userData } }; return originalLoad.call(this, request, parent, isMain); }; delete require.cache[DATA_DIR_MODULE]; delete require.cache[SESSIONS_MODULE]; try { return { dataDir: require(DATA_DIR_MODULE), sessions: require(SESSIONS_MODULE) }; } finally { Module._load = originalLoad; } } function writeRollout(root, id, title) { const dir = path.join(root, "sessions", "2026", "08", "06"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [ JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "/work/demo", timestamp: "2026-08-06T10:00:00.000Z" } }), JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: title } }), ].join("\n") + "\n"); } test("会话数据目录切换后列表读取新目录,配置可持久化", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-")); const userData = path.join(root, "user-data"); const first = path.join(root, "first-codex"); const second = path.join(root, "second-codex"); fs.mkdirSync(userData, { recursive: true }); writeRollout(first, "11111111-1111-4111-8111-111111111111", "第一份记录"); writeRollout(second, "22222222-2222-4222-8222-222222222222", "第二份记录"); try { let { dataDir, sessions } = loadModules(userData); assert.throws(() => dataDir.set(path.join(root, "not-codex")), /Codex 数据/); assert.equal(dataDir.set(first).path, first); assert.equal(sessions.getCodexHome(), first); assert.deepEqual(sessions.listConversations().map((row) => row.title), ["第一份记录"]); assert.equal(dataDir.set(second).path, second); assert.equal(sessions.getCodexHome(), second); assert.deepEqual(sessions.listConversations().map((row) => row.title), ["第二份记录"]); ({ dataDir, sessions } = loadModules(userData)); assert.equal(dataDir.info().path, second); assert.deepEqual(sessions.listConversations().map((row) => row.title), ["第二份记录"]); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test("会话列表默认限量,避免大量历史记录一次性阻塞 IPC", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-")); const userData = path.join(root, "user-data"); const dataDir = path.join(root, "many-codex"); fs.mkdirSync(userData, { recursive: true }); for (let i = 0; i < 520; i++) { const id = `${String(i).padStart(8, "0")}-1111-4111-8111-111111111111`; writeRollout(dataDir, id, `第 ${i} 份记录`); } try { const { dataDir: codexDir, sessions } = loadModules(userData); codexDir.set(dataDir); assert.equal(sessions.listConversations().length, 500); assert.equal(sessions.listConversations({ limit: 12 }).length, 12); assert.equal(sessions.listConversations({ limit: 9999 }).length, 520); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test("subagent 同时含 id/session_id 时使用自身 id,不误绑定父线程", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-")); const userData = path.join(root, "user-data"); const dataDir = path.join(root, "subagent-codex"); const parentId = "33333333-3333-4333-8333-333333333333"; const childId = "44444444-4444-4444-8444-444444444444"; const dir = path.join(dataDir, "sessions", "2026", "08", "17"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `rollout-${childId}.jsonl`), [ JSON.stringify({ type: "session_meta", payload: { id: childId, session_id: parentId, cwd: "/work/subagent", timestamp: "2026-08-17T10:00:00.000Z", thread_source: "subagent", }, }), JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "子任务" } }), ].join("\n") + "\n"); try { const { dataDir: codexDir, sessions } = loadModules(userData); codexDir.set(dataDir); const items = sessions.listConversations({ includeSubagent: true, limit: 10 }); assert.equal(items.length, 1); assert.equal(items[0].id, childId); const conversation = sessions.readConversation(items[0].path); assert.equal(conversation.meta.id, childId); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test("一键清空会话会清理正文、索引、状态库和侧栏目录并保留备份", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-clear-sessions-")); const userData = path.join(root, "user-data"); const dataDir = path.join(root, "codex"); const id = "55555555-5555-4555-8555-555555555555"; fs.mkdirSync(userData, { recursive: true }); writeRollout(dataDir, id, "待清除会话"); fs.mkdirSync(path.join(dataDir, "archived_sessions", "2026", "08"), { recursive: true }); fs.writeFileSync(path.join(dataDir, "archived_sessions", "2026", "08", `rollout-${id}.jsonl`), "archived\n"); fs.writeFileSync(path.join(dataDir, "session_index.jsonl"), JSON.stringify({ id, thread_name: "待清除会话" }) + "\n"); const SQL = require("sql.js"); const init = await SQL({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); const state = new init.Database(); state.run("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)"); state.run("CREATE TABLE thread_artifacts (thread_id TEXT, payload TEXT)"); state.run("INSERT INTO threads VALUES (?, ?)", [id, "待清除会话"]); state.run("INSERT INTO thread_artifacts VALUES (?, ?)", [id, "artifact"]); fs.writeFileSync(path.join(dataDir, "state_5.sqlite"), Buffer.from(state.export())); state.close(); fs.mkdirSync(path.join(dataDir, "sqlite"), { recursive: true }); const catalog = new init.Database(); catalog.run("CREATE TABLE local_thread_catalog (thread_id TEXT, display_title TEXT)"); catalog.run("CREATE TABLE automations (id TEXT PRIMARY KEY, name TEXT)"); catalog.run("INSERT INTO local_thread_catalog VALUES (?, ?)", [id, "待清除会话"]); catalog.run("INSERT INTO automations VALUES (?, ?)", ["keep", "保留自动化"]); fs.writeFileSync(path.join(dataDir, "sqlite", "codex-dev.db"), Buffer.from(catalog.export())); catalog.close(); try { const { dataDir: codexDir, sessions } = loadModules(userData); codexDir.set(dataDir); const result = await sessions.clearAllConversations(); assert.equal(result.filesRemoved, 2); assert.equal(sessions.listConversations({ includeArchived: true, includeSubagent: true }).length, 0); assert.equal(fs.readFileSync(path.join(dataDir, "session_index.jsonl"), "utf-8"), ""); assert.equal(fs.existsSync(result.backup), true); const stateAfter = new init.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite"))); assert.equal(stateAfter.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 0); assert.equal(stateAfter.exec("SELECT COUNT(*) FROM thread_artifacts")[0].values[0][0], 0); stateAfter.close(); const catalogAfter = new init.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); assert.equal(catalogAfter.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 0); assert.equal(catalogAfter.exec("SELECT COUNT(*) FROM automations")[0].values[0][0], 1); catalogAfter.close(); } finally { fs.rmSync(root, { recursive: true, force: true }); } });