Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 112 additions & 18 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -658,24 +658,103 @@ function sourceContentSha256(sourcePath) {
return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex");
}

function normalizeImportPath(value) {
if (typeof value !== "string" || value === "") {
return null;
}
// Codex records ledger/notification paths via Rust's fs::canonicalize, which on
// Windows emits verbatim extended-length paths (\\?\C:\... or \\?\UNC\...). Node's
// fs.realpathSync (and .native) never produce that prefix, so a raw === comparison
// can never match on Windows. Strip the prefix on both sides, and compare
// case-insensitively there where the filesystem is case-insensitive.
const stripped = value.replace(/^\\\\\?\\UNC\\/, "\\\\").replace(/^\\\\\?\\/, "");
const normalized = path.normalize(stripped);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}

function importCompletionSuccesses(completion) {
const results = Array.isArray(completion?.itemTypeResults) ? completion.itemTypeResults : [];
return results.flatMap((result) => (Array.isArray(result?.successes) ? result.successes : []));
}

function importedThreadIdFromCompletion(completion, sourcePath) {
// The completion notification carries the authoritative result: successes[].target
// is the imported thread id. Preferring it avoids the ledger round-trip, including
// the race where the ledger write lags the completed notification.
const wantedSource = normalizeImportPath(sourcePath);
let fallbackTarget = null;
for (const success of importCompletionSuccesses(completion)) {
const target = typeof success?.target === "string" ? success.target : null;
if (!target) {
continue;
}
if (wantedSource && normalizeImportPath(success?.source) === wantedSource) {
return target;
}
fallbackTarget ??= target;
}
return fallbackTarget;
}

function importFailureDetails(completion) {
const results = Array.isArray(completion?.itemTypeResults) ? completion.itemTypeResults : [];
const messages = [];
for (const result of results) {
const failures = Array.isArray(result?.failures) ? result.failures : [];
for (const failure of failures) {
const reason =
(typeof failure?.error === "string" && failure.error) ||
(typeof failure?.message === "string" && failure.message) ||
(typeof failure?.reason === "string" && failure.reason) ||
null;
if (reason) {
messages.push(reason);
}
}
}
return messages.length ? messages.join("\n") : null;
}

function safeRealpath(sourcePath) {
try {
return fs.realpathSync(sourcePath);
} catch {
return sourcePath;
}
}

function tryComputeContentSha256(sourcePath) {
try {
return sourceContentSha256(sourcePath);
} catch {
return null;
}
}

function importedThreadIdForSource(sourcePath) {
const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json");
if (!fs.existsSync(ledgerPath)) {
return null;
}
const ledger = readJsonFile(ledgerPath);
const canonicalSource = fs.realpathSync(sourcePath);
const contentSha256 = sourceContentSha256(canonicalSource);
const realSource = safeRealpath(sourcePath);
const wantedSource = normalizeImportPath(realSource);
const contentSha256 = tryComputeContentSha256(realSource);
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 candidates = records.filter(
(record) =>
typeof record?.imported_thread_id === "string" &&
wantedSource != null &&
normalizeImportPath(record?.source_path) === wantedSource
);
// The live Claude transcript keeps growing while Codex imports it, so the hash
// recomputed here usually no longer matches the imported snapshot. Prefer a hash
// match when one exists, but do not require it.
const preferred =
(contentSha256 &&
candidates.filter((record) => record?.content_sha256 === contentSha256).at(-1)) ||
candidates.at(-1);
return preferred?.imported_thread_id ?? null;
}

