Skip to content

Commit 8756cf3

Browse files
committed
fix(session): write native derived resume records
1 parent b28f4ba commit 8756cf3

2 files changed

Lines changed: 97 additions & 6 deletions

File tree

cli.js

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6364,6 +6364,53 @@ function buildSessionPlainText(messages) {
63646364
return lines.join('\n');
63656365
}
63666366

6367+
function buildCodexMessageContent(role, text) {
6368+
const normalizedRole = normalizeRole(role);
6369+
const contentType = normalizedRole === 'assistant' ? 'output_text' : 'input_text';
6370+
return [{ type: contentType, text: typeof text === 'string' ? text : '' }];
6371+
}
6372+
6373+
function buildClaudeMessageRecord({ role, text, sessionId, cwd, timestamp, uuid, parentUuid }) {
6374+
const normalizedRole = normalizeRole(role);
6375+
const safeText = typeof text === 'string' ? text : '';
6376+
const record = {
6377+
parentUuid: parentUuid || null,
6378+
isSidechain: false,
6379+
userType: 'external',
6380+
cwd,
6381+
sessionId,
6382+
version: 'codexmate-derived',
6383+
type: normalizedRole,
6384+
uuid,
6385+
timestamp
6386+
};
6387+
6388+
if (normalizedRole === 'assistant') {
6389+
record.message = {
6390+
id: `msg_${String(uuid || '').replace(/-/g, '')}`,
6391+
type: 'message',
6392+
role: 'assistant',
6393+
model: 'derived',
6394+
content: [{ type: 'text', text: safeText }],
6395+
stop_reason: 'end_turn',
6396+
stop_sequence: null,
6397+
usage: {
6398+
input_tokens: 0,
6399+
cache_creation_input_tokens: 0,
6400+
cache_read_input_tokens: 0,
6401+
output_tokens: 0
6402+
}
6403+
};
6404+
} else {
6405+
record.message = {
6406+
role: normalizedRole === 'system' ? 'system' : 'user',
6407+
content: [{ type: 'text', text: safeText }]
6408+
};
6409+
}
6410+
6411+
return record;
6412+
}
6413+
63676414
function getDerivedSessionMetaPath(filePath) {
63686415
if (!filePath) return '';
63696416
const base = filePath.toLowerCase().endsWith('.jsonl')
@@ -7023,25 +7070,31 @@ async function convertSessionToDerived(params = {}) {
70237070
lines.push(JSON.stringify({
70247071
type: 'response_item',
70257072
timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
7026-
payload: { type: 'message', role, content: text }
7073+
payload: { type: 'message', role, content: buildCodexMessageContent(role, text) }
70277074
}));
70287075
}
70297076
} else {
70307077
const claudeIndexPath = target === 'claude' ? path.join(outputDir, 'sessions-index.json') : '';
7078+
let parentUuid = null;
70317079
for (let i = 0; i < messages.length; i += 1) {
70327080
const message = messages[i];
70337081
if (!message) continue;
70347082
const role = normalizeRole(message.role);
70357083
if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
70367084
const text = typeof message.text === 'string' ? message.text : '';
70377085
if (!text) continue;
7038-
lines.push(JSON.stringify({
7039-
type: role,
7040-
timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
7086+
const messageUuid = generateCloneSessionId();
7087+
const timestamp = toIsoTime(message.timestamp, '') || new Date(now + i).toISOString();
7088+
lines.push(JSON.stringify(buildClaudeMessageRecord({
7089+
role,
7090+
text,
70417091
sessionId: derivedSessionId,
70427092
cwd,
7043-
message: { content: text }
7044-
}));
7093+
timestamp,
7094+
uuid: messageUuid,
7095+
parentUuid
7096+
})));
7097+
parentUuid = messageUuid;
70457098
}
70467099
if (claudeIndexPath) {
70477100
ensureClaudeSessionsIndex(claudeIndexPath, resolvedCwd);

tests/e2e/test-session-convert-derived.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,41 @@ function writeCodexSession(filePath, sessionId, messages, options = {}) {
7676
fs.writeFileSync(filePath, records.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf-8');
7777
}
7878

79+
function readJsonlRecords(filePath) {
80+
return fs.readFileSync(filePath, 'utf-8')
81+
.split(/\r?\n/)
82+
.map((line) => line.trim())
83+
.filter(Boolean)
84+
.map((line) => JSON.parse(line));
85+
}
86+
87+
function assertCodexNativeMessageContent(filePath) {
88+
const records = readJsonlRecords(filePath);
89+
const messages = records.filter((record) => record && record.type === 'response_item' && record.payload && record.payload.type === 'message');
90+
assert(messages.length > 0, 'derived codex file should contain message records');
91+
for (const record of messages) {
92+
const role = record.payload.role;
93+
const content = record.payload.content;
94+
assert(Array.isArray(content), 'derived codex message content should use native typed content array');
95+
assert(content.length === 1, 'derived codex message content should contain one text item');
96+
assert(content[0].type === (role === 'assistant' ? 'output_text' : 'input_text'), 'derived codex message content type mismatch');
97+
assert(typeof content[0].text === 'string' && content[0].text, 'derived codex message text missing');
98+
}
99+
}
100+
101+
function assertClaudeNativeMessageContent(filePath) {
102+
const records = readJsonlRecords(filePath);
103+
assert(records.length > 0, 'derived claude file should contain message records');
104+
for (const record of records) {
105+
assert(record.uuid && typeof record.uuid === 'string', 'derived claude record should include uuid');
106+
assert(Object.prototype.hasOwnProperty.call(record, 'parentUuid'), 'derived claude record should include parentUuid');
107+
assert(record.message && record.message.role, 'derived claude message role missing');
108+
const content = record.message.content;
109+
assert(Array.isArray(content), 'derived claude message content should use native typed content array');
110+
assert(content.length === 1 && content[0].type === 'text' && typeof content[0].text === 'string' && content[0].text, 'derived claude message text missing');
111+
}
112+
}
113+
79114
async function convertAndAssertListed(api, tmpHome, source, target, params = {}, options = {}) {
80115
const res = await api('convert-session', { source, target, ...params });
81116
assert(!res.error, `convert-session ${source}->${target} failed: ${res.error || ''}`);
@@ -139,6 +174,7 @@ module.exports = async function testSessionConvertDerived(ctx) {
139174
assert(detailClaude.messages.length === 2, 'session-detail(derived claude) should keep exact short length');
140175
assert(detailClaude.messages[0].text === 'hello', 'session-detail(derived claude) user text mismatch');
141176
assert(detailClaude.messages[1].text === 'world', 'session-detail(derived claude) assistant text mismatch');
177+
assertClaudeNativeMessageContent(derivedClaudePath);
142178

143179
const { res: derivedCodexRes, outPath: derivedCodexPath } = await convertAndAssertListed(api, tmpHome, 'claude', 'codex', {
144180
filePath: derivedClaudePath,
@@ -151,6 +187,7 @@ module.exports = async function testSessionConvertDerived(ctx) {
151187
assert(detailCodex.messages.length === 2, 'session-detail(derived codex) should keep exact short length');
152188
assert(detailCodex.messages[0].text === 'hello', 'session-detail(derived codex) user text mismatch');
153189
assert(detailCodex.messages[1].text === 'world', 'session-detail(derived codex) assistant text mismatch');
190+
assertCodexNativeMessageContent(derivedCodexPath);
154191

155192
const { outPath: derivedClaudePath2 } = await convertAndAssertListed(api, tmpHome, 'codex', 'claude', {
156193
sessionId,
@@ -188,6 +225,7 @@ module.exports = async function testSessionConvertDerived(ctx) {
188225
assert(texts.length === 2, 'claude derived codex session should keep message count');
189226
assert(texts[0] === 'hello from claude code session', 'claude derived codex user text mismatch');
190227
assert(texts[1] === 'initialized project', 'claude derived codex assistant text mismatch');
228+
assertCodexNativeMessageContent(claudeDerivedCodexPath);
191229
assert(sha256File(claudeSessionPath) === beforeClaudeHash, 'claude source session should remain unchanged after conversion');
192230
}
193231

0 commit comments

Comments
 (0)