// Native ownership replacements for historical Radio regression cases. // Real Gemini controller, action runner, DataLayerManager and RealtimeRadio consumers. import test from 'node:test'; import assert from 'node:assert/strict'; import { harness, tick, deferred, } from '../testSupport/geminiNativeHarness.mjs'; import { createGevActionRunner } from './gevActions.js'; import { createVoiceSession } from './session.js'; import { DataLayerManager } from '../data/manager.js'; const viewerFixture = () => ({ clock: { onTick: { addEventListener: () => () => {} } }, camera: { moveEnd: { addEventListener() {} } }, scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, }); const request = (ws, id, name, args = {}) => ws.message({ toolCall: { functionCalls: [{ id, name, args }] } }); const responses = (ws) => ws.sent.flatMap((frame) => frame.toolResponse?.functionResponses || []); const responseFor = (ws, id) => responses(ws).findLast((response) => response.id === id)?.response; const speechTurn = (ws) => { ws.message({ voiceActivity: { type: 'ACTIVITY_START' } }); ws.message({ voiceActivity: { type: 'ACTIVITY_END' } }); }; function drainRadioReply(h) { h.sockets.at(-1).message({ serverContent: { modelTurn: { parts: [{ inlineData: { data: 'AAAA' } }] }, turnComplete: true, }, }); h.captures.at(-1).drain(); } function radioSiblingFixture() { const viewer = viewerFixture(); const manager = new DataLayerManager(viewer); const lookupEntered = deferred(); const lookupReleased = deferred(); const state = { stationCount: 2, selected: { id: 'existing-station', name: 'Existing Station' }, audioState: 'stopped', }; const trace = { stops: 0, pauses: 0, selections: 0, options: new Map() }; const radio = { async init() {}, async enable() {}, async disable() {}, async update() {}, getStats: () => ({ count: 2 }), getUIState: () => ({ ...state }), setVoiceDucked() {}, pause({ origin } = {}) { if (origin === 'voice') trace.pauses++; state.audioState = 'paused'; return true; }, stopPlayback() { trace.stops++; state.audioState = 'stopped'; return true; }, selectRequestedStation() { trace.selections++; state.selected = { id: 'late-station', name: 'Late Station' }; return state.selected; }, subscribePlaybackControls: () => () => {}, }; manager.register( Object.assign(radio, { id: 'radio', name: 'Radio', updateInterval: -1, }), ); const runner = createGevActionRunner({ viewer, dataManager: manager, placeSearch: { async geocode(_query, { signal }) { trace.lookupSignal = signal; lookupEntered.resolve(); // Deliberately ignore abort: production currentness must still prevent // this late service completion from committing a station selection. await lookupReleased.promise; return { place: { lat: 10, lng: 20, label: 'Deferred Place' } }; }, }, }); const h = harness({ dataManager: manager, radioLayer: radio, runAction(name, args, options) { trace.options.set(options.callId, options); return runner(name, args, options); }, }); return { h, manager, runner, radio, trace, state, lookupEntered, lookupReleased, }; } for (const action of ['select', 'play']) { for (const stronger of ['stop', 'pause', 'disable', 'off']) { test(`native same-turn Radio ${stronger} preempts deferred ${action} without cancelling a query sibling`, async () => { const f = radioSiblingFixture(); const { h, trace, state } = f; try { await f.manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; speechTurn(ws); const turn = h.c.turn; request(ws, 'slow-selection', 'control_radio', { action, locationQuery: 'Deferred Place', }); await f.lookupEntered.promise; request(ws, 'query-sibling', 'control_radio', { action: 'status' }); request( ws, 'stronger-radio', stronger === 'off' ? 'set_layer_visibility' : 'control_radio', stronger === 'off' ? { layerId: 'radio', enabled: false } : { action: stronger }, ); await tick(); assert.equal( responseFor(ws, 'stronger-radio')?.ok, true, 'stronger control must finish before lookup release', ); assert.equal(trace.lookupSignal.aborted, true); await h.c.toolQueue; assert.equal( h.c.turn, turn, 'sibling control cannot invent a new turn', ); assert.equal(trace.selections, 0); assert.equal(state.selected.id, 'existing-station'); assert.equal(responseFor(ws, 'slow-selection').ok, false); assert.match(responseFor(ws, 'slow-selection').error, /cancelled/); assert.equal( responseFor(ws, 'slow-selection').enabled, !['disable', 'off'].includes(stronger), ); assert.equal(responseFor(ws, 'query-sibling').ok, true); assert.equal(trace.options.get('query-sibling').signal.aborted, false); assert.equal( responseFor(ws, 'stronger-radio').enabled, !['disable', 'off'].includes(stronger), ); assert.equal(trace.stops, stronger === 'stop' ? 1 : 0); assert.equal(trace.pauses, stronger === 'pause' ? 1 : 0); f.lookupReleased.resolve(); await tick(); assert.equal(trace.selections, 0, 'late lookup cannot commit'); assert.equal(h.c.pendingTools, 0); assert.equal(h.c.tools.size, 0); assert.equal(h.c.radio.radioToolHandoffReservations.size, 0); for (const id of ['slow-selection', 'query-sibling', 'stronger-radio']) assert.equal(responses(ws).filter((r) => r.id === id).length, 1); } finally { f.lookupReleased.resolve(); h.c.stop(); f.runner.dispose(); await f.manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); } } for (const guard of ['malformed', 'replay', 'invalid-arguments']) { test(`native stronger Radio ${guard} cannot revoke a same-turn lookup`, async () => { const f = radioSiblingFixture(); const { h, trace } = f; try { await f.manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; if (guard === 'replay') { request(ws, 'stronger-radio', 'control_radio', { action: 'stop' }); await h.c.toolQueue; } speechTurn(ws); if (guard === 'replay') ws.message({ serverContent: { interrupted: true, turnComplete: true }, }); request(ws, 'slow-selection', 'control_radio', { action: 'select', locationQuery: 'Deferred Place', }); await f.lookupEntered.promise; const stops = trace.stops; const handoffEpoch = h.c.radio.radioHandoffEpoch; request( ws, guard === 'malformed' ? '\n' : 'stronger-radio', 'control_radio', { action: 'stop', ...(guard === 'invalid-arguments' ? { latitude: 91, longitude: 0 } : {}), }, ); await tick(); assert.equal(trace.lookupSignal.aborted, false); assert.equal(trace.stops, stops); assert.equal(h.c.radio.radioHandoffEpoch, handoffEpoch); assert.equal(h.c.radio.radioToolHandoffReservations.size, 0); if (guard === 'invalid-arguments') { assert.equal(responseFor(ws, 'stronger-radio').ok, false); assert.match(responseFor(ws, 'stronger-radio').error, /coordinates/); } f.lookupReleased.resolve(); await h.c.toolQueue; assert.equal( trace.selections, 1, 'older eligible work retains authority', ); assert.equal(responseFor(ws, 'slow-selection').ok, true); assert.equal(h.c.pendingTools, 0); assert.equal(h.c.tools.size, 0); } finally { f.lookupReleased.resolve(); h.c.stop(); f.runner.dispose(); await f.manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); } test('native failed Stop releases its reservation and lets the older selection finish', async () => { const f = radioSiblingFixture(); const { h, trace } = f; const stopReleased = deferred(); f.radio.stopPlayback = () => stopReleased.promise; try { await f.manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; speechTurn(ws); request(ws, 'slow-selection', 'control_radio', { action: 'select', locationQuery: 'Deferred Place', }); await f.lookupEntered.promise; request(ws, 'failed-stop', 'control_radio', { action: 'stop' }); await tick(); assert.equal(h.c.radio.radioToolHandoffReservations.size, 1); assert.equal( trace.lookupSignal.aborted, false, 'provisional Stop cannot revoke work', ); assert.equal(h.c.pendingTools, 2); stopReleased.resolve(false); await tick(); assert.equal(responseFor(ws, 'failed-stop').ok, false); assert.match(responseFor(ws, 'failed-stop').error, /could not be stopped/); assert.equal(h.c.radio.radioToolHandoffReservations.size, 0); assert.equal(trace.lookupSignal.aborted, false); f.lookupReleased.resolve(); await h.c.toolQueue; assert.equal(responseFor(ws, 'slow-selection').ok, true); assert.equal(trace.selections, 1); assert.equal(f.state.selected.id, 'late-station'); assert.equal(h.c.pendingTools, 0); } finally { stopReleased.resolve(false); f.lookupReleased.resolve(); h.c.stop(); f.runner.dispose(); await f.manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); test('native pending Stop preserves selection completed before its own successful settlement', async () => { const f = radioSiblingFixture(); const { h } = f; const stopReleased = deferred(); f.radio.stopPlayback = () => stopReleased.promise; try { await f.manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; speechTurn(ws); request(ws, 'slow-selection', 'control_radio', { action: 'select', locationQuery: 'Deferred Place', }); await f.lookupEntered.promise; request(ws, 'pending-stop', 'control_radio', { action: 'stop' }); await tick(); f.lookupReleased.resolve(); await tick(); assert.equal(responseFor(ws, 'slow-selection').ok, true); assert.equal(f.state.selected.id, 'late-station'); assert.equal(h.c.radio.radioToolHandoffReservations.size, 1); assert.ok(h.c.radio.pendingRadioPlaybackResult); stopReleased.resolve(true); await h.c.toolQueue; assert.equal(responseFor(ws, 'pending-stop').ok, true); assert.equal(responseFor(ws, 'pending-stop').stationId, 'late-station'); assert.equal(f.state.selected.id, 'late-station'); assert.equal(h.c.radio.pendingRadioPlaybackResult, null); assert.equal(h.c.pendingTools, 0); } finally { stopReleased.resolve(false); f.lookupReleased.resolve(); h.c.stop(); f.runner.dispose(); await f.manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); for (const restart of [false, true]) { test(`native Stop${restart ? '/restart' : ''} retires concurrent stronger Radio work without late output or count underflow`, async () => { const f = radioSiblingFixture(); const { h } = f; const stopReleased = deferred(); f.radio.stopPlayback = () => stopReleased.promise; try { await f.manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; speechTurn(ws); request(ws, 'slow-selection', 'control_radio', { action: 'select', locationQuery: 'Deferred Place', }); await f.lookupEntered.promise; request(ws, 'pending-stop', 'control_radio', { action: 'stop' }); await tick(); const oldDrain = h.c.toolQueue; const stopOptions = f.trace.options.get('pending-stop'); assert.equal(h.c.pendingTools, 2); h.c.stop(); assert.equal(stopOptions.signal.aborted, true); assert.equal(f.trace.lookupSignal.aborted, true); if (restart) { await h.c.start(); request(h.sockets[1], 'new-query', 'control_radio', { action: 'status', }); await h.c.toolQueue; assert.equal(responseFor(h.sockets[1], 'new-query').ok, true); } stopReleased.resolve(true); f.lookupReleased.resolve(); await oldDrain; await tick(); assert.equal(responseFor(ws, 'pending-stop'), undefined); assert.equal(responseFor(ws, 'slow-selection'), undefined); assert.equal(f.trace.selections, 0); assert.equal(h.c.pendingTools, 0); assert.equal(h.c.tools.size, 0); assert.equal(h.c.radio.radioToolHandoffReservations.size, 0); assert.equal(h.c.isActive(), restart); } finally { stopReleased.resolve(false); f.lookupReleased.resolve(); h.c.stop(); f.runner.dispose(); await f.manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); } for (const phase of ['prepared', 'preflight']) { test(`native same-turn Stop revokes only voice-owned ${phase} playback and preserves completed station`, async () => { const f = radioSiblingFixture(); const { h } = f; const ready = deferred(); const stopped = []; let preflight; f.radio.playForVoice = () => ready.promise; f.radio.stopPlayback = (options) => { stopped.push(options); return true; }; try { await f.manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; speechTurn(ws); request(ws, 'prepared-play', 'control_radio', { action: 'play' }); await h.c.toolQueue; assert.equal( responseFor(ws, 'prepared-play').radioPlaybackRequested, true, ); if (phase === 'preflight') preflight = h.c.radio.startPendingRadioHandoff(); const attemptId = h.c.radio.radioHandoffAttemptId; request(ws, 'stronger-stop', 'control_radio', { action: 'stop' }); await h.c.toolQueue; assert.equal(responseFor(ws, 'stronger-stop').ok, true); assert.equal(f.state.selected.id, 'existing-station'); assert.equal(h.c.radio.pendingRadioPlaybackResult, null); assert.equal(h.c.radio.radioHandoffInFlight, false); const cleanups = stopped.filter((o) => o.origin === 'voice-cleanup'); assert.deepEqual( cleanups, phase === 'preflight' ? [{ origin: 'voice-cleanup', attemptId }] : [], ); ready.resolve(true); await preflight; await tick(); assert.equal( h.c.isActive(), true, 'late old readiness cannot stop voice', ); assert.equal(h.c.radio.radioToolHandoffReservations.size, 0); assert.equal(h.c.pendingTools, 0); } finally { ready.resolve(false); h.c.stop(); await preflight; f.runner.dispose(); await f.manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); } for (const phase of ['prepared', 'preflight', 'settling']) { test(`native Radio cancellation: ${phase} preserves selection truth and prevents stale speaker transfer`, async () => { const viewer = viewerFixture(); const manager = new DataLayerManager(viewer); const prepared = deferred(); const settleResult = deferred(); const playbackReady = deferred(); let playCalls = 0; let stopCalls = 0; let selections = 0; let activeOptions; const state = { stationCount: 2, selected: null, audioState: 'stopped', voiceDucked: false, }; const radio = { async init() {}, async enable() {}, async disable() {}, async update() {}, getStats: () => ({ count: 2 }), getUIState: () => ({ ...state }), setVoiceDucked(value) { state.voiceDucked = value; }, pause() { state.audioState = 'paused'; }, subscribePlaybackControls() { return () => {}; }, selectRequestedStation(_criteria, options) { assert.equal(options.autoplay, false); selections++; state.selected = { id: 'station-1', name: 'Station One' }; return state.selected; }, playForVoice() { playCalls++; return playbackReady.promise; }, stopPlayback() { stopCalls++; state.audioState = 'stopped'; }, }; manager.register({ id: 'radio', name: 'Radio', updateInterval: -1, ...radio, }); const runner = createGevActionRunner({ viewer, dataManager: manager }); let h; let preflight; const session = createVoiceSession({ runner: async (name, args, options) => { activeOptions = options; const result = await runner(name, args, options); prepared.resolve(result); if (phase === 'settling') await settleResult.promise; return result; }, createAdapter: (shared) => { h = harness({ ...shared, dataManager: manager, radioLayer: radio }); return { start: (settings) => h.c.start(settings), stop: (settings) => h.c.stop(settings), sendText: (text) => h.c.sendTextCommand(text), sendMapEvent: (event) => h.c.notifyMapEvent(event), }; }, }); try { await session.start(); const ws = h.sockets[0]; assert.equal(session.sendText('Select Station One on Radio'), true); request(ws, 'prepared-radio', 'control_radio', { action: 'select', stationQuery: 'Station One', }); const actual = await prepared.promise; assert.equal(actual.ok, true); assert.equal(actual.radioPlaybackRequested, true); assert.equal(actual.stationId, 'station-1'); assert.equal(manager.isEnabled('radio'), true); if (phase !== 'settling') { await h.c.toolQueue; assert.ok(h.c.radio.pendingRadioPlaybackResult); } if (phase === 'preflight') { preflight = h.c.radio.startPendingRadioHandoff(); assert.equal(h.c.radio.radioHandoffInFlight, true); assert.equal(playCalls, 1); } // Native call ownership revokes only this deferred playback. No caption // parsing or caption-driven provider policy participates in cancellation. ws.message({ toolCallCancellation: { ids: ['prepared-radio'] } }); if (phase === 'settling') assert.equal(activeOptions.signal.aborted, true); settleResult.resolve(); await h.c.toolQueue; assert.equal(h.c.radio.pendingRadioPlaybackResult, null); assert.equal(h.c.radio.radioHandoffInFlight, false); assert.equal(manager.isEnabled('radio'), true); assert.equal(state.selected.id, 'station-1'); assert.equal(selections, 1); const modelResult = responseFor(ws, 'prepared-radio'); if (phase === 'settling') assert.equal( modelResult, undefined, 'cancelled active call never sends a late result', ); else { assert.equal(modelResult.ok, true); assert.equal(modelResult.stationId, 'station-1'); } const stopsAfterCancellation = stopCalls; assert.equal(stopsAfterCancellation, phase === 'preflight' ? 1 : 0); drainRadioReply(h); playbackReady.resolve(true); await preflight; await tick(); assert.equal( playCalls, phase === 'preflight' ? 1 : 0, 'drain cannot revive cancelled playback', ); assert.equal(h.c.isActive(), true, 'late readiness cannot stop voice'); assert.equal(h.c.radio.radioHandoffInFlight, false); assert.equal(h.c.pendingTools, 0); assert.equal(h.c.tools.size, 0); assert.equal(session.sendText('Play radio'), true); request(ws, 'fresh-radio', 'control_radio', { action: 'play' }); await h.c.toolQueue; assert.ok(h.c.radio.pendingRadioPlaybackResult); drainRadioReply(h); await tick(); assert.equal(playCalls, phase === 'preflight' ? 2 : 1); assert.equal( h.c.isActive(), false, 'fresh typed Radio handoff alone ends voice', ); } finally { settleResult.resolve(); playbackReady.resolve(false); session.destroy(); await preflight; runner.dispose(); await manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); } // A stronger Radio control skips the ordered queue. An older Radio call still // queued behind a slow sibling must not start after that control succeeded, // while a failed control or a later call in the same turn keeps its order. for (const [label, ids, stopOk, expectPending] of [ [ 'older queued select is revoked by a later successful stop', ['slow', 'sel', 'stop'], true, false, ], [ 'a failed stop leaves the older queued select current', ['slow', 'sel', 'stop'], false, true, ], [ 'a select after the stop in the same turn still runs', ['slow', 'stop', 'sel'], true, true, ], ]) { test(`stronger Radio ordering: ${label}`, async () => { const viewer = viewerFixture(); const manager = new DataLayerManager(viewer); const state = { stationCount: 2, selected: { id: 'existing', name: 'Existing' }, audioState: 'stopped', }; const radio = { id: 'radio', name: 'Radio', updateInterval: -1, async init() {}, async enable() {}, async disable() {}, async update() {}, getStats: () => ({ count: 2 }), getUIState: () => ({ ...state }), setVoiceDucked() {}, pause() { state.audioState = 'paused'; return true; }, stopPlayback() { state.audioState = 'stopped'; return stopOk; }, selectRequestedStation() { state.selected = { id: 'late', name: 'Late' }; return state.selected; }, async playForVoice() { return true; }, subscribePlaybackControls: () => () => {}, }; manager.register(radio); const runner = createGevActionRunner({ viewer, dataManager: manager, placeSearch: { async geocode() { return { place: { lat: 10, lng: 20, label: 'Place' } }; }, }, }); const slow = deferred(); const h = harness({ dataManager: manager, radioLayer: radio, async runAction(name, args, options) { if (name === 'slow_sibling') { await slow.promise; return { ok: true, action: 'slow_sibling' }; } if (name === 'control_radio' && args.action === 'stop' && !stopOk) return { ok: false, action: 'control_radio', error: 'Radio stop failed', }; return runner(name, args, options); }, }); const calls = { slow: { id: 'slow', name: 'slow_sibling', args: {} }, sel: { id: 'sel', name: 'control_radio', args: { action: 'select', locationQuery: 'Place' }, }, stop: { id: 'stop', name: 'control_radio', args: { action: 'stop' } }, }; try { await manager.setEnabled('radio', true); await h.c.start(); const ws = h.sockets[0]; speechTurn(ws); ws.message({ toolCall: { functionCalls: ids.map((id) => calls[id]) } }); await tick(); assert.equal( responseFor(ws, 'stop')?.ok, stopOk, 'stop settles without waiting', ); slow.resolve(); await h.c.toolQueue; await tick(); assert.equal( Boolean(h.c.radio.pendingRadioPlaybackResult), expectPending, ); assert.equal(responseFor(ws, 'sel')?.ok, expectPending); } finally { slow.resolve(); h.c.stop(); runner.dispose(); await manager.setEnabled('radio', false); } assert.equal(h.timers.size, 0); }); }