feat(vela): add mocked turn transcript response slice
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user