Skip to content
308 changes: 292 additions & 16 deletions cli.js

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions cli/session-convert-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const fs = require('fs');
const path = require('path');

const { parseMaxMessagesValue } = require('../lib/cli-session-utils');

function ensureDir(dirPath) {
if (!dirPath) return;
if (fs.existsSync(dirPath)) return;
fs.mkdirSync(dirPath, { recursive: true });
}

function resolveOutputPath(outputPath, defaultFileName) {
const fallback = path.resolve(process.cwd(), defaultFileName);
if (typeof outputPath !== 'string' || !outputPath.trim()) return fallback;
const trimmed = outputPath.trim();
const resolved = path.resolve(trimmed);
if (/[\\\/]$/.test(trimmed)) {
ensureDir(resolved);
return path.join(resolved, defaultFileName);
}
if (fs.existsSync(resolved)) {
try { if (fs.statSync(resolved).isDirectory()) return path.join(resolved, defaultFileName); } catch (_) {}
}
return resolved;
}

function parseArgs(args = []) {
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', maxMessages: undefined };
const errors = [];
for (let i = 0; i < args.length; i += 1) {
const arg = String(args[i] || '');
const next = args[i + 1] || '';
if (!arg) continue;
if (arg === '--from') { options.from = next; i += 1; continue; }
if (arg.startsWith('--from=')) { options.from = arg.slice(7); continue; }
if (arg === '--to') { options.to = next; i += 1; continue; }
if (arg.startsWith('--to=')) { options.to = arg.slice(5); continue; }
if (arg === '--session-id') { options.sessionId = next; i += 1; continue; }
if (arg.startsWith('--session-id=')) { options.sessionId = arg.slice(13); continue; }
if (arg === '--file') { options.filePath = next; i += 1; continue; }
if (arg.startsWith('--file=')) { options.filePath = arg.slice(7); continue; }
if (arg === '--output') { options.output = next; i += 1; continue; }
if (arg.startsWith('--output=')) { options.output = arg.slice(9); continue; }
if (arg === '--max-messages') { options.maxMessages = next; i += 1; continue; }
if (arg.startsWith('--max-messages=')) { options.maxMessages = arg.slice(15); continue; }
errors.push(`未知参数: ${arg}`);
}
options.from = String(options.from || '').trim().toLowerCase();
options.to = String(options.to || '').trim().toLowerCase();
if (options.from !== 'codex' && options.from !== 'claude') errors.push('参数 --from 仅支持 codex 或 claude');
if (options.to !== 'codex' && options.to !== 'claude') errors.push('参数 --to 仅支持 codex 或 claude');
if (options.from && options.to && options.from === options.to) errors.push('--from 与 --to 不能相同');
if (!options.from) errors.push('缺少 --from');
if (!options.to) errors.push('缺少 --to');
if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce mutual exclusivity for --session-id and --file.

Line 55 currently rejects only the “both missing” case. It should also reject “both present” to match command contract and avoid ambiguous resolution.

Suggested fix
-    if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');
+    const hasSessionId = !!String(options.sessionId || '').trim();
+    const hasFilePath = !!String(options.filePath || '').trim();
+    if (hasSessionId === hasFilePath) errors.push('必须且只能指定 --session-id 或 --file');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');
const hasSessionId = !!String(options.sessionId || '').trim();
const hasFilePath = !!String(options.filePath || '').trim();
if (hasSessionId === hasFilePath) errors.push('必须且只能指定 --session-id 或 --file');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/session-convert-args.js` at line 55, The argument validation currently
only checks for both missing but not for both provided; update the check around
options.sessionId and options.filePath to also push an error when both are
present (e.g., if (options.sessionId && options.filePath) errors.push('不能同时指定
--session-id 与 --file');) so the command enforces mutual exclusivity between
options.sessionId and options.filePath and prevents ambiguous usage; locate the
validation near the existing line that uses errors.push('必须指定 --session-id 或
--file').

if (options.maxMessages !== undefined) {
const parsed = parseMaxMessagesValue(options.maxMessages);
if (parsed === null) errors.push('参数 --max-messages 无效');
else options.maxMessages = parsed === Infinity ? Infinity : Math.max(1, Math.floor(parsed));
}
return { options, error: errors.length ? errors.join(';') : '' };
}

module.exports = { ensureDir, resolveOutputPath, parseArgs };

82 changes: 82 additions & 0 deletions cli/session-convert-io.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const fs = require('fs');
const readline = require('readline');

const {
toIsoTime,
extractMessageText,
normalizeRole,
resolveMaxMessagesValue
} = require('../lib/cli-session-utils');

const { removeLeadingSystemMessage } = require('../lib/cli-sessions');

async function readSessionMessages(filePath, source, maxMessages) {
const limit = resolveMaxMessagesValue(maxMessages, 200);
const state = { sessionId: '', cwd: '', updatedAt: '', messages: [], truncated: false };
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = String(line || '').trim();
if (!trimmed) continue;
let record;
try { record = JSON.parse(trimmed); } catch (_) { continue; }
const timestamp = toIsoTime(record.timestamp, '');
if (timestamp) state.updatedAt = timestamp;
if (source === 'codex' && record.type === 'session_meta' && record.payload) {
if (!state.sessionId && record.payload.id) state.sessionId = String(record.payload.id || '');
if (!state.cwd && record.payload.cwd) state.cwd = String(record.payload.cwd || '');
continue;
}
if (source === 'claude') {
if (!state.sessionId && record.sessionId) state.sessionId = String(record.sessionId || '');
if (!state.cwd && record.cwd) state.cwd = String(record.cwd || '');
}
let role = '';
let text = '';
if (source === 'codex' && record.type === 'response_item' && record.payload && record.payload.type === 'message') {
role = normalizeRole(record.payload.role);
text = extractMessageText(record.payload.content);
} else if (source === 'claude') {
role = normalizeRole(record.type);
text = extractMessageText(record.message ? record.message.content : '');
}
if (!role || !text) continue;
state.messages.push({ role, text, timestamp });
if (limit !== Infinity && state.messages.length > limit) {
state.messages.shift();
state.truncated = true;
}
}
state.messages = removeLeadingSystemMessage(state.messages);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Leading system message is dropped, which breaks role/order preservation

Line 50 removes the first system message unconditionally. That mutates the conversation and can break “preserve message order and roles” behavior during conversion.

Proposed fix
-const { removeLeadingSystemMessage } = require('../lib/cli-sessions');
@@
-    state.messages = removeLeadingSystemMessage(state.messages);
     return state;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/session-convert-io.js` at line 50, The code unconditionally reassigns
state.messages = removeLeadingSystemMessage(state.messages) which mutates/drops
an original leading system message and breaks role/order preservation; instead,
only strip a leading system message when it's the exact sentinel you intend to
drop and do not mutate the original state array—call removeLeadingSystemMessage
on a copy (e.g., pass [...state.messages]) or guard the call with a check like
state.messages[0]?.role === 'system' && isDroppableSystem(state.messages[0])
before assigning, keeping the original state.messages intact otherwise;
reference removeLeadingSystemMessage and state.messages to locate the change.

return state;
}

