import { describe, expect, it } from 'vitest'; import { DurableSessionCoordinator, InMemoryDurableSessionStore, type DurableSessionIdentity, } from './durable-session.js'; const IDENTITY: DurableSessionIdentity = { agentName: 'Nova', sessionId: 'tess-session-1', tenantId: 'tenant-1', ownerId: 'owner-1', providerId: 'fleet', runtimeSessionId: 'nova', }; describe('DurableSessionCoordinator', () => { it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => { const store = new InMemoryDurableSessionStore(); const beforeRestart = new DurableSessionCoordinator(store); await beforeRestart.create(IDENTITY); await beforeRestart.receive({ sessionId: IDENTITY.sessionId, idempotencyKey: 'ingress-1', correlationId: 'correlation-1', content: 'continue the session', }); await beforeRestart.enqueueOutbox({ sessionId: IDENTITY.sessionId, idempotencyKey: 'outbox-1', correlationId: 'correlation-1', channelId: 'cli', kind: 'provider.send', content: 'resumable response', }); await beforeRestart.checkpoint({ sessionId: IDENTITY.sessionId, checkpointId: 'checkpoint-1', cursor: 'cursor-42', summary: 'operator asked for recovery proof', compactionEpoch: 0, }); await beforeRestart.handoff({ sessionId: IDENTITY.sessionId, handoffId: 'handoff-1', destination: 'mos', correlationId: 'correlation-1', checkpointId: 'checkpoint-1', status: 'pending', }); // Simulate an ungraceful process death: no in-memory coordinator state survives. const afterRestart = new DurableSessionCoordinator(store); const recovered = await afterRestart.recover(IDENTITY.sessionId); expect(recovered.identity).toEqual(IDENTITY); expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]); expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]); expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-1', cursor: 'cursor-42', }); expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]); }); it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => { const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore()); await coordinator.create(IDENTITY); await coordinator.create({ ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next', }); await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({ identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' }, }); await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow( /identity conflict/, ); }); it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => { const store = new InMemoryDurableSessionStore(); const firstProcess = new DurableSessionCoordinator(store); const handled: string[] = []; await firstProcess.create(IDENTITY); await expect( firstProcess.receive({ sessionId: IDENTITY.sessionId, idempotencyKey: 'ingress-duplicate', correlationId: 'correlation-2', content: 'only process me once', }), ).resolves.toMatchObject({ accepted: true }); await expect( firstProcess.receive({ sessionId: IDENTITY.sessionId, idempotencyKey: 'ingress-duplicate', correlationId: 'correlation-2', content: 'only process me once', }), ).resolves.toMatchObject({ accepted: false, status: 'pending' }); await expect( firstProcess.receive({ sessionId: IDENTITY.sessionId, idempotencyKey: 'ingress-duplicate', correlationId: 'forged-correlation', content: 'only process me once', }), ).rejects.toThrow(/idempotency conflict/); await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => { handled.push(entry.idempotencyKey); }); await firstProcess.checkpoint({ sessionId: IDENTITY.sessionId, checkpointId: 'checkpoint-after-inbox', cursor: 'cursor-43', summary: 'safe to compact', compactionEpoch: 1, }); const afterRestartAndCompaction = new DurableSessionCoordinator(store); await afterRestartAndCompaction.recover(IDENTITY.sessionId); await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => { handled.push(entry.idempotencyKey); }); expect(handled).toEqual(['ingress-duplicate']); }); it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => { const store = new InMemoryDurableSessionStore(); const beforeRestart = new DurableSessionCoordinator(store); const appliedEffects: string[] = []; await beforeRestart.create(IDENTITY); await beforeRestart.enqueueOutbox({ sessionId: IDENTITY.sessionId, idempotencyKey: 'effect-1', correlationId: 'correlation-3', channelId: 'cli', kind: 'provider.send', content: 'send exactly once', }); await expect( beforeRestart.enqueueOutbox({ sessionId: IDENTITY.sessionId, idempotencyKey: 'effect-1', correlationId: 'correlation-3', channelId: 'cli', kind: 'provider.send', content: 'send exactly once', }), ).resolves.toMatchObject({ accepted: false, status: 'pending' }); await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => { appliedEffects.push(entry.idempotencyKey); }); await beforeRestart.checkpoint({ sessionId: IDENTITY.sessionId, checkpointId: 'checkpoint-after-effect', cursor: 'cursor-44', summary: 'effect persisted before compaction', compactionEpoch: 1, }); const afterRestartAndCompaction = new DurableSessionCoordinator(store); await afterRestartAndCompaction.recover(IDENTITY.sessionId); await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => { appliedEffects.push(entry.idempotencyKey); }); expect(appliedEffects).toEqual(['effect-1']); }); it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => { const store = new InMemoryDurableSessionStore(); const coordinator = new DurableSessionCoordinator(store); await coordinator.create(IDENTITY); await coordinator.enqueueOutbox({ sessionId: IDENTITY.sessionId, idempotencyKey: 'outbox-conflict', correlationId: 'correlation-outbox', channelId: 'cli', kind: 'provider.send', content: 'original effect', }); await expect( coordinator.enqueueOutbox({ sessionId: IDENTITY.sessionId, idempotencyKey: 'outbox-conflict', correlationId: 'correlation-outbox', channelId: 'forged-channel', kind: 'provider.send', content: 'original effect', }), ).rejects.toThrow(/idempotency conflict/); }); it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => { const store = new InMemoryDurableSessionStore(); const beforeRestart = new DurableSessionCoordinator(store); await beforeRestart.create(IDENTITY); await beforeRestart.enqueueOutbox({ sessionId: IDENTITY.sessionId, idempotencyKey: 'ambiguous-effect', correlationId: 'correlation-ambiguous', channelId: 'cli', kind: 'provider.send', content: 'preserve this effect claim', }); await expect( beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise => { throw new Error('provider connection dropped after submit'); }), ).rejects.toThrow(/connection dropped/); const afterRestart = new DurableSessionCoordinator(store); await afterRestart.recover(IDENTITY.sessionId); const calls: string[] = []; await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { calls.push(entry.idempotencyKey); }); expect(calls).toEqual([]); expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({ outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }], }); }); it('rejects a handoff-id replay with different immutable state', async () => { const store = new InMemoryDurableSessionStore(); const coordinator = new DurableSessionCoordinator(store); await coordinator.create(IDENTITY); await coordinator.checkpoint({ sessionId: IDENTITY.sessionId, checkpointId: 'checkpoint-conflict', cursor: 'cursor-conflict', summary: 'handoff conflict proof', compactionEpoch: 0, }); await coordinator.handoff({ sessionId: IDENTITY.sessionId, handoffId: 'handoff-conflict', destination: 'mos', correlationId: 'correlation-conflict', checkpointId: 'checkpoint-conflict', status: 'pending', }); await expect( coordinator.handoff({ sessionId: IDENTITY.sessionId, handoffId: 'handoff-conflict', destination: 'forged-destination', correlationId: 'correlation-conflict', checkpointId: 'checkpoint-conflict', status: 'pending', }), ).rejects.toThrow(/identity conflict/); }); it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => { const store = new InMemoryDurableSessionStore(); const source = new DurableSessionCoordinator(store); await source.create(IDENTITY); await source.checkpoint({ sessionId: IDENTITY.sessionId, checkpointId: 'checkpoint-handoff', cursor: 'cursor-45', summary: 'portable state', compactionEpoch: 2, }); await source.handoff({ sessionId: IDENTITY.sessionId, handoffId: 'handoff-portable', destination: 'mos', correlationId: 'correlation-4', checkpointId: 'checkpoint-handoff', status: 'pending', }); await source.checkpoint({ sessionId: IDENTITY.sessionId, checkpointId: 'checkpoint-later', cursor: 'cursor-46', summary: 'newer compacted state must not strand the handoff', compactionEpoch: 3, }); const receivingProcess = new DurableSessionCoordinator(store); const handoff = await receivingProcess.resumeHandoff('handoff-portable'); expect(handoff.identity).toEqual(IDENTITY); expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' }); expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' }); }); });