Repository navigation
feat(sessions): derived conversion + resume commands #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
43bbf3c
d396b03
9d28ed3
33b6977
5d0d701
a078701
a5b9ba6
e8435a2
38c9a89
7e911df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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'); | ||
| 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 }; | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leading 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 |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Right now, any non- 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 |
||
|
|
||
| module.exports = { readSessionMessages, buildTargetRecords }; | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap conversion I/O path with explicit error handling. Line 29 onward can throw on read/parse/write failures. Add a 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 |
||
| if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all'); | ||
| console.log(); | ||
| } | ||
|
|
||
| module.exports = { cmdConvertSession }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| const path = require('path'); | ||
| const fs = require('fs'); | ||
| const crypto = require('crypto'); | ||
| const { assert } = require('./helpers'); | ||
|
|
||
| function sha256File(filePath) { | ||
| const buf = fs.readFileSync(filePath); | ||
| return crypto.createHash('sha256').update(buf).digest('hex'); | ||
| } | ||
|
|
||
| function derivedMetaPath(filePath) { | ||
| return filePath.endsWith('.jsonl') ? filePath.replace(/\.jsonl$/, '.meta.json') : `${filePath}.meta.json`; | ||
| } | ||
|
|
||
| module.exports = async function testSessionConvertDerived(ctx) { | ||
| const { api, tmpHome, sessionId, sessionPath } = ctx; | ||
|
|
||
| const beforeHash = sha256File(sessionPath); | ||
|
|
||
| const resCodexToClaude = await api('convert-session', { | ||
| source: 'codex', | ||
| target: 'claude', | ||
| sessionId, | ||
| maxMessages: 'all' | ||
| }); | ||
| assert(!resCodexToClaude.error, `convert-session codex->claude failed: ${resCodexToClaude.error || ''}`); | ||
| assert(resCodexToClaude.session && resCodexToClaude.session.filePath, 'convert-session codex->claude missing session.filePath'); | ||
| const derivedClaudePath = resCodexToClaude.session.filePath; | ||
| assert(fs.existsSync(derivedClaudePath), 'derived claude session file missing'); | ||
| assert(fs.existsSync(derivedMetaPath(derivedClaudePath)), 'derived claude meta missing'); | ||
| assert( | ||
| derivedClaudePath.startsWith(path.join(tmpHome, '.codexmate', 'sessions', 'derived', 'claude') + path.sep), | ||
| 'derived claude session path should stay inside ~/.codexmate' | ||
| ); | ||
|
|
||
| const listClaude = await api('list-sessions', { source: 'claude', limit: 200, forceRefresh: true }); | ||
| assert(Array.isArray(listClaude.sessions), 'list-sessions(claude) missing sessions'); | ||
| assert(listClaude.sessions.some((item) => item && item.filePath === derivedClaudePath), 'derived claude session not listed'); | ||
|
|
||
| const detailClaude = await api('session-detail', { source: 'claude', filePath: derivedClaudePath, maxMessages: 50 }); | ||
| assert(Array.isArray(detailClaude.messages), 'session-detail(derived claude) missing messages'); | ||
| assert(detailClaude.messages.length === 2, 'session-detail(derived claude) should keep exact short length'); | ||
| assert(detailClaude.messages[0].text === 'hello', 'session-detail(derived claude) user text mismatch'); | ||
| assert(detailClaude.messages[1].text === 'world', 'session-detail(derived claude) assistant text mismatch'); | ||
|
|
||
| const resClaudeToCodex = await api('convert-session', { | ||
| source: 'claude', | ||
| target: 'codex', | ||
| filePath: derivedClaudePath, | ||
| maxMessages: 'all' | ||
| }); | ||
| assert(!resClaudeToCodex.error, `convert-session claude->codex failed: ${resClaudeToCodex.error || ''}`); | ||
| assert(resClaudeToCodex.session && resClaudeToCodex.session.filePath, 'convert-session claude->codex missing session.filePath'); | ||
| const derivedCodexPath = resClaudeToCodex.session.filePath; | ||
| assert(fs.existsSync(derivedCodexPath), 'derived codex session file missing'); | ||
| assert(fs.existsSync(derivedMetaPath(derivedCodexPath)), 'derived codex meta missing'); | ||
| assert( | ||
| derivedCodexPath.startsWith(path.join(tmpHome, '.codexmate', 'sessions', 'derived', 'codex') + path.sep), | ||
| 'derived codex session path should stay inside ~/.codexmate' | ||
| ); | ||
|
|
||
| const listCodex = await api('list-sessions', { source: 'codex', limit: 200, forceRefresh: true }); | ||
| assert(Array.isArray(listCodex.sessions), 'list-sessions(codex) missing sessions'); | ||
| assert(listCodex.sessions.some((item) => item && item.filePath === derivedCodexPath), 'derived codex session not listed'); | ||
|
|
||
| const detailCodex = await api('session-detail', { source: 'codex', filePath: derivedCodexPath, maxMessages: 50 }); | ||
| assert(Array.isArray(detailCodex.messages), 'session-detail(derived codex) missing messages'); | ||
| assert(detailCodex.messages.length === 2, 'session-detail(derived codex) should keep exact short length'); | ||
| assert(detailCodex.messages[0].text === 'hello', 'session-detail(derived codex) user text mismatch'); | ||
| assert(detailCodex.messages[1].text === 'world', 'session-detail(derived codex) assistant text mismatch'); | ||
|
|
||
| const resCodexToClaude2 = await api('convert-session', { | ||
| source: 'codex', | ||
| target: 'claude', | ||
| sessionId, | ||
| maxMessages: 'all' | ||
| }); | ||
| assert(!resCodexToClaude2.error, `convert-session codex->claude #2 failed: ${resCodexToClaude2.error || ''}`); | ||
| const derivedClaudePath2 = resCodexToClaude2.session && resCodexToClaude2.session.filePath ? resCodexToClaude2.session.filePath : ''; | ||
| assert(derivedClaudePath2 && derivedClaudePath2 !== derivedClaudePath, 'second derived session should create a distinct file'); | ||
| assert(fs.existsSync(derivedClaudePath2), 'second derived claude session file missing'); | ||
|
|
||
| const afterHash = sha256File(sessionPath); | ||
| assert(afterHash === beforeHash, 'source codex session should remain unchanged after conversions'); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -173,6 +173,7 @@ async function waitForCondition(check, label) { | |
| module.exports = async function testWebUiSessionBrowser(ctx) { | ||
| const { api, tmpHome } = ctx; | ||
| const sessionsDir = path.join(tmpHome, '.codex', 'sessions'); | ||
| const derivedCodexDir = path.join(tmpHome, '.codexmate', 'sessions', 'derived', 'codex'); | ||
| fs.mkdirSync(sessionsDir, { recursive: true }); | ||
|
|
||
| const denseListSessionCount = 140; | ||
|
|
@@ -256,7 +257,13 @@ module.exports = async function testWebUiSessionBrowser(ctx) { | |
| }); | ||
|
|
||
| assert(vm.sessionsList.length >= denseListSessionCount + 3, 'session browser should load the large isolated codex dataset'); | ||
| assert(vm.sessionsList.every((item) => String(item.filePath || '').startsWith(sessionsDir)), 'session browser e2e should stay inside the isolated tmp HOME dataset'); | ||
| assert( | ||
| vm.sessionsList.every((item) => { | ||
| const filePath = String(item && item.filePath ? item.filePath : ''); | ||
| return filePath.startsWith(sessionsDir) || filePath.startsWith(derivedCodexDir); | ||
| }), | ||
|
Comment on lines
+260
to
+264
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strengthen path-boundary validation in the isolation assertion. On Line 263, Suggested patch+ const isWithin = (root, candidate) => {
+ if (!candidate) return false;
+ const rel = path.relative(root, candidate);
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
+ };
assert(
vm.sessionsList.every((item) => {
- const filePath = String(item && item.filePath ? item.filePath : '');
- return filePath.startsWith(sessionsDir) || filePath.startsWith(derivedCodexDir);
+ const filePath = typeof (item && item.filePath) === 'string' ? item.filePath : '';
+ return isWithin(sessionsDir, filePath) || isWithin(derivedCodexDir, filePath);
}),
'session browser e2e should stay inside the isolated tmp HOME dataset'
);🤖 Prompt for AI Agents |
||
| 'session browser e2e should stay inside the isolated tmp HOME dataset' | ||
| ); | ||
| assert(vm.activeSession && vm.activeSession.sessionId === initialHugeLineSessionId, 'session browser should select the newest session from the isolated dataset on tab entry'); | ||
| assert(vm.visibleSessionsList.length === vm.sessionListInitialBatchSize, 'session browser should render only the first session list batch initially'); | ||
| assert(vm.activeSessionMessages.length > 0, 'session browser should hydrate the initially selected huge-line session on tab entry'); | ||
|
|
||
| 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'); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce mutual exclusivity for
--session-idand--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
📝 Committable suggestion
🤖 Prompt for AI Agents