function buildTargetRecords(target, payload) {
const now = Date.now();
const sessionId = String(payload.sessionId || '').trim();
const cwd = String(payload.cwd || '').trim();
const messages = Array.isArray(payload.messages) ? payload.messages : [];
if (target === 'codex') {
const records = [{ type: 'session_meta', timestamp: new Date(now).toISOString(), payload: { id: sessionId, cwd } }];
for (let i = 0; i < messages.length; i += 1) {
const m = messages[i] || {};
const role = normalizeRole(m.role);
const text = typeof m.text === 'string' ? m.text : '';
if (!role || !text) continue;
records.push({ type: 'response_item', timestamp: m.timestamp || new Date(now + i).toISOString(), payload: { type: 'message', role, content: text } });
}
return records;
}
const records = [];
for (let i = 0; i < messages.length; i += 1) {
const m = messages[i] || {};
const role = normalizeRole(m.role);
const text = typeof m.text === 'string' ? m.text : '';
if (!role || !text) continue;
records.push({ type: role, timestamp: m.timestamp || new Date(now + i).toISOString(), sessionId, cwd, message: { content: text } });
}
return records;
}
Comment on lines +54 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate target explicitly instead of silently defaulting to Claude-style output

Right now, any non-codex value is treated as Claude output. A typo/invalid target would silently generate the wrong format instead of failing fast.

