diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index b806116c..3a8b70e2 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -1051,6 +1051,267 @@ describe('Codex Desktop import routes', () => { } }) + it('does not append imports to an archived Codex session', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-archived-target-test-')) + const store = new Store(':memory:') + const codexSessionId = '15151515-1515-4515-8515-151515151515' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + const archived = store.sessions.getOrCreateSession('archived-session', { + path: 'C:\\work\\project', + flavor: 'codex', + codexSessionId, + lifecycleState: 'archived' + }, {}, 'default') + store.messages.addMessage(archived.id, { type: 'text', text: 'archived old message' }, 'archived-1') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const sessions = store.sessions.getSessionsByNamespace('default') + expect(sessions).toHaveLength(2) + expect(result.hapiSessionIds?.[0]).not.toBe(archived.id) + expect(store.messages.getAllMessages(archived.id)).toHaveLength(1) + expect(store.messages.getAllMessages(result.hapiSessionIds![0]!)).toHaveLength(2) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('does not treat one session with duplicate Codex import ids as a duplicate group', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('imported-session', { + codexSessionId: 'codex-thread-1', + codexSourceSessionId: 'codex-thread-1', + lifecycleState: 'imported' + }, {}, 'default') + store.messages.addMessage(session.id, { type: 'text', text: 'imported message' }, 'message-1') + app.route('/api', createCodexDesktopRoutes({ + store, + getSyncEngine: () => null + })) + + try { + const duplicateResponse = await app.request('/api/codex/duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(duplicateResponse.status).toBe(200) + const duplicateBody = await duplicateResponse.json() as { success: true; duplicates: unknown[] } + expect(duplicateBody.success).toBe(true) + expect(duplicateBody.duplicates).toEqual([]) + + const mergeResponse = await app.request('/api/codex/merge-duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(mergeResponse.status).toBe(200) + const mergeBody = await mergeResponse.json() as { success: true; merged: unknown[]; mergedCount: number } + expect(mergeBody.success).toBe(true) + expect(mergeBody.merged).toEqual([]) + expect(mergeBody.mergedCount).toBe(0) + expect(store.sessions.getSessionByNamespace(session.id, 'default')).toBeDefined() + expect(store.messages.getAllMessages(session.id)).toHaveLength(1) + } finally { + store.close() + } + }) + + it('does not treat archived Codex sessions as mergeable duplicates', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + const store = new Store(':memory:') + const archived = store.sessions.getOrCreateSession('archived-session', { + codexSessionId: 'codex-thread-1', + lifecycleState: 'archived' + }, {}, 'default') + const imported = store.sessions.getOrCreateSession('imported-session', { codexSessionId: 'codex-thread-1' }, {}, 'default') + app.route('/api', createCodexDesktopRoutes({ + store, + getSyncEngine: () => null + })) + + try { + const duplicateResponse = await app.request('/api/codex/duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(duplicateResponse.status).toBe(200) + const duplicateBody = await duplicateResponse.json() as { success: true; duplicates: unknown[] } + expect(duplicateBody.success).toBe(true) + expect(duplicateBody.duplicates).toEqual([]) + + const mergeResponse = await app.request('/api/codex/merge-duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(mergeResponse.status).toBe(200) + const mergeBody = await mergeResponse.json() as { success: true; merged: unknown[]; mergedCount: number } + expect(mergeBody.success).toBe(true) + expect(mergeBody.merged).toEqual([]) + expect(mergeBody.mergedCount).toBe(0) + expect(store.sessions.getSessionsByNamespace('default').map((session) => session.id).sort()).toEqual([archived.id, imported.id].sort()) + } finally { + store.close() + } + }) + + it('does not treat engine-only sessions as mergeable Codex duplicates', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + const store = new Store(':memory:') + const storedSession = store.sessions.getOrCreateSession('stored-session', { codexSessionId: 'codex-thread-1' }, {}, 'default') + const engineOnlySession = { + id: 'engine-only-session', + active: false, + updatedAt: storedSession.updatedAt + 1, + metadata: { codexSessionId: 'codex-thread-1' } + } + const engine = { + getSessionsByNamespace: () => [engineOnlySession] + } as unknown as SyncEngine + app.route('/api', createCodexDesktopRoutes({ + store, + getSyncEngine: () => engine + })) + + try { + const duplicateResponse = await app.request('/api/codex/duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(duplicateResponse.status).toBe(200) + const duplicateBody = await duplicateResponse.json() as { success: true; duplicates: unknown[] } + expect(duplicateBody.success).toBe(true) + expect(duplicateBody.duplicates).toEqual([]) + + const mergeResponse = await app.request('/api/codex/merge-duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(mergeResponse.status).toBe(200) + const mergeBody = await mergeResponse.json() as { success: true; merged: unknown[]; mergedCount: number } + expect(mergeBody.success).toBe(true) + expect(mergeBody.merged).toEqual([]) + expect(mergeBody.mergedCount).toBe(0) + expect(store.sessions.getSessionsByNamespace('default').map((session) => session.id)).toEqual([storedSession.id]) + } finally { + store.close() + } + }) + + it('detects duplicate sessions from both SyncEngine cache and persistent store', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + const store = new Store(':memory:') + const storedSession = store.sessions.getOrCreateSession('stored-session', { codexSessionId: 'codex-thread-1' }, {}, 'default') + const engineSession = store.sessions.getOrCreateSession('engine-session', { codexSessionId: 'codex-thread-1' }, {}, 'default') + const engine = { + getSessionsByNamespace: () => [engineSession] + } as unknown as SyncEngine + app.route('/api', createCodexDesktopRoutes({ + store, + getSyncEngine: () => engine + })) + + try { + const response = await app.request('/api/codex/duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(response.status).toBe(200) + const body = await response.json() as { success: true; duplicates: Array<{ codexSessionId: string; hapiSessionIds: string[] }> } + expect(body.success).toBe(true) + expect(body.duplicates).toHaveLength(1) + expect(body.duplicates[0]?.hapiSessionIds.sort()).toEqual([storedSession.id, engineSession.id].sort()) + } finally { + store.close() + } + }) + + it('merges duplicate sessions discovered across SyncEngine cache and persistent store', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + const store = new Store(':memory:') + const storedSession = store.sessions.getOrCreateSession('stored-session', { codexSessionId: 'codex-thread-1' }, {}, 'default') + const engineSession = store.sessions.getOrCreateSession('engine-session', { codexSessionId: 'codex-thread-1' }, {}, 'default') + store.messages.addMessage(storedSession.id, { type: 'text', text: 'first stored message' }, 'stored-1') + store.messages.addMessage(storedSession.id, { type: 'text', text: 'second stored message' }, 'stored-2') + store.messages.addMessage(engineSession.id, { type: 'text', text: 'engine-only message' }, 'engine-1') + const engine = { + getSessionsByNamespace: () => [engineSession], + deleteSession: async (sessionId: string) => { + store.sessions.deleteSession(sessionId, 'default') + }, + handleRealtimeEvent: () => {}, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + } + } as unknown as SyncEngine + app.route('/api', createCodexDesktopRoutes({ + store, + getSyncEngine: () => engine + })) + + try { + const response = await app.request('/api/codex/merge-duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['codex-thread-1'] }) + }) + + expect(response.status).toBe(200) + const body = await response.json() as { success: true; merged: Array<{ canonicalSessionId?: string; removedSessionIds?: string[] }> } + expect(body.success).toBe(true) + expect(body.merged[0]?.canonicalSessionId).toBe(storedSession.id) + expect(body.merged[0]?.removedSessionIds).toEqual([engineSession.id]) + const sessions = store.sessions.getSessionsByNamespace('default') + expect(sessions.map((session) => session.id)).toEqual([storedSession.id]) + expect(store.messages.getAllMessages(storedSession.id)).toHaveLength(3) + } finally { + store.close() + } + }) + it('treats source and fork ids as the same duplicate-sessions group', async () => { const app = new Hono() app.use('*', async (c, next) => { diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index a2299189..0cb29979 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -117,6 +117,7 @@ type ImportCandidate = { active: boolean updatedAt: number metadata: Record | null + persisted: boolean } type ImportTargetSelection = { @@ -1087,27 +1088,43 @@ function collectImportCandidates( namespace: string, getSyncEngine?: () => SyncEngine | null ): ImportCandidate[] { - const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] - if (engineSessions.length > 0) { - return engineSessions.map((session) => ({ + const candidatesBySessionId = new Map() + for (const session of store.sessions.getSessionsByNamespace(namespace)) { + candidatesBySessionId.set(session.id, { sessionId: session.id, active: session.active, updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) + metadata: asRecord(session.metadata), + persisted: true + }) } - return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) + const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] + for (const session of engineSessions) { + const existing = candidatesBySessionId.get(session.id) + candidatesBySessionId.set(session.id, { + sessionId: session.id, + active: session.active || Boolean(existing?.active), + updatedAt: Math.max(session.updatedAt, existing?.updatedAt ?? 0), + metadata: asRecord(session.metadata) ?? existing?.metadata ?? null, + persisted: Boolean(existing?.persisted) + }) + } + + return Array.from(candidatesBySessionId.values()) } function getCodexImportIds(metadata: Record | null | undefined): string[] { - return [metadata?.codexSessionId, metadata?.codexSourceSessionId] - .filter((id): id is string => typeof id === 'string' && id.length > 0) + return Array.from(new Set([metadata?.codexSessionId, metadata?.codexSourceSessionId] + .filter((id): id is string => typeof id === 'string' && id.length > 0))) +} + +function isImportCandidateReusable(candidate: ImportCandidate): boolean { + const lifecycleState = candidate.metadata?.lifecycleState + if (lifecycleState === 'archived' || lifecycleState === 'deleted') { + return false + } + return true } function selectImportTargetSession( @@ -1118,6 +1135,7 @@ function selectImportTargetSession( sourceMachineId?: string | null ): ImportTargetSelection { const relatedCandidates = candidates + .filter((candidate) => candidate.persisted && isImportCandidateReusable(candidate)) .filter((candidate) => ( candidate.metadata?.codexSessionId === codexSessionId || candidate.metadata?.codexSourceSessionId === codexSessionId @@ -1178,6 +1196,9 @@ function listDuplicateCodexSessionGroups( const groups = new Map() for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { + if (!candidate.persisted || !isImportCandidateReusable(candidate)) { + continue + } for (const codexSessionId of getCodexImportIds(candidate.metadata)) { if (!requestedSessionIds.has(codexSessionId)) { continue @@ -1245,7 +1266,8 @@ async function mergeSingleDuplicateCodexSessionGroup(options: { getSyncEngine?: () => SyncEngine | null }): Promise { const engine = options.getSyncEngine?.() ?? null - const sessionStates = options.group.sessions + const uniqueSessions = Array.from(new Map(options.group.sessions.map((session) => [session.sessionId, session])).values()) + const sessionStates = uniqueSessions .map((candidate) => ({ ...candidate, storedMessages: options.store.messages.getAllMessages(candidate.sessionId), diff --git a/shared/src/apiTypes.test.ts b/shared/src/apiTypes.test.ts new file mode 100644 index 00000000..8decaff9 --- /dev/null +++ b/shared/src/apiTypes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { ListCodexSessionsRpcResponseSchema } from './apiTypes' + +describe('ListCodexSessionsRpcResponseSchema', () => { + it('preserves Codex session messages when parsing runner RPC responses', () => { + const parsed = ListCodexSessionsRpcResponseSchema.parse({ + success: true, + sessions: [{ + id: 'codex-session-id', + title: 'Codex Session', + file: '/home/user/.codex/sessions/session.jsonl', + modifiedAt: 1_000, + messages: [{ + role: 'user', + content: { + type: 'text', + text: 'hello' + }, + meta: { + sentFrom: 'cli' + } + }] + }] + }) + + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.sessions[0]?.messages).toHaveLength(1) + } + }) +}) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 10af4860..8448fff3 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -163,7 +163,7 @@ export const ListCodexSessionsRpcRequestSchema = z.object({ }) export const ListCodexSessionsRpcResponseSchema = z.union([ - z.object({ success: z.literal(true), sessions: z.array(z.union([CodexLocalSessionSummarySchema, CodexLocalSessionWithMessagesSchema])) }), + z.object({ success: z.literal(true), sessions: z.array(z.union([CodexLocalSessionWithMessagesSchema, CodexLocalSessionSummarySchema])) }), z.object({ success: z.literal(false), error: z.string() }) ]) diff --git a/web/src/router.tsx b/web/src/router.tsx index 8952b183..d7ea75b4 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -204,6 +204,7 @@ function SessionsPage() { const [isSyncConfirmOpen, setIsSyncConfirmOpen] = useState(false) const [isRestartingCodexDesktop, setIsRestartingCodexDesktop] = useState(false) const [pendingDuplicateSessionIds, setPendingDuplicateSessionIds] = useState([]) + const [pendingDuplicateHapiSessionIds, setPendingDuplicateHapiSessionIds] = useState([]) const [duplicateSessionGroups, setDuplicateSessionGroups] = useState([]) const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false) const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false) @@ -312,6 +313,7 @@ function SessionsPage() { // 中文注释:重复会话确认框关闭时一并清空“本次选中导入”的上下文,确保后续检测不会误用上一轮的 codexSessionId。 setIsDuplicateMergeConfirmOpen(false) setPendingDuplicateSessionIds([]) + setPendingDuplicateHapiSessionIds([]) setDuplicateSessionGroups([]) }, []) @@ -367,7 +369,9 @@ function SessionsPage() { const redirectTarget = selectedSessionId ? result.merged.find((group) => group.removedSessionIds?.includes(selectedSessionId)) - : undefined + ?? result.merged.find((group) => Boolean(group.canonicalSessionId)) + : result.merged.find((group) => Boolean(group.canonicalSessionId)) + const redirectSessionId = redirectTarget?.canonicalSessionId ?? pendingDuplicateHapiSessionIds[0] closeDuplicateMergeDialog() await Promise.all([ @@ -381,10 +385,10 @@ function SessionsPage() { ]) await refetch() - if (redirectTarget?.canonicalSessionId) { + if (redirectSessionId) { navigate({ to: '/sessions/$sessionId', - params: { sessionId: redirectTarget.canonicalSessionId } + params: { sessionId: redirectSessionId } }) } } catch (error) { @@ -408,6 +412,7 @@ function SessionsPage() { isMergingDuplicateSessions, navigate, normalizeCodexScriptError, + pendingDuplicateHapiSessionIds, pendingDuplicateSessionIds, queryClient, refetch, @@ -475,6 +480,7 @@ function SessionsPage() { await refetch() setPendingDuplicateSessionIds([]) + setPendingDuplicateHapiSessionIds(result.hapiSessionIds ?? []) setDuplicateSessionGroups([]) setIsDuplicateMergeConfirmOpen(false) try { @@ -489,6 +495,7 @@ function SessionsPage() { if (duplicateResult.duplicates.length > 0) { setPendingDuplicateSessionIds(sessionIds) + setPendingDuplicateHapiSessionIds(result.hapiSessionIds ?? []) setDuplicateSessionGroups(duplicateResult.duplicates) setIsDuplicateMergeConfirmOpen(true) } @@ -529,6 +536,7 @@ function SessionsPage() { refetch, setDuplicateSessionGroups, setIsDuplicateMergeConfirmOpen, + setPendingDuplicateHapiSessionIds, setPendingDuplicateSessionIds, t ])