mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
keep analyst counts, speech, cards, cancellation, pointer capture, tool schema boundaries, and session lifetime consistent add the exact Alps fallback and focused regression coverage
3783 lines
142 KiB
JavaScript
3783 lines
142 KiB
JavaScript
// Pure-logic unit tests for the voice-lifecycle size guards (Batch 11, M13).
|
||
// These helpers are DOM/WebRTC-free so they pin the screenshot down-scaling and
|
||
// payload-byte estimation that keep an oversized dc.send from stranding a turn.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { DataLayerManager } from '../data/manager.js';
|
||
import { controlRadio as runControlRadio, createGevActionRunner as createActionRunner } from './gevActions.js';
|
||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||
import {
|
||
computeDownscale,
|
||
renderFreshCesiumFrame,
|
||
estimateDataUrlBytes,
|
||
GevRealtimeController,
|
||
gateVoiceVisualizerLevel,
|
||
isBenignViewportDeleteError,
|
||
isEditingSpaceTarget,
|
||
isInteractiveSpaceTarget,
|
||
isPushToTalkKey,
|
||
isPushToTalkSurface,
|
||
PUSH_TO_TALK_HOLD_DELAY_MS,
|
||
resolveVoiceControlHint,
|
||
resolveVoiceVisualizerSpeaker,
|
||
selectVoiceVisualizerSignal,
|
||
silenceRadioForVoice,
|
||
startPreparedRadioAfterPlaybackReady,
|
||
shouldPauseRadioForVoice,
|
||
shouldHandlePushToTalkKeyDown,
|
||
shouldIgnoreVoiceButtonClick,
|
||
shouldStopVoiceAfterRadioTool,
|
||
readStoredVoiceTier,
|
||
readStoredVoiceLimits,
|
||
writeStoredVoiceTier,
|
||
writeStoredVoiceLimits,
|
||
withToolCatalog,
|
||
} from './gevRealtime.js';
|
||
import { createVoiceCostTracker } from './voiceCost.js';
|
||
|
||
test('push-to-talk recognizes Space by code or key', () => {
|
||
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
|
||
assert.equal(isPushToTalkKey({ code: 'Space', key: 'Unidentified' }), true);
|
||
assert.equal(isPushToTalkKey({ code: '', key: ' ' }), true);
|
||
assert.equal(isPushToTalkKey({ code: 'KeyM', key: 'm' }), false);
|
||
});
|
||
|
||
test('push-to-talk protects text entry but arbitrates non-editing controls', () => {
|
||
const plainTarget = { isContentEditable: false, closest: () => null };
|
||
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget }), true);
|
||
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget, metaKey: true }), false);
|
||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||
code: 'Space',
|
||
target: pushToTalkTarget({ tagName: 'INPUT', type: 'text' }),
|
||
}), false);
|
||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||
code: 'Space',
|
||
target: { isContentEditable: true, closest: () => null },
|
||
}), false);
|
||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||
code: 'Space',
|
||
target: pushToTalkTarget({ tagName: 'BUTTON' }),
|
||
}), true);
|
||
assert.equal(isEditingSpaceTarget(pushToTalkTarget({ tagName: 'INPUT', type: 'range' })), false);
|
||
});
|
||
|
||
// Dispatch through the installed document/window listeners, keeping WebRTC at
|
||
// the start boundary. Native button activation itself belongs to browser QA.
|
||
function createPushToTalkFixture(t) {
|
||
const savedGlobals = new Map(['document', 'window', 'setTimeout', 'clearTimeout'].map((name) => (
|
||
[name, Object.getOwnPropertyDescriptor(globalThis, name)]
|
||
)));
|
||
const eventHub = () => {
|
||
const listeners = new Map();
|
||
return {
|
||
addEventListener(type, listener) {
|
||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||
listeners.get(type).add(listener);
|
||
},
|
||
removeEventListener(type, listener) { listeners.get(type)?.delete(listener); },
|
||
dispatch(type, event = {}) {
|
||
for (const listener of listeners.get(type) || []) listener(event);
|
||
return event;
|
||
},
|
||
count(type) { return listeners.get(type)?.size || 0; },
|
||
};
|
||
};
|
||
let clock = 0;
|
||
let nextTimerId = 1;
|
||
const timers = new Map();
|
||
const setTimeout = (callback, delay = 0) => {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { callback, due: clock + Number(delay || 0) });
|
||
return id;
|
||
};
|
||
const clearTimeout = (id) => timers.delete(id);
|
||
const advance = (milliseconds) => {
|
||
const end = clock + milliseconds;
|
||
while (true) {
|
||
const due = [...timers.entries()]
|
||
.filter(([, timer]) => timer.due <= end)
|
||
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0];
|
||
if (!due) break;
|
||
const [id, timer] = due;
|
||
timers.delete(id);
|
||
clock = timer.due;
|
||
timer.callback();
|
||
}
|
||
clock = end;
|
||
};
|
||
const document = { ...eventHub(), visibilityState: 'visible', activeElement: null };
|
||
const window = eventHub();
|
||
Object.assign(globalThis, { document, window, setTimeout, clearTimeout });
|
||
const starts = [];
|
||
const radio = [];
|
||
const controllers = [];
|
||
const makeController = () => {
|
||
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
|
||
const controller = new GevRealtimeController({
|
||
ui: {
|
||
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
|
||
status: {}, detail: {},
|
||
},
|
||
runner: async () => ({}),
|
||
radioLayer: {
|
||
pause: () => radio.push('pause'),
|
||
setVoiceDucked: (ducked) => radio.push(ducked ? 'duck' : 'unduck'),
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.start = ({ pushToTalk }) => {
|
||
starts.push({ pushToTalk });
|
||
controller.pushToTalkMode = pushToTalk;
|
||
controller.status = 'listening';
|
||
controller.stream = {
|
||
getAudioTracks: () => [microphone],
|
||
getTracks: () => [microphone],
|
||
};
|
||
controller.setMicrophoneEnabled(true);
|
||
};
|
||
controllers.push(controller);
|
||
controller.bindPushToTalkShortcut();
|
||
return { controller, microphone };
|
||
};
|
||
t.after(() => {
|
||
for (const controller of controllers) controller.stop({ removeUi: true });
|
||
for (const [name, descriptor] of savedGlobals) {
|
||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||
else delete globalThis[name];
|
||
}
|
||
});
|
||
const key = (type, target, options = {}) => {
|
||
document.activeElement = options.activeElement === undefined ? target : options.activeElement;
|
||
return document.dispatch(type, {
|
||
code: 'Space', key: ' ', target, defaultPrevented: false,
|
||
preventDefault() { this.defaultPrevented = true; },
|
||
...options,
|
||
});
|
||
};
|
||
return { advance, document, window, starts, radio, key, makeController, timers, ...makeController() };
|
||
}
|
||
|
||
function pushToTalkTarget({
|
||
tagName = null,
|
||
id = null,
|
||
style = null,
|
||
parentElement = null,
|
||
role = null,
|
||
tabIndex = null,
|
||
href = null,
|
||
controls = false,
|
||
type = null,
|
||
contentEditable = false,
|
||
mapSurface = false,
|
||
} = {}) {
|
||
const resolvedTagName = (tagName || (style ? 'BUTTON' : 'DIV')).toUpperCase();
|
||
return {
|
||
tagName: resolvedTagName,
|
||
id,
|
||
style,
|
||
parentElement,
|
||
role,
|
||
tabIndex,
|
||
href,
|
||
controls,
|
||
type,
|
||
blurred: 0,
|
||
blur() { this.blurred += 1; },
|
||
isContentEditable: contentEditable,
|
||
closest(selector) {
|
||
for (let node = this; node; node = node.parentElement) {
|
||
if (selector === '#cesiumContainer' && (node.id === 'cesiumContainer' || node.mapSurface)) {
|
||
return node;
|
||
}
|
||
for (const part of selector.split(/,\s*/)) {
|
||
if (part.toUpperCase() === node.tagName) return node;
|
||
if (part === 'a[href]' && node.tagName === 'A' && node.href !== null) return node;
|
||
if (part === 'audio[controls]' && node.tagName === 'AUDIO' && node.controls) return node;
|
||
if (part === 'video[controls]' && node.tagName === 'VIDEO' && node.controls) return node;
|
||
if (part === '[contenteditable]' && node.isContentEditable) return node;
|
||
if (part === '[tabindex]' && node.tabIndex !== null) return node;
|
||
const roleMatch = part.match(/^\[role="([^"]+)"\]$/);
|
||
if (roleMatch && node.role === roleMatch[1]) return node;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
mapSurface,
|
||
};
|
||
}
|
||
|
||
test('push-to-talk starts from Cesium canvases and the document background', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const cesiumContainer = pushToTalkTarget({ tagName: 'DIV', id: 'cesiumContainer' });
|
||
const cesiumCanvas = pushToTalkTarget({
|
||
tagName: 'CANVAS',
|
||
tabIndex: 0,
|
||
parentElement: cesiumContainer,
|
||
});
|
||
const overlayCanvas = pushToTalkTarget({
|
||
tagName: 'CANVAS',
|
||
id: 'world-overlay-canvas',
|
||
tabIndex: 0,
|
||
});
|
||
|
||
for (const [index, canvas] of [cesiumCanvas, overlayCanvas].entries()) {
|
||
assert.equal(isPushToTalkSurface(canvas), true);
|
||
assert.equal(isInteractiveSpaceTarget(canvas), false);
|
||
assert.equal(f.key('keydown', canvas).defaultPrevented, true);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.starts.length, 1, 'the first map hold opens one reusable voice session');
|
||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||
assert.equal(f.microphone.enabled, true, `map hold ${index + 1} enables the microphone`);
|
||
assert.equal(f.key('keyup', canvas).defaultPrevented, true);
|
||
assert.equal(f.microphone.enabled, false, `map release ${index + 1} mutes the microphone`);
|
||
}
|
||
const background = pushToTalkTarget();
|
||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||
});
|
||
|
||
test('short Space on a focused control preserves release activation', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const button = pushToTalkTarget({ tagName: 'BUTTON' });
|
||
let activations = 0;
|
||
assert.equal(f.key('keydown', button).defaultPrevented, false);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||
const release = f.key('keyup', button);
|
||
if (!release.defaultPrevented) activations += 1;
|
||
assert.equal(release.defaultPrevented, false);
|
||
assert.equal(activations, 1);
|
||
assert.equal(button.blurred, 0);
|
||
assert.deepEqual(f.starts, []);
|
||
});
|
||
|
||
test('long Space blurs a focused control before voice and consumes release', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const button = pushToTalkTarget({ tagName: 'BUTTON' });
|
||
const order = [];
|
||
button.blur = () => {
|
||
button.blurred += 1;
|
||
order.push('blur');
|
||
f.document.activeElement = null;
|
||
};
|
||
f.controller.pauseRadioForVoice = () => order.push('voice');
|
||
assert.equal(f.key('keydown', button).defaultPrevented, false);
|
||
assert.equal(f.key('keydown', button, { repeat: true }).defaultPrevented, false);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.deepEqual(order, ['blur', 'voice']);
|
||
assert.equal(button.blurred, 1);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||
assert.equal(f.key('keydown', button, { repeat: true, activeElement: null }).defaultPrevented, true);
|
||
let activations = 0;
|
||
const release = f.key('keyup', button, { activeElement: null });
|
||
if (!release.defaultPrevented) activations += 1;
|
||
assert.equal(release.defaultPrevented, true);
|
||
assert.equal(activations, 0);
|
||
assert.equal(f.microphone.enabled, false);
|
||
});
|
||
|
||
test('push-to-talk still ignores modified, already-handled and typing keydowns', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const target = pushToTalkTarget();
|
||
for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']) {
|
||
assert.equal(f.key('keydown', target, { [modifier]: true }).defaultPrevented, false);
|
||
}
|
||
f.key('keydown', target, { defaultPrevented: true });
|
||
f.key('keydown', target, { code: 'Enter', key: 'Enter' });
|
||
for (const tagName of ['INPUT', 'TEXTAREA']) f.key('keydown', pushToTalkTarget({ tagName }));
|
||
f.key('keydown', pushToTalkTarget({ role: 'textbox' }));
|
||
f.key('keydown', { isContentEditable: true });
|
||
assert.deepEqual(f.starts, []);
|
||
assert.deepEqual(f.radio, []);
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
});
|
||
|
||
test('push-to-talk starts once at 500ms and repeat does not reset the threshold', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const background = pushToTalkTarget();
|
||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||
assert.deepEqual(f.starts, []);
|
||
assert.deepEqual(f.radio, []);
|
||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
|
||
f.advance(1);
|
||
assert.deepEqual(f.starts, [{ pushToTalk: true }]);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||
assert.equal(f.microphone.enabled, true);
|
||
const radioAfterStart = f.radio.slice();
|
||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS * 2);
|
||
assert.deepEqual(f.radio, radioAfterStart);
|
||
assert.equal(f.starts.length, 1);
|
||
assert.equal(
|
||
f.key('keyup', pushToTalkTarget({ tagName: 'BUTTON' })).defaultPrevented,
|
||
true,
|
||
'release follows a voice-owned hold after focus moves',
|
||
);
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||
assert.equal(f.microphone.enabled, false);
|
||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
|
||
});
|
||
|
||
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const background = pushToTalkTarget();
|
||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||
assert.deepEqual(f.starts, []);
|
||
assert.deepEqual(f.radio, []);
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||
assert.equal(f.timers.size, 0);
|
||
});
|
||
|
||
test('push-to-talk rechecks focus before the hold threshold claims voice', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const background = pushToTalkTarget();
|
||
const control = pushToTalkTarget({ tagName: 'BUTTON' });
|
||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||
f.document.activeElement = control;
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.deepEqual(f.starts, []);
|
||
assert.deepEqual(f.radio, []);
|
||
assert.equal(f.key('keyup', control).defaultPrevented, true, 'release follows the background gesture owner');
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
});
|
||
|
||
test('push-to-talk never claims a control-started hold after focus moves outside', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const control = pushToTalkTarget({ tagName: 'BUTTON' });
|
||
const background = pushToTalkTarget();
|
||
assert.equal(f.key('keydown', control).defaultPrevented, false);
|
||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, false);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||
assert.deepEqual(f.starts, []);
|
||
assert.deepEqual(f.radio, []);
|
||
});
|
||
|
||
test('push-to-talk cancels pending and active holds on blur or hidden document', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const background = pushToTalkTarget();
|
||
for (const exit of ['blur', 'hidden']) {
|
||
const startsBeforePending = f.starts.length;
|
||
const radioBeforePending = f.radio.length;
|
||
f.key('keydown', background);
|
||
const staleCallback = [...f.timers.values()][0].callback;
|
||
if (exit === 'blur') f.window.dispatch('blur');
|
||
else {
|
||
f.document.visibilityState = 'hidden';
|
||
f.document.dispatch('visibilitychange');
|
||
}
|
||
staleCallback();
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.starts.length, startsBeforePending);
|
||
assert.equal(f.radio.length, radioBeforePending);
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
f.document.visibilityState = 'visible';
|
||
|
||
f.key('keydown', background);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.microphone.enabled, true);
|
||
if (exit === 'blur') f.window.dispatch('blur');
|
||
else {
|
||
f.document.visibilityState = 'hidden';
|
||
f.document.dispatch('visibilitychange');
|
||
}
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||
assert.equal(f.microphone.enabled, false);
|
||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||
f.document.visibilityState = 'visible';
|
||
}
|
||
});
|
||
|
||
test('push-to-talk timer refuses hidden or unfocused documents before lifecycle events arrive', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const background = pushToTalkTarget();
|
||
for (const state of ['hidden', 'unfocused']) {
|
||
f.key('keydown', background);
|
||
if (state === 'hidden') f.document.visibilityState = 'hidden';
|
||
else f.document.hasFocus = () => false;
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.deepEqual(f.starts, []);
|
||
assert.deepEqual(f.radio, []);
|
||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||
f.document.visibilityState = 'visible';
|
||
delete f.document.hasFocus;
|
||
}
|
||
});
|
||
|
||
test('focused controls and background Space preserve a click-started open mic', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
f.controller.start({ pushToTalk: false });
|
||
f.starts.length = 0;
|
||
const style = pushToTalkTarget({ style: 'retro' });
|
||
f.key('keydown', style);
|
||
f.key('keydown', style, { repeat: true });
|
||
assert.equal(f.key('keyup', style).defaultPrevented, false);
|
||
assert.deepEqual(f.radio, []);
|
||
assert.equal(f.microphone.enabled, true);
|
||
assert.equal(f.controller.pushToTalkMode, false);
|
||
assert.equal(f.key('keydown', pushToTalkTarget()).defaultPrevented, true);
|
||
assert.equal(f.controller.spaceKeyHeld, true);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||
assert.equal(f.timers.size, 0);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||
assert.equal(f.key('keyup', style).defaultPrevented, true);
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
assert.equal(f.microphone.enabled, true, 'an outside Space gesture must not claim or mute an open-mic session');
|
||
assert.deepEqual(f.starts, []);
|
||
});
|
||
|
||
test('push-to-talk teardown removes installed listeners and replacement binds once', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const outside = pushToTalkTarget();
|
||
f.controller.bindPushToTalkShortcut();
|
||
assert.equal(f.document.count('keydown'), 1);
|
||
f.key('keydown', outside);
|
||
const staleCallback = [...f.timers.values()][0].callback;
|
||
f.controller.stop({ removeUi: true });
|
||
staleCallback();
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.microphone.stopped, false);
|
||
assert.equal(f.controller.spaceKeyHeld, false);
|
||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||
assert.equal(f.controller.pushToTalkMode, false);
|
||
assert.equal(f.document.count('keydown'), 0);
|
||
assert.equal(f.document.count('keyup'), 0);
|
||
assert.equal(f.document.count('visibilitychange'), 0);
|
||
assert.equal(f.window.count('blur'), 0);
|
||
f.key('keydown', outside);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.starts.length, 0, 'the removed controller must not respond');
|
||
const replacement = f.makeController();
|
||
replacement.controller.bindPushToTalkShortcut();
|
||
assert.equal(f.document.count('keydown'), 1);
|
||
assert.equal(f.document.count('keyup'), 1);
|
||
assert.equal(f.document.count('visibilitychange'), 1);
|
||
assert.equal(f.window.count('blur'), 1);
|
||
f.key('keydown', pushToTalkTarget({ tagName: 'INPUT', type: 'text' }));
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.starts.length, 0);
|
||
f.key('keydown', outside);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(f.starts.length, 1);
|
||
assert.equal(replacement.microphone.enabled, true);
|
||
f.key('keyup', outside);
|
||
assert.equal(replacement.microphone.enabled, false);
|
||
});
|
||
|
||
test('mic clicks are ignored while Space is physically held', () => {
|
||
assert.equal(shouldIgnoreVoiceButtonClick(true), true);
|
||
assert.equal(shouldIgnoreVoiceButtonClick(false), false);
|
||
});
|
||
|
||
test('voice control help tray reflects the push-to-talk key state', () => {
|
||
assert.equal(
|
||
resolveVoiceControlHint(false, false),
|
||
'Hold Space to speak · tap Space to activate focused controls',
|
||
);
|
||
assert.equal(resolveVoiceControlHint(true, true), 'Release Space to send');
|
||
assert.equal(
|
||
resolveVoiceControlHint(true, false),
|
||
'Hold Space to speak · tap Space to activate focused controls',
|
||
);
|
||
});
|
||
|
||
test('voice visualizer reads assistant output while the assistant is speaking', () => {
|
||
const input = { analyser: { id: 'mic' }, data: new Uint8Array([1]) };
|
||
const output = { analyser: { id: 'speaker' }, data: new Uint8Array([2]) };
|
||
assert.equal(selectVoiceVisualizerSignal('user', input, output), input);
|
||
assert.equal(selectVoiceVisualizerSignal('ai', input, output), output);
|
||
assert.equal(selectVoiceVisualizerSignal('ai', input, { analyser: null, data: null }), null);
|
||
});
|
||
|
||
test('voice visualizer keeps assistant audio selected after response.done', () => {
|
||
assert.equal(resolveVoiceVisualizerSpeaker('ai', 'idle', true), 'ai');
|
||
assert.equal(resolveVoiceVisualizerSpeaker('ai', 'user', false), 'user');
|
||
assert.equal(resolveVoiceVisualizerSpeaker('user', 'idle', true), 'idle');
|
||
});
|
||
|
||
test('voice visualizer noise gate holds room tone at the baseline', () => {
|
||
assert.equal(gateVoiceVisualizerLevel(0.12, 0.12), 0);
|
||
assert.equal(gateVoiceVisualizerLevel(0.06, 0.12), 0);
|
||
assert.ok(gateVoiceVisualizerLevel(0.5, 0.12) > 0);
|
||
assert.equal(gateVoiceVisualizerLevel(1, 0.12), 1);
|
||
assert.equal(gateVoiceVisualizerLevel(NaN, 0.12), 0);
|
||
});
|
||
|
||
test('voice activation, speech, and push-to-talk pause Radio without an idle auto-resume', () => {
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'connecting' }), true);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'executing' }), true);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'listening', speaker: 'user' }), true);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'listening', speaker: 'ai' }), true);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'listening', pushToTalkKeyHeld: true }), true);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'listening' }), false);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'idle' }), false);
|
||
assert.equal(shouldPauseRadioForVoice({ status: 'error' }), false);
|
||
});
|
||
|
||
test('successful Radio activation tools close voice after handing control to Radio', () => {
|
||
for (const radioAction of ['play', 'resume', 'select', 'next', 'previous']) {
|
||
assert.equal(shouldStopVoiceAfterRadioTool({ ok: true, action: 'control_radio', radioAction }), true);
|
||
}
|
||
assert.equal(shouldStopVoiceAfterRadioTool({ ok: true, action: 'control_radio', radioAction: 'enable' }), false);
|
||
assert.equal(shouldStopVoiceAfterRadioTool({ ok: true, action: 'control_radio', radioAction: 'volume' }), false);
|
||
assert.equal(shouldStopVoiceAfterRadioTool({ ok: false, action: 'control_radio', radioAction: 'play' }), false);
|
||
assert.equal(shouldStopVoiceAfterRadioTool({ ok: true, action: 'control_cctv', radioAction: 'play' }), false);
|
||
});
|
||
|
||
test('prepared Radio playback is verified under mute before voice closes', async () => {
|
||
const order = [];
|
||
const handoff = await startPreparedRadioAfterPlaybackReady({
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
}, {
|
||
prepareRadio: async () => {
|
||
order.push('radio-play-muted');
|
||
return true;
|
||
},
|
||
stopVoice: () => order.push('voice-stop'),
|
||
});
|
||
assert.deepEqual(order, ['radio-play-muted', 'voice-stop']);
|
||
assert.equal(handoff.handled, true);
|
||
assert.equal(handoff.result.ok, true);
|
||
assert.equal(handoff.result.audioState, 'playing');
|
||
});
|
||
|
||
test('failed or superseded Radio preflight keeps voice open and cancels audio', async () => {
|
||
for (const scenario of [
|
||
{ started: false, current: true, cancelled: false },
|
||
{ started: true, current: false, cancelled: true },
|
||
]) {
|
||
const order = [];
|
||
const handoff = await startPreparedRadioAfterPlaybackReady({
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioPlaybackRequested: true,
|
||
}, {
|
||
prepareRadio: async () => scenario.started,
|
||
isCurrent: () => scenario.current,
|
||
cancelRadio: () => order.push('radio-cancel'),
|
||
stopVoice: () => order.push('voice-stop'),
|
||
});
|
||
assert.deepEqual(order, ['radio-cancel']);
|
||
assert.equal(handoff.result.ok, false);
|
||
assert.equal(Boolean(handoff.cancelled), scenario.cancelled);
|
||
}
|
||
});
|
||
|
||
test('replacement input invalidates an in-flight Radio preflight immediately', () => {
|
||
const order = [];
|
||
const controller = new GevRealtimeController({
|
||
ui: {},
|
||
runner: async () => ({}),
|
||
radioLayer: { stopPlayback: () => order.push('radio-stop') },
|
||
});
|
||
controller.pendingRadioPlaybackResult = { ok: true };
|
||
controller.radioHandoffInFlight = true;
|
||
const priorEpoch = controller.radioHandoffEpoch;
|
||
controller.cancelRadioHandoff();
|
||
assert.deepEqual(order, ['radio-stop']);
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
assert.equal(controller.radioHandoffInFlight, false);
|
||
assert.equal(controller.radioHandoffEpoch, priorEpoch + 1);
|
||
});
|
||
|
||
test('direct Radio pause or stop cancels a pending voice handoff', async () => {
|
||
const order = [];
|
||
let playbackControl = null;
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async () => ({ ok: true }),
|
||
radioLayer: {
|
||
subscribePlaybackControls(listener) {
|
||
playbackControl = listener;
|
||
return () => { playbackControl = null; };
|
||
},
|
||
playForVoice: async () => {
|
||
order.push('radio-play');
|
||
return true;
|
||
},
|
||
stopPlayback: () => order.push('radio-stop'),
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send() {},
|
||
close() { order.push('voice-stop'); },
|
||
};
|
||
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
|
||
|
||
playbackControl('stop');
|
||
await controller.handleRealtimeEvent({
|
||
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
|
||
});
|
||
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
assert.deepEqual(order, [], 'the explicit stop remains authoritative');
|
||
});
|
||
|
||
test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
|
||
const order = [];
|
||
let playbackControl = null;
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async () => ({ ok: true }),
|
||
radioLayer: {
|
||
subscribePlaybackControls(listener) {
|
||
playbackControl = listener;
|
||
return () => { playbackControl = null; };
|
||
},
|
||
setVoiceDucked(ducked) { order.push(ducked ? 'radio-muted' : 'radio-fade-in'); },
|
||
stopPlayback() { order.push('radio-stop'); },
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.status = 'listening';
|
||
controller.radioVoiceDucked = true;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
close() { order.push('voice-stop'); },
|
||
};
|
||
|
||
playbackControl('play');
|
||
|
||
assert.deepEqual(order, ['voice-stop', 'radio-fade-in']);
|
||
assert.equal(controller.status, 'idle');
|
||
assert.equal(controller.dc, null);
|
||
});
|
||
|
||
test('manual playback takeover survives stale voice preflight cleanup', async () => {
|
||
let playbackControl = null;
|
||
let resolvePreflight;
|
||
let activeAttemptId = null;
|
||
const stopRequests = [];
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const radioLayer = {
|
||
subscribePlaybackControls(listener) {
|
||
playbackControl = listener;
|
||
return () => { playbackControl = null; };
|
||
},
|
||
setVoiceDucked() {},
|
||
async playForVoice({ attemptId }) {
|
||
activeAttemptId = attemptId;
|
||
return new Promise((resolve) => { resolvePreflight = resolve; });
|
||
},
|
||
stopPlayback({ attemptId, origin }) {
|
||
const owned = activeAttemptId === attemptId;
|
||
stopRequests.push({ attemptId, origin, owned });
|
||
if (owned) activeAttemptId = null;
|
||
return owned;
|
||
},
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async () => ({ ok: true }),
|
||
radioLayer,
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.status = 'listening';
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
|
||
|
||
const pending = controller.handleRealtimeEvent({
|
||
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
const voiceAttemptId = activeAttemptId;
|
||
assert.match(voiceAttemptId, /^voice-radio-/);
|
||
|
||
activeAttemptId = 'manual-attempt';
|
||
playbackControl({ action: 'play', origin: 'user', attemptId: activeAttemptId });
|
||
resolvePreflight(true);
|
||
await pending;
|
||
|
||
assert.equal(activeAttemptId, 'manual-attempt');
|
||
assert.deepEqual(stopRequests, [{
|
||
attemptId: voiceAttemptId,
|
||
origin: 'voice-cleanup',
|
||
owned: false,
|
||
}]);
|
||
assert.equal(controller.status, 'idle');
|
||
});
|
||
|
||
test('internal Radio cleanup controls do not masquerade as newer user input', () => {
|
||
let playbackControl = null;
|
||
const controller = new GevRealtimeController({
|
||
ui: {},
|
||
runner: async () => ({}),
|
||
radioLayer: {
|
||
subscribePlaybackControls(listener) {
|
||
playbackControl = listener;
|
||
return () => {};
|
||
},
|
||
stopPlayback() {},
|
||
},
|
||
});
|
||
controller.pendingRadioPlaybackResult = { ok: true };
|
||
controller.radioHandoffInFlight = true;
|
||
controller.radioHandoffAttemptId = 'voice-attempt';
|
||
|
||
playbackControl({ action: 'stop', origin: 'voice-cleanup', attemptId: 'voice-attempt' });
|
||
|
||
assert.ok(controller.pendingRadioPlaybackResult);
|
||
assert.equal(controller.radioHandoffInFlight, true);
|
||
assert.equal(controller.radioHandoffAttemptId, 'voice-attempt');
|
||
});
|
||
|
||
test('manual Radio playback does not close voice when voice is already idle', () => {
|
||
let playbackControl = null;
|
||
let voiceStops = 0;
|
||
const controller = new GevRealtimeController({
|
||
ui: {},
|
||
runner: async () => ({}),
|
||
radioLayer: {
|
||
subscribePlaybackControls(listener) {
|
||
playbackControl = listener;
|
||
return () => { playbackControl = null; };
|
||
},
|
||
},
|
||
});
|
||
controller.dc = { readyState: 'open', close() { voiceStops += 1; } };
|
||
|
||
playbackControl('play');
|
||
|
||
assert.equal(voiceStops, 0);
|
||
assert.equal(controller.dc?.readyState, 'open');
|
||
});
|
||
|
||
test('active-response Realtime errors invalidate pending and in-flight Radio handoffs', async () => {
|
||
const order = [];
|
||
let playbackControl = null;
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async () => ({ ok: true }),
|
||
radioLayer: {
|
||
subscribePlaybackControls(listener) {
|
||
playbackControl = listener;
|
||
return () => { playbackControl = null; };
|
||
},
|
||
stopPlayback() {
|
||
order.push('radio-stop');
|
||
playbackControl?.('stop');
|
||
},
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.dc = { readyState: 'open', send() {}, close() { order.push('voice-stop'); } };
|
||
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
|
||
controller.radioHandoffInFlight = true;
|
||
|
||
await controller.handleRealtimeEvent({
|
||
data: JSON.stringify({
|
||
type: 'error',
|
||
error: { code: 'conversation_already_has_active_response', message: 'response active' },
|
||
}),
|
||
});
|
||
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
assert.equal(controller.radioHandoffInFlight, false);
|
||
assert.deepEqual(order, ['radio-stop']);
|
||
});
|
||
|
||
test('voice ownership ducks tuner static before pausing broadcaster audio', () => {
|
||
const order = [];
|
||
const paused = silenceRadioForVoice({
|
||
duckRadio: () => order.push('duck-static'),
|
||
pauseRadio: () => {
|
||
order.push('pause-stream');
|
||
return false;
|
||
},
|
||
});
|
||
assert.deepEqual(order, ['duck-static', 'pause-stream']);
|
||
assert.equal(paused, false, 'a stopped stream can remain stopped while static is still silenced');
|
||
});
|
||
|
||
test('Radio handoff waits for response.done so later multi-intent tools execute before voice closes', async () => {
|
||
const order = [];
|
||
const confirmationInstructions = [];
|
||
const ui = {
|
||
root: {
|
||
dataset: {},
|
||
classList: { remove() {} },
|
||
querySelectorAll: () => [],
|
||
},
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const runner = async (name) => {
|
||
order.push(`tool:${name}`);
|
||
if (name === 'control_radio') {
|
||
return {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
};
|
||
}
|
||
return { ok: true, action: name };
|
||
};
|
||
const radioLayer = {
|
||
setVoiceDucked() {},
|
||
pause: () => false,
|
||
playForVoice: async () => {
|
||
order.push('radio-play');
|
||
return true;
|
||
},
|
||
stopPlayback() { order.push('radio-stop'); },
|
||
};
|
||
const controller = new GevRealtimeController({ runner, ui, radioLayer });
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) {
|
||
const payload = JSON.parse(message);
|
||
if (payload.type === 'response.create') {
|
||
order.push('assistant-confirmation-requested');
|
||
confirmationInstructions.push(payload.response?.instructions || '');
|
||
}
|
||
},
|
||
close() { order.push('voice-stop'); },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
await controller.handleRealtimeEvent(event({ type: 'response.created', response: { id: 'response-1' } }));
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-1',
|
||
call_id: 'radio-call',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
assert.deepEqual(order, ['tool:control_radio'], 'prepared playback keeps the response channel open');
|
||
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-1',
|
||
call_id: 'style-call',
|
||
name: 'set_visual_style',
|
||
arguments: '{"style":"night-vision"}',
|
||
}));
|
||
assert.deepEqual(order, ['tool:control_radio', 'tool:set_visual_style']);
|
||
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: 'response-1', status: 'completed' },
|
||
}));
|
||
assert.deepEqual(order, [
|
||
'tool:control_radio',
|
||
'tool:set_visual_style',
|
||
'assistant-confirmation-requested',
|
||
]);
|
||
assert.match(confirmationInstructions[0], /Turning on the radio/);
|
||
|
||
await controller.handleRealtimeEvent(event({ type: 'response.created', response: { id: 'response-confirm' } }));
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: 'response-confirm', status: 'completed' },
|
||
}));
|
||
assert.deepEqual(order, [
|
||
'tool:control_radio',
|
||
'tool:set_visual_style',
|
||
'assistant-confirmation-requested',
|
||
'radio-play',
|
||
'voice-stop',
|
||
]);
|
||
});
|
||
|
||
test('Radio playback failure leaves voice connected and speaks a correction', async () => {
|
||
const sent = [];
|
||
const order = [];
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async () => ({
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'play',
|
||
radioPlaybackRequested: true,
|
||
}),
|
||
radioLayer: {
|
||
setVoiceDucked() {},
|
||
pause: () => false,
|
||
playForVoice: async () => false,
|
||
stopPlayback: () => order.push('radio-stop'),
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() { order.push('voice-stop'); },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
await controller.handleRealtimeEvent(event({ type: 'response.created', response: { id: 'response-fail' } }));
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-fail',
|
||
call_id: 'radio-fail',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"play"}',
|
||
}));
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: 'response-fail', status: 'completed' },
|
||
}));
|
||
await controller.handleRealtimeEvent(event({ type: 'response.created', response: { id: 'response-fail-confirm' } }));
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: 'response-fail-confirm', status: 'completed' },
|
||
}));
|
||
|
||
assert.deepEqual(order, ['radio-stop']);
|
||
assert.equal(controller.dc?.readyState, 'open');
|
||
assert.ok(sent.some((message) => (
|
||
message.type === 'response.create'
|
||
&& message.response?.instructions?.includes('Voice is still on')
|
||
)));
|
||
});
|
||
|
||
test('detected speech cancels a prepared Radio handoff before a cancelled response completes', async () => {
|
||
const order = [];
|
||
const ui = {
|
||
root: {
|
||
dataset: {},
|
||
classList: { remove() {} },
|
||
querySelectorAll: () => [],
|
||
},
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async () => ({
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
}),
|
||
radioLayer: {
|
||
setVoiceDucked() {},
|
||
pause: () => false,
|
||
playForVoice: async () => {
|
||
order.push('radio-play');
|
||
return true;
|
||
},
|
||
stopPlayback: () => order.push('radio-stop'),
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send() {},
|
||
close() { order.push('voice-stop'); },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
await controller.handleRealtimeEvent(event({ type: 'response.created', response: { id: 'response-2' } }));
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-2',
|
||
call_id: 'radio-call-2',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
assert.ok(controller.pendingRadioPlaybackResult);
|
||
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: 'response-2', status: 'completed' },
|
||
}));
|
||
await controller.handleRealtimeEvent(event({ type: 'response.created', response: { id: 'response-2-confirm' } }));
|
||
|
||
await controller.handleRealtimeEvent(event({ type: 'input_audio_buffer.speech_started' }));
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: 'response-2-confirm', status: 'cancelled' },
|
||
}));
|
||
|
||
assert.deepEqual(order, [], 'the new user turn keeps voice open and Radio silent');
|
||
assert.equal(controller.dc?.readyState, 'open');
|
||
});
|
||
|
||
test('a Radio tool result that resolves after speech interruption cannot re-arm playback', async () => {
|
||
let resolveRunner;
|
||
const runnerResult = new Promise((resolve) => { resolveRunner = resolve; });
|
||
let receivedSignal = null;
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (_name, _args, options) => {
|
||
receivedSignal = options.signal;
|
||
return runnerResult;
|
||
},
|
||
radioLayer: {
|
||
setVoiceDucked() {},
|
||
pause: () => false,
|
||
stopPlayback() {},
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
const sent = [];
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingTool = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-stale-radio',
|
||
call_id: 'radio-stale-call',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
await Promise.resolve();
|
||
assert.equal(receivedSignal?.aborted, false);
|
||
await controller.handleRealtimeEvent(event({ type: 'input_audio_buffer.speech_started' }));
|
||
assert.equal(receivedSignal.aborted, true, 'barge-in aborts the underlying tool work');
|
||
resolveRunner({
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
});
|
||
await pendingTool;
|
||
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
const output = sent.find((message) => message.type === 'conversation.item.create');
|
||
const result = JSON.parse(output.item.output);
|
||
assert.equal(result.ok, false);
|
||
assert.equal(result.cancelled, true);
|
||
assert.equal(result.radioPlaybackRequested, false);
|
||
assert.equal(sent.some((message) => message.type === 'response.create'), false);
|
||
});
|
||
|
||
test('sibling tool calls in one response do not abort an in-flight Radio action', async () => {
|
||
let resolveRadio;
|
||
let radioSignal = null;
|
||
const radioResult = new Promise((resolve) => { resolveRadio = resolve; });
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (name, _args, options) => {
|
||
if (name === 'control_radio') {
|
||
radioSignal = options.signal;
|
||
return radioResult;
|
||
}
|
||
return { ok: true, action: name };
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingRadio = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-multi-tool',
|
||
call_id: 'radio-sibling',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select","locationId":"austin"}',
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-multi-tool',
|
||
call_id: 'style-sibling',
|
||
name: 'set_visual_style',
|
||
arguments: '{"style":"normal"}',
|
||
}));
|
||
assert.equal(radioSignal?.aborted, false);
|
||
resolveRadio({ ok: true, action: 'control_radio', radioAction: 'status' });
|
||
await pendingRadio;
|
||
assert.equal(radioSignal.aborted, false);
|
||
});
|
||
|
||
test('Radio stop does not abort an unrelated sibling tool from the same response', async () => {
|
||
let releaseStyle;
|
||
let styleSignal = null;
|
||
const styleResult = new Promise((resolve) => { releaseStyle = resolve; });
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (name, _args, options) => {
|
||
if (name === 'set_visual_style') {
|
||
styleSignal = options.signal;
|
||
return styleResult;
|
||
}
|
||
return { ok: true, action: 'control_radio', radioAction: 'stop' };
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingStyle = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-multi-tool-stop',
|
||
call_id: 'style-sibling-stop',
|
||
name: 'set_visual_style',
|
||
arguments: '{"style":"normal"}',
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'response-multi-tool-stop',
|
||
call_id: 'radio-stop-sibling',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
assert.equal(styleSignal?.aborted, false);
|
||
releaseStyle({ ok: true, action: 'set_visual_style' });
|
||
await pendingStyle;
|
||
assert.equal(styleSignal.aborted, false);
|
||
});
|
||
|
||
test('real Radio Select cannot enable or play after a same-response Disable completes', async () => {
|
||
const originalWindow = globalThis.window;
|
||
const originalFetch = globalThis.fetch;
|
||
let releaseLookup;
|
||
let announceLookupStarted;
|
||
const lookupGate = new Promise((resolve) => { releaseLookup = resolve; });
|
||
const lookupStarted = new Promise((resolve) => { announceLookupStarted = resolve; });
|
||
const sent = [];
|
||
const managerCalls = [];
|
||
const selectionCalls = [];
|
||
let voiceClosed = false;
|
||
let enabled = true;
|
||
const state = {
|
||
stationCount: 1,
|
||
selected: null,
|
||
audioState: 'playing',
|
||
volume: 0.8,
|
||
voiceDucked: true,
|
||
};
|
||
const radioLayer = {
|
||
getUIState: () => ({ ...state, enabled }),
|
||
selectRequestedStation(criteria) {
|
||
selectionCalls.push(criteria);
|
||
state.selected = { id: 'late-austin', name: 'Late Austin' };
|
||
return state.selected;
|
||
},
|
||
};
|
||
const dataManager = {
|
||
layers: new Map([['radio', { module: radioLayer }]]),
|
||
isEnabled: () => enabled,
|
||
async setEnabled(_layerId, nextEnabled) {
|
||
managerCalls.push(nextEnabled);
|
||
enabled = nextEnabled;
|
||
if (!nextEnabled) state.audioState = 'stopped';
|
||
return true;
|
||
},
|
||
};
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
globalThis.window = { __GOOGLE_MAPS_API_KEY__: 'test-key' };
|
||
globalThis.fetch = async () => {
|
||
announceLookupStarted();
|
||
await lookupGate;
|
||
return {
|
||
json: async () => ({
|
||
status: 'OK',
|
||
results: [{
|
||
formatted_address: 'Austin, TX',
|
||
geometry: { location: { lat: 30.2672, lng: -97.7431 } },
|
||
}],
|
||
}),
|
||
};
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer,
|
||
runner: (_name, args, options) => controlRadio({}, dataManager, args, options),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() { voiceClosed = true; },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
try {
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'same-radio-response',
|
||
call_id: 'radio-select-sibling',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select","locationQuery":"Delayed place"}',
|
||
}));
|
||
await lookupStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'same-radio-response',
|
||
call_id: 'radio-disable-sibling',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"disable"}',
|
||
}));
|
||
|
||
assert.equal(enabled, false);
|
||
releaseLookup();
|
||
await pendingSelect;
|
||
const selectOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === 'select');
|
||
assert.equal(selectOutput?.ok, false, JSON.stringify({ selectOutput, epoch: controller.radioHandoffEpoch }));
|
||
assert.equal(selectOutput?.cancelled, true);
|
||
assert.equal(selectOutput?.enabled, false);
|
||
assert.equal(selectOutput?.audioState, 'stopped');
|
||
assert.equal(selectOutput?.radioPlaybackRequested, undefined);
|
||
assert.deepEqual(managerCalls, [false]);
|
||
assert.deepEqual(selectionCalls, []);
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
assert.equal(voiceClosed, false);
|
||
assert.equal(sent.some((message) => (
|
||
message.type === 'response.create'
|
||
&& message.response?.instructions?.includes('Turning on the radio')
|
||
)), false);
|
||
} finally {
|
||
globalThis.window = originalWindow;
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('generic same-response Radio visibility disable supersedes delayed Select', async () => {
|
||
const originalWindow = globalThis.window;
|
||
const originalFetch = globalThis.fetch;
|
||
let releaseLookup;
|
||
let announceLookupStarted;
|
||
const lookupGate = new Promise((resolve) => { releaseLookup = resolve; });
|
||
const lookupStarted = new Promise((resolve) => { announceLookupStarted = resolve; });
|
||
const sent = [];
|
||
const visibilityEvents = [];
|
||
const selectionCalls = [];
|
||
let dataManager = null;
|
||
const state = {
|
||
stationCount: 1,
|
||
selected: null,
|
||
audioState: 'playing',
|
||
volume: 0.8,
|
||
voiceDucked: true,
|
||
};
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
updateInterval: -1,
|
||
init: async () => true,
|
||
enable: async () => true,
|
||
disable: async () => {
|
||
state.audioState = 'stopped';
|
||
return true;
|
||
},
|
||
update: async () => true,
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({ ...state, enabled: dataManager?.isEnabled('radio') || false }),
|
||
selectRequestedStation(criteria) {
|
||
selectionCalls.push(criteria);
|
||
state.selected = { id: 'late-generic', name: 'Late generic station' };
|
||
return state.selected;
|
||
},
|
||
};
|
||
dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
await dataManager.setEnabled('radio', true);
|
||
dataManager.subscribe((change) => {
|
||
if (change.type === 'visibility') {
|
||
visibilityEvents.push({ enabled: change.enabled, origin: change.origin });
|
||
}
|
||
});
|
||
const viewer = {
|
||
clock: { onTick: { addEventListener: () => () => {} } },
|
||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||
camera: { moveEnd: { addEventListener() {} } },
|
||
};
|
||
const genericRunner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
globalThis.window = { __GOOGLE_MAPS_API_KEY__: 'test-key' };
|
||
globalThis.fetch = async () => {
|
||
announceLookupStarted();
|
||
await lookupGate;
|
||
return {
|
||
json: async () => ({
|
||
status: 'OK',
|
||
results: [{
|
||
formatted_address: 'Austin, TX',
|
||
geometry: { location: { lat: 30.2672, lng: -97.7431 } },
|
||
}],
|
||
}),
|
||
};
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
radioLayer,
|
||
runner: (name, args, options) => (
|
||
name === 'control_radio'
|
||
? controlRadio({}, dataManager, args, options)
|
||
: genericRunner(name, args, options)
|
||
),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
try {
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'same-radio-generic-response',
|
||
call_id: 'radio-select-before-generic-disable',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select","locationQuery":"Delayed place"}',
|
||
}));
|
||
await lookupStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'same-radio-generic-response',
|
||
call_id: 'generic-radio-disable',
|
||
name: 'set_layer_visibility',
|
||
arguments: '{"layerId":"radio","enabled":false}',
|
||
}));
|
||
assert.equal(dataManager.isEnabled('radio'), false);
|
||
releaseLookup();
|
||
await pendingSelect;
|
||
|
||
const outputs = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
const visibilityOutput = outputs.find((result) => result.action === 'set_layer_visibility');
|
||
const selectOutput = outputs.find((result) => result.radioAction === 'select');
|
||
assert.equal(visibilityOutput?.ok, true);
|
||
assert.equal(visibilityOutput?.enabled, false);
|
||
assert.equal(visibilityOutput?.lifecycleState, 'disabled');
|
||
assert.equal(visibilityOutput?.lifecycleUncertain, false);
|
||
assert.equal(selectOutput?.cancelled, true);
|
||
assert.equal(selectOutput?.enabled, false);
|
||
assert.equal(selectOutput?.lifecycleState, 'disabled');
|
||
assert.equal(selectOutput?.lifecycleUncertain, false);
|
||
assert.equal(selectOutput?.radioPlaybackRequested, undefined);
|
||
assert.deepEqual(visibilityEvents, [{ enabled: false, origin: 'voice' }]);
|
||
assert.deepEqual(selectionCalls, []);
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
} finally {
|
||
globalThis.window = originalWindow;
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('same-response Pause aborts a real manager enable already in flight', async () => {
|
||
let releaseEnable;
|
||
let announceEnableStarted;
|
||
const enableGate = new Promise((resolve) => { releaseEnable = resolve; });
|
||
const enableStarted = new Promise((resolve) => { announceEnableStarted = resolve; });
|
||
const trace = [];
|
||
const sent = [];
|
||
const state = {
|
||
stationCount: 1,
|
||
selected: null,
|
||
audioState: 'stopped',
|
||
volume: 0.8,
|
||
};
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
updateInterval: -1,
|
||
init: async () => true,
|
||
enable: async () => {
|
||
trace.push('enable:start');
|
||
announceEnableStarted();
|
||
await enableGate;
|
||
trace.push('enable:finish');
|
||
return true;
|
||
},
|
||
disable: async () => {
|
||
trace.push('disable');
|
||
state.audioState = 'stopped';
|
||
return true;
|
||
},
|
||
update: async () => {
|
||
trace.push('update');
|
||
return true;
|
||
},
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({ ...state }),
|
||
pause: ({ origin } = {}) => {
|
||
trace.push(`pause:${origin || 'unknown'}`);
|
||
if (origin === 'voice') state.audioState = 'paused';
|
||
return true;
|
||
},
|
||
selectRequestedStation: () => {
|
||
trace.push('select');
|
||
state.selected = { id: 'late', name: 'Late station' };
|
||
return state.selected;
|
||
},
|
||
};
|
||
const dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
dataManager.subscribe((change) => {
|
||
if (change.type === 'visibility') trace.push(`visibility:${change.enabled}`);
|
||
});
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer,
|
||
runner: (_name, args, options) => controlRadio({}, dataManager, args, options),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
const responseId = 'same-response-real-manager-pause';
|
||
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: 'select-enabling',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
await enableStarted;
|
||
const pendingPause = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: 'pause-owner',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"pause"}',
|
||
}));
|
||
await pendingPause;
|
||
trace.push('pause:return');
|
||
releaseEnable();
|
||
await pendingSelect;
|
||
|
||
const outputs = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
const selectOutput = outputs.find((result) => result.radioAction === 'select');
|
||
const pauseOutput = outputs.find((result) => result.radioAction === 'pause');
|
||
assert.equal(dataManager.isEnabled('radio'), false);
|
||
assert.equal(selectOutput?.cancelled, true);
|
||
assert.equal(selectOutput?.enabled, false);
|
||
assert.equal(pauseOutput?.ok, true);
|
||
assert.equal(pauseOutput?.changed, false);
|
||
assert.equal(pauseOutput?.enabled, false);
|
||
assert.equal(pauseOutput?.audioState, 'stopped');
|
||
// The successful Pause may touch the current player before its reservation
|
||
// commits. Its commit then aborts the pending auto-enable synchronously, and
|
||
// the reconciled tool output reports the authoritative stopped/OFF result.
|
||
assert.equal(trace.includes('pause:voice'), true, trace.join(' → '));
|
||
assert.equal(trace.includes('select'), false, trace.join(' → '));
|
||
assert.equal(trace.includes('update'), false, trace.join(' → '));
|
||
assert.equal(trace.includes('visibility:true'), false, trace.join(' → '));
|
||
assert.ok(trace.indexOf('disable') < trace.indexOf('enable:finish'), trace.join(' → '));
|
||
});
|
||
|
||
test('Pause and Stop preserve independent dedicated and generic Radio ON across manager phases', async (t) => {
|
||
for (const onRoute of ['dedicated', 'generic']) {
|
||
for (const controlAction of ['pause', 'stop']) {
|
||
for (const heldPhase of ['init', 'enable', 'update']) {
|
||
for (const controlSucceeds of [true, false]) {
|
||
await t.test(
|
||
`${onRoute} ON + ${controlAction} ${controlSucceeds ? 'success' : 'failure'} during ${heldPhase}`,
|
||
async () => {
|
||
let releasePhase;
|
||
let markPhaseStarted;
|
||
const phaseGate = new Promise((resolve) => { releasePhase = resolve; });
|
||
const phaseStarted = new Promise((resolve) => { markPhaseStarted = resolve; });
|
||
const sent = [];
|
||
const visibility = [];
|
||
const trace = [];
|
||
let dataManager;
|
||
const runPhase = (phase) => async () => {
|
||
trace.push(`${phase}:start`);
|
||
if (phase === heldPhase) {
|
||
markPhaseStarted();
|
||
await phaseGate;
|
||
}
|
||
trace.push(`${phase}:finish`);
|
||
return true;
|
||
};
|
||
const state = { audioState: 'playing', volume: 0.8 };
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
updateInterval: -1,
|
||
init: runPhase('init'),
|
||
enable: runPhase('enable'),
|
||
update: runPhase('update'),
|
||
disable: async () => true,
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({
|
||
...state,
|
||
enabled: dataManager?.isEnabled('radio') || false,
|
||
}),
|
||
pause: () => {
|
||
trace.push(`pause:${controlSucceeds}`);
|
||
if (controlSucceeds) state.audioState = 'paused';
|
||
return controlSucceeds;
|
||
},
|
||
stopPlayback: () => {
|
||
trace.push(`stop:${controlSucceeds}`);
|
||
if (controlSucceeds) state.audioState = 'stopped';
|
||
return controlSucceeds;
|
||
},
|
||
};
|
||
dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
dataManager.subscribe((change) => {
|
||
if (change.type === 'visibility') visibility.push(change.enabled);
|
||
});
|
||
const viewer = {
|
||
clock: { onTick: { addEventListener: () => () => {} } },
|
||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||
camera: { moveEnd: { addEventListener() {} } },
|
||
};
|
||
const genericRunner = createGevActionRunner({
|
||
viewer,
|
||
styleManager: {},
|
||
dataManager,
|
||
});
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer,
|
||
runner: (name, args, options) => (
|
||
name === 'control_radio'
|
||
? controlRadio({}, dataManager, args, options)
|
||
: genericRunner(name, args, options)
|
||
),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
const responseId = `${onRoute}-${controlAction}-${heldPhase}-${controlSucceeds}`;
|
||
|
||
const pendingOn = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: `${responseId}-on`,
|
||
name: onRoute === 'dedicated' ? 'control_radio' : 'set_layer_visibility',
|
||
arguments: onRoute === 'dedicated'
|
||
? '{"action":"enable"}'
|
||
: '{"layerId":"radio","enabled":true}',
|
||
}));
|
||
await phaseStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: `${responseId}-${controlAction}`,
|
||
name: 'control_radio',
|
||
arguments: JSON.stringify({ action: controlAction }),
|
||
}));
|
||
releasePhase();
|
||
await pendingOn;
|
||
|
||
const outputs = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
const onOutput = outputs.find((result) => onRoute === 'dedicated'
|
||
? result.radioAction === 'enable'
|
||
: result.action === 'set_layer_visibility');
|
||
const controlOutput = outputs.find((result) => result.radioAction === controlAction);
|
||
assert.equal(onOutput?.ok, true, trace.join(' → '));
|
||
assert.notEqual(onOutput?.cancelled, true, trace.join(' → '));
|
||
assert.equal(controlOutput?.ok, controlSucceeds, trace.join(' → '));
|
||
assert.equal(dataManager.isEnabled('radio'), true, trace.join(' → '));
|
||
assert.deepEqual(visibility, [true], trace.join(' → '));
|
||
assert.equal(visibility.includes(false), false, trace.join(' → '));
|
||
assert.equal(
|
||
state.audioState,
|
||
controlSucceeds ? (controlAction === 'pause' ? 'paused' : 'stopped') : 'playing',
|
||
);
|
||
await dataManager.destroyAll();
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test('successful same- or newer-response Stop aborts Select across real manager lifecycle phases', async (t) => {
|
||
for (const responseMode of ['same-response', 'newer-response']) {
|
||
for (const heldPhase of ['init', 'enable', 'update']) {
|
||
await t.test(`${responseMode} ${heldPhase}`, async () => {
|
||
let releasePhase;
|
||
let announcePhaseStarted;
|
||
const phaseGate = new Promise((resolve) => { releasePhase = resolve; });
|
||
const phaseStarted = new Promise((resolve) => { announcePhaseStarted = resolve; });
|
||
const trace = [];
|
||
const sent = [];
|
||
const persistenceWrites = [];
|
||
const state = {
|
||
stationCount: 1,
|
||
selected: null,
|
||
audioState: 'stopped',
|
||
volume: 0.8,
|
||
};
|
||
let dataManager = null;
|
||
const runPhase = async (phase) => {
|
||
trace.push(`${phase}:start`);
|
||
if (heldPhase === phase) {
|
||
announcePhaseStarted();
|
||
await phaseGate;
|
||
}
|
||
trace.push(`${phase}:finish`);
|
||
return true;
|
||
};
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
updateInterval: -1,
|
||
init: () => runPhase('init'),
|
||
enable: () => runPhase('enable'),
|
||
update: () => runPhase('update'),
|
||
disable: async () => {
|
||
trace.push('disable');
|
||
state.audioState = 'stopped';
|
||
return true;
|
||
},
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({ ...state, enabled: dataManager?.isEnabled('radio') || false }),
|
||
stopPlayback: ({ origin } = {}) => {
|
||
trace.push(`stop:${origin || 'unknown'}`);
|
||
state.audioState = 'stopped';
|
||
return true;
|
||
},
|
||
selectRequestedStation: () => {
|
||
trace.push('select');
|
||
state.selected = { id: 'late-stop', name: 'Late stop station' };
|
||
return state.selected;
|
||
},
|
||
};
|
||
dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
dataManager.subscribe((change) => {
|
||
if (change.type === 'visibility') {
|
||
trace.push(`visibility:${change.enabled}`);
|
||
if (change.origin === 'voice') persistenceWrites.push(change.enabled);
|
||
}
|
||
});
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
radioLayer,
|
||
runner: (_name, args, options) => controlRadio({}, dataManager, args, options),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
const selectResponseId = `${responseMode}-real-manager-select-${heldPhase}`;
|
||
const stopResponseId = responseMode === 'same-response'
|
||
? selectResponseId
|
||
: `${responseMode}-real-manager-stop-${heldPhase}`;
|
||
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: selectResponseId,
|
||
call_id: `select-${heldPhase}`,
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
await phaseStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: stopResponseId,
|
||
call_id: `stop-${heldPhase}`,
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
trace.push('stop:return');
|
||
assert.equal(dataManager.isEnabled('radio'), false, trace.join(' → '));
|
||
releasePhase();
|
||
await pendingSelect;
|
||
|
||
const outputs = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
const selectOutput = outputs.find((result) => result.radioAction === 'select');
|
||
const stopOutput = outputs.find((result) => result.radioAction === 'stop');
|
||
assert.equal(stopOutput?.ok, true);
|
||
assert.equal(stopOutput?.audioState, 'stopped');
|
||
assert.equal(selectOutput?.cancelled, true);
|
||
assert.equal(selectOutput?.enabled, false);
|
||
assert.equal(dataManager.isEnabled('radio'), false);
|
||
assert.deepEqual(persistenceWrites, []);
|
||
assert.equal(trace.includes('select'), false, trace.join(' → '));
|
||
assert.equal(trace.includes('visibility:true'), false, trace.join(' → '));
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
test('same-response pause and disable suppress play/select handoffs without cancelling results', async (t) => {
|
||
for (const [playbackAction, stopAction] of [['play', 'pause'], ['select', 'disable']]) {
|
||
await t.test(`${playbackAction} + ${stopAction}`, async () => {
|
||
let releasePlayback;
|
||
const playbackGate = new Promise((resolve) => { releasePlayback = resolve; });
|
||
const sent = [];
|
||
const radioState = { enabled: true, audioState: 'playing' };
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
radioLayer: { getUIState: () => ({ ...radioState }) },
|
||
runner: async (_name, args) => {
|
||
if (args.action === playbackAction) {
|
||
await playbackGate;
|
||
return {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: playbackAction,
|
||
radioPlaybackRequested: true,
|
||
};
|
||
}
|
||
if (stopAction === 'pause') radioState.audioState = 'paused';
|
||
else {
|
||
radioState.enabled = false;
|
||
radioState.audioState = 'stopped';
|
||
}
|
||
return { ok: true, action: 'control_radio', radioAction: stopAction };
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
const responseId = `same-response-${playbackAction}-${stopAction}`;
|
||
|
||
const pendingPlayback = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: `${playbackAction}-call`,
|
||
name: 'control_radio',
|
||
arguments: JSON.stringify({ action: playbackAction }),
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: `${stopAction}-call`,
|
||
name: 'control_radio',
|
||
arguments: JSON.stringify({ action: stopAction }),
|
||
}));
|
||
releasePlayback();
|
||
await pendingPlayback;
|
||
|
||
const playbackOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === playbackAction);
|
||
assert.equal(playbackOutput?.ok, true);
|
||
assert.equal(playbackOutput?.cancelled, undefined);
|
||
assert.equal(playbackOutput?.radioPlaybackRequested, false);
|
||
assert.equal(playbackOutput?.radioPlaybackSuppressed, true);
|
||
assert.equal(playbackOutput?.audioState, stopAction === 'pause' ? 'paused' : 'stopped');
|
||
assert.equal(playbackOutput?.enabled, stopAction !== 'disable');
|
||
assert.equal(playbackOutput?.lifecycleState, stopAction === 'disable' ? 'disabled' : 'enabled');
|
||
assert.equal(playbackOutput?.lifecycleUncertain, false);
|
||
assert.match(
|
||
controller.pendingResponseInstructions,
|
||
stopAction === 'pause' ? /remains paused/ : /remains disabled/,
|
||
);
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('Realtime Radio route exceptions preserve the authoritative lifecycle summary', async () => {
|
||
for (const route of ['dedicated', 'generic']) {
|
||
const sent = [];
|
||
const lifecycle = { enabled: true, lifecycleState: 'disabling', uncertain: true };
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager: {
|
||
getLayerLifecycleState: () => ({ ...lifecycle }),
|
||
isEnabled: () => false,
|
||
},
|
||
radioLayer: { getUIState: () => ({ enabled: false, audioState: 'stopped' }) },
|
||
runner: async () => { throw new Error('route failed'); },
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
|
||
await controller.handleRealtimeEvent({
|
||
data: JSON.stringify({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: `exception-${route}`,
|
||
call_id: `exception-${route}-call`,
|
||
name: route === 'dedicated' ? 'control_radio' : 'set_layer_visibility',
|
||
arguments: route === 'dedicated'
|
||
? '{"action":"disable"}'
|
||
: '{"layerId":"radio","enabled":false}',
|
||
}),
|
||
});
|
||
|
||
const output = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.tool);
|
||
assert.equal(output?.ok, false);
|
||
assert.equal(output?.error, 'route failed');
|
||
assert.equal(output?.enabled, true);
|
||
assert.equal(output?.lifecycleState, 'disabling');
|
||
assert.equal(output?.lifecycleUncertain, true);
|
||
}
|
||
});
|
||
|
||
test('different-response Radio stop remains a cancellation authority for an older playback tool', async () => {
|
||
let releaseSelect;
|
||
const selectGate = new Promise((resolve) => { releaseSelect = resolve; });
|
||
const sent = [];
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (_name, args) => {
|
||
if (args.action === 'select') {
|
||
await selectGate;
|
||
return {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
};
|
||
}
|
||
return { ok: true, action: 'control_radio', radioAction: 'stop' };
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'older-response',
|
||
call_id: 'older-select',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'newer-response',
|
||
call_id: 'newer-stop',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
releaseSelect();
|
||
await pendingSelect;
|
||
|
||
const selectOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === 'select');
|
||
assert.equal(selectOutput?.ok, false);
|
||
assert.equal(selectOutput?.cancelled, true);
|
||
assert.equal(selectOutput?.radioPlaybackRequested, false);
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
});
|
||
|
||
test('failed same-response stop does not suppress a successful playback request', async () => {
|
||
let releasePlay;
|
||
const playGate = new Promise((resolve) => { releasePlay = resolve; });
|
||
const sent = [];
|
||
const state = {
|
||
stationCount: 1,
|
||
selected: { id: 'station-1', name: 'Station 1' },
|
||
audioState: 'playing',
|
||
volume: 0.8,
|
||
};
|
||
const dataManager = {
|
||
layers: new Map([['radio', {
|
||
module: {
|
||
getUIState: () => ({ ...state }),
|
||
stopPlayback: () => false,
|
||
},
|
||
}]]),
|
||
isEnabled: () => true,
|
||
};
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (_name, args) => {
|
||
const result = await controlRadio({}, dataManager, args);
|
||
if (args.action === 'play') await playGate;
|
||
return result;
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingPlay = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'failed-stop-response',
|
||
call_id: 'play-before-failed-stop',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"play"}',
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'failed-stop-response',
|
||
call_id: 'failed-stop',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
releasePlay();
|
||
await pendingPlay;
|
||
|
||
const playOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === 'play');
|
||
const stopOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === 'stop');
|
||
assert.equal(stopOutput?.ok, false);
|
||
assert.equal(stopOutput?.audioState, 'playing');
|
||
assert.equal(playOutput?.ok, true);
|
||
assert.equal(playOutput?.radioPlaybackRequested, true);
|
||
assert.equal(playOutput?.radioPlaybackSuppressed, undefined);
|
||
assert.equal(controller.pendingRadioPlaybackResult?.radioAction, 'play');
|
||
});
|
||
|
||
test('failed same-response Stop preserves Select auto-enable held inside real manager init', async () => {
|
||
let releaseInit;
|
||
let markInitStarted;
|
||
const initGate = new Promise((resolve) => { releaseInit = resolve; });
|
||
const initStarted = new Promise((resolve) => { markInitStarted = resolve; });
|
||
const sent = [];
|
||
const trace = [];
|
||
const state = {
|
||
selected: null,
|
||
audioState: 'playing',
|
||
volume: 0.8,
|
||
};
|
||
let dataManager;
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
updateInterval: -1,
|
||
init: async () => {
|
||
trace.push('init:start');
|
||
markInitStarted();
|
||
await initGate;
|
||
trace.push('init:finish');
|
||
return true;
|
||
},
|
||
enable: async () => true,
|
||
update: async () => true,
|
||
disable: async () => {
|
||
trace.push('disable');
|
||
return true;
|
||
},
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({
|
||
...state,
|
||
enabled: dataManager?.isEnabled('radio') || false,
|
||
stationCount: 1,
|
||
}),
|
||
stopPlayback: () => {
|
||
trace.push('stop:false');
|
||
return false;
|
||
},
|
||
selectRequestedStation: () => {
|
||
trace.push('select');
|
||
state.selected = { id: 'station-1', name: 'Station 1' };
|
||
return state.selected;
|
||
},
|
||
};
|
||
dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer,
|
||
runner: (_name, args, options) => controlRadio({}, dataManager, args, options),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
const responseId = 'failed-stop-during-select-init';
|
||
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: 'select-held-in-init',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select","category":"all"}',
|
||
}));
|
||
await initStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: 'failed-stop-during-init',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
releaseInit();
|
||
await pendingSelect;
|
||
|
||
const outputs = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
const selectOutput = outputs.find((result) => result.radioAction === 'select');
|
||
const stopOutput = outputs.find((result) => result.radioAction === 'stop');
|
||
assert.equal(stopOutput?.ok, false, trace.join(' → '));
|
||
assert.equal(selectOutput?.ok, true, trace.join(' → '));
|
||
assert.notEqual(selectOutput?.cancelled, true, trace.join(' → '));
|
||
assert.equal(dataManager.isEnabled('radio'), true, trace.join(' → '));
|
||
assert.equal(state.selected?.id, 'station-1', trace.join(' → '));
|
||
assert.equal(controller.pendingRadioPlaybackResult?.radioAction, 'select');
|
||
assert.equal(trace.includes('disable'), false, trace.join(' → '));
|
||
await dataManager.destroyAll();
|
||
});
|
||
|
||
test('failed same-response Pause and Disable do not suppress valid older playback', async (t) => {
|
||
for (const action of ['pause', 'disable']) {
|
||
await t.test(action, async () => {
|
||
let releasePlay;
|
||
const playGate = new Promise((resolve) => { releasePlay = resolve; });
|
||
const sent = [];
|
||
const state = {
|
||
stationCount: 1,
|
||
selected: { id: 'station-1', name: 'Station 1' },
|
||
audioState: 'playing',
|
||
volume: 0.8,
|
||
};
|
||
const requestListeners = new Set();
|
||
const visibilityListeners = new Set();
|
||
const dataManager = {
|
||
layers: new Map([['radio', {
|
||
module: {
|
||
getUIState: () => ({ ...state }),
|
||
pause: () => false,
|
||
},
|
||
}]]),
|
||
isEnabled: () => true,
|
||
setEnabled: async () => false,
|
||
subscribeVisibilityRequests(listener) {
|
||
requestListeners.add(listener);
|
||
return () => requestListeners.delete(listener);
|
||
},
|
||
subscribe(listener) {
|
||
visibilityListeners.add(listener);
|
||
return () => visibilityListeners.delete(listener);
|
||
},
|
||
};
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer: dataManager.layers.get('radio').module,
|
||
runner: async (_name, args) => {
|
||
const result = await controlRadio({}, dataManager, args);
|
||
if (args.action === 'play') await playGate;
|
||
return result;
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
const responseId = `failed-${action}-response`;
|
||
|
||
const pendingPlay = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: `play-before-${action}`,
|
||
name: 'control_radio',
|
||
arguments: '{"action":"play"}',
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: `failed-${action}`,
|
||
name: 'control_radio',
|
||
arguments: JSON.stringify({ action }),
|
||
}));
|
||
releasePlay();
|
||
await pendingPlay;
|
||
|
||
const outputs = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
const strongerOutput = outputs.find((result) => result.radioAction === action);
|
||
const playOutput = outputs.find((result) => result.radioAction === 'play');
|
||
assert.equal(strongerOutput?.ok, false);
|
||
assert.equal(playOutput?.ok, true);
|
||
assert.equal(playOutput?.radioPlaybackRequested, true);
|
||
assert.equal(playOutput?.radioPlaybackSuppressed, undefined);
|
||
assert.equal(controller.pendingRadioPlaybackResult?.radioAction, 'play');
|
||
});
|
||
}
|
||
});
|
||
|
||
test('failed generic same-response Radio OFF does not suppress valid older playback', async () => {
|
||
let releaseSelect;
|
||
const selectGate = new Promise((resolve) => { releaseSelect = resolve; });
|
||
const sent = [];
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (name, args) => {
|
||
if (name === 'set_layer_visibility') {
|
||
return { ok: false, action: 'set_layer_visibility', layerId: 'radio', enabled: true };
|
||
}
|
||
await selectGate;
|
||
return {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: args.action,
|
||
radioPlaybackRequested: true,
|
||
};
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'failed-generic-off',
|
||
call_id: 'select-before-generic-off',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
await Promise.resolve();
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'failed-generic-off',
|
||
call_id: 'generic-off-fails',
|
||
name: 'set_layer_visibility',
|
||
arguments: '{"layerId":"radio","enabled":false}',
|
||
}));
|
||
releaseSelect();
|
||
await pendingSelect;
|
||
|
||
const selectOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === 'select');
|
||
assert.equal(selectOutput?.ok, true);
|
||
assert.equal(selectOutput?.radioPlaybackRequested, true);
|
||
assert.equal(selectOutput?.radioPlaybackSuppressed, undefined);
|
||
});
|
||
|
||
test('direct user Radio OFF aborts an in-flight voice enable before settled publication', async () => {
|
||
let releaseInit;
|
||
let markInitStarted;
|
||
const initGate = new Promise((resolve) => { releaseInit = resolve; });
|
||
const initStarted = new Promise((resolve) => { markInitStarted = resolve; });
|
||
const sent = [];
|
||
const visibility = [];
|
||
let dataManager;
|
||
const state = { selected: null, audioState: 'stopped', volume: 0.8 };
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
icon: '',
|
||
source: 'test',
|
||
updateInterval: 0,
|
||
init: async () => {
|
||
markInitStarted();
|
||
await initGate;
|
||
return true;
|
||
},
|
||
enable: async () => true,
|
||
update: async () => true,
|
||
disable: async () => true,
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({ ...state, enabled: dataManager?.isEnabled('radio') || false }),
|
||
selectRequestedStation: () => {
|
||
state.selected = { id: 'late', name: 'Late station' };
|
||
return state.selected;
|
||
},
|
||
};
|
||
dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
dataManager.subscribe((change) => {
|
||
if (change.type === 'visibility') visibility.push(`${change.origin}:${change.enabled}`);
|
||
});
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer,
|
||
runner: (_name, args, options) => controlRadio({}, dataManager, args, options),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) { sent.push(JSON.parse(message)); },
|
||
close() {},
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingSelect = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'voice-select-before-ui-off',
|
||
call_id: 'voice-select',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
await initStarted;
|
||
const userOff = dataManager.setEnabled('radio', false, { origin: 'user' });
|
||
releaseInit();
|
||
await Promise.all([pendingSelect, userOff]);
|
||
|
||
const selectOutput = sent
|
||
.map((message) => message.item?.output)
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
.find((result) => result.radioAction === 'select');
|
||
assert.equal(selectOutput?.cancelled, true);
|
||
assert.equal(dataManager.isEnabled('radio'), false);
|
||
assert.deepEqual(
|
||
visibility,
|
||
['user:false'],
|
||
'voice ON never settles and the latest direct OFF alone owns publication',
|
||
);
|
||
assert.equal(state.selected, null);
|
||
await dataManager.destroyAll();
|
||
});
|
||
|
||
test('direct user Radio OFF freezes a prepared handoff until disable success or failure settles', async (t) => {
|
||
for (const disableSucceeds of [true, false]) {
|
||
await t.test(disableSucceeds ? 'success commits cancellation' : 'failure resumes prepared playback', async () => {
|
||
let releaseDisable;
|
||
let markDisableStarted;
|
||
const disableGate = new Promise((resolve) => { releaseDisable = resolve; });
|
||
const disableStarted = new Promise((resolve) => { markDisableStarted = resolve; });
|
||
const trace = [];
|
||
let dataManager;
|
||
const radioLayer = {
|
||
id: 'radio',
|
||
name: 'Radio',
|
||
updateInterval: -1,
|
||
init: async () => true,
|
||
enable: async () => true,
|
||
update: async () => true,
|
||
disable: async () => {
|
||
trace.push('disable:start');
|
||
markDisableStarted();
|
||
await disableGate;
|
||
trace.push(`disable:${disableSucceeds}`);
|
||
return disableSucceeds;
|
||
},
|
||
destroy: async () => true,
|
||
getStats: () => ({}),
|
||
getUIState: () => ({
|
||
enabled: dataManager?.isEnabled('radio') || false,
|
||
audioState: 'stopped',
|
||
}),
|
||
setVoiceDucked: () => trace.push('duck'),
|
||
playForVoice: async () => {
|
||
trace.push('playForVoice');
|
||
return true;
|
||
},
|
||
stopPlayback: () => {
|
||
trace.push('stopPlayback');
|
||
return true;
|
||
},
|
||
};
|
||
dataManager = new DataLayerManager({});
|
||
dataManager.register(radioLayer);
|
||
await dataManager.setEnabled('radio', true);
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
dataManager,
|
||
radioLayer,
|
||
runner: async () => ({ ok: true }),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.pendingRadioPlaybackResult = {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
};
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send() {},
|
||
close() { trace.push('voice:close'); },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const userOff = dataManager.setEnabled('radio', false, { origin: 'user' });
|
||
await disableStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: `prepared-off-${disableSucceeds}`, status: 'completed' },
|
||
}));
|
||
assert.equal(trace.includes('playForVoice'), false, trace.join(' → '));
|
||
assert.equal(trace.includes('voice:close'), false, trace.join(' → '));
|
||
assert.equal(controller.pendingRadioPlaybackResult?.radioAction, 'select');
|
||
|
||
releaseDisable();
|
||
const disabled = await userOff;
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(disabled, disableSucceeds ? true : false);
|
||
assert.equal(trace.includes('playForVoice'), !disableSucceeds, trace.join(' → '));
|
||
assert.equal(trace.includes('voice:close'), !disableSucceeds, trace.join(' → '));
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('dedicated and generic Radio OFF reservations freeze an in-flight playback handoff', async (t) => {
|
||
for (const route of ['dedicated-disable', 'dedicated-stop', 'generic-off']) {
|
||
for (const controlSucceeds of [true, false]) {
|
||
await t.test(`${route} ${controlSucceeds ? 'success commits' : 'failure resumes'}`, async () => {
|
||
let releaseFirstPlay;
|
||
let markFirstPlayStarted;
|
||
let releaseControl;
|
||
let markControlStarted;
|
||
const firstPlayGate = new Promise((resolve) => { releaseFirstPlay = resolve; });
|
||
const firstPlayStarted = new Promise((resolve) => { markFirstPlayStarted = resolve; });
|
||
const controlGate = new Promise((resolve) => { releaseControl = resolve; });
|
||
const controlStarted = new Promise((resolve) => { markControlStarted = resolve; });
|
||
const trace = [];
|
||
let playCalls = 0;
|
||
const radioLayer = {
|
||
setVoiceDucked: () => trace.push('duck'),
|
||
playForVoice: async () => {
|
||
playCalls++;
|
||
trace.push(`play:${playCalls}:start`);
|
||
if (playCalls === 1) {
|
||
markFirstPlayStarted();
|
||
await firstPlayGate;
|
||
}
|
||
trace.push(`play:${playCalls}:finish`);
|
||
return true;
|
||
},
|
||
stopPlayback: () => {
|
||
trace.push('stopPlayback');
|
||
return true;
|
||
},
|
||
getUIState: () => ({ enabled: true, audioState: 'stopped' }),
|
||
};
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
radioLayer,
|
||
dataManager: { isEnabled: () => true },
|
||
runner: async (name, args) => {
|
||
const isDedicatedControl = route.startsWith('dedicated-');
|
||
const radioAction = route.replace('dedicated-', '');
|
||
const isStrongerControl = isDedicatedControl
|
||
? name === 'control_radio' && args.action === radioAction
|
||
: name === 'set_layer_visibility' && args.enabled === false;
|
||
if (!isStrongerControl) return { ok: false, error: 'unexpected route' };
|
||
trace.push('control:start');
|
||
markControlStarted();
|
||
await controlGate;
|
||
trace.push(`control:${controlSucceeds}`);
|
||
return isDedicatedControl
|
||
? {
|
||
ok: controlSucceeds,
|
||
action: 'control_radio',
|
||
radioAction,
|
||
}
|
||
: {
|
||
ok: controlSucceeds,
|
||
action: 'set_layer_visibility',
|
||
enabled: !controlSucceeds,
|
||
};
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.pendingRadioPlaybackResult = {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
};
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send() {},
|
||
close() { trace.push('voice:close'); },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingHandoff = controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: `${route}-handoff`, status: 'completed' },
|
||
}));
|
||
await firstPlayStarted;
|
||
const pendingControl = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: `${route}-control`,
|
||
call_id: `${route}-call`,
|
||
name: route.startsWith('dedicated-') ? 'control_radio' : 'set_layer_visibility',
|
||
arguments: route.startsWith('dedicated-')
|
||
? JSON.stringify({ action: route.replace('dedicated-', '') })
|
||
: '{"layerId":"radio","enabled":false}',
|
||
}));
|
||
await controlStarted;
|
||
assert.equal(trace.includes('stopPlayback'), true, trace.join(' → '));
|
||
|
||
releaseFirstPlay();
|
||
await pendingHandoff;
|
||
assert.equal(trace.includes('voice:close'), false, trace.join(' → '));
|
||
assert.equal(controller.pendingRadioPlaybackResult?.radioAction, 'select');
|
||
|
||
releaseControl();
|
||
await pendingControl;
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(playCalls, controlSucceeds ? 1 : 2, trace.join(' → '));
|
||
assert.equal(trace.includes('voice:close'), !controlSucceeds, trace.join(' → '));
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
test('delayed Stop reports its result before committing or resuming a prepared handoff', async (t) => {
|
||
for (const outcome of ['success', 'false', 'reject']) {
|
||
await t.test(outcome, async () => {
|
||
let releaseStop;
|
||
let markStopStarted;
|
||
const stopGate = new Promise((resolve) => { releaseStop = resolve; });
|
||
const stopStarted = new Promise((resolve) => { markStopStarted = resolve; });
|
||
const trace = [];
|
||
const radioLayer = {
|
||
setVoiceDucked: () => trace.push('duck'),
|
||
playForVoice: async () => {
|
||
trace.push('play:start');
|
||
return true;
|
||
},
|
||
stopPlayback: () => {
|
||
trace.push('stopPlayback');
|
||
return true;
|
||
},
|
||
getUIState: () => ({ enabled: true, audioState: 'stopped' }),
|
||
};
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
radioLayer,
|
||
dataManager: { isEnabled: () => true },
|
||
runner: async () => {
|
||
trace.push('stop:start');
|
||
markStopStarted();
|
||
await stopGate;
|
||
if (outcome === 'reject') throw new Error('Stop rejected');
|
||
return {
|
||
ok: outcome === 'success',
|
||
action: 'control_radio',
|
||
radioAction: 'stop',
|
||
};
|
||
},
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.pendingRadioPlaybackResult = {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
};
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send(message) {
|
||
const payload = JSON.parse(message);
|
||
if (payload.item?.call_id === `delayed-stop-${outcome}`) trace.push('output:stop');
|
||
},
|
||
close() { trace.push('voice:close'); },
|
||
};
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
const pendingStop = controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: `delayed-stop-response-${outcome}`,
|
||
call_id: `delayed-stop-${outcome}`,
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
await stopStarted;
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.done',
|
||
response: { id: `delayed-stop-response-${outcome}`, status: 'completed' },
|
||
}));
|
||
assert.equal(trace.includes('play:start'), false, trace.join(' → '));
|
||
|
||
releaseStop();
|
||
await pendingStop;
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(trace.includes('output:stop'), true, trace.join(' → '));
|
||
assert.equal(trace.includes('play:start'), outcome !== 'success', trace.join(' → '));
|
||
if (outcome !== 'success') {
|
||
assert.ok(
|
||
trace.indexOf('output:stop') < trace.indexOf('play:start'),
|
||
trace.join(' → '),
|
||
);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
test('stale handoff cleanup cannot erase a resumed successor across repeated failed reservations', async () => {
|
||
const playbackAttempts = [];
|
||
const trace = [];
|
||
const radioLayer = {
|
||
setVoiceDucked: () => trace.push('duck'),
|
||
playForVoice: ({ attemptId }) => new Promise((resolve) => {
|
||
playbackAttempts.push({ attemptId, resolve });
|
||
trace.push(`play:${attemptId}`);
|
||
}),
|
||
stopPlayback: ({ attemptId }) => {
|
||
trace.push(`stop:${attemptId}`);
|
||
return true;
|
||
},
|
||
};
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
radioLayer,
|
||
runner: async () => ({ ok: false }),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.queueResponseCreate = () => {};
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
const preparedResult = {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
};
|
||
controller.pendingRadioPlaybackResult = preparedResult;
|
||
|
||
const handoffA = controller.startPendingRadioHandoff();
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(playbackAttempts.length, 1);
|
||
|
||
const reservationA = controller.reserveRadioToolHandoff();
|
||
controller.settleRadioToolHandoffReservation(reservationA, { commit: false });
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(playbackAttempts.length, 2, trace.join(' → '));
|
||
const resumedAttemptId = playbackAttempts[1].attemptId;
|
||
|
||
playbackAttempts[0].resolve(true);
|
||
await handoffA;
|
||
assert.equal(controller.radioHandoffInFlight, true);
|
||
assert.equal(controller.radioHandoffAttemptId, resumedAttemptId);
|
||
assert.equal(controller.radioHandoffInFlightResult, preparedResult);
|
||
|
||
const reservationB = controller.reserveRadioToolHandoff();
|
||
controller.settleRadioToolHandoffReservation(reservationB, { commit: false });
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(playbackAttempts.length, 3, trace.join(' → '));
|
||
assert.equal(controller.radioHandoffInFlight, true);
|
||
assert.notEqual(controller.radioHandoffAttemptId, resumedAttemptId);
|
||
assert.equal(controller.radioHandoffInFlightResult, preparedResult);
|
||
|
||
playbackAttempts[1].resolve(true);
|
||
playbackAttempts[2].resolve(true);
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
});
|
||
|
||
test('a later same-response stop clears an already prepared playback result', async () => {
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: async (_name, args) => args.action === 'select'
|
||
? {
|
||
ok: true,
|
||
action: 'control_radio',
|
||
radioAction: 'select',
|
||
radioPlaybackRequested: true,
|
||
}
|
||
: { ok: true, action: 'control_radio', radioAction: 'stop' },
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.sendVisualContextIfUseful = async () => false;
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
const event = (payload) => ({ data: JSON.stringify(payload) });
|
||
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'prepared-then-stop',
|
||
call_id: 'prepared-select',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"select"}',
|
||
}));
|
||
assert.equal(controller.pendingRadioPlaybackResult?.radioAction, 'select');
|
||
await controller.handleRealtimeEvent(event({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: 'prepared-then-stop',
|
||
call_id: 'stop-prepared',
|
||
name: 'control_radio',
|
||
arguments: '{"action":"stop"}',
|
||
}));
|
||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||
});
|
||
|
||
test('computeDownscale leaves an already-small frame untouched', () => {
|
||
assert.deepEqual(computeDownscale(800, 600, 1200 * 900), { width: 800, height: 600 });
|
||
});
|
||
|
||
test('computeDownscale clamps a huge landscape frame under the pixel budget', () => {
|
||
const maxPx = 1200 * 900; // 1,080,000
|
||
const out = computeDownscale(3840, 2160, maxPx); // 4K, 8.29 MP
|
||
assert.ok(out.width * out.height <= maxPx, `pixels ${out.width * out.height} <= ${maxPx}`);
|
||
// aspect ratio preserved (16:9) within rounding
|
||
assert.ok(Math.abs(out.width / out.height - 3840 / 2160) < 0.01);
|
||
});
|
||
|
||
test('computeDownscale clamps a TALL PORTRAIT frame too (the width-only bug)', () => {
|
||
const maxPx = 1200 * 900;
|
||
// A narrow-but-very-tall portrait window: width alone (1080) would have
|
||
// passed the old 1200px width clamp untouched, but its 1080*3000 = 3.24 MP
|
||
// total blows the budget and must be scaled down.
|
||
const out = computeDownscale(1080, 3000, maxPx);
|
||
assert.ok(out.width * out.height <= maxPx, `portrait pixels ${out.width * out.height} <= ${maxPx}`);
|
||
assert.ok(out.height < 3000, 'height was reduced');
|
||
assert.ok(out.width < 1080, 'width was reduced');
|
||
assert.ok(Math.abs(out.width / out.height - 1080 / 3000) < 0.01, 'aspect preserved');
|
||
});
|
||
|
||
test('computeDownscale never upscales and never returns a zero dimension', () => {
|
||
assert.deepEqual(computeDownscale(10, 10, 1_000_000), { width: 10, height: 10 });
|
||
const tiny = computeDownscale(1, 1, 1);
|
||
assert.ok(tiny.width >= 1 && tiny.height >= 1);
|
||
});
|
||
|
||
test('computeDownscale is defensive against garbage input', () => {
|
||
const out = computeDownscale(0, 0, 0);
|
||
assert.ok(out.width >= 1 && out.height >= 1);
|
||
const nan = computeDownscale(NaN, NaN, NaN);
|
||
assert.ok(nan.width >= 1 && nan.height >= 1);
|
||
});
|
||
|
||
test('estimateDataUrlBytes decodes base64 length minus the data-URL prefix', () => {
|
||
// "AAAA" (4 base64 chars, no padding) = 3 bytes.
|
||
assert.equal(estimateDataUrlBytes('data:image/jpeg;base64,AAAA'), 3);
|
||
// one '=' pad => 2 bytes; two '==' pads => 1 byte
|
||
assert.equal(estimateDataUrlBytes('data:image/jpeg;base64,AAA='), 2);
|
||
assert.equal(estimateDataUrlBytes('data:image/jpeg;base64,AA=='), 1);
|
||
});
|
||
|
||
test('estimateDataUrlBytes handles a raw base64 string with no comma prefix', () => {
|
||
assert.equal(estimateDataUrlBytes('AAAA'), 3);
|
||
});
|
||
|
||
test('estimateDataUrlBytes is safe on non-strings', () => {
|
||
assert.equal(estimateDataUrlBytes(null), 0);
|
||
assert.equal(estimateDataUrlBytes(undefined), 0);
|
||
assert.equal(estimateDataUrlBytes(12345), 0);
|
||
});
|
||
|
||
test('estimateDataUrlBytes flags a payload over the 200KB ceiling', () => {
|
||
const LIMIT = 200 * 1024;
|
||
// ~300 KB of base64 (each char ~0.75 bytes) decodes well over the ceiling.
|
||
const big = 'data:image/jpeg;base64,' + 'A'.repeat(300 * 1024);
|
||
assert.ok(estimateDataUrlBytes(big) > LIMIT);
|
||
// A small one stays under.
|
||
const small = 'data:image/jpeg;base64,' + 'A'.repeat(1024);
|
||
assert.ok(estimateDataUrlBytes(small) < LIMIT);
|
||
});
|
||
|
||
// --- M14: item_not_found from a stale viewport-delete must be non-fatal ---
|
||
|
||
test('isBenignViewportDeleteError matches an item_not_found by code', () => {
|
||
const payload = { type: 'error', error: { code: 'item_not_found' } };
|
||
assert.equal(isBenignViewportDeleteError(payload, new Set()), true);
|
||
});
|
||
|
||
test('isBenignViewportDeleteError matches an echoed delete event_id even without the code', () => {
|
||
const pending = new Set(['evt_del_abc']);
|
||
const payload = { type: 'error', event_id: 'evt_del_abc', error: { code: 'server_error' } };
|
||
assert.equal(isBenignViewportDeleteError(payload, pending), true);
|
||
});
|
||
|
||
test('isBenignViewportDeleteError does NOT swallow unrelated errors', () => {
|
||
const pending = new Set(['evt_del_abc']);
|
||
// Different code, event_id we never issued → must stay fatal.
|
||
const payload = { type: 'error', event_id: 'evt_other', error: { code: 'invalid_request_error' } };
|
||
assert.equal(isBenignViewportDeleteError(payload, pending), false);
|
||
});
|
||
|
||
test('isBenignViewportDeleteError is false for non-error payloads and junk', () => {
|
||
assert.equal(isBenignViewportDeleteError({ type: 'response.done' }, new Set()), false);
|
||
assert.equal(isBenignViewportDeleteError(null, new Set()), false);
|
||
assert.equal(isBenignViewportDeleteError(undefined), false);
|
||
assert.equal(isBenignViewportDeleteError({ type: 'error' }, new Set()), false);
|
||
});
|
||
|
||
|
||
test('hidden document yields no fresh frame — capture must not label a stale canvas Current', async () => {
|
||
const originalDocument = globalThis.document;
|
||
let requested = 0;
|
||
const scene = {
|
||
postRender: { addEventListener() { return () => {}; } },
|
||
requestRender() { requested += 1; },
|
||
};
|
||
try {
|
||
globalThis.document = { hidden: true };
|
||
const fresh = await renderFreshCesiumFrame({ scene });
|
||
assert.equal(fresh, false, 'hidden capture reports non-fresh');
|
||
assert.equal(requested, 0, 'no secret render restart while hidden');
|
||
} finally {
|
||
globalThis.document = originalDocument;
|
||
}
|
||
});
|
||
|
||
test('visible document with a rendering scene reports a fresh frame', async () => {
|
||
const originalDocument = globalThis.document;
|
||
try {
|
||
globalThis.document = { hidden: false };
|
||
let fire = null;
|
||
const scene = {
|
||
postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } },
|
||
requestRender() { queueMicrotask(() => fire?.()); },
|
||
};
|
||
const fresh = await renderFreshCesiumFrame({ scene });
|
||
assert.equal(fresh, true);
|
||
} finally {
|
||
globalThis.document = originalDocument;
|
||
}
|
||
});
|
||
|
||
test('a tab switch during the bounded render wait invalidates freshness', async () => {
|
||
const originalDocument = globalThis.document;
|
||
try {
|
||
const doc = { hidden: false };
|
||
globalThis.document = doc;
|
||
let fire = null;
|
||
const scene = {
|
||
postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } },
|
||
requestRender() { doc.hidden = true; queueMicrotask(() => fire?.()); },
|
||
};
|
||
const fresh = await renderFreshCesiumFrame({ scene });
|
||
assert.equal(fresh, false, 'freshness rechecked after the await');
|
||
} finally {
|
||
globalThis.document = originalDocument;
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Voice cost control — persistence.
|
||
// The stored tier picks the OpenAI model id for the next session and the stored
|
||
// limits arm the spend cap, so both must survive corruption without either
|
||
// throwing or silently disarming the guard.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Minimal in-memory localStorage stand-in. */
|
||
function fakeVoiceStorage(seed = {}) {
|
||
const map = new Map(Object.entries(seed));
|
||
return {
|
||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||
setItem: (key, value) => map.set(key, String(value)),
|
||
dump: () => Object.fromEntries(map),
|
||
};
|
||
}
|
||
|
||
test('voice tier round-trips through storage', () => {
|
||
const storage = fakeVoiceStorage();
|
||
assert.equal(writeStoredVoiceTier('mini', storage), 'mini');
|
||
assert.equal(readStoredVoiceTier(storage), 'mini');
|
||
assert.equal(writeStoredVoiceTier('standard', storage), 'standard');
|
||
assert.equal(readStoredVoiceTier(storage), 'standard');
|
||
});
|
||
|
||
test('an unset or hand-edited tier reads back as standard', () => {
|
||
assert.equal(readStoredVoiceTier(fakeVoiceStorage()), 'standard');
|
||
assert.equal(
|
||
readStoredVoiceTier(fakeVoiceStorage({ 'godsEyeView.voiceCost.tier': 'gpt-4o' })),
|
||
'standard'
|
||
);
|
||
assert.equal(
|
||
readStoredVoiceTier(fakeVoiceStorage({ 'godsEyeView.voiceCost.tier': '__proto__' })),
|
||
'standard'
|
||
);
|
||
});
|
||
|
||
test('writing a bogus tier persists the safe fallback, not the bogus value', () => {
|
||
const storage = fakeVoiceStorage();
|
||
assert.equal(writeStoredVoiceTier('turbo', storage), 'standard');
|
||
assert.equal(storage.dump()['godsEyeView.voiceCost.tier'], 'standard');
|
||
});
|
||
|
||
test('a storage that throws never breaks the mic', () => {
|
||
const hostile = {
|
||
getItem() {
|
||
throw new Error('SecurityError');
|
||
},
|
||
setItem() {
|
||
throw new Error('SecurityError');
|
||
},
|
||
};
|
||
assert.equal(readStoredVoiceTier(hostile), 'standard');
|
||
assert.equal(writeStoredVoiceTier('mini', hostile), 'mini');
|
||
assert.deepEqual(readStoredVoiceLimits(hostile), { warnUsd: 2, capUsd: 5 });
|
||
});
|
||
|
||
test('spend limits round-trip as one object', () => {
|
||
const storage = fakeVoiceStorage();
|
||
writeStoredVoiceLimits({ warnUsd: 1, capUsd: 3 }, storage);
|
||
assert.deepEqual(readStoredVoiceLimits(storage), { warnUsd: 1, capUsd: 3 });
|
||
});
|
||
|
||
test('corrupt stored limits fall back to defaults rather than disarming the cap', () => {
|
||
// A disarmed cap is the dangerous failure — assert we land on the default,
|
||
// not on Infinity.
|
||
const limits = readStoredVoiceLimits(
|
||
fakeVoiceStorage({ 'godsEyeView.voiceCost.limits': '{oops' })
|
||
);
|
||
assert.deepEqual(limits, { warnUsd: 2, capUsd: 5 });
|
||
});
|
||
|
||
test('partially stored limits keep the default for the missing threshold', () => {
|
||
const limits = readStoredVoiceLimits(
|
||
fakeVoiceStorage({ 'godsEyeView.voiceCost.limits': '{"warnUsd":0.5}' })
|
||
);
|
||
assert.deepEqual(limits, { warnUsd: 0.5, capUsd: 5 });
|
||
});
|
||
|
||
test('a disabled threshold survives the storage round-trip', () => {
|
||
// Infinity JSON-serializes to null, which reads back as "absent" and restores
|
||
// the DEFAULT — silently re-arming a cap the user turned off. The 'off'
|
||
// sentinel is what makes disabling persist.
|
||
const storage = fakeVoiceStorage();
|
||
writeStoredVoiceLimits({ warnUsd: 0, capUsd: 0 }, storage);
|
||
const raw = storage.dump()['godsEyeView.voiceCost.limits'];
|
||
assert.ok(!raw.includes('null'), `must not persist null: ${raw}`);
|
||
const restored = readStoredVoiceLimits(storage);
|
||
assert.equal(restored.warnUsd, Infinity);
|
||
assert.equal(restored.capUsd, Infinity);
|
||
});
|
||
|
||
test('one disabled and one live threshold both round-trip', () => {
|
||
const storage = fakeVoiceStorage();
|
||
writeStoredVoiceLimits({ warnUsd: 0, capUsd: 7 }, storage);
|
||
const restored = readStoredVoiceLimits(storage);
|
||
assert.equal(restored.warnUsd, Infinity);
|
||
assert.equal(restored.capUsd, 7);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Voice cost control — REAL handlers driven by a mocked Realtime event stream.
|
||
// These exercise updateResponseState / handleRealtimeEvent / stop() rather than
|
||
// the pure module, because the bugs they pin were all in the wiring.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Usage worth exactly $`usd` on STANDARD rates (audio out is $64/1M). */
|
||
const usdUsage = (usd) => ({
|
||
input_tokens: 0,
|
||
output_tokens: 15625 * usd,
|
||
output_token_details: { text_tokens: 0, audio_tokens: 15625 * usd },
|
||
});
|
||
|
||
/** A controller wired to inert UI stubs, with the cost surface present. */
|
||
function costControllerHarness({ runner } = {}) {
|
||
const toolCalls = [];
|
||
const ui = {
|
||
root: { dataset: {}, classList: { remove() {}, add() {} }, querySelectorAll: () => [] },
|
||
status: { textContent: '' },
|
||
detail: { textContent: '', title: '' },
|
||
errorDetail: { textContent: '' },
|
||
button: { dataset: {} },
|
||
buttonLabel: { textContent: '' },
|
||
tierButton: {
|
||
textContent: '',
|
||
title: '',
|
||
attrs: {},
|
||
setAttribute(key, value) { this.attrs[key] = value; },
|
||
getAttribute(key) { return this.attrs[key] ?? null; },
|
||
},
|
||
costValue: { textContent: '', title: '', dataset: {} },
|
||
};
|
||
const controller = new GevRealtimeController({
|
||
ui,
|
||
runner: runner || (async (name) => { toolCalls.push(name); return { ok: true }; }),
|
||
});
|
||
controller.debugLog = () => {};
|
||
controller.updateVoiceButtonLabel = () => {};
|
||
return { controller, ui, toolCalls };
|
||
}
|
||
|
||
const doneEvent = (usage) => ({
|
||
data: JSON.stringify({
|
||
type: 'response.done',
|
||
response: { id: 'r1', status: 'completed', usage },
|
||
}),
|
||
});
|
||
|
||
const fnCallEvent = (name, itemId, callId) => ({
|
||
data: JSON.stringify({
|
||
type: 'response.function_call_arguments.done',
|
||
item_id: itemId,
|
||
call_id: callId,
|
||
name,
|
||
arguments: '{}',
|
||
}),
|
||
});
|
||
|
||
test('F1: toggling tier mid-session does not erase accrued spend', () => {
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening'; // live session
|
||
controller.costTracker = createVoiceCostTracker({
|
||
modelId: 'gpt-realtime-2',
|
||
limits: { warnUsd: 2, capUsd: 5 },
|
||
});
|
||
controller.recordUsage(usdUsage(3));
|
||
assert.ok(Math.abs(controller.costTracker.state().totalUsd - 3) < 1e-9);
|
||
|
||
controller.setVoiceTier('mini');
|
||
|
||
const after = controller.costTracker.state();
|
||
assert.ok(Math.abs(after.totalUsd - 3) < 1e-9, `accrued spend survived: ${after.totalUsd}`);
|
||
assert.equal(after.modelId, 'gpt-realtime-2', 'session keeps its original model binding');
|
||
});
|
||
|
||
test('F1: the cap still fires after a mid-session toggle, at the original rates', () => {
|
||
// The bug this pins: rebuilding the tracker on toggle reset the meter, so
|
||
// repeated toggles could hold a session under the cap forever.
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.costTracker = createVoiceCostTracker({
|
||
modelId: 'gpt-realtime-2',
|
||
limits: { warnUsd: 2, capUsd: 5 },
|
||
});
|
||
for (let i = 0; i < 4; i += 1) {
|
||
controller.recordUsage(usdUsage(1));
|
||
controller.setVoiceTier(i % 2 === 0 ? 'mini' : 'standard'); // toggle spam
|
||
}
|
||
assert.equal(controller.costTracker.state().capReached, false, '$4 is under the cap');
|
||
controller.recordUsage(usdUsage(1)); // $5 — crosses
|
||
assert.equal(controller.costTracker.state().capReached, true);
|
||
assert.equal(controller.isSessionEnding(), true);
|
||
});
|
||
|
||
test('F1: the toggle still records the next-session preference while live', () => {
|
||
const { controller, ui } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.setVoiceTier('mini');
|
||
assert.equal(controller.voiceTier, 'mini');
|
||
assert.equal(ui.tierButton.textContent, 'MINI');
|
||
// ...and says so, rather than implying the live session switched.
|
||
assert.match(ui.tierButton.title, /this session stays on/i);
|
||
});
|
||
|
||
test('F1: when idle, toggling does re-price the preview meter', () => {
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'idle';
|
||
controller.setVoiceTier('mini');
|
||
assert.equal(controller.costTracker.state().modelId, 'gpt-realtime-2.1-mini');
|
||
});
|
||
|
||
test('F4: once the cap latches, queued function calls do not execute', async () => {
|
||
const { controller, toolCalls } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.costCapStopped = true; // latched by a prior cap
|
||
await controller.handleRealtimeEvent({
|
||
data: JSON.stringify({
|
||
type: 'response.function_call_arguments.done',
|
||
item_id: 'i1',
|
||
call_id: 'c1',
|
||
name: 'fly_to_location',
|
||
arguments: '{"query":"London"}',
|
||
}),
|
||
});
|
||
assert.deepEqual(toolCalls, [], 'no map mutation after the session ended');
|
||
});
|
||
|
||
test('F4: a cap between tool events stops every later tool', async () => {
|
||
// The real sequence this guards. Function-call events arrive BEFORE the
|
||
// response.done that carries usage, so tool N runs, the cap then trips, and
|
||
// tool N+1 must not dispatch. extractFunctionCalls yields at most one call
|
||
// per event, so this across-events path is the only reachable one — the
|
||
// single pre-loop gate covers it, and there is no per-iteration check.
|
||
const executed = [];
|
||
const { controller } = costControllerHarness({
|
||
runner: async (name) => { executed.push(name); return { ok: true }; },
|
||
});
|
||
controller.status = 'listening';
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
controller.costTracker = createVoiceCostTracker({
|
||
modelId: 'gpt-realtime-2',
|
||
limits: { warnUsd: 2, capUsd: 5 },
|
||
});
|
||
|
||
await controller.handleRealtimeEvent(fnCallEvent('set_hud', 'i1', 'c1'));
|
||
assert.deepEqual(executed, ['set_hud'], 'healthy session executes normally');
|
||
|
||
await controller.handleRealtimeEvent(doneEvent(usdUsage(9))); // trips the cap
|
||
assert.equal(controller.isSessionEnding(), true);
|
||
|
||
await controller.handleRealtimeEvent(fnCallEvent('fly_to_location', 'i2', 'c2'));
|
||
assert.deepEqual(executed, ['set_hud'], 'no map mutation dispatched after the cap');
|
||
});
|
||
|
||
test('F4: tools DO execute normally while the session is healthy', async () => {
|
||
// Guards the gate against being trivially always-on.
|
||
const { controller, toolCalls } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
await controller.handleRealtimeEvent({
|
||
data: JSON.stringify({
|
||
type: 'response.function_call_arguments.done',
|
||
item_id: 'i2',
|
||
call_id: 'c2',
|
||
name: 'fly_to_location',
|
||
arguments: '{"query":"London"}',
|
||
}),
|
||
});
|
||
assert.deepEqual(toolCalls, ['fly_to_location']);
|
||
});
|
||
|
||
test('F4: an output_item.done arriving after the cap does not execute either', async () => {
|
||
// The other extractor path (response.output_item.done), same guarantee.
|
||
const { controller, toolCalls } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
controller.costCapStopped = true;
|
||
await controller.handleRealtimeEvent({
|
||
data: JSON.stringify({
|
||
type: 'response.output_item.done',
|
||
item: {
|
||
type: 'function_call',
|
||
id: 'i4',
|
||
call_id: 'c4',
|
||
name: 'set_layer_visibility',
|
||
arguments: '{"layerId":"flights","enabled":true}',
|
||
},
|
||
}),
|
||
});
|
||
assert.deepEqual(toolCalls, []);
|
||
});
|
||
|
||
test('F5: teardown always closes the channel, in one step', () => {
|
||
// The drain that used to live here could not work: stop() closes the peer
|
||
// connection immediately after, which closes the data channels a drain would
|
||
// listen on. Teardown is now unconditional and single-step.
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
let closed = false;
|
||
let pcClosed = false;
|
||
controller.responseActive = true;
|
||
controller.dc = { readyState: 'open', send() {}, close() { closed = true; } };
|
||
controller.pc = { close() { pcClosed = true; } };
|
||
controller.stop();
|
||
assert.equal(closed, true, 'data channel closed');
|
||
assert.equal(pcClosed, true, 'peer connection closed');
|
||
assert.equal(controller.dc, null);
|
||
assert.equal(controller.pc, null);
|
||
});
|
||
|
||
test('F5: a response in flight at teardown marks the accounting INCOMPLETE', () => {
|
||
// We have no token telemetry for an unfinished response, so we flag the
|
||
// accounting as partial rather than inventing a number for it. Deliberately
|
||
// not framed as a floor — the estimate can also run high.
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.costTracker = createVoiceCostTracker({
|
||
modelId: 'gpt-realtime-2',
|
||
limits: { warnUsd: 2, capUsd: 5 },
|
||
});
|
||
controller.recordUsage(usdUsage(1));
|
||
controller.responseActive = true;
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
controller.stop();
|
||
const state = controller.costTracker.state();
|
||
assert.equal(state.incomplete, true);
|
||
assert.equal(state.display, '~$1.00*', 'see-note mark, not a direction claim');
|
||
assert.match(state.note, /incomplete/i, 'the tooltip explains why');
|
||
assert.doesNotMatch(state.note, /at least|lower bound|floor/i, 'no floor claim');
|
||
});
|
||
|
||
test('F5: a clean teardown does not mark the total incomplete', () => {
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.costTracker = createVoiceCostTracker({ modelId: 'gpt-realtime-2' });
|
||
controller.recordUsage(usdUsage(1));
|
||
controller.responseActive = false;
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
controller.stop();
|
||
assert.equal(controller.costTracker.state().incomplete, false);
|
||
assert.equal(controller.costTracker.state().display, '~$1.00');
|
||
assert.equal(controller.costTracker.state().note, null, 'no note when complete');
|
||
});
|
||
|
||
test('F2/F5: no stray message listener survives teardown to double-meter', () => {
|
||
// The removed drain attached a SECOND 'message' listener while the normal
|
||
// handler stayed attached, so a late response.done was metered twice.
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
const added = [];
|
||
controller.responseActive = true;
|
||
controller.dc = {
|
||
readyState: 'open',
|
||
send() {},
|
||
close() {},
|
||
addEventListener(type) { added.push(type); },
|
||
removeEventListener() {},
|
||
};
|
||
controller.stop();
|
||
assert.deepEqual(added, [], 'teardown attaches no new listeners');
|
||
});
|
||
|
||
test('F3: setVoiceTier does NOT rebuild the tracker while transport is live', () => {
|
||
// 'error' reports !isActive() but can still hold an open channel delivering a
|
||
// late response.done — rebuilding there sends that usage to a preview tracker.
|
||
const { controller } = costControllerHarness();
|
||
controller.costTracker = createVoiceCostTracker({
|
||
modelId: 'gpt-realtime-2',
|
||
limits: { warnUsd: 2, capUsd: 5 },
|
||
});
|
||
controller.recordUsage(usdUsage(3));
|
||
controller.status = 'error';
|
||
controller.dc = { readyState: 'open', send() {}, close() {} };
|
||
assert.equal(controller.isVoiceSessionSettled(), false);
|
||
|
||
controller.setVoiceTier('mini');
|
||
|
||
const state = controller.costTracker.state();
|
||
assert.ok(Math.abs(state.totalUsd - 3) < 1e-9, `spend survived: ${state.totalUsd}`);
|
||
assert.equal(state.modelId, 'gpt-realtime-2');
|
||
});
|
||
|
||
test('F3: once fully settled, setVoiceTier does rebuild the preview tracker', () => {
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'idle';
|
||
controller.dc = null;
|
||
controller.pc = null;
|
||
assert.equal(controller.isVoiceSessionSettled(), true);
|
||
controller.setVoiceTier('mini');
|
||
assert.equal(controller.costTracker.state().modelId, 'gpt-realtime-2.1-mini');
|
||
});
|
||
|
||
test('F4: two clicks during a live session return to the original preference', () => {
|
||
// The bug: toggleVoiceTier() derived the next tier from the immutable live
|
||
// TRACKER, so every click during a standard session selected 'mini' again.
|
||
const { controller, ui } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.costTracker = createVoiceCostTracker({ modelId: 'gpt-realtime-2' });
|
||
controller.voiceTier = 'standard';
|
||
|
||
controller.toggleVoiceTier();
|
||
assert.equal(controller.voiceTier, 'mini', 'first click switches to mini');
|
||
|
||
controller.toggleVoiceTier();
|
||
assert.equal(controller.voiceTier, 'standard', 'second click switches back');
|
||
assert.equal(ui.tierButton.textContent, 'STD');
|
||
});
|
||
|
||
test('F4: the toggle alternates across many clicks mid-session', () => {
|
||
const { controller } = costControllerHarness();
|
||
controller.status = 'listening';
|
||
controller.costTracker = createVoiceCostTracker({ modelId: 'gpt-realtime-2' });
|
||
controller.voiceTier = 'standard';
|
||
const seen = [];
|
||
for (let i = 0; i < 4; i += 1) seen.push(controller.toggleVoiceTier());
|
||
assert.deepEqual(seen, ['mini', 'standard', 'mini', 'standard']);
|
||
});
|
||
|
||
test('F3: an unrecognised session model bills at the most expensive known rates', () => {
|
||
// An env override can point a tier at any model id; pricing it cheap (or
|
||
// free) is what lets a cap be overrun.
|
||
const tracker = createVoiceCostTracker({
|
||
modelId: 'gpt-realtime-9-experimental',
|
||
limits: { warnUsd: 2, capUsd: 5 },
|
||
});
|
||
assert.equal(tracker.state().ratesRecognized, false);
|
||
const standard = createVoiceCostTracker({ modelId: 'gpt-realtime-2' });
|
||
const unknownCost = tracker.record(usdUsage(1)).totalUsd;
|
||
const standardCost = standard.record(usdUsage(1)).totalUsd;
|
||
assert.ok(unknownCost >= standardCost, 'never cheaper than the priciest known model');
|
||
});
|
||
|
||
/**
|
||
* The Realtime API rejects a second concurrent `response.create`
|
||
* (`conversation_already_has_active_response`) and the rejected turn is lost.
|
||
* Every client trigger goes through the in-flight guard except the typed-command
|
||
* path, which fired straight out — so a typed command landing mid-answer was
|
||
* silently dropped instead of answered.
|
||
*/
|
||
function textCommandController() {
|
||
const sent = [];
|
||
const controller = new GevRealtimeController({
|
||
ui: {},
|
||
runner: async () => ({}),
|
||
});
|
||
controller.dc = { readyState: 'open' };
|
||
controller.sendRealtimeEvent = (event, label) => {
|
||
sent.push(label || event?.type);
|
||
return true;
|
||
};
|
||
controller.debugLog = () => {};
|
||
controller.cancelRadioHandoff = () => {};
|
||
return { controller, sent };
|
||
}
|
||
|
||
test('a typed command mid-response defers its turn instead of colliding', () => {
|
||
const { controller, sent } = textCommandController();
|
||
controller.responseActive = true;
|
||
controller.sendTextCommand('zoom to the globe');
|
||
assert.deepEqual(sent, ['client.user_text'], 'no second response.create while one is active');
|
||
assert.equal(controller.pendingUserTextResponse, true);
|
||
});
|
||
|
||
test('the deferred typed turn is answered once when the active response finishes', () => {
|
||
const { controller, sent } = textCommandController();
|
||
controller.responseActive = true;
|
||
controller.sendTextCommand('zoom to the globe');
|
||
controller.setVoiceSpeaker = () => {};
|
||
controller.recordUsage = () => {};
|
||
controller.updateResponseState({ type: 'response.done', response: { status: 'completed' } });
|
||
assert.deepEqual(sent, ['client.user_text', 'client.response_create.user_text']);
|
||
assert.equal(controller.pendingUserTextResponse, false);
|
||
// A second completion must not produce a second answer to the same command.
|
||
controller.updateResponseState({ type: 'response.done', response: { status: 'completed' } });
|
||
assert.deepEqual(sent, ['client.user_text', 'client.response_create.user_text']);
|
||
});
|
||
|
||
test('a typed command with nothing in flight is answered immediately', () => {
|
||
const { controller, sent } = textCommandController();
|
||
controller.sendTextCommand('zoom to the globe');
|
||
assert.deepEqual(sent, ['client.user_text', 'client.response_create.user_text']);
|
||
assert.equal(controller.responseCreatePending, true);
|
||
});
|
||
|
||
test('an overlapping-response rejection is dropped, never replayed', () => {
|
||
const { controller, sent } = textCommandController();
|
||
controller.responseActive = true;
|
||
controller.sendTextCommand('zoom to the globe');
|
||
controller.setStatus = () => {};
|
||
controller.handleRealtimeEvent({
|
||
data: JSON.stringify({
|
||
type: 'error',
|
||
error: { type: 'invalid_request_error', code: 'conversation_already_has_active_response' },
|
||
}),
|
||
});
|
||
assert.equal(controller.pendingUserTextResponse, false, 'a rejected turn must not re-arm itself');
|
||
controller.setVoiceSpeaker = () => {};
|
||
controller.recordUsage = () => {};
|
||
controller.updateResponseState({ type: 'response.done', response: { status: 'completed' } });
|
||
assert.deepEqual(sent, ['client.user_text'], 'and must not speak the same turn again');
|
||
});
|
||
|
||
/** A late function call from response `responseId`, as the server sends it. */
|
||
function lateToolEvent(responseId, callId) {
|
||
return {
|
||
data: JSON.stringify({
|
||
type: 'response.function_call_arguments.done',
|
||
response_id: responseId,
|
||
call_id: callId,
|
||
name: 'fly_to_location',
|
||
arguments: '{"query":"Paris"}',
|
||
}),
|
||
};
|
||
}
|
||
|
||
/** Wire the dispatch collaborators a bare controller does not have in a unit test. */
|
||
function toolDispatchController() {
|
||
const { controller, sent } = textCommandController();
|
||
const dispatched = [];
|
||
controller.runner = async (name) => { dispatched.push(name); return { ok: true }; };
|
||
controller.setStatus = () => {};
|
||
controller.setVoiceSpeaker = () => {};
|
||
controller.recordUsage = () => {};
|
||
controller.captureViewportScreenshot = async () => null;
|
||
return { controller, sent, dispatched };
|
||
}
|
||
|
||
test('CONTROL: a live response’s function call is dispatched normally', async () => {
|
||
const { controller, dispatched } = toolDispatchController();
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_live' } });
|
||
await controller.handleRealtimeEvent(lateToolEvent('resp_live', 'call_live'));
|
||
assert.deepEqual(dispatched, ['fly_to_location'], 'the guard must not block ordinary tool calls');
|
||
});
|
||
|
||
test('a typed command supersedes the old response, so its late tools never fire', async () => {
|
||
const { controller, sent, dispatched } = toolDispatchController();
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_old' } });
|
||
assert.equal(controller.activeResponseId, 'resp_old');
|
||
|
||
controller.sendTextCommand('stop');
|
||
assert.equal(controller.isSupersededResponse('resp_old'), true);
|
||
|
||
// The same event the control case dispatched, now belonging to a turn the
|
||
// operator has replaced.
|
||
await controller.handleRealtimeEvent(lateToolEvent('resp_old', 'call_stale'));
|
||
assert.deepEqual(dispatched, [], 'a stale turn must not mutate the map after a newer command');
|
||
// The call is REFUSED, not ignored: it is still answered (see the terminal
|
||
// output pin below), but nothing about it creates a response.
|
||
assert.deepEqual(sent, ['client.user_text', 'client.function_call_output']);
|
||
assert.equal(
|
||
sent.filter((label) => label.startsWith('client.response_create')).length,
|
||
0,
|
||
'the refusal must not produce an answer of its own',
|
||
);
|
||
});
|
||
|
||
test('a typed command drops the old response’s queued follow-up confirmation', () => {
|
||
const { controller, sent } = textCommandController();
|
||
controller.setVoiceSpeaker = () => {};
|
||
controller.recordUsage = () => {};
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_old' } });
|
||
controller.queueResponseCreate('Briefly confirm the completed GEV action once.');
|
||
assert.ok(controller.pendingResponseInstructions, 'a follow-up is queued behind the active response');
|
||
|
||
controller.sendTextCommand('stop');
|
||
assert.equal(
|
||
controller.pendingResponseInstructions,
|
||
null,
|
||
'the stale confirmation is dropped — the typed turn is the single answer now',
|
||
);
|
||
|
||
controller.updateResponseState({ type: 'response.done', response: { status: 'completed' } });
|
||
assert.deepEqual(
|
||
sent,
|
||
['client.user_text', 'client.response_create.user_text'],
|
||
'exactly one response follows, and it answers the newest command',
|
||
);
|
||
});
|
||
|
||
test('a burst of typed commands coalesces into one answer, keeping both items', () => {
|
||
// Deliberate policy, pinned so it cannot drift: both commands stay in the
|
||
// conversation so the model sees the full intent, but they share one
|
||
// response rather than racing two.
|
||
const { controller, sent } = textCommandController();
|
||
controller.setVoiceSpeaker = () => {};
|
||
controller.recordUsage = () => {};
|
||
controller.responseActive = true;
|
||
controller.sendTextCommand('zoom to the globe');
|
||
controller.sendTextCommand('actually, show me Texas');
|
||
assert.deepEqual(sent, ['client.user_text', 'client.user_text'], 'both items are kept');
|
||
|
||
controller.updateResponseState({ type: 'response.done', response: { status: 'completed' } });
|
||
assert.deepEqual(sent, [
|
||
'client.user_text',
|
||
'client.user_text',
|
||
'client.response_create.user_text',
|
||
], 'and exactly one response covers the burst');
|
||
});
|
||
|
||
test('a live response is untouched when no typed command superseded it', () => {
|
||
const { controller } = textCommandController();
|
||
controller.setVoiceSpeaker = () => {};
|
||
controller.recordUsage = () => {};
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_live' } });
|
||
assert.equal(controller.isSupersededResponse('resp_live'), false);
|
||
assert.equal(controller.isSupersededResponse(null), false, 'an unattributed call is not stale');
|
||
});
|
||
|
||
/** Observe the actual protocol output rather than replacing its implementation. */
|
||
function observeToolOutputs(controller, record) {
|
||
const send = controller.sendRealtimeEvent;
|
||
controller.sendRealtimeEvent = (message, label) => {
|
||
if (message.type === 'conversation.item.create' && message.item?.type === 'function_call_output') {
|
||
record(message.item.call_id, JSON.parse(message.item.output));
|
||
}
|
||
return send.call(controller, message, label);
|
||
};
|
||
}
|
||
|
||
test('a refused superseded call is still answered with a terminal output', async () => {
|
||
// Every function call must be answered. Leaving one unanswered strands a
|
||
// pending call in the conversation and deadlocks the model — the same hazard
|
||
// callDedupeKeys is written to avoid. Refusing is not ignoring.
|
||
const { controller, sent, dispatched } = toolDispatchController();
|
||
const outputs = [];
|
||
observeToolOutputs(controller, (callId, result) => outputs.push({ callId, result }));
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_old' } });
|
||
controller.sendTextCommand('stop');
|
||
await controller.handleRealtimeEvent(lateToolEvent('resp_old', 'call_stale'));
|
||
|
||
assert.deepEqual(dispatched, [], 'the stale tool still must not run');
|
||
assert.equal(outputs.length, 1, 'exactly one terminal output for the refused call');
|
||
assert.equal(outputs[0].callId, 'call_stale');
|
||
assert.equal(outputs[0].result.ok, false);
|
||
assert.equal(outputs[0].result.superseded, true);
|
||
assert.equal(outputs[0].result.action, 'fly_to_location');
|
||
assert.ok(/superseded/i.test(outputs[0].result.error), 'and it says plainly why');
|
||
assert.equal(
|
||
sent.filter((label) => label === 'client.response_create.tool_followup').length,
|
||
0,
|
||
'a refused call must not create a response of its own',
|
||
);
|
||
});
|
||
|
||
/** The OTHER server surface for the same call: response.output_item.done. */
|
||
function lateToolItemEvent(responseId, callId, itemId = 'item_stale') {
|
||
return {
|
||
data: JSON.stringify({
|
||
type: 'response.output_item.done',
|
||
response_id: responseId,
|
||
item: {
|
||
type: 'function_call',
|
||
id: itemId,
|
||
call_id: callId,
|
||
name: 'fly_to_location',
|
||
arguments: '{"query":"Paris"}',
|
||
},
|
||
}),
|
||
};
|
||
}
|
||
|
||
test('the two server surfaces of one refused call collapse to a single output', async () => {
|
||
// The same call arrives as BOTH response.function_call_arguments.done and
|
||
// response.output_item.done. Replaying one type twice would only prove the
|
||
// event is idempotent; the collapse that matters is across surfaces, since
|
||
// two outputs for one call_id is its own protocol error.
|
||
const { controller } = toolDispatchController();
|
||
const outputs = [];
|
||
observeToolOutputs(controller, (callId) => outputs.push(callId));
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_old' } });
|
||
controller.sendTextCommand('stop');
|
||
await controller.handleRealtimeEvent(lateToolEvent('resp_old', 'call_stale'));
|
||
await controller.handleRealtimeEvent(lateToolItemEvent('resp_old', 'call_stale'));
|
||
assert.deepEqual(outputs, ['call_stale'], 'exactly one output across both surfaces');
|
||
});
|
||
|
||
test('a genuinely different refused call still gets its own output', async () => {
|
||
// The collapse must key on call identity, not on "we already refused one".
|
||
const { controller } = toolDispatchController();
|
||
const outputs = [];
|
||
observeToolOutputs(controller, (callId) => outputs.push(callId));
|
||
controller.updateResponseState({ type: 'response.created', response: { id: 'resp_old' } });
|
||
controller.sendTextCommand('stop');
|
||
await controller.handleRealtimeEvent(lateToolEvent('resp_old', 'call_one'));
|
||
await controller.handleRealtimeEvent(lateToolItemEvent('resp_old', 'call_two', 'item_two'));
|
||
assert.deepEqual(outputs, ['call_one', 'call_two'], 'each distinct call is answered');
|
||
});
|
||
|
||
test('a claimed Space hold barges in on the assistant; a short tap never does', (t) => {
|
||
const f = createPushToTalkFixture(t);
|
||
const canvas = pushToTalkTarget({ tagName: 'CANVAS', id: 'world-overlay-canvas', tabIndex: 0 });
|
||
let bargeIns = 0;
|
||
f.controller._turns.bargeIn = () => {
|
||
bargeIns += 1;
|
||
return true;
|
||
};
|
||
// First hold starts the push-to-talk session; nothing to interrupt yet.
|
||
f.key('keydown', canvas);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
f.key('keyup', canvas);
|
||
assert.equal(bargeIns, 0);
|
||
// A short tap during the reply stays a tap.
|
||
f.key('keydown', canvas);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||
f.key('keyup', canvas);
|
||
assert.equal(bargeIns, 0);
|
||
// Only the 500 ms claim interrupts, and the microphone opens as before.
|
||
f.key('keydown', canvas);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.equal(bargeIns, 1);
|
||
assert.equal(f.microphone.enabled, true);
|
||
f.key('keyup', canvas);
|
||
// Radio's claim is read before Radio is paused, and a held handoff is
|
||
// never interrupted.
|
||
const order = [];
|
||
const input = f.controller._input;
|
||
input.mayClaimSpeaker = () => {
|
||
order.push('claim');
|
||
return false;
|
||
};
|
||
const pause = input.pauseRadioForVoice;
|
||
input.pauseRadioForVoice = (...args) => {
|
||
order.push('pause');
|
||
return pause(...args);
|
||
};
|
||
f.key('keydown', canvas);
|
||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||
assert.deepEqual(order.slice(0, 2), ['claim', 'pause']);
|
||
assert.equal(bargeIns, 1, 'no barge-in while Radio holds the speaker');
|
||
f.key('keyup', canvas);
|
||
});
|
||
|
||
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
|
||
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
|
||
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
|
||
|
||
test('voice runs app actions itself and other tools through the catalog', async () => {
|
||
const actions = [];
|
||
const runner = async (name, args) => {
|
||
actions.push([name, args]);
|
||
return { ok: true, action: name };
|
||
};
|
||
const resets = [];
|
||
const disposals = [];
|
||
runner.resetConversation = (...args) => {
|
||
resets.push(args);
|
||
return 'reset';
|
||
};
|
||
runner.dispose = (...args) => {
|
||
disposals.push(args);
|
||
return 'disposed';
|
||
};
|
||
const calls = [];
|
||
let loads = 0;
|
||
const catalog = {
|
||
get: (name) => (name === 'get_wind' ? { name } : undefined),
|
||
async call(name, args, options) {
|
||
calls.push([name, args, options.signal]);
|
||
return { summary: 'Calm.', data: { calm: true } };
|
||
},
|
||
};
|
||
const run = withToolCatalog(runner, async () => {
|
||
loads += 1;
|
||
return catalog;
|
||
});
|
||
const signal = new AbortController().signal;
|
||
assert.deepEqual(await run('zoom_to_globe', {}), {
|
||
ok: true,
|
||
action: 'zoom_to_globe',
|
||
});
|
||
assert.equal(loads, 0);
|
||
assert.deepEqual(
|
||
await run('get_wind', { location: { place: 'Oslo' } }, { signal }),
|
||
{ ok: true, tool: 'get_wind', summary: 'Calm.', data: { calm: true } },
|
||
);
|
||
assert.deepEqual(calls, [
|
||
['get_wind', { location: { place: 'Oslo' } }, signal],
|
||
]);
|
||
await run('not_a_tool', {});
|
||
assert.deepEqual(
|
||
actions.map(([name]) => name),
|
||
['zoom_to_globe', 'not_a_tool'],
|
||
);
|
||
assert.equal(run.resetConversation('stopped'), 'reset');
|
||
assert.equal(run.dispose('removed'), 'disposed');
|
||
assert.deepEqual(resets, [['stopped']]);
|
||
assert.deepEqual(disposals, [['removed']]);
|
||
assert.equal(withToolCatalog(runner, undefined), runner);
|
||
});
|