Proposed fix
 function buildTargetRecords(target, payload) {
+    if (target !== 'codex' && target !== 'claude') {
+        throw new Error(`Unsupported target format: ${String(target)}`);
+    }
     const now = Date.now();
@@
-    const records = [];
+    const records = [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/session-convert-io.js` around lines 54 - 79, The function
buildTargetRecords currently treats any non-'codex' target as Claude-style
output which hides typos/invalid targets; add an explicit validation at the
start of buildTargetRecords to only accept known targets (e.g., 'codex' and
'claude') and throw a clear error (TypeError or Error) for any other value;
implement this by checking target === 'codex' then else if target === 'claude'
produce the existing Claude-style records, otherwise throw with a message
referencing the invalid target so callers fail fast.


module.exports = { readSessionMessages, buildTargetRecords };

43 changes: 43 additions & 0 deletions cli/session-convert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const fs = require('fs');
const path = require('path');

const { parseArgs, ensureDir, resolveOutputPath } = require('./session-convert-args');
const { readSessionMessages, buildTargetRecords } = require('./session-convert-io');

function printUsage() {
console.log('\n用法:');
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
}

async function cmdConvertSession(args = [], deps = {}) {
const parsed = parseArgs(args);
if (parsed.error) {
console.error('错误:', parsed.error);
printUsage();
process.exit(1);
}
if (!deps || typeof deps.resolveSessionFilePath !== 'function') {
console.error('错误: convert-session missing resolver');
process.exit(1);
}
const opt = parsed.options;
const filePath = deps.resolveSessionFilePath(opt.from, opt.filePath, opt.sessionId);
if (!filePath) {
console.error('转换失败: Session file not found');
process.exit(1);
}
const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);
const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });
const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;
const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
ensureDir(path.dirname(outputPath));
fs.writeFileSync(outputPath, jsonl, 'utf-8');
console.log('\n✓ 会话已转换:', outputPath);
Comment on lines +29 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wrap conversion I/O path with explicit error handling.

Line 29 onward can throw on read/parse/write failures. Add a try/catch so the command consistently reports a user-facing error and exits with code 1.

Suggested fix
-    const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);
-    const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');
-    const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
-    const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });
-    const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;
-    const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
-    ensureDir(path.dirname(outputPath));
-    fs.writeFileSync(outputPath, jsonl, 'utf-8');
-    console.log('\n✓ 会话已转换:', outputPath);
-    if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');
-    console.log();
+    try {
+        const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);
+        const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');
+        const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
+        const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });
+        const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;
+        const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
+        ensureDir(path.dirname(outputPath));
+        fs.writeFileSync(outputPath, jsonl, 'utf-8');
+        console.log('\n✓ 会话已转换:', outputPath);
+        if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');
+        console.log();
+    } catch (e) {
+        const msg = e && e.message ? e.message : String(e || 'unknown error');
+        console.error('转换失败:', msg);
+        process.exit(1);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/session-convert.js` around lines 29 - 37, Wrap the conversion steps
(calling readSessionMessages, computing sessionId/safeSessionId,
buildTargetRecords, resolveOutputPath, ensureDir, and fs.writeFileSync) in a
try/catch block so any read/parse/write errors are caught; in the catch, call
console.error with a clear user-facing message including the error details and
exit the process with code 1 (process.exit(1)) to ensure consistent failure
handling. Ensure the try covers the JSON serialization and file write path and
that the caught error is included in the logged output.

if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');
console.log();
}

module.exports = { cmdConvertSession };

43 changes: 43 additions & 0 deletions tests/unit/session-convert-web-ui.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'assert';
import { buildConvertedSessionJsonl } from '../../web-ui/logic.session-convert.mjs';

test('buildConvertedSessionJsonl emits codex jsonl', () => {
const text = buildConvertedSessionJsonl('codex', {
sessionId: 'sess-1',
cwd: '/repo',
messages: [
{ role: 'system', text: 'sys', timestamp: '2026-04-29T00:00:00.000Z' },
{ role: 'user', text: 'hi', timestamp: '2026-04-29T00:00:01.000Z' },
{ role: 'assistant', text: 'hello', timestamp: '2026-04-29T00:00:02.000Z' }
]
});
const lines = text.trim().split('\n').map((line) => JSON.parse(line));
assert.strictEqual(lines[0].type, 'session_meta');
assert.strictEqual(lines[0].payload.id, 'sess-1');
assert.strictEqual(lines[0].payload.cwd, '/repo');
assert.strictEqual(lines[1].payload.role, 'user');
assert.strictEqual(lines[1].payload.content, 'hi');
assert.strictEqual(lines[2].payload.role, 'assistant');
assert.strictEqual(lines[2].payload.content, 'hello');
});

test('buildConvertedSessionJsonl emits claude jsonl', () => {
const text = buildConvertedSessionJsonl('claude', {
sessionId: 'sess-2',
cwd: '/repo',
messages: [
{ role: 'system', text: 'sys', timestamp: '2026-04-29T00:00:00.000Z' },
{ role: 'user', text: 'hi', timestamp: '2026-04-29T00:00:01.000Z' },
{ role: 'assistant', text: 'hello', timestamp: '2026-04-29T00:00:02.000Z' }
]
});
const lines = text.trim().split('\n').map((line) => JSON.parse(line));
assert.strictEqual(lines.length, 2);
assert.strictEqual(lines[0].type, 'user');
assert.strictEqual(lines[0].sessionId, 'sess-2');
assert.strictEqual(lines[0].cwd, '/repo');
assert.strictEqual(lines[0].message.content, 'hi');
assert.strictEqual(lines[1].type, 'assistant');
assert.strictEqual(lines[1].message.content, 'hello');
});

76 changes: 76 additions & 0 deletions tests/unit/session-convert.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from 'assert';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const { cmdConvertSession } = require('../../cli/session-convert');

function writeJsonl(filePath, records) {
const lines = records.map((r) => JSON.stringify(r));
fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf-8');
}

function listFiles(dirPath) {
return fs.readdirSync(dirPath).filter(Boolean).sort();
}

test('convert-session converts codex jsonl to claude jsonl', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-convert-'));
const inputPath = path.join(tmpDir, 'input.jsonl');
const outDir = path.join(tmpDir, 'out');
fs.mkdirSync(outDir);

writeJsonl(inputPath, [
{ type: 'session_meta', timestamp: '2026-04-29T00:00:00.000Z', payload: { id: 'sess-1', cwd: '/repo' } },
{ type: 'response_item', timestamp: '2026-04-29T00:00:01.000Z', payload: { type: 'message', role: 'user', content: 'hi' } },
{ type: 'response_item', timestamp: '2026-04-29T00:00:02.000Z', payload: { type: 'message', role: 'assistant', content: 'hello' } }
]);

await cmdConvertSession(
['--from', 'codex', '--to', 'claude', '--file', inputPath, '--output', `${outDir}/`],
{ resolveSessionFilePath: () => inputPath }
);

const files = listFiles(outDir);
assert.deepStrictEqual(files, ['claude-session-sess-1.jsonl']);
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
const records = content.split('\n').map((line) => JSON.parse(line));
assert.strictEqual(records.length, 2);
assert.strictEqual(records[0].type, 'user');
assert.strictEqual(records[0].sessionId, 'sess-1');
assert.strictEqual(records[0].cwd, '/repo');
assert.strictEqual(records[0].message.content, 'hi');
assert.strictEqual(records[1].type, 'assistant');
assert.strictEqual(records[1].message.content, 'hello');
});

