|
| 1 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 2 | +import { buildServer, type ServerInstance } from '@collagility/server'; |
| 3 | +import { WebSocketClient } from '../client/ws-client.js'; |
| 4 | +import { createConfig } from '../config/config.js'; |
| 5 | +import { StreamManager } from '@collagility/stream'; |
| 6 | +import { MockAIAdapter } from '@collagility/adapters'; |
| 7 | + |
| 8 | +describe('Interactive AI Workflow Integration', () => { |
| 9 | + let serverInstance: ServerInstance; |
| 10 | + let serverUrl: string; |
| 11 | + |
| 12 | + beforeEach(async () => { |
| 13 | + serverInstance = buildServer(); |
| 14 | + const address = await serverInstance.listen(0, '127.0.0.1'); |
| 15 | + const port = (serverInstance.app.server.address() as { port: number }).port; |
| 16 | + serverUrl = `ws://127.0.0.1:${port}/ws`; |
| 17 | + }); |
| 18 | + |
| 19 | + afterEach(async () => { |
| 20 | + await serverInstance.close(); |
| 21 | + }); |
| 22 | + |
| 23 | + it('should process bidirectional questions, plans, confirmations and tool approvals', async () => { |
| 24 | + const ownerClient = new WebSocketClient(createConfig({ |
| 25 | + serverUrl, |
| 26 | + maxReconnectAttempts: 1, |
| 27 | + reconnectIntervalMs: 100, |
| 28 | + })); |
| 29 | + |
| 30 | + const memberClient = new WebSocketClient(createConfig({ |
| 31 | + serverUrl, |
| 32 | + maxReconnectAttempts: 1, |
| 33 | + reconnectIntervalMs: 100, |
| 34 | + })); |
| 35 | + |
| 36 | + await ownerClient.connect(); |
| 37 | + await memberClient.connect(); |
| 38 | + |
| 39 | + let createdSessionId = ''; |
| 40 | + let ownerJoined = false; |
| 41 | + let memberReceivedPlan = false; |
| 42 | + let memberReceivedQuestion = false; |
| 43 | + |
| 44 | + // Set up owner session creation |
| 45 | + ownerClient.createSession(); |
| 46 | + await new Promise<void>((resolve) => { |
| 47 | + ownerClient['events'].onSessionCreated = (session) => { |
| 48 | + createdSessionId = String(session['id']); |
| 49 | + ownerJoined = true; |
| 50 | + resolve(); |
| 51 | + }; |
| 52 | + }); |
| 53 | + |
| 54 | + expect(ownerJoined).toBe(true); |
| 55 | + expect(createdSessionId).toBeTruthy(); |
| 56 | + |
| 57 | + // Join member client |
| 58 | + memberClient.joinSession(createdSessionId); |
| 59 | + await new Promise<void>((resolve) => { |
| 60 | + memberClient['events'].onSessionJoined = () => resolve(); |
| 61 | + }); |
| 62 | + |
| 63 | + // Subscribe member to interactive events |
| 64 | + memberClient['events'].onPlan = (payload) => { |
| 65 | + if (payload.title === 'Refactor Monorepo Structure') { |
| 66 | + memberReceivedPlan = true; |
| 67 | + } |
| 68 | + }; |
| 69 | + |
| 70 | + memberClient['events'].onQuestion = (payload) => { |
| 71 | + if (payload.prompt.includes('Which framework')) { |
| 72 | + memberReceivedQuestion = true; |
| 73 | + } |
| 74 | + }; |
| 75 | + |
| 76 | + // Owner broadcasts interactive AI Plan |
| 77 | + ownerClient.send('ai.plan', { |
| 78 | + planId: 'plan-101', |
| 79 | + streamId: 'stream-1', |
| 80 | + title: 'Refactor Monorepo Structure', |
| 81 | + steps: ['Create package', 'Migrate code', 'Run tests'], |
| 82 | + requiresApproval: true, |
| 83 | + }); |
| 84 | + |
| 85 | + // Owner broadcasts interactive AI Question |
| 86 | + ownerClient.send('ai.question', { |
| 87 | + questionId: 'q-101', |
| 88 | + streamId: 'stream-1', |
| 89 | + prompt: 'Which framework would you like to use?', |
| 90 | + options: ['Next.js', 'Vite'], |
| 91 | + }); |
| 92 | + |
| 93 | + await new Promise((r) => setTimeout(r, 200)); |
| 94 | + |
| 95 | + expect(memberReceivedPlan).toBe(true); |
| 96 | + expect(memberReceivedQuestion).toBe(true); |
| 97 | + |
| 98 | + // Owner approves plan & answers question |
| 99 | + let planApprovedBroadcast = false; |
| 100 | + let questionAnswerBroadcast = false; |
| 101 | + |
| 102 | + memberClient['events'].onFrame = (frame) => { |
| 103 | + if (frame.type === 'ai.plan.approve') { |
| 104 | + planApprovedBroadcast = true; |
| 105 | + } |
| 106 | + if (frame.type === 'ai.answer') { |
| 107 | + questionAnswerBroadcast = true; |
| 108 | + } |
| 109 | + }; |
| 110 | + |
| 111 | + ownerClient.send('ai.plan.approve', { planId: 'plan-101', streamId: 'stream-1' }); |
| 112 | + ownerClient.send('ai.answer', { questionId: 'q-101', streamId: 'stream-1', answer: 'Next.js' }); |
| 113 | + |
| 114 | + await new Promise((r) => setTimeout(r, 200)); |
| 115 | + |
| 116 | + expect(planApprovedBroadcast).toBe(true); |
| 117 | + expect(questionAnswerBroadcast).toBe(true); |
| 118 | + |
| 119 | + ownerClient.disconnect(); |
| 120 | + memberClient.disconnect(); |
| 121 | + }); |
| 122 | + |
| 123 | + it('should clean up completed stream state and support consecutive AI prompts', async () => { |
| 124 | + const streamManager = new StreamManager(); |
| 125 | + const sessionId = 'test-session-multi-prompt'; |
| 126 | + const ownerId = 'owner-123'; |
| 127 | + |
| 128 | + // First stream |
| 129 | + const stream1 = streamManager.startStream({ |
| 130 | + sessionId, |
| 131 | + ownerId, |
| 132 | + prompt: 'First Prompt', |
| 133 | + adapterName: 'mock', |
| 134 | + }); |
| 135 | + |
| 136 | + expect(streamManager.isStreamActive(sessionId)).toBe(true); |
| 137 | + |
| 138 | + // Complete first stream |
| 139 | + streamManager.completeStream(sessionId); |
| 140 | + |
| 141 | + // Verify stream is no longer active and cleaned up |
| 142 | + expect(streamManager.isStreamActive(sessionId)).toBe(false); |
| 143 | + |
| 144 | + // Second stream must succeed without throwing STREAM_ALREADY_ACTIVE |
| 145 | + const stream2 = streamManager.startStream({ |
| 146 | + sessionId, |
| 147 | + ownerId, |
| 148 | + prompt: 'Second Prompt', |
| 149 | + adapterName: 'mock', |
| 150 | + }); |
| 151 | + |
| 152 | + expect(stream2.streamId).not.toBe(stream1.streamId); |
| 153 | + expect(streamManager.isStreamActive(sessionId)).toBe(true); |
| 154 | + |
| 155 | + streamManager.completeStream(sessionId); |
| 156 | + expect(streamManager.isStreamActive(sessionId)).toBe(false); |
| 157 | + }); |
| 158 | +}); |
0 commit comments