feat(vela): add mocked turn transcript response slice

This commit is contained in:
2026-04-08 19:39:20 +02:00
parent 4b11703c93
commit ff78fc4c8f
20 changed files with 997 additions and 372 deletions

View File

@@ -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

View 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);
});

View File

@@ -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"
}
}

View 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
}
]
});

View 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>

View File

@@ -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 />

View 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');
});
});

View File

@@ -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/**']
}
});