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 childProcess = require("node:child_process"); const initSqlJs = require("sql.js"); const PROVIDER_MODULE = path.resolve(__dirname, "../electron/lib/provider_sync.js"); const DESKTOP_MODULE = path.resolve(__dirname, "../electron/lib/codex_desktop.js"); function writeRollout(root, id) { const dir = path.join(root, "sessions", "2026", "08", "09"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [ JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "C:/work/demo", timestamp: "2026-08-09T10:00:00.000Z", model_provider: "old" } }), JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "Windows restore test" } }), ].join("\n") + "\n"); } // subagent 会话的 session_meta.payload.source 是对象({subagent:{thread_spawn:{...}}}), // 不是字符串。曾导致 Windows(无 sqlite3 CLI,走 sql.js 绑定)整批 upsert 抛错回滚, // state 库 0 线程而侧栏 catalog 正常,Codex 启动 full reconcile 后会话"闪现后全部消失"。 function writeSubagentRollout(root, id, parentId) { const dir = path.join(root, "sessions", "2026", "08", "09"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [ JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "C:/work/demo", timestamp: "2026-08-09T11:00:00.000Z", model_provider: "old", source: { subagent: { thread_spawn: { parent_thread_id: parentId, depth: 1, agent_path: null, agent_nickname: "Dirac", agent_role: null } } }, thread_source: "subagent", }, }), JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "subagent task" } }), ].join("\n") + "\n"); } async function writeLegacyPollutedState(root, id, subId) { const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); const state = new SQL.Database(); state.run("CREATE TABLE threads (id TEXT PRIMARY KEY, source TEXT, thread_source TEXT, has_user_event INTEGER)"); state.run("INSERT INTO threads VALUES (?,?,?,?)", [id, "vscode", "user", 1]); state.run("INSERT INTO threads VALUES (?,?,?,?)", [subId, "[object Object]", "subagent", 1]); fs.writeFileSync(path.join(root, "state_5.sqlite"), Buffer.from(state.export())); state.close(); fs.mkdirSync(path.join(root, "sqlite"), { recursive: true }); const catalog = new SQL.Database(); catalog.run("CREATE TABLE local_thread_catalog (thread_id TEXT PRIMARY KEY, thread_source TEXT)"); catalog.run("CREATE TABLE local_thread_catalog_metadata (id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL DEFAULT 0)"); catalog.run("INSERT INTO local_thread_catalog VALUES (?,?)", [id, "user"]); catalog.run("INSERT INTO local_thread_catalog VALUES (?,?)", [subId, "subagent"]); catalog.run("INSERT INTO local_thread_catalog_metadata VALUES (1,0)"); fs.writeFileSync(path.join(root, "sqlite", "codex-dev.db"), Buffer.from(catalog.export())); catalog.close(); } test("会话恢复在没有 sqlite3 时插入线程并创建侧栏目录", { concurrency: false }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-restore-")); const dataDir = path.join(root, "codex"); const userData = path.join(root, "app-data"); const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const originalLoad = Module._load; const originalSpawnSync = childProcess.spawnSync; const originalBundleRoot = global.__BUNDLE_ROOT__; try { fs.mkdirSync(userData, { recursive: true }); fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true }); fs.writeFileSync(path.join(dataDir, "config.toml"), 'model_provider = "openai"\n'); writeRollout(dataDir, id); Module._load = function (request, parent, isMain) { if (request === "electron") return { app: { getPath: () => userData } }; return originalLoad.call(this, request, parent, isMain); }; childProcess.spawnSync = function (command, args, options) { if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" }; return originalSpawnSync.call(this, command, args, options); }; delete require.cache[PROVIDER_MODULE]; delete require.cache[DESKTOP_MODULE]; global.__BUNDLE_ROOT__ = path.resolve(__dirname, ".."); const provider = require(PROVIDER_MODULE); const desktop = require(DESKTOP_MODULE); desktop.codexRunning = () => false; provider.setCodexDataDir(dataDir); const result = await provider.syncProviderToCurrent(() => {}); assert.equal(result.insertedRows, 1); assert.equal(result.catalogRebuilt, 1); assert.equal(result.stateDbCreated, 1); assert.equal(result.stateDbCreateError, ""); assert.equal(fs.existsSync(path.join(dataDir, "sqlite", "codex-dev.db")), true); assert.equal(fs.existsSync(path.join(dataDir, "session_index.jsonl")), true); assert.equal(fs.existsSync(path.join(dataDir, "state_5.sqlite")), true); const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); const restoredState = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite"))); assert.equal(restoredState.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 1); assert.equal(restoredState.exec("SELECT has_user_event FROM threads WHERE id='" + id + "'")[0].values[0][0], 1); assert.equal(restoredState.exec("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('thread_sections','thread_dynamic_tools','thread_spawn_edges','backfill_state','external_agent_config_imports','remote_control_enrollments')")[0].values[0][0], 6); assert.equal(restoredState.exec("SELECT COUNT(*) FROM _sqlx_migrations")[0].values[0][0], 45); restoredState.close(); const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1); catalog.close(); } finally { Module._load = originalLoad; childProcess.spawnSync = originalSpawnSync; global.__BUNDLE_ROOT__ = originalBundleRoot; delete require.cache[PROVIDER_MODULE]; delete require.cache[DESKTOP_MODULE]; fs.rmSync(root, { recursive: true, force: true }); } }); test("subagent source 对象不再让 Windows(sql.js) 路径整批失败,且自建 catalog 库带 user_version", { concurrency: false }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-restore-")); const dataDir = path.join(root, "codex"); const userData = path.join(root, "app-data"); const id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const subId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; const originalLoad = Module._load; const originalSpawnSync = childProcess.spawnSync; const originalBundleRoot = global.__BUNDLE_ROOT__; try { fs.mkdirSync(userData, { recursive: true }); fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true }); fs.writeFileSync(path.join(dataDir, "config.toml"), 'model_provider = "openai"\n'); writeRollout(dataDir, id); writeSubagentRollout(dataDir, subId, id); Module._load = function (request, parent, isMain) { if (request === "electron") return { app: { getPath: () => userData } }; return originalLoad.call(this, request, parent, isMain); }; childProcess.spawnSync = function (command, args, options) { if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" }; return originalSpawnSync.call(this, command, args, options); }; delete require.cache[PROVIDER_MODULE]; delete require.cache[DESKTOP_MODULE]; global.__BUNDLE_ROOT__ = path.resolve(__dirname, ".."); const provider = require(PROVIDER_MODULE); const desktop = require(DESKTOP_MODULE); desktop.codexRunning = () => false; provider.setCodexDataDir(dataDir); const result = await provider.syncProviderToCurrent(() => {}, () => {}); // 两条线程都必须进 state 库(修复前 subagent 的 source 对象会让整批回滚,insertedRows=0) assert.equal(result.insertedRows, 2); const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); const state = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite"))); assert.equal(state.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 2); // subagent 的 source 以 JSON 字符串入库(与 Codex 自身存储一致),而不是 '[object Object]' const subSource = state.exec(`SELECT source FROM threads WHERE id='${subId}'`)[0].values[0][0]; assert.ok(subSource.startsWith('{"subagent"'), `source 应为 JSON 字符串,实际: ${subSource}`); assert.ok(subSource.includes("Dirac")); assert.equal(state.exec(`SELECT has_user_event FROM threads WHERE id='${subId}'`)[0].values[0][0], 0); state.close(); // 自建 codex-dev.db 必须带 user_version(对齐 Codex 迁移链 v30), // 否则 Codex 重跑迁移 v27/28/29 会置空 last_full_reconciled_at 触发 full reconcile const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); const uv = catalog.exec("PRAGMA user_version")[0].values[0][0]; assert.equal(uv, 30); // 子任务仍进 state 库,但不再作为 Codex 顶层侧栏任务展示; // 否则大量 subagent 会话会污染侧栏,点击派生线程容易一直转圈。 assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1); catalog.close(); } finally { Module._load = originalLoad; childProcess.spawnSync = originalSpawnSync; global.__BUNDLE_ROOT__ = originalBundleRoot; delete require.cache[PROVIDER_MODULE]; delete require.cache[DESKTOP_MODULE]; fs.rmSync(root, { recursive: true, force: true }); } }); test("启动轻量迁移清理旧 subagent 顶层目录污染", { concurrency: false }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-repair-")); const dataDir = path.join(root, "codex"); const userData = path.join(root, "app-data"); const id = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; const subId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; const originalLoad = Module._load; const originalSpawnSync = childProcess.spawnSync; const originalBundleRoot = global.__BUNDLE_ROOT__; try { fs.mkdirSync(userData, { recursive: true }); fs.mkdirSync(dataDir, { recursive: true }); await writeLegacyPollutedState(dataDir, id, subId); Module._load = function (request, parent, isMain) { if (request === "electron") return { app: { getPath: () => userData } }; return originalLoad.call(this, request, parent, isMain); }; childProcess.spawnSync = function (command, args, options) { if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" }; return originalSpawnSync.call(this, command, args, options); }; delete require.cache[PROVIDER_MODULE]; delete require.cache[DESKTOP_MODULE]; global.__BUNDLE_ROOT__ = path.resolve(__dirname, ".."); const provider = require(PROVIDER_MODULE); const desktop = require(DESKTOP_MODULE); desktop.codexRunning = () => false; provider.setCodexDataDir(dataDir); const result = await provider.repairLegacySessionVisibility(() => {}); assert.equal(result.stateRows, 1); assert.equal(result.catalogRows, 1); const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); const state = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite"))); assert.equal(state.exec(`SELECT has_user_event FROM threads WHERE id='${subId}'`)[0].values[0][0], 0); state.close(); const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1); assert.equal(catalog.exec(`SELECT COUNT(*) FROM local_thread_catalog WHERE thread_id='${subId}'`)[0].values[0][0], 0); catalog.close(); } finally { Module._load = originalLoad; childProcess.spawnSync = originalSpawnSync; global.__BUNDLE_ROOT__ = originalBundleRoot; delete require.cache[PROVIDER_MODULE]; delete require.cache[DESKTOP_MODULE]; fs.rmSync(root, { recursive: true, force: true }); } });