// @vitest-environment node import { describe, expect, it } from 'vitest'; import type { ConversationInteraction } from '../../electron/coding-runtime/contracts'; import { PiInteractionStore } from '../../electron/coding-runtime/pi/interaction'; import type { PiRpcCommand } from '../../electron/coding-runtime/pi/rpc-client'; import type { PiGenerationResourceInput } from '../../electron/coding-runtime/pi/worker-pool'; describe('Pi interaction store', () => { it('responds by exact id and option, then rejects stale generation responses', async () => { const sent: PiRpcCommand[] = []; const changes: ConversationInteraction[] = []; const resources = new Map void>(); let generation = 1; let runId = 'run-1'; const store = new PiInteractionStore({ getState: () => ({ conversationId: 'conversation-a', workerId: 'worker-a', state: 'running', generation, session: { piSessionId: 'session-a', sessionKey: 'key-a' }, }), getActiveRun: () => ({ generation, runId }), send: async (_conversationId, command) => { sent.push(command); }, trackGenerationResource: (input: PiGenerationResourceInput) => { resources.set(input.id, input.cancel); return () => resources.delete(input.id); }, }, (interaction) => changes.push(interaction)); const opened = store.open('conversation-a', 1, 'run-1', { type: 'extension_ui_request', id: 'question-1', method: 'select', title: 'Choose', options: ['Alpha', 'Beta'], }); expect(opened?.status).toBe('pending'); await store.respond('conversation-a', { interactionId: 'question-1', optionId: 'question-1:option:1', }); expect(sent).toEqual([{ type: 'extension_ui_response', id: 'question-1', value: 'Beta', }]); expect(changes.at(-1)?.status).toBe('answered'); await expect(store.respond('conversation-a', { interactionId: 'question-1', optionId: 'question-1:option:0', })).rejects.toThrow('not pending'); store.open('conversation-a', 1, 'run-1', { type: 'extension_ui_request', id: 'question-2', method: 'confirm', title: 'Continue?', message: 'Proceed', }); generation = 2; runId = 'run-2'; await expect(store.respond('conversation-a', { interactionId: 'question-2', confirmed: true, })).rejects.toThrow('stale'); expect(changes.at(-1)?.status).toBe('cancelled'); }); it('cancels every pending dialog on abort', async () => { const sent: PiRpcCommand[] = []; const changes: ConversationInteraction[] = []; const store = new PiInteractionStore({ getState: () => ({ conversationId: 'conversation-a', workerId: 'worker-a', state: 'running', generation: 1, session: { piSessionId: 'session-a', sessionKey: 'key-a' }, }), getActiveRun: () => ({ generation: 1, runId: 'run-1' }), send: async (_conversationId, command) => { sent.push(command); }, trackGenerationResource: () => () => undefined, }, (interaction) => changes.push(interaction)); for (const id of ['question-1', 'question-2']) { store.open('conversation-a', 1, 'run-1', { type: 'extension_ui_request', id, method: 'input', title: id, }); } await store.cancelRun('conversation-a', 'run-1', true); expect(sent).toHaveLength(2); expect(changes.map(({ status }) => status)).toEqual(['cancelled', 'cancelled']); expect(store.list()).toEqual([]); }); it('makes response ownership atomic across double submit and generation invalidation', async () => { const sent: PiRpcCommand[] = []; const changes: ConversationInteraction[] = []; let cancelResource = () => undefined; let releaseSend = () => undefined; const sendGate = new Promise((resolve) => { releaseSend = resolve; }); const store = new PiInteractionStore({ getState: () => ({ conversationId: 'conversation-a', workerId: 'worker-a', state: 'running', generation: 1, session: { piSessionId: 'session-a', sessionKey: 'key-a' }, }), getActiveRun: () => ({ generation: 1, runId: 'run-1' }), send: async (_conversationId, command) => { sent.push(command); await sendGate; }, trackGenerationResource: (input) => { cancelResource = input.cancel; return () => undefined; }, }, (interaction) => changes.push(interaction)); store.open('conversation-a', 1, 'run-1', { type: 'extension_ui_request', id: 'question-race', method: 'confirm', title: 'Continue?', }); const first = store.respond('conversation-a', { interactionId: 'question-race', confirmed: true, }); await expect(store.respond('conversation-a', { interactionId: 'question-race', confirmed: false, })).rejects.toThrow('already in progress'); cancelResource(); releaseSend(); await expect(first).rejects.toThrow('stale'); expect(sent).toHaveLength(1); expect(changes.map(({ status }) => status)).toEqual(['cancelled']); }); });