mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(codex): improve local session import compatibility (#995)
Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>
This commit is contained in:
@@ -46,6 +46,276 @@ function createTranscript(codexHome: string, sessionId: string, cwd = 'C:\\work\
|
||||
writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8')
|
||||
}
|
||||
|
||||
function createMirroredTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'mirrored user message' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'mirrored user message\n' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function createMirroredAgentMessageTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', message: 'duplicated assistant message' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'duplicated assistant message' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function createInjectedResponseUserTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: `# AGENTS.md instructions for /repo
|
||||
<INSTRUCTIONS>ignore</INSTRUCTIONS>` }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: `<environment_context>
|
||||
<current_date>2026-07-07</current_date>
|
||||
<filesystem>workspace</filesystem>
|
||||
</environment_context>` }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'real event user message' }
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function createEmbeddedEnvironmentPromptTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: `Please inspect this transcript block:
|
||||
<environment_context>
|
||||
<current_date>2026-07-07</current_date>
|
||||
</environment_context>
|
||||
This is part of my prompt.` }]
|
||||
}
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function createSameSourceDuplicateTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'same source user message' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'same source user message' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'same source assistant message' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'same source assistant message' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function createRepeatedSameTextDistinctTurnTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'repeat user message' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'repeat user message' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'assistant answer' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'repeat user message' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function createMirroredAndDistinctUserTranscript(codexHome: string, sessionId: string): void {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl')
|
||||
const transcriptLines = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
cwd: 'C:/work/project',
|
||||
originator: 'codex_cli_rs',
|
||||
cli_version: '0.0.0-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'first user message' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'first user message' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'second user message' }
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
function writeSessionIndex(codexHome: string, records: Array<{ id: string; thread_name: string; updated_at: string }>): void {
|
||||
writeFileSync(
|
||||
join(codexHome, 'session_index.jsonl'),
|
||||
records.map((record) => JSON.stringify(record)).join('\n') + '\n',
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
|
||||
function createMachine(id: string, workspaceRoots: string[], namespace = 'default'): Machine {
|
||||
return {
|
||||
id,
|
||||
@@ -163,6 +433,403 @@ describe('Codex Desktop import routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates mirrored event_msg and response_item user messages', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-mirror-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '66666666-6666-4666-8666-666666666666'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createMirroredTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].content).toEqual({
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'mirrored user message'
|
||||
},
|
||||
meta: {
|
||||
sentFrom: 'cli'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates adjacent mirrored agent messages with different ids', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-agent-mirror-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createMirroredAgentMessageTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].content).toEqual({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: AGENT_MESSAGE_PAYLOAD_TYPE,
|
||||
data: {
|
||||
type: 'message',
|
||||
message: 'duplicated assistant message',
|
||||
id: expect.any(String)
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
sentFrom: 'cli'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips injected response_item user context messages', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-injected-user-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '88888888-8888-4888-8888-888888888888'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createInjectedResponseUserTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].content).toEqual({
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'real event user message'
|
||||
},
|
||||
meta: {
|
||||
sentFrom: 'cli'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a real response_item user prompt containing embedded environment_context text', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-embedded-env-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = 'abababab-abab-4bab-8bab-abababababab'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createEmbeddedEnvironmentPromptTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].content).toMatchObject({
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: expect.stringContaining('<environment_context>')
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves adjacent duplicate messages from the same transcript source', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-same-source-duplicate-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = 'babababa-baba-4aba-8aba-babababababa'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createSameSourceDuplicateTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
expect(messages).toHaveLength(4)
|
||||
expect(messages.map((message) => (message.content as { role?: unknown }).role)).toEqual([
|
||||
'user',
|
||||
'user',
|
||||
'agent',
|
||||
'agent'
|
||||
])
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a later response_item-only user turn with the same text', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-repeat-user-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '99999999-9999-4999-8999-999999999999'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createRepeatedSameTextDistinctTurnTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
const roles = messages.map((message) => (message.content as { role?: unknown }).role)
|
||||
expect(roles).toEqual(['user', 'agent', 'user'])
|
||||
expect(messages[0].content).toMatchObject({
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'repeat user message'
|
||||
}
|
||||
})
|
||||
expect(messages[2].content).toMatchObject({
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'repeat user message'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps distinct user turns while deduplicating mirrored user events', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-distinct-user-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '77777777-7777-4777-8777-777777777777'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createMirroredAndDistinctUserTranscript(codexHome, codexSessionId)
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session).toBeDefined()
|
||||
const messages = store.messages.getAllMessages(session.id)
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages.map((message) => message.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'first user message'
|
||||
},
|
||||
meta: {
|
||||
sentFrom: 'cli'
|
||||
}
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'second user message'
|
||||
},
|
||||
meta: {
|
||||
sentFrom: 'cli'
|
||||
}
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the latest session_index thread_name for list and imported session title', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-index-title-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createTranscript(codexHome, codexSessionId)
|
||||
writeSessionIndex(codexHome, [
|
||||
{
|
||||
id: codexSessionId,
|
||||
thread_name: 'old thread title',
|
||||
updated_at: '2026-07-07T01:00:00.000000000Z'
|
||||
},
|
||||
{
|
||||
id: codexSessionId,
|
||||
thread_name: 'new thread title',
|
||||
updated_at: '2026-07-07T02:00:00.000000000Z'
|
||||
}
|
||||
])
|
||||
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
|
||||
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('new thread title')
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session.metadata).toMatchObject({
|
||||
name: 'new thread title',
|
||||
flavor: 'codex',
|
||||
codexSessionId
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to transcript title when session_index is missing', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-no-index-title-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createTranscript(codexHome, codexSessionId)
|
||||
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
|
||||
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message')
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session.metadata).toMatchObject({
|
||||
name: 'normal user message',
|
||||
flavor: 'codex',
|
||||
codexSessionId
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to transcript title when session_index has no matching id', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-index-miss-title-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createTranscript(codexHome, codexSessionId)
|
||||
writeSessionIndex(codexHome, [
|
||||
{
|
||||
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
thread_name: 'unrelated title',
|
||||
updated_at: '2026-07-07T03:00:00.000000000Z'
|
||||
}
|
||||
])
|
||||
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
|
||||
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message')
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session.metadata).toMatchObject({
|
||||
name: 'normal user message',
|
||||
flavor: 'codex',
|
||||
codexSessionId
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('binds imported transcripts to the unique online machine that owns the cwd', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-'))
|
||||
const store = new Store(':memory:')
|
||||
|
||||
@@ -88,10 +88,21 @@ type CodexImportedMessageContent = {
|
||||
}
|
||||
}
|
||||
|
||||
type CodexImportedMessageSource = 'event_msg' | 'response_item'
|
||||
type CodexImportedMessageEntry = {
|
||||
source: CodexImportedMessageSource
|
||||
message: CodexImportedMessageContent
|
||||
}
|
||||
|
||||
type CodexTranscriptImportData = CodexLocalSessionSummary & {
|
||||
messages: CodexImportedMessageContent[]
|
||||
}
|
||||
|
||||
type CodexSessionIndexTitle = {
|
||||
threadName: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type ImportCandidate = {
|
||||
sessionId: string
|
||||
active: boolean
|
||||
@@ -207,6 +218,10 @@ function getCodexSessionRoots(): string[] {
|
||||
return [join(codexHome, 'sessions')]
|
||||
}
|
||||
|
||||
function getCodexSessionIndexPath(): string {
|
||||
return join(getCodexHome(), 'session_index.jsonl')
|
||||
}
|
||||
|
||||
function collectJsonlFiles(root: string, files: string[]): void {
|
||||
if (!existsSync(root)) return
|
||||
let entries
|
||||
@@ -272,10 +287,13 @@ function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value
|
||||
}
|
||||
|
||||
function shouldIgnoreSyntheticUserMessage(text: string): boolean {
|
||||
function shouldIgnoreInjectedResponseUserMessage(text: string): boolean {
|
||||
const normalized = text.trim()
|
||||
return normalized.startsWith('# AGENTS.md instructions')
|
||||
|| normalized.startsWith('<environment_context>')
|
||||
const lower = normalized.toLowerCase()
|
||||
const isAgentInstructions = lower.startsWith('# agents.md instructions')
|
||||
const isEnvironmentContext = lower.startsWith('<environment_context>')
|
||||
&& lower.endsWith('</environment_context>')
|
||||
return isAgentInstructions || isEnvironmentContext
|
||||
}
|
||||
|
||||
function inferSessionIdFromFileName(filePath: string): string | null {
|
||||
@@ -371,7 +389,7 @@ function getLatestCodexUserMessage(lines: string[]): string | null {
|
||||
const payload = asRecord(record.payload)
|
||||
if (payload?.type !== 'message' || payload.role !== 'user') continue
|
||||
const text = extractCodexText(payload.content)
|
||||
if (text && !shouldIgnoreSyntheticUserMessage(text)) {
|
||||
if (text && !shouldIgnoreInjectedResponseUserMessage(text)) {
|
||||
return truncateText(text, 140)
|
||||
}
|
||||
} catch {
|
||||
@@ -384,9 +402,14 @@ function getLatestCodexUserMessage(lines: string[]): string | null {
|
||||
function getCodexSessionTitle(
|
||||
cwd: string | null | undefined,
|
||||
sessionId: string,
|
||||
sessionIndexTitle: string | null,
|
||||
changedTitle: string | null,
|
||||
firstUserMessage: string | null
|
||||
): string {
|
||||
if (sessionIndexTitle) {
|
||||
return truncateText(sessionIndexTitle, 80)
|
||||
}
|
||||
|
||||
if (changedTitle) {
|
||||
return truncateText(changedTitle, 80)
|
||||
}
|
||||
@@ -410,7 +433,48 @@ function isSubagentSource(value: unknown): boolean {
|
||||
return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false
|
||||
}
|
||||
|
||||
function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null {
|
||||
function readCodexSessionIndexTitles(): Map<string, CodexSessionIndexTitle> {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(getCodexSessionIndexPath(), 'utf-8')
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const titles = new Map<string, CodexSessionIndexTitle>()
|
||||
for (const line of content.split(/\r?\n/).filter(Boolean)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = asRecord(parsed)
|
||||
const id = typeof record?.id === 'string' ? record.id : null
|
||||
const threadName = typeof record?.thread_name === 'string' && record.thread_name.trim()
|
||||
? record.thread_name.trim()
|
||||
: null
|
||||
const updatedAt = typeof record?.updated_at === 'string' && record.updated_at.trim()
|
||||
? record.updated_at.trim()
|
||||
: null
|
||||
if (!id || !threadName || !updatedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = titles.get(id)
|
||||
if (!previous || previous.updatedAt < updatedAt) {
|
||||
titles.set(id, { threadName, updatedAt })
|
||||
}
|
||||
}
|
||||
|
||||
return titles
|
||||
}
|
||||
|
||||
function parseCodexLocalSession(
|
||||
filePath: string,
|
||||
sessionIndexTitles = new Map<string, CodexSessionIndexTitle>()
|
||||
): CodexLocalSessionSummary | null {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(filePath, 'utf-8')
|
||||
@@ -461,7 +525,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu
|
||||
const payload = asRecord(record?.payload)
|
||||
if (payload?.type === 'message' && payload.role === 'user') {
|
||||
const text = extractCodexText(payload.content)
|
||||
if (text && !shouldIgnoreSyntheticUserMessage(text)) {
|
||||
if (text && !shouldIgnoreInjectedResponseUserMessage(text)) {
|
||||
firstUserMessage = text
|
||||
}
|
||||
}
|
||||
@@ -473,6 +537,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu
|
||||
|
||||
sessionId = sessionId ?? inferSessionIdFromFileName(filePath)
|
||||
if (!sessionId) return null
|
||||
const sessionIndexTitle = sessionIndexTitles.get(sessionId)?.threadName ?? null
|
||||
|
||||
let modifiedAt = Date.now()
|
||||
try {
|
||||
@@ -483,7 +548,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
title: getCodexSessionTitle(cwd, sessionId, changedTitle, firstUserMessage),
|
||||
title: getCodexSessionTitle(cwd, sessionId, sessionIndexTitle, changedTitle, firstUserMessage),
|
||||
lastUserMessage,
|
||||
cwd,
|
||||
file: filePath,
|
||||
@@ -499,9 +564,10 @@ function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Codex
|
||||
collectJsonlFiles(root, files)
|
||||
}
|
||||
|
||||
const sessionIndexTitles = readCodexSessionIndexTitles()
|
||||
const deduped = new Map<string, CodexLocalSessionSummary>()
|
||||
for (const filePath of files) {
|
||||
const session = parseCodexLocalSession(filePath)
|
||||
const session = parseCodexLocalSession(filePath, sessionIndexTitles)
|
||||
if (!session) continue
|
||||
const previous = deduped.get(session.id)
|
||||
if (!previous || previous.modifiedAt < session.modifiedAt) {
|
||||
@@ -557,7 +623,7 @@ function convertCodexRecordToImportedMessage(record: Record<string, unknown>): C
|
||||
const text = asString(payload.message)
|
||||
?? asString(payload.text)
|
||||
?? asString(payload.content)
|
||||
if (!text || shouldIgnoreSyntheticUserMessage(text)) {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return buildImportedUserMessage(text)
|
||||
@@ -595,11 +661,11 @@ function convertCodexRecordToImportedMessage(record: Record<string, unknown>): C
|
||||
if (itemType === 'message') {
|
||||
const role = asString(payload.role)
|
||||
const text = extractCodexText(payload.content)
|
||||
if (!text || shouldIgnoreSyntheticUserMessage(text)) {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
if (role === 'user') {
|
||||
return buildImportedUserMessage(text)
|
||||
return shouldIgnoreInjectedResponseUserMessage(text) ? null : buildImportedUserMessage(text)
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
return buildImportedAgentMessage({ type: 'message', message: text, id: randomUUID() })
|
||||
@@ -639,6 +705,86 @@ function convertCodexRecordToImportedMessage(record: Record<string, unknown>): C
|
||||
return null
|
||||
}
|
||||
|
||||
function getCodexImportedMessageSource(record: Record<string, unknown>): CodexImportedMessageSource | null {
|
||||
const type = asString(record.type)
|
||||
return type === 'event_msg' || type === 'response_item' ? type : null
|
||||
}
|
||||
|
||||
function normalizeComparableUserMessage(content: unknown): string | null {
|
||||
const record = asRecord(content)
|
||||
if (!record || record.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
|
||||
const body = asRecord(record.content)
|
||||
if (body?.type !== 'text' || typeof body.text !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return stableSerialize({
|
||||
role: 'user',
|
||||
text: body.text.trimEnd()
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeComparableAgentMessage(content: unknown): string | null {
|
||||
const record = asRecord(content)
|
||||
if (!record || record.role !== 'agent') {
|
||||
return null
|
||||
}
|
||||
|
||||
const body = asRecord(record.content)
|
||||
if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = asRecord(body.data)
|
||||
if (data?.type !== 'message' || typeof data.message !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return stableSerialize({
|
||||
role: 'agent',
|
||||
type: 'message',
|
||||
message: data.message
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAdjacentDuplicateMessage(content: unknown): string | null {
|
||||
return normalizeComparableUserMessage(content) ?? normalizeComparableAgentMessage(content)
|
||||
}
|
||||
|
||||
function isAdjacentDuplicateImportedMessage(
|
||||
previous: CodexImportedMessageContent,
|
||||
next: CodexImportedMessageContent
|
||||
): boolean {
|
||||
const previousKey = normalizeAdjacentDuplicateMessage(previous)
|
||||
const nextKey = normalizeAdjacentDuplicateMessage(next)
|
||||
return previousKey !== null && previousKey === nextKey
|
||||
}
|
||||
|
||||
function isMirroredAdjacentDuplicate(
|
||||
previous: CodexImportedMessageEntry | undefined,
|
||||
next: CodexImportedMessageEntry
|
||||
): boolean {
|
||||
return Boolean(
|
||||
previous
|
||||
&& previous.source !== next.source
|
||||
&& isAdjacentDuplicateImportedMessage(previous.message, next.message)
|
||||
)
|
||||
}
|
||||
|
||||
function isResponseItemDuplicateOfEventUserMessage(
|
||||
entry: CodexImportedMessageEntry,
|
||||
recentEventUserMessageKey: string | null
|
||||
): boolean {
|
||||
if (entry.source !== 'response_item' || recentEventUserMessageKey === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return normalizeComparableUserMessage(entry.message) === recentEventUserMessageKey
|
||||
}
|
||||
|
||||
function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null {
|
||||
let content: string
|
||||
try {
|
||||
@@ -648,7 +794,8 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/).filter(Boolean)
|
||||
const messages: CodexImportedMessageContent[] = []
|
||||
const entries: CodexImportedMessageEntry[] = []
|
||||
let recentEventUserMessageKey: string | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
let parsed: unknown
|
||||
@@ -660,15 +807,38 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code
|
||||
|
||||
const record = asRecord(parsed)
|
||||
if (!record) continue
|
||||
const source = getCodexImportedMessageSource(record)
|
||||
if (!source) continue
|
||||
const message = convertCodexRecordToImportedMessage(record)
|
||||
if (message) {
|
||||
messages.push(message)
|
||||
const entry = { source, message }
|
||||
const userMessageKey = normalizeComparableUserMessage(message)
|
||||
if (source === 'event_msg' && userMessageKey !== null) {
|
||||
const previous = entries[entries.length - 1]
|
||||
if (previous?.source === 'response_item' && isMirroredAdjacentDuplicate(previous, entry)) {
|
||||
entries[entries.length - 1] = entry
|
||||
} else {
|
||||
entries.push(entry)
|
||||
}
|
||||
recentEventUserMessageKey = userMessageKey
|
||||
continue
|
||||
}
|
||||
if (isResponseItemDuplicateOfEventUserMessage(entry, recentEventUserMessageKey)) {
|
||||
continue
|
||||
}
|
||||
const previous = entries[entries.length - 1]
|
||||
if (isMirroredAdjacentDuplicate(previous, entry)) {
|
||||
recentEventUserMessageKey = null
|
||||
continue
|
||||
}
|
||||
entries.push(entry)
|
||||
recentEventUserMessageKey = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
messages
|
||||
messages: entries.map((entry) => entry.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user