diff --git a/apps/vela-gateway/src/index.js b/apps/vela-gateway/src/index.js index e7f5148..5ee489f 100644 --- a/apps/vela-gateway/src/index.js +++ b/apps/vela-gateway/src/index.js @@ -22,7 +22,8 @@ function createSessionRecord() { audioChunkCount: 0, started: false, mockedTurnInFlight: false, - mockedTurnTimers: [] + mockedTurnTimers: [], + activeMockedTurnId: null }; } @@ -33,11 +34,17 @@ function clearMockedTurn(session) { session.mockedTurnTimers = []; session.mockedTurnInFlight = false; + session.activeMockedTurnId = null; } -function scheduleMockedTurnStep(session, delay, callback) { +function scheduleMockedTurnStep(session, turnId, delay, callback) { const timer = setTimeout(() => { session.mockedTurnTimers = session.mockedTurnTimers.filter((activeTimer) => activeTimer !== timer); + + if (!session.mockedTurnInFlight || session.activeMockedTurnId !== turnId) { + return; + } + callback(); }, delay); @@ -53,23 +60,25 @@ function startMockedTurn(socket, session) { clearMockedTurn(session); session.audioChunkCount = 0; session.mockedTurnInFlight = true; + const turnId = crypto.randomUUID(); + session.activeMockedTurnId = turnId; updateSessionState(socket, session, 'listening'); - scheduleMockedTurnStep(session, 75, () => { + scheduleMockedTurnStep(session, turnId, 75, () => { sendSocketMessage(socket, 'transcript.final', { text: MOCKED_USER_TRANSCRIPT }); updateSessionState(socket, session, 'thinking'); }); - scheduleMockedTurnStep(session, 150, () => { + scheduleMockedTurnStep(session, turnId, 150, () => { updateSessionState(socket, session, 'speaking'); sendSocketMessage(socket, 'response.text.delta', { text: '[mocked assistant] ' }); }); - scheduleMockedTurnStep(session, 225, () => { + scheduleMockedTurnStep(session, turnId, 225, () => { sendSocketMessage(socket, 'response.text.delta', { text: MOCKED_ASSISTANT_RESPONSE.replace('[mocked assistant] ', '') }); }); - scheduleMockedTurnStep(session, 300, () => { + scheduleMockedTurnStep(session, turnId, 300, () => { sendSocketMessage(socket, 'response.completed', {}); clearMockedTurn(session); updateSessionState(socket, session, 'idle'); diff --git a/apps/vela-gateway/test/websocket-session.test.js b/apps/vela-gateway/test/websocket-session.test.js index 7330812..689d542 100644 --- a/apps/vela-gateway/test/websocket-session.test.js +++ b/apps/vela-gateway/test/websocket-session.test.js @@ -405,3 +405,67 @@ test('websocket rejects a second mocked turn while one is in flight', async () = await server.close(); } }); + +test('websocket cancel stops an active mocked turn and allows a new one without reconnecting', async () => { + const server = await startServer(); + + try { + const client = await connectWebSocket(server.port); + await client.nextMessage(); + await client.nextMessage(); + + client.sendJson({ type: 'mocked.turn.trigger', payload: {} }); + assert.deepEqual(await client.nextMessage(), { + type: 'session.state', + payload: { value: 'listening' } + }); + assert.deepEqual(await client.nextMessage(), { + type: 'transcript.final', + payload: { text: '[mocked user] What is the current mocked vertical slice?' } + }); + assert.deepEqual(await client.nextMessage(), { + type: 'session.state', + payload: { value: 'thinking' } + }); + + client.sendJson({ type: 'response.cancel', payload: {} }); + assert.deepEqual(await client.nextMessage(), { + type: 'session.state', + payload: { value: 'idle' } + }); + await assert.rejects(() => client.nextMessage(150), /timed out waiting for websocket message/); + + client.sendJson({ type: 'mocked.turn.trigger', payload: {} }); + assert.deepEqual(await client.nextMessage(), { + type: 'session.state', + payload: { value: 'listening' } + }); + + await client.close(); + } finally { + await server.close(); + } +}); + +test('websocket safely accepts cancel when no turn is active', async () => { + const server = await startServer(); + + try { + const client = await connectWebSocket(server.port); + await client.nextMessage(); + await client.nextMessage(); + + client.sendJson({ type: 'response.cancel', payload: {} }); + await assert.rejects(() => client.nextMessage(150), /timed out waiting for websocket message/); + + client.sendJson({ type: 'mocked.turn.trigger', payload: {} }); + assert.deepEqual(await client.nextMessage(), { + type: 'session.state', + payload: { value: 'listening' } + }); + + await client.close(); + } finally { + await server.close(); + } +}); diff --git a/apps/vela-ui/e2e/voice-session.spec.js b/apps/vela-ui/e2e/voice-session.spec.js index 56eacdb..bf72cd3 100644 --- a/apps/vela-ui/e2e/voice-session.spec.js +++ b/apps/vela-ui/e2e/voice-session.spec.js @@ -46,3 +46,34 @@ test('voice session shell covers the mocked transcript/response slice', async ({ await expect(page.getByTestId('user-transcript')).toHaveText(MOCKED_USER_TRANSCRIPT); await expect(page.getByTestId('assistant-response')).toHaveText(MOCKED_ASSISTANT_RESPONSE); }); + +test('voice session shell can cancel an active mocked turn and start another one', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('hydration-status')).toHaveText('ready'); + + await expect(page.getByTestId('cancel-turn-button')).toBeDisabled(); + await page.getByTestId('connect-button').click(); + + await expect(page.getByTestId('connection-state')).toHaveText('connected'); + await expect(page.getByTestId('mocked-turn-button')).toBeEnabled(); + + await page.getByTestId('mocked-turn-button').click(); + await expect(page.getByTestId('mocked-turn-status')).toHaveText('running'); + await expect(page.getByTestId('cancel-turn-button')).toBeEnabled(); + await expect(page.getByTestId('user-transcript')).toHaveText(MOCKED_USER_TRANSCRIPT); + await expect(page.getByTestId('assistant-response')).toContainText('[mocked assistant]'); + + await page.getByTestId('cancel-turn-button').click(); + + await expect(page.getByTestId('gateway-session-state')).toHaveText('idle'); + await expect(page.getByTestId('mocked-turn-status')).toHaveText('idle'); + await expect(page.getByTestId('cancel-turn-button')).toBeDisabled(); + await expect(page.getByTestId('mocked-turn-button')).toBeEnabled(); + await expect(page.getByTestId('user-transcript')).toHaveText(MOCKED_USER_TRANSCRIPT); + await expect(page.getByTestId('assistant-response')).toContainText('[mocked assistant]'); + + await page.getByTestId('mocked-turn-button').click(); + await expect(page.getByTestId('mocked-turn-status')).toHaveText('running'); + await expect(page.getByTestId('assistant-response')).toHaveText(MOCKED_ASSISTANT_RESPONSE); + await expect(page.getByTestId('mocked-turn-status')).toHaveText('idle'); +}); diff --git a/apps/vela-ui/src/lib/VoiceSessionShell.svelte b/apps/vela-ui/src/lib/VoiceSessionShell.svelte index 5dd82b8..184fe3d 100644 --- a/apps/vela-ui/src/lib/VoiceSessionShell.svelte +++ b/apps/vela-ui/src/lib/VoiceSessionShell.svelte @@ -62,6 +62,13 @@ sessionReadyReceived && !mockedTurnInFlight; + $: canCancelMockedTurn = + typeof WebSocket !== 'undefined' && + connectionState === 'connected' && + socket?.readyState === WebSocket.OPEN && + sessionReadyReceived && + mockedTurnInFlight; + function clearSocketHandlers(targetSocket) { targetSocket.onopen = null; targetSocket.onmessage = null; @@ -105,6 +112,23 @@ socket.send(JSON.stringify(createMessageEnvelope('mocked.turn.trigger', {}))); } + function cancelActiveResponse() { + if (!socket || socket.readyState !== WebSocket.OPEN || connectionState !== 'connected') { + connectionDetail = 'Connect to the gateway before cancelling a mocked turn.'; + lastError = 'response.cancel requires an active WebSocket connection'; + return; + } + + if (!sessionReadyReceived) { + connectionDetail = 'Wait for the gateway session to be ready before cancelling a mocked turn.'; + lastError = 'response.cancel requires session.ready'; + return; + } + + lastError = 'none'; + socket.send(JSON.stringify(createMessageEnvelope('response.cancel', {}))); + } + function connect() { if (typeof window === 'undefined') { return; @@ -301,6 +325,9 @@ +