feat(vela): add mocked turn transcript response slice
This commit is contained in:
@@ -7,4 +7,7 @@ Current status:
|
||||
- SvelteKit app boots in the Yarn workspace
|
||||
- root page shows a minimal voice-session shell with connect/disconnect controls
|
||||
- the shell can connect to the gateway `/ws` endpoint and display developer-visible session status
|
||||
- microphone capture, transcript rendering, and audio playback remain future increments
|
||||
- the shell can trigger one deterministic mocked turn and render the mocked transcript plus assistant response
|
||||
- Vitest covers connect/disconnect plus the deterministic mocked transcript/response UI flow without requiring a browser harness
|
||||
- Playwright remains optional for deeper browser-level checks
|
||||
- microphone capture and audio playback remain future increments
|
||||
|
||||
48
apps/vela-ui/e2e/voice-session.spec.js
Normal file
48
apps/vela-ui/e2e/voice-session.spec.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const MOCKED_USER_TRANSCRIPT = '[mocked user] What is the current mocked vertical slice?';
|
||||
const MOCKED_ASSISTANT_RESPONSE =
|
||||
'[mocked assistant] This is a deterministic mocked response from the gateway vertical slice.';
|
||||
|
||||
test('voice session shell covers the mocked transcript/response slice', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('hydration-status')).toHaveText('ready');
|
||||
|
||||
await expect(page.getByTestId('connection-state')).toHaveText('not connected');
|
||||
await expect(page.getByTestId('mocked-turn-button')).toBeDisabled();
|
||||
await expect(page.getByTestId('session-id')).toHaveText('not assigned');
|
||||
await expect(page.getByTestId('gateway-session-state')).toHaveText('not received');
|
||||
|
||||
await page.getByTestId('connect-button').click();
|
||||
|
||||
await expect(page.getByTestId('connection-state')).toHaveText('connected');
|
||||
await expect(page.getByTestId('gateway-session-state')).toHaveText('idle');
|
||||
await expect(page.getByTestId('session-id')).not.toHaveText('not assigned');
|
||||
await expect(page.getByTestId('mocked-turn-button')).toBeEnabled();
|
||||
const sessionId = await page.getByTestId('session-id').textContent();
|
||||
|
||||
await page.getByTestId('mocked-turn-button').click();
|
||||
|
||||
await expect(page.getByTestId('mocked-turn-status')).toHaveText('running');
|
||||
await expect(page.getByTestId('user-transcript')).toHaveText('waiting for mocked transcript…');
|
||||
await expect(page.getByTestId('assistant-response')).toHaveText('waiting for mocked response…');
|
||||
|
||||
await expect(page.getByTestId('user-transcript')).toHaveText(MOCKED_USER_TRANSCRIPT);
|
||||
await expect(page.getByTestId('assistant-response')).toHaveText(MOCKED_ASSISTANT_RESPONSE);
|
||||
await expect(page.getByTestId('conversation-render-order')).toHaveText('transcript>response');
|
||||
await expect(page.getByTestId('mocked-turn-status')).toHaveText('idle');
|
||||
|
||||
await page.getByTestId('disconnect-button').click();
|
||||
|
||||
await expect(page.getByTestId('connection-state')).toHaveText('disconnected');
|
||||
await expect(page.getByTestId('connection-detail')).toHaveText('Gateway WebSocket is closed.');
|
||||
await expect(page.getByTestId('gateway-session-state')).toHaveText('idle');
|
||||
await expect(page.getByTestId('session-id')).toHaveText(sessionId ?? '');
|
||||
await expect(page.getByTestId('mocked-turn-button')).toBeDisabled();
|
||||
await expect(page.getByTestId('user-transcript')).toHaveText(MOCKED_USER_TRANSCRIPT);
|
||||
await expect(page.getByTestId('assistant-response')).toHaveText(MOCKED_ASSISTANT_RESPONSE);
|
||||
await expect(page.getByTestId('session-id')).toHaveText(sessionId ?? '');
|
||||
await expect(page.getByTestId('gateway-session-state')).toHaveText('idle');
|
||||
await expect(page.getByTestId('user-transcript')).toHaveText(MOCKED_USER_TRANSCRIPT);
|
||||
await expect(page.getByTestId('assistant-response')).toHaveText(MOCKED_ASSISTANT_RESPONSE);
|
||||
});
|
||||
@@ -4,22 +4,28 @@
|
||||
"version": "0.0.0",
|
||||
"description": "Minimal SvelteKit app for the Vela browser UI.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "svelte-kit sync && vite dev",
|
||||
"build": "svelte-kit sync && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "svelte-kit sync && vite dev",
|
||||
"build": "svelte-kit sync && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vela/protocol": "0.0.0",
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/kit": "^2.17.1",
|
||||
"svelte": "^5.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@testing-library/svelte": "^5.2.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
41
apps/vela-ui/playwright.config.js
Normal file
41
apps/vela-ui/playwright.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const workspaceDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(workspaceDir, '..', '..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 15_000,
|
||||
expect: {
|
||||
timeout: 5_000
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: 'line',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
browserName: 'chromium',
|
||||
headless: true,
|
||||
launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
? {
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: `HOST=127.0.0.1 PORT=3001 npm run start --workspace vela-gateway --prefix "${repoRoot}"`,
|
||||
url: 'http://127.0.0.1:3001/health',
|
||||
reuseExistingServer: true,
|
||||
timeout: 15_000
|
||||
},
|
||||
{
|
||||
command: `VITE_VELA_GATEWAY_WS_URL=ws://127.0.0.1:3001/ws npm run dev --workspace vela-ui --prefix "${repoRoot}" -- --host 127.0.0.1 --port 4173`,
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 15_000
|
||||
}
|
||||
]
|
||||
});
|
||||
480
apps/vela-ui/src/lib/VoiceSessionShell.svelte
Normal file
480
apps/vela-ui/src/lib/VoiceSessionShell.svelte
Normal file
@@ -0,0 +1,480 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import {
|
||||
CLIENT_EVENT_TYPES,
|
||||
PROTOCOL_PACKAGE_NAME,
|
||||
SERVER_EVENT_TYPES,
|
||||
SESSION_STATES,
|
||||
createMessageEnvelope,
|
||||
isMessageEnvelope,
|
||||
isServerEventType
|
||||
} from '@vela/protocol';
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = '3001';
|
||||
const FALLBACK_GATEWAY_URL = `ws://localhost:${DEFAULT_GATEWAY_PORT}/ws`;
|
||||
const configuredGatewayUrl = import.meta.env.VITE_VELA_GATEWAY_WS_URL;
|
||||
|
||||
function resolveGatewayWebSocketUrl() {
|
||||
if (configuredGatewayUrl) {
|
||||
return configuredGatewayUrl;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return FALLBACK_GATEWAY_URL;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||||
|
||||
if (isLocalhost && window.location.port !== DEFAULT_GATEWAY_PORT) {
|
||||
return `${protocol}//${window.location.hostname}:${DEFAULT_GATEWAY_PORT}/ws`;
|
||||
}
|
||||
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function formatCloseReason(event) {
|
||||
const reason = event.reason ? ` (${event.reason})` : '';
|
||||
return `code ${event.code}, clean ${event.wasClean ? 'yes' : 'no'}${reason}`;
|
||||
}
|
||||
|
||||
let gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
|
||||
let connectionState = 'not connected';
|
||||
let connectionDetail = 'Socket is idle.';
|
||||
let gatewaySessionState = 'not received';
|
||||
let sessionId = 'not assigned';
|
||||
let sessionReadyReceived = false;
|
||||
let lastServerEvent = 'none';
|
||||
let lastError = 'none';
|
||||
let lastClose = 'not closed';
|
||||
let socket = null;
|
||||
let connectionAttempts = 0;
|
||||
let mockedUserTranscript = 'none';
|
||||
let mockedAssistantResponse = 'none';
|
||||
let mockedTurnInFlight = false;
|
||||
let mockedConversationRenderOrder = [];
|
||||
let hydrationStatus = 'mounting';
|
||||
|
||||
$: canTriggerMockedTurn =
|
||||
typeof WebSocket !== 'undefined' &&
|
||||
connectionState === 'connected' &&
|
||||
socket?.readyState === WebSocket.OPEN &&
|
||||
sessionReadyReceived &&
|
||||
!mockedTurnInFlight;
|
||||
|
||||
function clearSocketHandlers(targetSocket) {
|
||||
targetSocket.onopen = null;
|
||||
targetSocket.onmessage = null;
|
||||
targetSocket.onerror = null;
|
||||
targetSocket.onclose = null;
|
||||
}
|
||||
|
||||
function resetSessionStatus() {
|
||||
gatewaySessionState = 'not received';
|
||||
sessionId = 'not assigned';
|
||||
sessionReadyReceived = false;
|
||||
lastServerEvent = 'none';
|
||||
mockedUserTranscript = 'none';
|
||||
mockedAssistantResponse = 'none';
|
||||
mockedTurnInFlight = false;
|
||||
mockedConversationRenderOrder = [];
|
||||
}
|
||||
|
||||
function triggerMockedTurn() {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || connectionState !== 'connected') {
|
||||
connectionDetail = 'Connect to the gateway before triggering a mocked turn.';
|
||||
lastError = 'mocked turn requires an active WebSocket connection';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionReadyReceived) {
|
||||
connectionDetail = 'Wait for the gateway session to be ready before triggering a mocked turn.';
|
||||
lastError = 'mocked turn requires session.ready';
|
||||
return;
|
||||
}
|
||||
|
||||
if (mockedTurnInFlight) {
|
||||
connectionDetail = 'A mocked turn is already running for this session.';
|
||||
return;
|
||||
}
|
||||
|
||||
mockedUserTranscript = 'waiting for mocked transcript…';
|
||||
mockedAssistantResponse = 'waiting for mocked response…';
|
||||
mockedTurnInFlight = true;
|
||||
lastError = 'none';
|
||||
socket.send(JSON.stringify(createMessageEnvelope('mocked.turn.trigger', {})));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket && (connectionState === 'connecting' || connectionState === 'connected')) {
|
||||
return;
|
||||
}
|
||||
|
||||
gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
|
||||
resetSessionStatus();
|
||||
lastError = 'none';
|
||||
lastClose = 'not closed';
|
||||
connectionState = 'connecting';
|
||||
connectionDetail = 'Opening WebSocket connection to gateway.';
|
||||
connectionAttempts += 1;
|
||||
|
||||
const nextSocket = new WebSocket(gatewayWebSocketUrl);
|
||||
socket = nextSocket;
|
||||
|
||||
nextSocket.onopen = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState = 'connected';
|
||||
connectionDetail = 'Gateway WebSocket is open.';
|
||||
};
|
||||
|
||||
nextSocket.onmessage = ({ data }) => {
|
||||
if (socket !== nextSocket || typeof data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Received non-JSON message from gateway.';
|
||||
lastError = 'invalid server message: JSON parse failed';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMessageEnvelope(message) || !isServerEventType(message.type)) {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Received unsupported message from gateway.';
|
||||
lastError = 'invalid server message: envelope or event type mismatch';
|
||||
return;
|
||||
}
|
||||
|
||||
lastServerEvent = message.type;
|
||||
|
||||
if (message.type === 'session.ready') {
|
||||
sessionReadyReceived = true;
|
||||
sessionId = message.payload.sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'session.state') {
|
||||
gatewaySessionState = message.payload.value;
|
||||
if (message.payload.value === 'idle') {
|
||||
mockedTurnInFlight = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'transcript.final') {
|
||||
mockedUserTranscript = message.payload.text;
|
||||
mockedAssistantResponse = '…';
|
||||
mockedConversationRenderOrder = [...mockedConversationRenderOrder, 'transcript'];
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'response.text.delta') {
|
||||
if (!mockedConversationRenderOrder.includes('response')) {
|
||||
mockedConversationRenderOrder = [...mockedConversationRenderOrder, 'response'];
|
||||
}
|
||||
|
||||
mockedAssistantResponse =
|
||||
mockedAssistantResponse === 'none' ||
|
||||
mockedAssistantResponse === 'waiting for mocked response…' ||
|
||||
mockedAssistantResponse === '…'
|
||||
? message.payload.text
|
||||
: `${mockedAssistantResponse}${message.payload.text}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'response.completed') {
|
||||
mockedTurnInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
if (message.payload.retryable === false) {
|
||||
mockedTurnInFlight = false;
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Gateway reported a protocol error.';
|
||||
} else {
|
||||
connectionDetail = 'Gateway reported a recoverable protocol error.';
|
||||
}
|
||||
lastError = `${message.payload.code}: ${message.payload.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
nextSocket.onerror = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Browser reported a WebSocket error.';
|
||||
lastError = 'browser websocket error';
|
||||
};
|
||||
|
||||
nextSocket.onclose = (event) => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastClose = formatCloseReason(event);
|
||||
mockedTurnInFlight = false;
|
||||
connectionState = connectionState === 'error' ? 'error' : 'disconnected';
|
||||
connectionDetail =
|
||||
connectionState === 'error' ? 'Socket closed after an error.' : 'Gateway WebSocket is closed.';
|
||||
clearSocketHandlers(nextSocket);
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (!socket) {
|
||||
connectionState = 'disconnected';
|
||||
connectionDetail = 'No active socket to close.';
|
||||
return;
|
||||
}
|
||||
|
||||
connectionDetail = 'Closing WebSocket connection.';
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close(1000, 'client disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSocket = socket;
|
||||
clearSocketHandlers(activeSocket);
|
||||
socket = null;
|
||||
|
||||
if (activeSocket.readyState === WebSocket.OPEN || activeSocket.readyState === WebSocket.CONNECTING) {
|
||||
activeSocket.close(1000, 'page dispose');
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
hydrationStatus = 'ready';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Vela UI</p>
|
||||
<h1>Voice session shell</h1>
|
||||
<p>
|
||||
This minimal browser shell can connect to the gateway WebSocket, trigger one deterministic
|
||||
mocked turn, and render the mocked transcript plus assistant response for the active session.
|
||||
</p>
|
||||
|
||||
<p class="contract-note">
|
||||
Shared protocol package loaded with {CLIENT_EVENT_TYPES.length} client event types and
|
||||
{SERVER_EVENT_TYPES.length} server event types across {SESSION_STATES.length} gateway session
|
||||
states.
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
data-testid="connect-button"
|
||||
on:click={connect}
|
||||
disabled={connectionState === 'connecting' || connectionState === 'connected'}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
<button
|
||||
data-testid="disconnect-button"
|
||||
on:click={disconnect}
|
||||
disabled={!socket && connectionState !== 'connected' && connectionState !== 'connecting'}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
<button data-testid="mocked-turn-button" on:click={triggerMockedTurn} disabled={!canTriggerMockedTurn}>
|
||||
Run mocked turn
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="conversation">
|
||||
<div>
|
||||
<span>Mocked user transcript</span>
|
||||
<p data-testid="user-transcript">{mockedUserTranscript}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Mocked assistant response</span>
|
||||
<p data-testid="assistant-response">{mockedAssistantResponse}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="test-hook" data-testid="conversation-render-order">{mockedConversationRenderOrder.join('>') || 'none'}</p>
|
||||
<p class="test-hook" data-testid="hydration-status">{hydrationStatus}</p>
|
||||
|
||||
<div class="meta">
|
||||
<div>
|
||||
<span>UI connection state</span>
|
||||
<strong data-testid="connection-state">{connectionState}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Connection detail</span>
|
||||
<strong data-testid="connection-detail">{connectionDetail}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gateway WebSocket URL</span>
|
||||
<strong data-testid="gateway-url">{gatewayWebSocketUrl}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Session ID</span>
|
||||
<strong data-testid="session-id">{sessionId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gateway session state</span>
|
||||
<strong data-testid="gateway-session-state">{gatewaySessionState}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last server event</span>
|
||||
<strong data-testid="last-server-event">{lastServerEvent}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last error</span>
|
||||
<strong data-testid="last-error">{lastError}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last close</span>
|
||||
<strong data-testid="last-close">{lastClose}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Connection attempts</span>
|
||||
<strong data-testid="connection-attempts">{connectionAttempts}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Mocked turn status</span>
|
||||
<strong data-testid="mocked-turn-status">{mockedTurnInFlight ? 'running' : 'idle'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Protocol package</span>
|
||||
<strong>{PROTOCOL_PACKAGE_NAME}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
background: #08111f;
|
||||
color: #e6eef8;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 52rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid #1f3147;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #0d1728 0%, #0a1321 100%);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.8rem;
|
||||
color: #8bb9ff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: #c7d6e8;
|
||||
}
|
||||
|
||||
.contract-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: #9ab4d1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #36516f;
|
||||
background: #102138;
|
||||
color: #e6eef8;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.conversation div,
|
||||
.meta div {
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(139, 185, 255, 0.08);
|
||||
}
|
||||
|
||||
.test-hook {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #8da3bf;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -7,352 +7,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
CLIENT_EVENT_TYPES,
|
||||
PROTOCOL_PACKAGE_NAME,
|
||||
SERVER_EVENT_TYPES,
|
||||
SESSION_STATES,
|
||||
isMessageEnvelope,
|
||||
isServerEventType
|
||||
} from '@vela/protocol';
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = '3001';
|
||||
const FALLBACK_GATEWAY_URL = `ws://localhost:${DEFAULT_GATEWAY_PORT}/ws`;
|
||||
const configuredGatewayUrl = import.meta.env.VITE_VELA_GATEWAY_WS_URL;
|
||||
|
||||
function resolveGatewayWebSocketUrl() {
|
||||
if (configuredGatewayUrl) {
|
||||
return configuredGatewayUrl;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return FALLBACK_GATEWAY_URL;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||||
|
||||
if (isLocalhost && window.location.port !== DEFAULT_GATEWAY_PORT) {
|
||||
return `${protocol}//${window.location.hostname}:${DEFAULT_GATEWAY_PORT}/ws`;
|
||||
}
|
||||
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function formatCloseReason(event) {
|
||||
const reason = event.reason ? ` (${event.reason})` : '';
|
||||
return `code ${event.code}, clean ${event.wasClean ? 'yes' : 'no'}${reason}`;
|
||||
}
|
||||
|
||||
let gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
|
||||
let connectionState = 'not connected';
|
||||
let connectionDetail = 'Socket is idle.';
|
||||
let gatewaySessionState = 'not received';
|
||||
let sessionId = 'not assigned';
|
||||
let lastServerEvent = 'none';
|
||||
let lastError = 'none';
|
||||
let lastClose = 'not closed';
|
||||
let socket = null;
|
||||
let connectionAttempts = 0;
|
||||
|
||||
function clearSocketHandlers(targetSocket) {
|
||||
targetSocket.onopen = null;
|
||||
targetSocket.onmessage = null;
|
||||
targetSocket.onerror = null;
|
||||
targetSocket.onclose = null;
|
||||
}
|
||||
|
||||
function resetSessionStatus() {
|
||||
gatewaySessionState = 'not received';
|
||||
sessionId = 'not assigned';
|
||||
lastServerEvent = 'none';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket && (connectionState === 'connecting' || connectionState === 'connected')) {
|
||||
return;
|
||||
}
|
||||
|
||||
gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
|
||||
resetSessionStatus();
|
||||
lastError = 'none';
|
||||
lastClose = 'not closed';
|
||||
connectionState = 'connecting';
|
||||
connectionDetail = 'Opening WebSocket connection to gateway.';
|
||||
connectionAttempts += 1;
|
||||
|
||||
const nextSocket = new WebSocket(gatewayWebSocketUrl);
|
||||
socket = nextSocket;
|
||||
|
||||
nextSocket.onopen = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState = 'connected';
|
||||
connectionDetail = 'Gateway WebSocket is open.';
|
||||
};
|
||||
|
||||
nextSocket.onmessage = ({ data }) => {
|
||||
if (socket !== nextSocket || typeof data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Received non-JSON message from gateway.';
|
||||
lastError = 'invalid server message: JSON parse failed';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMessageEnvelope(message) || !isServerEventType(message.type)) {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Received unsupported message from gateway.';
|
||||
lastError = 'invalid server message: envelope or event type mismatch';
|
||||
return;
|
||||
}
|
||||
|
||||
lastServerEvent = message.type;
|
||||
|
||||
if (message.type === 'session.ready') {
|
||||
sessionId = message.payload.sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'session.state') {
|
||||
gatewaySessionState = message.payload.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Gateway reported a protocol error.';
|
||||
lastError = `${message.payload.code}: ${message.payload.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
nextSocket.onerror = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Browser reported a WebSocket error.';
|
||||
lastError = 'browser websocket error';
|
||||
};
|
||||
|
||||
nextSocket.onclose = (event) => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastClose = formatCloseReason(event);
|
||||
connectionState = connectionState === 'error' ? 'error' : 'disconnected';
|
||||
connectionDetail = connectionState === 'error'
|
||||
? 'Socket closed after an error.'
|
||||
: 'Gateway WebSocket is closed.';
|
||||
clearSocketHandlers(nextSocket);
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (!socket) {
|
||||
connectionState = 'disconnected';
|
||||
connectionDetail = 'No active socket to close.';
|
||||
return;
|
||||
}
|
||||
|
||||
connectionDetail = 'Closing WebSocket connection.';
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close(1000, 'client disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSocket = socket;
|
||||
clearSocketHandlers(activeSocket);
|
||||
socket = null;
|
||||
|
||||
if (activeSocket.readyState === WebSocket.OPEN || activeSocket.readyState === WebSocket.CONNECTING) {
|
||||
activeSocket.close(1000, 'page dispose');
|
||||
}
|
||||
});
|
||||
import VoiceSessionShell from '$lib/VoiceSessionShell.svelte';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Vela UI</p>
|
||||
<h1>Voice session shell</h1>
|
||||
<p>
|
||||
This minimal browser shell can connect to the gateway WebSocket and expose developer-visible
|
||||
session status. Microphone capture, transcript rendering, and audio playback remain future
|
||||
increments.
|
||||
</p>
|
||||
|
||||
<p class="contract-note">
|
||||
Shared protocol package loaded with {CLIENT_EVENT_TYPES.length} client event types and
|
||||
{SERVER_EVENT_TYPES.length} server event types across {SESSION_STATES.length} gateway session
|
||||
states.
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
<button on:click={connect} disabled={connectionState === 'connecting' || connectionState === 'connected'}>
|
||||
Connect
|
||||
</button>
|
||||
<button on:click={disconnect} disabled={!socket && connectionState !== 'connected' && connectionState !== 'connecting'}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div>
|
||||
<span>UI connection state</span>
|
||||
<strong>{connectionState}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Connection detail</span>
|
||||
<strong>{connectionDetail}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gateway WebSocket URL</span>
|
||||
<strong>{gatewayWebSocketUrl}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Session ID</span>
|
||||
<strong>{sessionId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gateway session state</span>
|
||||
<strong>{gatewaySessionState}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last server event</span>
|
||||
<strong>{lastServerEvent}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last error</span>
|
||||
<strong>{lastError}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last close</span>
|
||||
<strong>{lastClose}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Connection attempts</span>
|
||||
<strong>{connectionAttempts}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Protocol package</span>
|
||||
<strong>{PROTOCOL_PACKAGE_NAME}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
background: #08111f;
|
||||
color: #e6eef8;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 42rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid #1f3147;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #0d1728 0%, #0a1321 100%);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.8rem;
|
||||
color: #8bb9ff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: #c7d6e8;
|
||||
}
|
||||
|
||||
.contract-note {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.8rem 1.1rem;
|
||||
border: 1px solid #2b4a6b;
|
||||
border-radius: 0.75rem;
|
||||
background: #12233a;
|
||||
color: #e6eef8;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meta div {
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(139, 185, 255, 0.08);
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #8da3bf;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
<VoiceSessionShell />
|
||||
|
||||
157
apps/vela-ui/tests/voice-session.test.js
Normal file
157
apps/vela-ui/tests/voice-session.test.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createMessageEnvelope } from '@vela/protocol';
|
||||
import VoiceSessionShell from '../src/lib/VoiceSessionShell.svelte';
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances = [];
|
||||
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = MockWebSocket.CONNECTING;
|
||||
this.sent = [];
|
||||
this.onopen = null;
|
||||
this.onmessage = null;
|
||||
this.onerror = null;
|
||||
this.onclose = null;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(message) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
message(payload) {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
close(code = 1000, reason = 'client disconnect', wasClean = true) {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.({ code, reason, wasClean });
|
||||
}
|
||||
|
||||
static latest() {
|
||||
return MockWebSocket.instances.at(-1);
|
||||
}
|
||||
|
||||
static reset() {
|
||||
MockWebSocket.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
function getByTestId(id) {
|
||||
return screen.getByTestId(id);
|
||||
}
|
||||
|
||||
describe('voice session shell', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.reset();
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps mocked turn unavailable while disconnected and after disconnect', async () => {
|
||||
render(VoiceSessionShell);
|
||||
|
||||
expect(getByTestId('connection-state').textContent).toBe('not connected');
|
||||
expect(getByTestId('mocked-turn-button').hasAttribute('disabled')).toBe(true);
|
||||
|
||||
await fireEvent.click(getByTestId('connect-button'));
|
||||
const socket = MockWebSocket.latest();
|
||||
socket.open();
|
||||
socket.message(createMessageEnvelope('session.ready', { sessionId: 'session-123' }));
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'idle' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('connection-state').textContent).toBe('connected');
|
||||
expect(getByTestId('mocked-turn-button').hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('disconnect-button'));
|
||||
socket.close(1000, 'client disconnect', true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('connection-state').textContent).toBe('disconnected');
|
||||
expect(getByTestId('mocked-turn-button').hasAttribute('disabled')).toBe(true);
|
||||
expect(getByTestId('session-id').textContent).toBe('session-123');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders mocked transcript before assistant response for a connected session', async () => {
|
||||
render(VoiceSessionShell);
|
||||
|
||||
await fireEvent.click(getByTestId('connect-button'));
|
||||
const socket = MockWebSocket.latest();
|
||||
socket.open();
|
||||
socket.message(createMessageEnvelope('session.ready', { sessionId: 'session-456' }));
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'idle' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('connection-state').textContent).toBe('connected');
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('mocked-turn-button'));
|
||||
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
const sentMessage = JSON.parse(socket.sent[0]);
|
||||
expect(sentMessage.type).toBe('mocked.turn.trigger');
|
||||
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'listening' }));
|
||||
socket.message(createMessageEnvelope('transcript.final', { text: 'Turn on the office lamp.' }));
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'thinking' }));
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'speaking' }));
|
||||
socket.message(createMessageEnvelope('response.text.delta', { text: 'Mocked ' }));
|
||||
socket.message(createMessageEnvelope('response.text.delta', { text: 'assistant response.' }));
|
||||
socket.message(createMessageEnvelope('response.completed', { reason: 'mocked_turn_complete' }));
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'idle' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('user-transcript').textContent).toBe('Turn on the office lamp.');
|
||||
expect(getByTestId('assistant-response').textContent).toBe('Mocked assistant response.');
|
||||
expect(getByTestId('conversation-render-order').textContent).toBe('transcript>response');
|
||||
expect(getByTestId('gateway-session-state').textContent).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks mocked turn trigger before session.ready and allows it after session.ready', async () => {
|
||||
render(VoiceSessionShell);
|
||||
|
||||
await fireEvent.click(getByTestId('connect-button'));
|
||||
const socket = MockWebSocket.latest();
|
||||
socket.open();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('connection-state').textContent).toBe('connected');
|
||||
});
|
||||
|
||||
expect(getByTestId('mocked-turn-button').hasAttribute('disabled')).toBe(true);
|
||||
await fireEvent.click(getByTestId('mocked-turn-button'));
|
||||
expect(socket.sent).toHaveLength(0);
|
||||
expect(getByTestId('last-error').textContent).toBe('mocked turn requires session.ready');
|
||||
|
||||
socket.message(createMessageEnvelope('session.ready', { sessionId: 'session-789' }));
|
||||
socket.message(createMessageEnvelope('session.state', { value: 'idle' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('mocked-turn-button').hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('mocked-turn-button'));
|
||||
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(JSON.parse(socket.sent[0]).type).toBe('mocked.turn.trigger');
|
||||
});
|
||||
});
|
||||
@@ -2,5 +2,13 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()],
|
||||
resolve: {
|
||||
conditions: ['browser']
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['tests/**/*.test.js'],
|
||||
exclude: ['e2e/**']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user