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,5 @@ Current status:
- Fastify server boots in the Yarn workspace
- `/` and `/health` endpoints provide a runnable service baseline
- WebSocket session skeleton wiring now exists
- Full WebSocket session orchestration and behavior remain future work
- the gateway accepts one deterministic mocked-turn trigger per session and emits protocol-valid transcript/response events
- Full provider-backed WebSocket session orchestration remains future work

View File

@@ -11,6 +11,8 @@ const {
const WEBSOCKET_ROUTE = '/ws';
const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
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.';
function createSessionRecord() {
return {
@@ -18,10 +20,62 @@ function createSessionRecord() {
connectedAt: new Date().toISOString(),
state: 'idle',
audioChunkCount: 0,
started: false
started: false,
mockedTurnInFlight: false,
mockedTurnTimers: []
};
}
function clearMockedTurn(session) {
for (const timer of session.mockedTurnTimers) {
clearTimeout(timer);
}
session.mockedTurnTimers = [];
session.mockedTurnInFlight = false;
}
function scheduleMockedTurnStep(session, delay, callback) {
const timer = setTimeout(() => {
session.mockedTurnTimers = session.mockedTurnTimers.filter((activeTimer) => activeTimer !== timer);
callback();
}, delay);
session.mockedTurnTimers.push(timer);
}
function startMockedTurn(socket, session) {
if (session.mockedTurnInFlight) {
sendSocketError(socket, 'mocked_turn_in_flight', 'Only one mocked turn can run per session at a time.');
return;
}
clearMockedTurn(session);
session.audioChunkCount = 0;
session.mockedTurnInFlight = true;
updateSessionState(socket, session, 'listening');
scheduleMockedTurnStep(session, 75, () => {
sendSocketMessage(socket, 'transcript.final', { text: MOCKED_USER_TRANSCRIPT });
updateSessionState(socket, session, 'thinking');
});
scheduleMockedTurnStep(session, 150, () => {
updateSessionState(socket, session, 'speaking');
sendSocketMessage(socket, 'response.text.delta', { text: '[mocked assistant] ' });
});
scheduleMockedTurnStep(session, 225, () => {
sendSocketMessage(socket, 'response.text.delta', { text: MOCKED_ASSISTANT_RESPONSE.replace('[mocked assistant] ', '') });
});
scheduleMockedTurnStep(session, 300, () => {
sendSocketMessage(socket, 'response.completed', {});
clearMockedTurn(session);
updateSessionState(socket, session, 'idle');
});
}
function createWebSocketAcceptValue(key) {
return crypto.createHash('sha1').update(`${key}${WEBSOCKET_GUID}`).digest('base64');
}
@@ -164,15 +218,29 @@ function handleClientMessage(socket, session, rawMessage) {
sendSocketMessage(socket, 'session.ready', { sessionId: session.id });
sendSocketMessage(socket, 'session.state', { value: session.state });
break;
case 'mocked.turn.trigger':
startMockedTurn(socket, session);
break;
case 'input_audio.append':
if (session.mockedTurnInFlight) {
sendSocketError(socket, 'mocked_turn_in_flight', 'Wait for the mocked turn to finish before sending more input.');
break;
}
session.audioChunkCount += 1;
updateSessionState(socket, session, 'listening');
break;
case 'input_audio.commit':
if (session.mockedTurnInFlight) {
sendSocketError(socket, 'mocked_turn_in_flight', 'Wait for the mocked turn to finish before committing input.');
break;
}
session.audioChunkCount = 0;
updateSessionState(socket, session, 'idle');
break;
case 'response.cancel':
clearMockedTurn(session);
session.audioChunkCount = 0;
updateSessionState(socket, session, 'idle');
break;
@@ -241,6 +309,7 @@ function registerWebSocketSessionRoute(app) {
}
closed = true;
clearMockedTurn(session);
app.websocketSessions.delete(session.id);
app.log.info({ sessionId: session.id }, 'websocket session disconnected');
};

View File

@@ -326,3 +326,82 @@ test('websocket handles valid and invalid client messages safely', async () => {
await server.close();
}
});
test('websocket mocked turn emits deterministic transcript and response events in order', 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' }
});
assert.deepEqual(await client.nextMessage(), {
type: 'session.state',
payload: { value: 'speaking' }
});
assert.deepEqual(await client.nextMessage(), {
type: 'response.text.delta',
payload: { text: '[mocked assistant] ' }
});
assert.deepEqual(await client.nextMessage(), {
type: 'response.text.delta',
payload: { text: 'This is a deterministic mocked response from the gateway vertical slice.' }
});
assert.deepEqual(await client.nextMessage(), {
type: 'response.completed',
payload: {}
});
assert.deepEqual(await client.nextMessage(), {
type: 'session.state',
payload: { value: 'idle' }
});
await client.close();
} finally {
await server.close();
}
});
test('websocket rejects a second mocked turn while one is in flight', 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' }
});
client.sendJson({ type: 'mocked.turn.trigger', payload: {} });
assert.deepEqual(await client.nextMessage(), {
type: 'error',
payload: {
code: 'mocked_turn_in_flight',
message: 'Only one mocked turn can run per session at a time.',
retryable: true
}
});
await client.close();
} finally {
await server.close();
}
});