mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
createVoiceControl now returns a presenter instead of building the control with innerHTML: the session, cost, input, card and panel owners write its state by part and listen for interactions, and VoiceControl.vue renders it in the command dock, between the location bar and the visual presets as before. Focus moves wait for the render; the waveform's bars publish apart from the control and only when they change; the minutes field keeps a draft until its owner writes it. Owner tests use a real presenter on their own store, and view tests render the control.
262 lines
7.9 KiB
JavaScript
262 lines
7.9 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
|
import test from 'node:test';
|
|
import { GevRealtimeController } from './realtimeController.js';
|
|
|
|
function fixture({ pointer = null, onUserSend = () => {} } = {}) {
|
|
const sent = [];
|
|
const executed = [];
|
|
const rejectedTexts = new Set();
|
|
let rejectResponse = false;
|
|
const controller = new GevRealtimeController({
|
|
runner: async (name) => {
|
|
executed.push(name);
|
|
return { ok: true };
|
|
},
|
|
backend: {},
|
|
debugSink: null,
|
|
pointer,
|
|
ui: createTestVoiceUi(),
|
|
});
|
|
controller.dc = {
|
|
readyState: 'open',
|
|
send(body) {
|
|
const value = JSON.parse(body);
|
|
const text = value.item?.role === 'user' && value.item.content?.[0]?.text;
|
|
if (value.item?.role === 'user') onUserSend();
|
|
if (
|
|
rejectedTexts.has(text) ||
|
|
(rejectResponse && value.type === 'response.create')
|
|
)
|
|
throw new Error('Synthetic channel rejection');
|
|
sent.push(value);
|
|
},
|
|
close() {
|
|
this.readyState = 'closed';
|
|
},
|
|
};
|
|
controller.status = 'listening';
|
|
controller._turns.narration = null;
|
|
const event = (payload) =>
|
|
controller.handleRealtimeEvent({ data: JSON.stringify(payload) });
|
|
const created = (id) => event({ type: 'response.created', response: { id } });
|
|
const done = (id) =>
|
|
event({
|
|
type: 'response.done',
|
|
response: { id, status: 'completed', output: [] },
|
|
});
|
|
const creates = () =>
|
|
sent.filter(
|
|
(x) =>
|
|
x.type === 'response.create' &&
|
|
x.response?.metadata?.gev !== 'progress',
|
|
);
|
|
const texts = () =>
|
|
sent
|
|
.filter((x) => x.item?.role === 'user')
|
|
.map((x) => x.item.content[0].text);
|
|
return {
|
|
controller,
|
|
sent,
|
|
executed,
|
|
rejectedTexts,
|
|
event,
|
|
created,
|
|
done,
|
|
creates,
|
|
texts,
|
|
rejectResponses(value) {
|
|
rejectResponse = value;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('rejected idle text schedules no answer, and a successful successor answers once', async () => {
|
|
const f = fixture();
|
|
try {
|
|
f.rejectedTexts.add('rejected');
|
|
assert.equal(f.controller.sendTextCommand('rejected'), undefined);
|
|
assert.equal(f.creates().length, 0);
|
|
assert.equal(f.controller.pendingUserTextResponse, false);
|
|
assert.equal(f.controller.responseCreatePending, false);
|
|
assert.equal(f.controller.sendTextCommand('successor'), undefined);
|
|
assert.deepEqual(f.texts(), ['successor']);
|
|
assert.equal(f.creates().length, 1);
|
|
await f.created('successor');
|
|
await f.done('successor');
|
|
assert.equal(f.creates().length, 1);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
|
|
test('rejected text behind an active response cannot queue an answer', async () => {
|
|
const f = fixture();
|
|
try {
|
|
await f.created('active');
|
|
f.controller.queueResponseCreate('Old tool follow-up');
|
|
assert.equal(
|
|
f.controller.pendingResponseInstructions,
|
|
'Old tool follow-up',
|
|
);
|
|
const runningTool = new AbortController();
|
|
f.controller._turns.activeToolAbortControllers.add(runningTool);
|
|
f.rejectedTexts.add('rejected');
|
|
f.controller.sendTextCommand('rejected');
|
|
assert.equal(f.controller.pendingUserTextResponse, false);
|
|
assert.equal(f.controller.pendingResponseInstructions, null);
|
|
assert.equal(f.controller._turns.isSupersededResponse('active'), true);
|
|
assert.equal(runningTool.signal.aborted, true);
|
|
const staleCall = {
|
|
type: 'response.function_call_arguments.done',
|
|
response_id: 'active',
|
|
call_id: 'stale-call',
|
|
name: 'fly_to_location',
|
|
arguments: '{"query":"Paris"}',
|
|
};
|
|
await f.event(staleCall);
|
|
await f.event(staleCall);
|
|
assert.deepEqual(f.executed, []);
|
|
const outputs = f.sent.filter(
|
|
(x) => x.item?.type === 'function_call_output',
|
|
);
|
|
assert.equal(outputs.length, 1);
|
|
assert.equal(outputs[0].item.call_id, 'stale-call');
|
|
assert.equal(JSON.parse(outputs[0].item.output).superseded, true);
|
|
await f.done('active');
|
|
assert.equal(f.creates().length, 0);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
|
|
test('rejected text still prepares narration and pointer context before sending', () => {
|
|
const steps = [];
|
|
const f = fixture({
|
|
pointer: {
|
|
beginTurn() {
|
|
steps.push('pointer');
|
|
return null;
|
|
},
|
|
clear() {},
|
|
},
|
|
onUserSend() {
|
|
steps.push('send');
|
|
},
|
|
});
|
|
f.controller._turns.narration = {
|
|
cancel() {
|
|
steps.push('cancel');
|
|
},
|
|
userTurnEnded() {
|
|
steps.push('user-turn-ended');
|
|
},
|
|
};
|
|
try {
|
|
f.rejectedTexts.add('rejected');
|
|
f.controller.sendTextCommand('rejected');
|
|
assert.deepEqual(steps, ['cancel', 'user-turn-ended', 'pointer', 'send']);
|
|
assert.equal(f.creates().length, 0);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
|
|
test('earlier accepted deferred input survives a newer failed send and answers once', async () => {
|
|
const f = fixture();
|
|
try {
|
|
await f.created('active');
|
|
f.controller.sendTextCommand('accepted');
|
|
f.rejectedTexts.add('rejected');
|
|
f.controller.sendTextCommand('rejected');
|
|
assert.deepEqual(f.texts(), ['accepted']);
|
|
assert.equal(f.controller.pendingUserTextResponse, true);
|
|
assert.equal(f.creates().length, 0);
|
|
await f.done('active');
|
|
assert.equal(f.creates().length, 1);
|
|
await f.done('active');
|
|
assert.equal(f.creates().length, 1);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
|
|
test('rejected text preserves an outstanding create without adding deferred input', async () => {
|
|
const f = fixture();
|
|
try {
|
|
f.controller.sendTextCommand('accepted');
|
|
f.rejectedTexts.add('rejected');
|
|
f.controller.sendTextCommand('rejected');
|
|
assert.equal(f.controller.responseCreatePending, true);
|
|
assert.equal(f.controller.pendingUserTextResponse, false);
|
|
await f.created('accepted');
|
|
await f.done('accepted');
|
|
assert.equal(f.creates().length, 1);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
|
|
for (const acceptedFirst of [false, true]) {
|
|
test(`progress completion answers only accepted deferred input (accepted first=${acceptedFirst})`, async () => {
|
|
const f = fixture();
|
|
try {
|
|
assert.equal(
|
|
f.controller._turns.speakProgressLine('Checking the map.'),
|
|
true,
|
|
);
|
|
await f.event({
|
|
type: 'response.created',
|
|
response: { id: 'progress', metadata: { gev: 'progress' } },
|
|
});
|
|
if (acceptedFirst) f.controller.sendTextCommand('accepted');
|
|
f.rejectedTexts.add('rejected');
|
|
f.controller.sendTextCommand('rejected');
|
|
assert.equal(f.controller.pendingUserTextResponse, acceptedFirst);
|
|
await f.event({
|
|
type: 'response.done',
|
|
response: {
|
|
id: 'progress',
|
|
status: 'cancelled',
|
|
metadata: { gev: 'progress' },
|
|
},
|
|
});
|
|
assert.equal(f.creates().length, acceptedFirst ? 1 : 0);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
test('accepted text with a rejected response.create releases the pending latch for a successor', async () => {
|
|
const f = fixture();
|
|
try {
|
|
f.rejectResponses(true);
|
|
f.controller.sendTextCommand('accepted without response');
|
|
assert.equal(f.controller.responseCreatePending, false);
|
|
assert.equal(f.creates().length, 0);
|
|
f.rejectResponses(false);
|
|
f.controller.sendTextCommand('successor');
|
|
assert.deepEqual(f.texts(), ['accepted without response', 'successor']);
|
|
assert.equal(f.creates().length, 1);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|
|
|
|
test('successful deferred typed burst still retains both items and coalesces one answer', async () => {
|
|
const f = fixture();
|
|
try {
|
|
await f.created('active');
|
|
f.controller.sendTextCommand('first');
|
|
f.controller.sendTextCommand('second');
|
|
assert.deepEqual(f.texts(), ['first', 'second']);
|
|
assert.equal(f.creates().length, 0);
|
|
await f.done('active');
|
|
await f.done('active');
|
|
assert.equal(f.creates().length, 1);
|
|
} finally {
|
|
f.controller.stop();
|
|
}
|
|
});
|