test('convert-session converts claude jsonl to codex jsonl', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-convert-'));
const inputPath = path.join(tmpDir, 'input.jsonl');
const outDir = path.join(tmpDir, 'out');
fs.mkdirSync(outDir);

writeJsonl(inputPath, [
{ type: 'user', timestamp: '2026-04-29T00:00:01.000Z', sessionId: 'sess-2', cwd: '/repo', message: { content: 'hi' } },
{ type: 'assistant', timestamp: '2026-04-29T00:00:02.000Z', sessionId: 'sess-2', cwd: '/repo', message: { content: 'hello' } }
]);

await cmdConvertSession(
['--from', 'claude', '--to', 'codex', '--file', inputPath, '--output', `${outDir}/`],
{ resolveSessionFilePath: () => inputPath }
);

const files = listFiles(outDir);
assert.deepStrictEqual(files, ['codex-session-sess-2.jsonl']);
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
const records = content.split('\n').map((line) => JSON.parse(line));
assert.strictEqual(records[0].type, 'session_meta');
assert.strictEqual(records[0].payload.id, 'sess-2');
assert.strictEqual(records[0].payload.cwd, '/repo');
assert.strictEqual(records[1].type, 'response_item');
assert.strictEqual(records[1].payload.role, 'user');
assert.strictEqual(records[2].payload.role, 'assistant');
});

4 changes: 3 additions & 1 deletion tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'promptComposerVarValuesRaw',
'showPromptTemplateVarModal',
'promptTemplateVarDraftName',
'promptTemplateVarDraftError'
'promptTemplateVarDraftError',
'sessionConverting'
);
if (parityAgainstHead) {
const allowedExtraKeySet = new Set(allowedExtraCurrentKeys);
Expand Down Expand Up @@ -438,6 +439,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'findProviderByName',
'getProviderValidation',
'getShareCommandPrefixInvocation',
'convertSession',
'invalidateSessionsUsageData',
'isReservedProviderCreationName',
'isSessionLoadNativeDialogEnabled',
Expand Down
1 change: 1 addition & 0 deletions web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ document.addEventListener('DOMContentLoaded', () => {
gemini: 0
},
sessionExporting: {},
sessionConverting: {},
sessionCloning: {},
sessionDeleting: {},
activeSession: null,
Expand Down
Loading
Loading