118 lines
4.9 KiB
TypeScript
118 lines
4.9 KiB
TypeScript
// @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<string, () => 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<void>((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']);
|
|
});
|
|
});
|