function externalAgentSessionMigration(sourcePath, cwd) {
Expand Down Expand Up @@ -711,7 +790,7 @@ async function requestExternalAgentSessionImport(client, params) {

client.setNotificationHandler((message) => {
if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) {
resolveCompleted();
resolveCompleted(message.params ?? null);
return;
}
previousHandler?.(message);
Expand All @@ -722,7 +801,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);
Expand Down Expand Up @@ -1066,8 +1145,12 @@ export async function importExternalAgentSession(cwd, options = {}) {

return withDirectAppServer(cwd, async (client) => {
emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring");
let completion = null;
try {
await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd));
completion = await requestExternalAgentSessionImport(
client,
externalAgentSessionMigration(options.sourcePath, cwd)
);
} catch (error) {
if (error?.rpcCode === -32601) {
throw new Error(
Expand All @@ -1077,11 +1160,16 @@ export async function importExternalAgentSession(cwd, options = {}) {
}
throw error;
}
const threadId = importedThreadIdForSource(options.sourcePath);
const threadId =
importedThreadIdFromCompletion(completion, options.sourcePath) ??
importedThreadIdForSource(options.sourcePath);
Comment on lines +1163 to +1165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject failed re-imports before reading the ledger

When this source was imported previously and a later import completes without a success target, the unconditional ledger fallback can return the earlier thread. Because importedThreadIdForSource now accepts a path match even when the current content hash differs, a changed transcript whose new import fails is reported as successfully imported while the user is given a stale thread that lacks the latest session content; only use the ledger when the completion lacks result data, not when it explicitly contains no matching success.

Useful? React with 👍 / 👎.

if (!threadId) {
const stderr = cleanCodexStderr(client.stderr);
const details = [importFailureDetails(completion), cleanCodexStderr(client.stderr)].filter(Boolean);
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."}`
[
"Codex reported that the Claude import completed, but did not record an imported thread.",
...(details.length ? details : ["Check the Codex app-server logs for the underlying import error."])
].join("\n")
);
}
emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId });
Expand Down Expand Up @@ -1216,4 +1304,10 @@ export function readOutputSchema(schemaPath) {
return readJsonFile(schemaPath);
}

export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX };
export {
DEFAULT_CONTINUE_PROMPT,
TASK_THREAD_PREFIX,
normalizeImportPath,
importedThreadIdFromCompletion,
importFailureDetails
};
106 changes: 106 additions & 0 deletions tests/codex-transfer.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
normalizeImportPath,
importedThreadIdFromCompletion,
importFailureDetails
} from "../plugins/codex/scripts/lib/codex.mjs";

// Regression coverage for issue #618: `/codex:transfer` always fails on Windows
// because the ledger lookup can never match. Codex records paths via Rust's
// fs::canonicalize (Windows verbatim `\\?\...` prefix) while the plugin compared
// them against Node's fs.realpathSync output, which never carries that prefix.

test("normalizeImportPath makes a Windows verbatim path match its plain form", () => {
const verbatim = String.raw`\\?\C:\Users\me\.claude\projects\p\id.jsonl`;
const plain = String.raw`C:\Users\me\.claude\projects\p\id.jsonl`;

const normalizedVerbatim = normalizeImportPath(verbatim);
assert.ok(normalizedVerbatim, "verbatim path should normalize to a value");
assert.equal(
normalizedVerbatim,
normalizeImportPath(plain),
"verbatim and plain paths must normalize to the same value"
);
assert.equal(
normalizedVerbatim.startsWith("\\\\?\\"),
false,
"the verbatim extended-length prefix must be stripped"
);
});

test("normalizeImportPath maps a verbatim UNC path onto its plain UNC form", () => {
const verbatim = String.raw`\\?\UNC\server\share\sess.jsonl`;
const plain = String.raw`\\server\share\sess.jsonl`;

assert.equal(normalizeImportPath(verbatim), normalizeImportPath(plain));
});

test("normalizeImportPath returns null for empty or non-string input", () => {
assert.equal(normalizeImportPath(""), null);
assert.equal(normalizeImportPath(undefined), null);
assert.equal(normalizeImportPath(42), null);
});

test("importedThreadIdFromCompletion resolves target from the matching success", () => {
const source = String.raw`C:\Users\me\.claude\projects\p\id.jsonl`;
const completion = {
itemTypeResults: [
{
itemType: "SESSIONS",
successes: [
{
itemType: "SESSIONS",
cwd: null,
source: String.raw`\\?\C:\Users\me\.claude\projects\p\id.jsonl`,
target: "019fe103-b7aa-74c1-8fb2-ef6afd58785f"
}
],
failures: []
}
]
};

assert.equal(
importedThreadIdFromCompletion(completion, source),
"019fe103-b7aa-74c1-8fb2-ef6afd58785f"
);
});

test("importedThreadIdFromCompletion falls back to the sole success target", () => {
const completion = {
itemTypeResults: [
{ successes: [{ source: "some/other/path.jsonl", target: "thread-xyz" }], failures: [] }
]
};

assert.equal(
importedThreadIdFromCompletion(completion, String.raw`C:\Users\me\unrelated.jsonl`),
"thread-xyz"
);
});

test("importedThreadIdFromCompletion returns null when there is no target", () => {
assert.equal(importedThreadIdFromCompletion(null, "x"), null);
assert.equal(importedThreadIdFromCompletion({}, "x"), null);
assert.equal(
importedThreadIdFromCompletion(
{ itemTypeResults: [{ successes: [{ source: "x" }], failures: [] }] },
"x"
),
null
);
});

test("importFailureDetails surfaces failure reasons and is null when there are none", () => {
const withFailures = {
itemTypeResults: [
{ successes: [], failures: [{ error: "boom" }, { message: "second" }] }
]
};
assert.equal(importFailureDetails(withFailures), "boom\nsecond");

assert.equal(importFailureDetails({ itemTypeResults: [{ successes: [], failures: [] }] }), null);
assert.equal(importFailureDetails(null), null);
});