diff --git a/README.md b/README.md index 937a3037b..59407b1b3 100644 --- a/README.md +++ b/README.md @@ -173,10 +173,13 @@ Examples: ```bash /codex:transfer /codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl +/codex:transfer --refresh ``` The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. +Codex imports each Claude transcript only once. If the session was already transferred (by this command or by importing Claude history in the Codex App), `/codex:transfer` prints the existing thread instead and says whether later turns are missing from it. `--refresh` removes the earlier import record from `$CODEX_HOME/external_agent_session_imports.json` (a backup is written next to it) and imports the current transcript into a new Codex thread; the earlier thread is left unchanged. + ### `/codex:status` Shows running and recent Codex jobs for the current repository. diff --git a/plugins/codex/commands/transfer.md b/plugins/codex/commands/transfer.md index 42170e51d..974ccade2 100644 --- a/plugins/codex/commands/transfer.md +++ b/plugins/codex/commands/transfer.md @@ -1,6 +1,6 @@ --- description: Transfer the current Claude Code session into a resumable Codex thread -argument-hint: "[--source ]" +argument-hint: "[--source ] [--refresh]" disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..dcf8eff35 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -80,7 +80,7 @@ function printUsage() { " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", - " node scripts/codex-companion.mjs transfer [--source ] [--json]", + " node scripts/codex-companion.mjs transfer [--source ] [--refresh] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", " node scripts/codex-companion.mjs cancel [job-id] [--json]" @@ -614,11 +614,28 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId } function renderTransferResult(payload) { + if (payload.alreadyImported) { + const when = payload.importedAt ? ` on ${payload.importedAt}` : ""; + const lines = [ + payload.upToDate + ? `This Claude session was already transferred to Codex${when}, and the transcript has not changed since.` + : `This Claude session was already transferred to Codex${when}. Codex imports a transcript only once, so that thread does not include later turns.`, + `Codex session ID: ${payload.threadId}`, + `Resume in Codex: ${payload.resumeCommand}` + ]; + if (payload.refreshCommand) { + lines.push(`To import the current session into a new Codex thread, run \`${payload.refreshCommand}\`.`); + } + return `${lines.join("\n")}\n`; + } const lines = [ "Transferred the Claude session into a Codex thread with visible turn history.", `Codex session ID: ${payload.threadId}`, `Resume in Codex: ${payload.resumeCommand}` ]; + if (payload.refreshed) { + lines.push(`Replaced the earlier import record for this session; the earlier Codex thread is unchanged. Ledger backup: ${payload.ledgerBackupPath}`); + } return `${lines.join("\n")}\n`; } @@ -626,12 +643,24 @@ async function executeTransfer(cwd, options = {}) { const sourcePath = resolveClaudeSessionPath(cwd, { source: options.source }); - const result = await importExternalAgentSession(cwd, { sourcePath }); + const result = await importExternalAgentSession(cwd, { sourcePath, refresh: Boolean(options.refresh) }); + // Keep an explicit --source in the hint; without it, --refresh would resolve the session's own transcript. + const sourceArgument = /[\s"'\\]/.test(sourcePath) ? JSON.stringify(sourcePath) : sourcePath; + const refreshCommand = + result.alreadyImported && !result.upToDate + ? `/codex:transfer --refresh${options.source ? ` --source ${sourceArgument}` : ""}` + : null; const payload = { threadId: result.threadId, resumeCommand: `codex resume ${result.threadId}`, sourcePath, - sessionId: path.basename(sourcePath, ".jsonl") + sessionId: path.basename(sourcePath, ".jsonl"), + alreadyImported: result.alreadyImported, + upToDate: result.upToDate, + importedAt: result.importedAt, + refreshCommand, + refreshed: result.refreshed, + ledgerBackupPath: result.ledgerBackupPath }; return { @@ -825,12 +854,13 @@ async function handleTask(argv) { async function handleTransfer(argv) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd", "source"], - booleanOptions: ["json"] + booleanOptions: ["json", "refresh"] }); const cwd = resolveCommandCwd(options); const { payload, rendered } = await executeTransfer(cwd, { - source: options.source + source: options.source, + refresh: options.refresh }); outputCommandResult(payload, rendered, options.json); } diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..fd69fa5cb 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -49,6 +49,7 @@ const TASK_THREAD_PREFIX = "Codex Companion Task"; const DEFAULT_CONTINUE_PROMPT = "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved."; const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed"; +const LEDGER_REWRITE_ATTEMPTS = 5; const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000; function cleanCodexStderr(stderr) { @@ -658,24 +659,114 @@ function sourceContentSha256(sourcePath) { return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"); } -function importedThreadIdForSource(sourcePath) { - const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json"); - if (!fs.existsSync(ledgerPath)) { +function importLedgerPath() { + return path.join(resolveCodexHome(), "external_agent_session_imports.json"); +} + +function readImportLedger() { + const ledgerPath = importLedgerPath(); + return fs.existsSync(ledgerPath) ? readJsonFile(ledgerPath) : null; +} + +function importRecordsForSource(canonicalSource) { + const ledger = readImportLedger(); + const records = Array.isArray(ledger?.records) ? ledger.records : []; + return records.filter( + (record) => record?.source_path === canonicalSource && typeof record?.imported_thread_id === "string" + ); +} + +// Codex writes the ledger without a lock the plugin could share, so rewrites are optimistic: +// re-read the ledger right before the rename and start over if another import changed it. +// `update` returns the new records, or null to leave the ledger as it is. +function rewriteImportLedger(update, { backup = false } = {}) { + const ledgerPath = importLedgerPath(); + for (let attempt = 0; attempt < LEDGER_REWRITE_ATTEMPTS; attempt += 1) { + if (!fs.existsSync(ledgerPath)) { + return null; + } + const original = fs.readFileSync(ledgerPath, "utf8"); + const ledger = JSON.parse(original); + const records = Array.isArray(ledger?.records) ? ledger.records : []; + const nextRecords = update(records); + if (!nextRecords) { + return null; + } + const tempPath = `${ledgerPath}.tmp-${process.pid}`; + fs.writeFileSync(tempPath, `${JSON.stringify({ ...ledger, records: nextRecords }, null, 2)}\n`); + if (fs.readFileSync(ledgerPath, "utf8") !== original) { + fs.rmSync(tempPath, { force: true }); + continue; + } + const backupPath = backup ? `${ledgerPath}.bak-${Date.now()}` : null; + if (backupPath) { + fs.writeFileSync(backupPath, original); + } + fs.renameSync(tempPath, ledgerPath); + return { records, backupPath }; + } + throw new Error("Codex's import ledger kept changing while the plugin tried to update it. Retry in a moment."); +} + +// Codex records each imported transcript by path and skips a path it has already imported, +// even after the transcript has grown. `--refresh` removes this transcript's records (after +// backing up the ledger) so that Codex imports the current content into a new thread. +export function forgetImportRecordsForSource(canonicalSource) { + const result = rewriteImportLedger( + (records) => { + const kept = records.filter((record) => record?.source_path !== canonicalSource); + return kept.length === records.length ? null : kept; + }, + { backup: true } + ); + if (!result) { + return { removed: 0, removedRecords: [], backupPath: null }; + } + const removedRecords = result.records.filter((record) => record?.source_path === canonicalSource); + return { removed: removedRecords.length, removedRecords, backupPath: result.backupPath }; +} + +// Puts back the records a failed `--refresh` removed, unless Codex has recorded this source again. +export function restoreImportRecordsForSource(canonicalSource, removedRecords) { + if (!removedRecords?.length) { + return false; + } + const result = rewriteImportLedger((records) => + records.some((record) => record?.source_path === canonicalSource) ? null : [...records, ...removedRecords] + ); + return Boolean(result); +} + +function withRefreshRestored(error, canonicalSource, refresh) { + if (!refresh.removed) { + return error; + } + let note; + try { + note = restoreImportRecordsForSource(canonicalSource, refresh.removedRecords) + ? `The earlier import record for this transcript was restored (ledger backup: ${refresh.backupPath}).` + : `Codex recorded this transcript again, so the earlier import record was not restored (ledger backup: ${refresh.backupPath}).`; + } catch { + note = `The earlier import record for this transcript could not be restored. The ledger backup is at ${refresh.backupPath}.`; + } + const message = error instanceof Error ? error.message : String(error); + return new Error(`${message}\n${note}`, { cause: error }); +} + +function sessionImportResults(completion) { + if (!Array.isArray(completion?.itemTypeResults)) { return null; } - const ledger = readJsonFile(ledgerPath); - const canonicalSource = fs.realpathSync(sourcePath); - const contentSha256 = sourceContentSha256(canonicalSource); - const records = Array.isArray(ledger?.records) ? ledger.records : []; - const match = records - .filter( - (record) => - record?.source_path === canonicalSource && - record?.content_sha256 === contentSha256 && - typeof record?.imported_thread_id === "string" - ) - .at(-1); - return match?.imported_thread_id ?? null; + const sessions = completion.itemTypeResults.filter((result) => result?.itemType === "SESSIONS"); + return { + successes: sessions.flatMap((result) => (Array.isArray(result.successes) ? result.successes : [])), + failures: sessions.flatMap((result) => (Array.isArray(result.failures) ? result.failures : [])) + }; +} + +function importedAtIso(record) { + const seconds = Number(record?.imported_at); + return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null; } function externalAgentSessionMigration(sourcePath, cwd) { @@ -711,7 +802,7 @@ async function requestExternalAgentSessionImport(client, params) { client.setNotificationHandler((message) => { if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) { - resolveCompleted(); + resolveCompleted(message.params ?? null); return; } previousHandler?.(message); @@ -722,7 +813,7 @@ async function requestExternalAgentSessionImport(client, params) { try { await client.request("externalAgentConfig/import", params); - await completed; + return await completed; } finally { clearTimeout(timeout); client.setNotificationHandler(previousHandler ?? null); @@ -1064,31 +1155,80 @@ export async function importExternalAgentSession(cwd, options = {}) { throw new Error("A Claude session source path is required."); } + const canonicalSource = fs.realpathSync(options.sourcePath); return withDirectAppServer(cwd, async (client) => { - emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); + const priorRecords = importRecordsForSource(canonicalSource); + const priorThreadIds = new Set(priorRecords.map((record) => record.imported_thread_id)); + const refresh = options.refresh + ? forgetImportRecordsForSource(canonicalSource) + : { removed: 0, removedRecords: [], backupPath: null }; + if (refresh.removed > 0) { + emitProgress(options.onProgress, `Removed the earlier import record (ledger backup: ${refresh.backupPath}).`, "transferring"); + } try { - await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); - } catch (error) { - if (error?.rpcCode === -32601) { - throw new Error( - "This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.", - { cause: error } - ); + emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); + let completion; + try { + completion = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(canonicalSource, cwd)); + } catch (error) { + if (error?.rpcCode === -32601) { + throw new Error( + "This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.", + { cause: error } + ); + } + throw error; } - throw error; - } - const threadId = importedThreadIdForSource(options.sourcePath); - if (!threadId) { const stderr = cleanCodexStderr(client.stderr); + const results = sessionImportResults(completion); + const imported = results?.successes.find((success) => typeof success?.target === "string" && success.target); + // Codex versions that do not report results: use the ledger record this import added. Match + // on path only, because the live transcript can grow after Codex hashed it. + const created = imported + ? null + : importRecordsForSource(canonicalSource) + .filter((record) => !priorThreadIds.has(record.imported_thread_id)) + .at(-1); + const threadId = imported?.target ?? created?.imported_thread_id ?? null; + if (threadId) { + emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); + return { + threadId, + alreadyImported: false, + upToDate: null, + importedAt: null, + refreshed: refresh.removed > 0, + ledgerBackupPath: refresh.backupPath, + stderr + }; + } + if (results?.failures.length) { + const detail = results.failures.map((failure) => failure?.message).filter(Boolean).join("; "); + throw new Error(`Codex could not import the Claude session${detail ? `: ${detail}` : "."}${stderr ? `\n${stderr}` : ""}`); + } + // Codex skipped the transcript because it already imported this path. + const previous = options.refresh ? null : priorRecords.at(-1); + if (previous) { + emitProgress(options.onProgress, `Claude session already imported (${previous.imported_thread_id}).`, "completed", { + threadId: previous.imported_thread_id + }); + return { + threadId: previous.imported_thread_id, + alreadyImported: true, + upToDate: previous.content_sha256 === sourceContentSha256(canonicalSource), + importedAt: importedAtIso(previous), + refreshed: false, + ledgerBackupPath: null, + stderr + }; + } throw new Error( `Codex reported that the Claude import completed, but did not record an imported thread.${stderr ? `\n${stderr}` : " Check the Codex app-server logs for the underlying import error."}` ); + } catch (error) { + // A failed --refresh must not leave the transcript without its earlier import record. + throw withRefreshRestored(error, canonicalSource, refresh); } - emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); - return { - threadId, - stderr: cleanCodexStderr(client.stderr) - }; }); } diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..ec04aebcf 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -368,16 +368,34 @@ rl.on("line", (line) => { throw new Error("missing external session migration"); } const sourcePath = fs.realpathSync(session.path); + const sessionResults = (successes, failures) => ({ + importId: "import_" + message.id, + itemTypeResults: [{ itemType: "SESSIONS", successes, failures }] + }); + if (BEHAVIOR === "external-import-reports-failure") { + send({ id: message.id, result: { importId: "import_" + message.id } }); + send({ + method: "externalAgentConfig/import/completed", + params: sessionResults([], [{ + itemType: "SESSIONS", + errorType: null, + subErrorType: null, + failureStage: "import", + message: "unsupported transcript record", + cwd: session.cwd, + source: sourcePath + }]) + }); + break; + } const contents = fs.readFileSync(sourcePath, "utf8"); const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex"); const ledger = loadImportLedger(); - let record = ledger.records.find( - (candidate) => candidate.source_path === sourcePath && candidate.content_sha256 === contentSha256 - ); - let thread; - if (record) { - thread = ensureThread(state, record.imported_thread_id); - } else { + // Like Codex 0.166: a source path that is already in the ledger is skipped, even when + // its content changed, and the completion reports neither a success nor a failure. + let record = ledger.records.find((candidate) => candidate.source_path === sourcePath); + let thread = null; + if (!record) { const records = contents.split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)); const title = records.find((entry) => entry.type === "custom-title")?.customTitle || null; const messages = records @@ -399,8 +417,19 @@ rl.on("line", (line) => { saveState(state); saveImportLedger(ledger); } - send({ id: message.id, result: {} }); - send({ method: "externalAgentConfig/import/completed", params: {} }); + if (BEHAVIOR === "external-import-legacy-live") { + // Older Codex versions complete without structured results, and the live Claude + // transcript keeps growing after Codex has hashed it. + fs.appendFileSync(sourcePath, JSON.stringify({ type: "user", message: { role: "user", content: "late turn" } }) + "\\n"); + send({ id: message.id, result: {} }); + send({ method: "externalAgentConfig/import/completed", params: {} }); + break; + } + const successes = thread + ? [{ itemType: "SESSIONS", cwd: session.cwd, source: sourcePath, target: thread.id, title: thread.name }] + : []; + send({ id: message.id, result: { importId: "import_" + message.id } }); + send({ method: "externalAgentConfig/import/completed", params: sessionResults(successes, []) }); break; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..2baf2b6a1 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -303,6 +303,164 @@ test("transfer fails visibly when native import completes without a ledger recor assert.match(result.stderr, /did not record an imported thread/); }); +function setupTransferFixture(behavior) { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const projectDir = path.join(home, ".claude", "projects", "-repo"); + const sourcePath = path.join(projectDir, "session.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir, behavior); + initGitRepo(repo); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "First request." } })}\n`, + "utf8" + ); + const codexHome = path.join(home, ".codex"); + const env = { + ...buildEnv(binDir), + HOME: home, + CODEX_HOME: codexHome, + CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath + }; + // Commands receive "$ARGUMENTS" as a single argv element, so pass options the same way. + const transfer = (rawArguments) => run("node", [SCRIPT, "transfer", rawArguments], { cwd: repo, env }); + const appendTurn = (content) => + fs.appendFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content } })}\n`); + const ledgerRecords = () => + JSON.parse(fs.readFileSync(path.join(codexHome, "external_agent_session_imports.json"), "utf8")).records; + const fakeThreads = () => JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")).threads; + const useBehavior = (nextBehavior) => installFakeCodex(binDir, nextBehavior); + return { codexHome, sourcePath, transfer, appendTurn, ledgerRecords, fakeThreads, useBehavior }; +} + +test("transfer reports an already-imported session instead of failing", () => { + const fixture = setupTransferFixture(); + + const first = fixture.transfer("--json"); + assert.equal(first.status, 0, first.stderr); + assert.equal(JSON.parse(first.stdout).threadId, "thr_1"); + + const unchanged = fixture.transfer("--json"); + assert.equal(unchanged.status, 0, unchanged.stderr); + const unchangedPayload = JSON.parse(unchanged.stdout); + assert.equal(unchangedPayload.threadId, "thr_1"); + assert.equal(unchangedPayload.alreadyImported, true); + assert.equal(unchangedPayload.upToDate, true); + + fixture.appendTurn("More work after the first transfer."); + const grown = fixture.transfer("--json"); + assert.equal(grown.status, 0, grown.stderr); + const grownPayload = JSON.parse(grown.stdout); + assert.equal(grownPayload.threadId, "thr_1"); + assert.equal(grownPayload.alreadyImported, true); + assert.equal(grownPayload.upToDate, false); + assert.match(grownPayload.importedAt, /^\d{4}-\d{2}-\d{2}T/); + + const rendered = fixture.transfer(""); + assert.equal(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /already transferred to Codex/); + assert.match(rendered.stdout, /codex resume thr_1/); + assert.match(rendered.stdout, /\/codex:transfer --refresh/); + assert.equal(fixture.fakeThreads().length, 1); +}); + +test("transfer --refresh re-imports a grown transcript into a new thread", () => { + const fixture = setupTransferFixture(); + assert.equal(fixture.transfer("").status, 0); + fixture.appendTurn("More work after the first transfer."); + + const refreshed = fixture.transfer(`--refresh --source ${fixture.sourcePath} --json`); + assert.equal(refreshed.status, 0, refreshed.stderr); + const payload = JSON.parse(refreshed.stdout); + assert.equal(payload.threadId, "thr_2"); + assert.equal(payload.alreadyImported, false); + assert.equal(payload.refreshed, true); + assert.ok(fs.existsSync(payload.ledgerBackupPath), "the previous ledger is backed up"); + assert.equal(path.dirname(payload.ledgerBackupPath), fixture.codexHome); + + const records = fixture.ledgerRecords(); + assert.equal(records.length, 1); + assert.equal(records[0].imported_thread_id, "thr_2"); + assert.equal(fixture.fakeThreads().length, 2); + assert.deepEqual( + fixture.fakeThreads().find((thread) => thread.id === "thr_2").visibleMessages.map((message) => message.text), + ["First request.", "More work after the first transfer."] + ); + + const rendered = fixture.transfer(""); + assert.match(rendered.stdout, /already transferred to Codex/); + assert.match(rendered.stdout, /codex resume thr_2/); +}); + +test("transfer keeps an explicit --source in the refresh hint", () => { + const fixture = setupTransferFixture(); + const olderSource = path.join(path.dirname(fixture.sourcePath), "older-session.jsonl"); + fs.writeFileSync(olderSource, `${JSON.stringify({ type: "user", message: { role: "user", content: "Older request." } })}\n`); + assert.equal(fixture.transfer("").status, 0); + assert.equal(fixture.transfer(`--source ${olderSource}`).status, 0); + fs.appendFileSync(olderSource, `${JSON.stringify({ type: "user", message: { role: "user", content: "Older follow-up." } })}\n`); + + const result = fixture.transfer(`--source ${olderSource} --json`); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.alreadyImported, true); + assert.equal(payload.refreshCommand, `/codex:transfer --refresh --source ${fs.realpathSync(olderSource)}`); + + // Following the hint refreshes the older transcript, not the session's own transcript. + const refreshed = fixture.transfer(`${payload.refreshCommand.replace("/codex:transfer ", "")} --json`); + assert.equal(refreshed.status, 0, refreshed.stderr); + const records = fixture.ledgerRecords(); + assert.equal(records.find((record) => record.source_path === fs.realpathSync(fixture.sourcePath)).imported_thread_id, "thr_1"); + assert.equal(records.find((record) => record.source_path === fs.realpathSync(olderSource)).imported_thread_id, "thr_3"); +}); + +test("transfer --refresh restores the earlier import record when Codex rejects the import", () => { + const fixture = setupTransferFixture(); + assert.equal(fixture.transfer("").status, 0); + fixture.appendTurn("More work after the first transfer."); + + for (const behavior of ["external-import-reports-failure", "external-import-unsupported"]) { + fixture.useBehavior(behavior); + const rejected = fixture.transfer("--refresh"); + assert.notEqual(rejected.status, 0, behavior); + assert.match(rejected.stderr, /earlier import record for this transcript was restored \(ledger backup: .+\)/, behavior); + assert.deepEqual( + fixture.ledgerRecords().map((record) => record.imported_thread_id), + ["thr_1"], + behavior + ); + } + + fixture.useBehavior(); + const afterwards = JSON.parse(fixture.transfer("--json").stdout); + assert.equal(afterwards.alreadyImported, true); + assert.equal(afterwards.threadId, "thr_1"); + assert.equal(fixture.fakeThreads().length, 1); +}); + +test("transfer surfaces import failures reported by Codex", () => { + const fixture = setupTransferFixture("external-import-reports-failure"); + + const result = fixture.transfer(""); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Codex could not import the Claude session: unsupported transcript record/); +}); + +test("transfer resolves the imported thread when Codex omits results and the transcript keeps growing", () => { + const fixture = setupTransferFixture("external-import-legacy-live"); + + const result = fixture.transfer("--json"); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.threadId, "thr_1"); + assert.equal(payload.alreadyImported, false); +}); + test("transfer rejects sources outside the Claude projects directory", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); diff --git a/tests/transfer-ledger.test.mjs b/tests/transfer-ledger.test.mjs new file mode 100644 index 000000000..74f78b25f --- /dev/null +++ b/tests/transfer-ledger.test.mjs @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { makeTempDir } from "./helpers.mjs"; +import { forgetImportRecordsForSource, restoreImportRecordsForSource } from "../plugins/codex/scripts/lib/codex.mjs"; + +function importRecord(sourcePath, threadId) { + return { + source_path: sourcePath, + content_sha256: `sha-${threadId}`, + imported_thread_id: threadId, + imported_at: 1, + source_modified_at: null + }; +} + +function useLedger(t, records) { + const codexHome = makeTempDir(); + const ledgerPath = path.join(codexHome, "external_agent_session_imports.json"); + fs.writeFileSync(ledgerPath, JSON.stringify({ records, detected_connector_records: [] }, null, 2)); + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + t.after(() => { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + }); + return { codexHome, ledgerPath }; +} + +const threadIds = (ledgerPath) => + JSON.parse(fs.readFileSync(ledgerPath, "utf8")).records.map((record) => record.imported_thread_id); + +test("--refresh keeps ledger records that another import adds while it rewrites the ledger", (t) => { + const { codexHome, ledgerPath } = useLedger(t, [importRecord("/a.jsonl", "thr_a"), importRecord("/b.jsonl", "thr_b")]); + + // Codex imports for another session write the ledger without any lock. Land one of those + // writes while the refresh is preparing its replacement file. + const writeFileSync = fs.writeFileSync; + let interleaved = false; + t.mock.method(fs, "writeFileSync", (file, ...rest) => { + if (!interleaved && String(file).startsWith(`${ledgerPath}.tmp-`)) { + interleaved = true; + const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); + ledger.records.push(importRecord("/c.jsonl", "thr_c")); + writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2)); + } + return writeFileSync(file, ...rest); + }); + + const result = forgetImportRecordsForSource("/a.jsonl"); + + assert.equal(interleaved, true); + assert.equal(result.removed, 1); + const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); + assert.deepEqual( + ledger.records.map((record) => record.imported_thread_id), + ["thr_b", "thr_c"] + ); + assert.deepEqual(ledger.detected_connector_records, []); + const backup = JSON.parse(fs.readFileSync(result.backupPath, "utf8")); + assert.deepEqual( + backup.records.map((record) => record.imported_thread_id), + ["thr_a", "thr_b", "thr_c"] + ); + assert.deepEqual( + fs.readdirSync(codexHome).filter((name) => name.includes(".tmp-")), + [] + ); +}); + +test("a failed --refresh puts the removed records back without dropping other updates", (t) => { + const { ledgerPath } = useLedger(t, [importRecord("/a.jsonl", "thr_a"), importRecord("/b.jsonl", "thr_b")]); + const forgotten = forgetImportRecordsForSource("/a.jsonl"); + assert.deepEqual(forgotten.removedRecords.map((record) => record.imported_thread_id), ["thr_a"]); + + // Another session imports while the refreshed import is running. + const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); + ledger.records.push(importRecord("/c.jsonl", "thr_c")); + fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2)); + + assert.equal(restoreImportRecordsForSource("/a.jsonl", forgotten.removedRecords), true); + assert.deepEqual(threadIds(ledgerPath), ["thr_b", "thr_c", "thr_a"]); +}); + +test("restoring import records leaves a record that Codex wrote for the source in place", (t) => { + const { ledgerPath } = useLedger(t, [importRecord("/a.jsonl", "thr_new")]); + + assert.equal(restoreImportRecordsForSource("/a.jsonl", [importRecord("/a.jsonl", "thr_old")]), false); + assert.deepEqual(threadIds(ledgerPath), ["thr_new"]); +});