Files
gods-eye-view/src/voice/panel/host.test.mjs
T
manjunath22466 0a8d824124 fix(voice): keep a stopped round's actions and follow-up memory
A reply stopped partway through a round now keeps the actions that ran,
answering the calls it never reached as skipped, so the history stays
paired for both providers. A failed typed turn no longer resets the
pointer, referents and follow-up memory, since its conversation continues.

The server now tells a rejected key (401, or Gemini's 400 with reason
API_KEY_INVALID, matched exactly), denied access (403) and an account out
of credit (OpenAI's 429 insufficient_quota) apart; each cools down for a
minute and gets its own message in the chat.
2026-11-03 22:34:21 +05:30

1223 lines
42 KiB
JavaScript

import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createVoiceControl } from '../control.js';
import { useVoiceStore } from '../../ui/stores/voice.js';
import { createGevActionRunner } from '../gevActions.js';
import { createVoiceCommands as bind } from '../sessionCommands.js';
import { createVoiceCommands as compose } from '../commands.js';
import { createProviderCommands } from '../providerCommands.js';
import { createVoicePanelHost } from './host.js';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
const $ = (selector) => globalThis.document.querySelector(selector);
const exists = (selector) => $(selector) !== null;
const active = () => globalThis.document.activeElement?.id ?? null;
const key = (node, name) =>
node.dispatchEvent(
new KeyboardEvent('keydown', {
key: name,
bubbles: true,
cancelable: true,
}),
);
function controlledClock() {
let now = 0,
next = 0;
const tasks = new Map();
return {
now: () => now,
setTimer(fn, ms) {
const id = ++next;
tasks.set(id, { fn, at: now + ms });
return id;
},
clearTimer: (id) => tasks.delete(id),
advance(ms) {
now += ms;
for (const [id, task] of [...tasks])
if (task.at <= now && tasks.delete(id)) task.fn();
},
};
}
// The custom inactivity minutes, then the select's Custom choice.
function chooseCustomTimeout(ui, minutes) {
ui.update('preferences', { timeoutMinutes: minutes });
ui.emit('timeout-select', 'custom');
}
function factory(instances, { stopError = null, native = false } = {}) {
return (hooks) => {
const input = {
pushToTalkKeyHeld: false,
setMicrophoneEnabled() {},
releasePushToTalkKey() {
this.pushToTalkKeyHeld = false;
},
resetSession() {},
};
const controller = native
? {
input,
_input: input,
_turns: { observeSpeech() {} },
status: 'idle',
dc: null,
sendTextCommand(text) {
if (!text) return false;
hooks.emit({
type: 'pointer',
pointer: { kind: 'here', label: 'Typed target' },
});
},
createAudio(options) {
this.audioCallbacks = options;
return {
pending: false,
play() {},
clearPlayback() {},
close() {},
};
},
handleMessage() {},
userActivityOpen: false,
pendingTools: 0,
isActive: () => true,
}
: {};
const adapter = {
controller,
capabilities: { pushToTalk: true },
starts: 0,
stops: 0,
bindControls() {},
start() {
this.starts++;
if (native) {
hooks.emit({ type: 'state', state: 'connecting' });
controller.audio = controller.createAudio({ pushToTalk: false });
hooks.emit({ type: 'state', state: 'connecting' });
// Native open-mic startup marks presentation pending before speech.
controller.userTurnPending = true;
}
hooks.emit({ type: 'state', state: 'listening' });
},
stop() {
this.stops++;
if (stopError) throw stopError;
},
sendText: () => false,
sendMapEvent() {},
getStartOptions: () => ({ pushToTalk: false }),
hooks,
input,
};
instances.push(adapter);
return adapter;
};
}
// With `view`, the real VoiceControl renders the control from its store.
function mounted(t, options = {}) {
const view = options.view ? mountVoiceView(t) : null;
const pinia = view?.pinia ?? createPinia();
const clock = controlledClock();
const host = createVoicePanelHost({
storage: options.storage ?? null,
clock,
readCockpitState: options.readCockpitState,
});
const instances = [];
const commands = bind({
runner: options.runner ?? (async () => ({ ok: true })),
dataManager: { layers: new Map() },
createSession: factory(instances, options),
createControl: (control) => createTestVoiceUi({ ...control, pinia }),
panelHost: host,
nativePanelProvider: options.native
? (options.nativeProvider ?? 'gemini')
: null,
});
t.after(() => {
try {
commands.session.destroy();
} catch {}
host.destroy();
});
return {
clock,
host,
instances,
commands,
ui: commands.ui,
settle: view?.settle,
};
}
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', async (t) => {
const view = mountVoiceView(t);
const store = useVoiceStore(view.pinia);
const baseline = createVoiceControl({ store });
t.after(() => baseline.remove());
assert.equal(baseline.panel, false);
await view.settle();
assert.equal(exists('#gev-voice-control'), true);
assert.equal(exists('#gev-voice-panel'), false);
const ui = createVoiceControl({ panel: true, store });
t.after(() => ui.remove());
await view.settle();
const panel = $('#gev-voice-panel');
assert.equal(panel !== null, true);
assert.equal(
$('#gev-voice-settings-button').getAttribute('aria-controls'),
panel.id,
);
for (const [tab, pane] of [
['#gev-voice-preview-tab', 'gev-voice-preview-panel'],
['#gev-voice-settings-tab', 'gev-voice-settings-panel'],
]) {
assert.equal($(tab).getAttribute('aria-controls'), pane);
assert.equal(exists(`#${pane}`), true);
}
assert.equal(
$('#gev-voice-preview-panel').contains($('#gev-voice-card')),
true,
);
const tray = $('#gev-voice-control .gev-voice-error-tray');
assert.equal(tray !== null, true);
assert.equal(panel.contains(tray), false);
assert.equal(
$('#gev-voice-control').querySelectorAll('#gev-voice-card').length,
1,
);
});
test('actual binder/card has launcher toggle, Settings tab toggle, focus and disabled Preview', async (t) => {
const { ui, commands, settle } = mounted(t, { view: true });
await settle();
$('#gev-voice-settings-button').click();
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
$('#gev-voice-settings-button').click();
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
await settle();
$('#gev-voice-settings-tab').click();
key($('#gev-voice-settings-tab'), 'End');
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
// Preview has no visible tab, so the Settings tab toggles back to it.
$('#gev-voice-settings-tab').click();
assert.equal(commands.voicePanel.snapshot().view, 'preview');
commands.voicePanel.select('preview');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
final: true,
text: 'A visible reply',
});
await settle();
const dismiss = $('#gev-voice-card-dismiss');
dismiss.focus();
dismiss.click();
await settle();
assert.equal(ui.state.card.visible, false);
assert.equal($('#gev-voice-card').hidden, true);
assert.equal(commands.voicePanel.snapshot().panelOpen, true);
assert.equal(active(), 'gev-voice-preview-tab');
key(globalThis.document.activeElement, 'Escape');
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
commands.voicePanel.select('settings');
ui.emit('preview-change', false);
commands.voicePanel.select('preview');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
final: true,
text: 'A hidden reply',
});
assert.equal(ui.state.card.visible, false);
assert.equal(ui.state.previewEmpty.text, 'Preview is off.');
await settle();
$('#gev-voice-preview-tab').focus();
key(globalThis.document.activeElement, 'Escape');
await settle();
assert.equal(active(), 'gev-chat-button');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
final: true,
text: 'Later reply',
});
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
});
test('actual facade remount keeps failed-save live preferences/view and manual close, with no restore writes or focus theft', async (t) => {
const view = mountVoiceView(t);
const writes = [];
const storage = {
getItem: () => null,
setItem(key, value) {
writes.push([key, value]);
throw Error('blocked');
},
};
const host = createVoicePanelHost({ storage });
const instances = [];
const commands = createProviderCommands(
{
runner: async () => ({}),
dataManager: { layers: new Map() },
supportedProviders: ['openai', 'gemini'],
},
{
bind: (options) =>
bind({
...options,
panelHost: host,
createControl: (control) =>
createTestVoiceUi({ ...control, pinia: view.pinia }),
}),
factories: { openai: factory(instances), gemini: factory(instances) },
storage,
},
);
t.after(() => {
commands.destroy();
host.destroy();
});
const old = commands.ui;
commands.voicePanel.select('settings');
old.emit('preview-change', false);
chooseCustomTimeout(old, '7');
const preferenceWrites = () =>
writes.filter(([key]) => key.startsWith('godsEyeView.voice.')).length;
assert.equal(preferenceWrites(), 2);
await view.settle();
const oldRoot = $('#gev-voice-control');
old.emit('provider-change', 'gemini');
assert.equal(commands.ui !== old, true);
await view.settle();
assert.equal(oldRoot.isConnected, false);
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
assert.deepEqual(commands.getVoicePreferences(), {
previewEnabled: false,
minutes: 7,
previewPersisted: false,
timeoutPersisted: false,
});
assert.equal(commands.ui.state.preferences.previewChecked, false);
assert.equal($('#gev-voice-preview-enabled').checked, false);
assert.match(commands.ui.state.preferences.timeoutNote, /could not be saved/);
assert.equal(preferenceWrites(), 2);
assert.equal(active(), 'gev-voice-provider');
commands.ui.emit('preview-change', true);
commands.voicePanel.close();
await commands.start();
const retired = instances[1];
commands.ui.emit('provider-change', 'openai');
await commands.start();
assert.equal(commands.session.isActive(), true);
retired.hooks.emit({
type: 'transcript',
role: 'assistant',
text: 'Retired output',
final: true,
});
const emit = instances[2].hooks.emit;
emit({
type: 'transcript',
role: 'assistant',
text: 'Current output after remount',
final: true,
});
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
emit({ type: 'interruption', reason: 'user-speech' });
emit({
type: 'transcript',
role: 'assistant',
text: 'Reply after genuine input',
final: true,
});
assert.equal(commands.voicePanel.snapshot().previewVisible, true);
});
test('explicit successful mic Stop closes; failed Stop idle and startup error do not', async (t) => {
const good = mounted(t);
await good.commands.session.start();
good.commands.voicePanel.select('settings');
good.ui.emit('button');
assert.equal(good.commands.voicePanel.snapshot().panelOpen, false);
const error = Error('stop failed');
const bad = mounted(t, { stopError: error });
await bad.commands.session.start();
bad.commands.voicePanel.select('settings');
assert.throws(
() => bad.ui.emit('button'),
(value) => value === error,
);
assert.equal(bad.commands.session.state, 'idle');
assert.equal(bad.commands.voicePanel.snapshot().settingsOpen, true);
bad.instances[0].hooks.emit({
type: 'state',
state: 'error',
detail: 'startup failed',
});
assert.equal(bad.commands.voicePanel.snapshot().settingsOpen, true);
});
test('reentrant manual choice/new start during Stop cannot close the successor choice', async (t) => {
const f = mounted(t);
await f.commands.session.start();
f.commands.voicePanel.select('settings');
const unsubscribe = f.commands.session.subscribe((event) => {
if (event.type === 'state' && event.state === 'idle')
f.commands.voicePanel.select('preview');
});
f.ui.emit('button');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
unsubscribe();
});
test('integrated inactivity rechecks held PTT at expiry, then normal stop closes on later quiet expiry', async (t) => {
const f = mounted(t, { native: true });
await f.commands.session.start();
f.commands.voicePanel.select('settings');
chooseCustomTimeout(f.ui, '1');
f.instances[0].input.pushToTalkKeyHeld = true;
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 0);
f.instances[0].input.releasePushToTalkKey();
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 1);
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
});
test('None cancels integrated app timer and unknown custom hosts never guess expiry', async (t) => {
const native = mounted(t, { native: true });
await native.commands.session.start();
native.ui.emit('timeout-select', '0');
native.clock.advance(600000);
assert.equal(native.instances[0].stops, 0);
assert.equal(native.commands.getVoicePreferences().minutes, 0);
const unknown = mounted(t);
await unknown.commands.session.start();
unknown.clock.advance(600000);
assert.equal(unknown.instances[0].stops, 0);
assert.match(unknown.ui.state.preferences.timeoutStatus, /unavailable/);
});
test('transient native OpenAI response activity does not announce connection unavailability', async (t) => {
const f = mounted(t, { native: true, nativeProvider: 'openai' });
await f.commands.session.start();
chooseCustomTimeout(f.ui, '1');
f.instances[0].controller._turns.observeSpeech({ type: 'response.created' });
assert.equal(f.ui.state.preferences.timeoutStatus, '');
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 0, 'unknown output still prevents expiry');
f.instances[0].controller._turns.observeSpeech({
type: 'output_audio_buffer.cleared',
});
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 1, 'known quiet output permits expiry');
});
test('native factory with custom controller remains uninstrumented despite panel opt-in', (t) => {
let inspected = 0;
const commands = compose({
panel: true,
panelStorage: null,
runner: async () => ({}),
dataManager: { layers: new Map() },
createController: (options) => ({
ui: options.ui,
get _turns() {
inspected++;
throw Error('custom internal getter');
},
start() {},
stop() {},
sendTextCommand() {},
notifyMapEvent() {},
syncCostUi() {},
bindPushToTalkShortcut() {},
toggleVoiceTier() {},
}),
});
t.after(() => commands.session.destroy());
assert.equal(inspected, 0);
assert.equal(commands.getVoicePanelState().activity.known, false);
assert.equal(inspected, 0);
});
test('native connecting phases retain output observation; idle starts before first speech and waits for drain', async (t) => {
const f = mounted(t, { native: true });
await f.commands.session.start();
const adapter = f.instances[0];
const controller = adapter.controller;
assert.equal(
f.commands.getVoicePanelState().inactivity.eligible,
true,
'open microphone presentation-pending is not admitted speech',
);
controller.audio.pending = true;
controller.handleMessage({});
f.clock.advance(300000);
assert.equal(adapter.stops, 0);
assert.equal(f.commands.getVoicePanelState().activity.outputBusy, true);
controller.audio.pending = false;
controller.audioCallbacks.onDrain();
f.clock.advance(300000);
assert.equal(adapter.stops, 1);
});
test('a failed typed turn keeps follow-up memory; a stop or voice error clears it', async (t) => {
let resets = 0;
const runner = async () => ({ ok: true });
runner.resetConversation = () => resets++;
const f = mounted(t, { runner });
const adapter = f.instances[0];
adapter.getStartOptions = () => ({ textOnly: true });
await f.commands.session.start({ textOnly: true });
adapter.hooks.emit({ type: 'state', state: 'error', detail: 'Busy' });
assert.equal(resets, 0, 'the typed conversation continues');
f.commands.session.stop();
assert.equal(resets, 1);
adapter.getStartOptions = () => ({});
await f.commands.session.start();
adapter.hooks.emit({ type: 'state', state: 'error', detail: 'Lost' });
assert.equal(resets, 2);
});
test('the voice inactivity timeout never ends a typed chat session', async (t) => {
const f = mounted(t, { native: true });
const adapter = f.instances[0];
adapter.getStartOptions = () => ({ textOnly: true });
await f.commands.session.start({ textOnly: true });
adapter.controller.handleMessage({});
f.clock.advance(600000);
assert.equal(adapter.stops, 0);
assert.equal(f.commands.session.isActive(), true);
// A quiet voice session started afterwards still times out.
f.commands.session.stop();
const stops = adapter.stops;
adapter.getStartOptions = () => ({});
await f.commands.session.start();
adapter.controller.handleMessage({});
f.clock.advance(600000);
assert.equal(adapter.stops, stops + 1);
});
async function directActions(
t,
{ storage = null, delayExit = false, exposePanel = true } = {},
) {
let f, releaseExit;
const state = { active: true, exits: 0, visual: [] };
const styleManager = {
getCockpitState: () => ({ active: state.active }),
controlCockpit(action) {
assert.equal(action, 'exit');
state.exits++;
state.active = false;
const result = { ok: true, state: { active: false } };
return delayExit
? new Promise((resolve) => {
releaseExit = () => resolve(result);
})
: result;
},
setPanelCollapsed(...args) {
state.visual.push(args);
},
};
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } },
},
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
readVoicePanelOperation: () =>
exposePanel ? f?.commands.captureVoicePanelOperation() : undefined,
});
f = mounted(t, { native: true, runner, storage });
await f.commands.session.start();
return {
...f,
runner,
state,
run: f.instances[0].hooks.runAction,
releaseExit: () => releaseExit(),
};
}
test('actual runner applies exact timeout directly and reports live-unsaved truth, invalid/cancelled attempts stay inert', async (t) => {
const f = await directActions(t);
const result = await f.run('set_voice_inactivity_timeout', { minutes: 7 });
assert.equal(result.applied, true);
assert.equal(result.persisted, false);
assert.equal(result.minutes, 7);
assert.match(result.say, /could not be saved/);
assert.equal(f.commands.getVoicePreferences().minutes, 7);
const invalid = await f.run('set_voice_inactivity_timeout', { minutes: 7.5 });
assert.equal(invalid.applied, false);
assert.equal(f.commands.getVoicePreferences().minutes, 7);
const abort = new AbortController();
abort.abort();
const cancelled = await f.runner(
'set_voice_inactivity_timeout',
{ minutes: 2 },
{ signal: abort.signal },
);
assert.equal(cancelled.cancelled, true);
assert.equal(f.commands.getVoicePreferences().minutes, 7);
const operation = f.commands.captureVoicePanelOperation();
f.commands.session.stop();
assert.equal(operation.setMinutes(2).cancelled, true);
});
test('named voice settings exits Cockpit directly; Preview stays disabled and visual presets stay separate from Scenes', async (t) => {
const f = await directActions(t);
const result = await f.run('set_panel_open', {
panelId: 'voice-settings',
open: true,
});
assert.equal(f.state.exits, 1);
assert.equal(result.settingsOpen, true);
f.ui.emit('preview-change', false);
const preview = await f.run('set_panel_open', {
panelId: 'voice control',
open: true,
});
assert.equal(preview.view, 'preview');
assert.equal(preview.previewEnabled, false);
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
for (const panelId of [
'visual styles',
'visual preset',
'visual presets',
'scenes',
])
await f.run('set_panel_open', { panelId, open: true });
assert.equal(f.state.exits, 1);
assert.deepEqual(
f.state.visual.map(([id]) => id),
['control-panel', 'control-panel', 'control-panel', 'scene-panel'],
);
await assert.rejects(
() => f.run('set_panel_open', { panelId: 'settings', open: true }),
/Unknown panel: settings/,
);
assert.equal(
f.state.visual.length,
4,
'an unnamed settings target never guesses a panel',
);
});
test('canonical exit reveals requested voice UI; newer manual choice or Stop defeats pending reveal', async (t) => {
const f = await directActions(t, { delayExit: true });
const pending = f.run('control_cockpit', {
action: 'exit',
voicePanel: 'settings',
});
f.commands.voicePanel.select('preview');
f.releaseExit();
const result = await pending;
assert.equal(result.cancelled, true);
assert.equal(f.state.active, false);
assert.equal(result.exit.ok, true);
assert.equal(result.cockpitExited, true);
assert.match(result.say, /Cockpit exited/);
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
f.state.active = true;
const alias = f.run('control_cockpit', {
action: 'leave cockpit',
voicePanel: 'settings',
});
f.releaseExit();
assert.equal((await alias).settingsOpen, true);
f.commands.voicePanel.select('preview');
f.state.active = true;
const stopped = f.run('control_cockpit', {
action: 'quit cockpit',
voicePanel: 'settings',
});
f.commands.session.stop();
f.releaseExit();
await assert.rejects(stopped, { name: 'AbortError' });
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
});
test('unavailable host refuses direct voice operations before Cockpit or preference effects', async (t) => {
const f = await directActions(t, { exposePanel: false });
const timeout = await f.run('set_voice_inactivity_timeout', { minutes: 1 });
assert.equal(timeout.unavailable, true);
assert.equal(timeout.applied, false);
const panel = await f.run('set_panel_open', {
panelId: 'voice-settings',
open: true,
});
assert.equal(panel.unavailable, true);
assert.equal(f.state.exits, 0);
assert.equal(f.commands.getVoicePreferences().minutes, 5);
});
test('voice timeout change preserves a pending voice reveal', async (t) => {
const f = await directActions(t, { delayExit: true });
const pending = f.run('control_cockpit', {
action: 'exit',
voicePanel: 'settings',
});
const timeout = await f.run('set_voice_inactivity_timeout', { minutes: 0 });
assert.equal(timeout.minutes, 0);
assert.equal(timeout.applied, true);
f.releaseExit();
const result = await pending;
assert.equal(result.settingsOpen, true);
assert.equal(f.commands.getVoicePreferences().minutes, 0);
});
test('active provider output reveals Preview after done; manual choice wins until genuine input, replays do not reset idle', async (t) => {
const f = mounted(t, { native: true, nativeProvider: 'openai', view: true });
await f.commands.session.start();
const emit = f.instances[0].hooks.emit;
const reply = (text, responseId = 'r1', extra = {}) =>
emit({
type: 'transcript',
role: 'assistant',
text,
responseId,
final: true,
...extra,
});
emit({ type: 'completion', responseId: 'r1' });
reply('Late answer');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
const timer = f.commands.voiceCard.hideTimer;
reply('Late answer');
reply('Late', 'r1', { final: false });
assert.equal(f.commands.voiceCard.hideTimer, timer);
assert.equal(f.commands.voiceCard.state.assistant, 'Late answer');
f.commands.voicePanel.close();
emit({
type: 'transcript',
role: 'user',
text: 'advisory caption',
final: false,
});
f.commands.voiceCard.handle({ type: 'dismiss' });
const advisory = f.commands.voiceCard.state.user;
emit({
type: 'interruption',
reason: 'user-speech',
preserveAdvisoryCaption: true,
});
assert.equal(f.commands.voiceCard.state.dismissed, true);
assert.equal(f.commands.voiceCard.state.user, advisory);
reply('Older reply', 'r2');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
emit({ type: 'interruption', reason: 'user-speech' });
reply('A reply with no newest-request attribution', 'older-id');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
f.commands.voicePanel.select('settings');
reply('Another reply', 'r3');
assert.equal(f.commands.voicePanel.snapshot().settingsOpen, true);
f.commands.voicePanel.close();
f.commands.voiceCard.handle({ type: 'dismiss' });
f.commands.sendTextCommand('Show the typed target');
reply('Typed reply', 'typed-id');
assert.equal(f.commands.voicePanel.snapshot().previewVisible, true);
assert.equal(f.commands.voiceCard.state.dismissed, false);
assert.equal(f.commands.voiceCard.state.pointer.label, 'Typed target');
f.commands.session.stop();
reply('Stopped stale output', 'r4');
assert.equal(f.commands.voiceCard.state.assistant, null);
f.commands.session.destroy();
reply('Disposed stale output', 'r5');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
});
test('Preview off and actual Cockpit state suppress automatic presentation without enabling preference', async (t) => {
let cockpit = false;
const f = mounted(t, {
native: true,
view: true,
readCockpitState: () => ({ active: cockpit }),
});
await f.commands.session.start();
const emit = f.instances[0].hooks.emit;
const reply = (text) =>
emit({ type: 'transcript', role: 'assistant', text, final: true });
reply('Visible');
await f.settle();
$('#gev-voice-preview-tab').focus();
cockpit = true;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.state.voicePanel.hidden, true);
await f.settle();
assert.equal(active(), 'gev-chat-button');
f.commands.voicePanel.select('settings', { focus: true });
await f.settle();
assert.equal(active(), 'gev-voice-settings-tab');
f.commands.voicePanel.select('preview', { focus: true });
await f.settle();
assert.equal(active(), 'gev-chat-button');
const outside = document.createElement('button');
outside.id = 'unrelated';
document.body.appendChild(outside);
outside.focus();
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
await f.settle();
assert.equal(active(), 'unrelated');
assert.equal(f.commands.voicePanel.snapshot().previewVisible, false);
reply('Suppressed in Cockpit');
assert.equal(f.ui.state.voicePanel.hidden, true);
f.commands.voicePanel.close();
cockpit = false;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.state.voicePanel.hidden, true);
f.ui.emit('preview-change', false);
emit({ type: 'interruption', reason: 'user-speech' });
reply('Preference stays off');
assert.equal(f.ui.state.voicePanel.hidden, true);
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
});
test('Provider Settings uses its ready dialog handlers and refuses unavailable or cancelled requests', async (t) => {
const f = await directActions(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
const request = (open, options) =>
f.run('set_panel_open', { panelId: 'provider-settings', open }, options);
assert.equal((await request(true)).unavailable, true);
const doc = globalThis.document;
// The setup owner needs only its dialog element; the view is not mounted,
// so the published dialog state stands in for it.
const panel = doc.createElement('aside');
panel.id = 'key-setup';
doc.body.append(panel);
const dialog = () => settings.view?.dialog;
let frames = 0;
const previousFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => {
frames++;
callback();
};
t.after(() => {
if (previousFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = previousFrame;
});
const { initKeySetup } = await import('../../keySetup.js');
const { useProviderSettingsStore } =
await import('../../ui/stores/providerSettings.js');
const { applicationPinia } = await import('../../ui/vueRuntime.js');
const settings = useProviderSettingsStore(applicationPinia());
let finishStatus;
const pending = initKeySetup({
documentRef: doc,
fetchImpl: () =>
new Promise((resolve) => {
finishStatus = resolve;
}),
});
assert.equal(
(await request(true)).unavailable,
true,
'initializing is not ready',
);
finishStatus({
ok: true,
json: async () => ({ keys: [], setCount: 0, total: 1 }),
});
const setup = await pending;
t.after(() => setup.destroy());
doc.body.classList.add('cockpit-mode');
assert.equal((await request(true)).unavailable, true);
assert.equal(
dialog().hidden,
true,
'a concealed view cannot schedule a later surprise reveal',
);
doc.body.classList.remove('cockpit-mode');
const abort = new AbortController();
abort.abort();
await assert.rejects(() => request(true, { signal: abort.signal }), {
name: 'AbortError',
});
assert.equal(dialog().hidden, true);
assert.equal((await request(true)).ok, true);
assert.equal(dialog().hidden, false);
assert.equal(dialog().visible, true);
assert.equal((await request(true)).ok, true);
assert.equal(frames, 1, 'repeated open reuses the dialog');
assert.equal((await request(false)).ok, true);
assert.equal(dialog().visible, false);
t.mock.timers.tick(400);
assert.equal(dialog().hidden, true);
assert.equal((await request(false)).ok, true);
assert.equal(
f.state.visual.length,
0,
'provider setup never opens a visual or scene panel',
);
});
// Typed chat, rendered by the real VoiceControl.
const typedChat = (f) => {
f.instances[0].getStartOptions = () => ({ textOnly: true });
f.instances[0].sendText = () => true;
};
const type = (text) => {
const input = $('#gev-chat-input');
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
};
test('the chat button opens, minimizes and closes chat', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
$('#gev-chat-button').click();
await f.settle();
assert.equal($('#gev-chat-panel').hidden, false);
assert.equal($('#gev-voice-control').dataset.assistantMode, 'chat');
assert.equal(active(), 'gev-chat-input');
$('#gev-chat-button').click();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
$('#gev-chat-restore').click();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
$('#gev-chat-close').click();
await f.settle();
assert.equal($('#gev-chat-panel').hidden, true);
assert.equal($('#gev-voice-control').dataset.assistantMode, 'idle');
});
test('a typed message sends, shows the reply and is recalled with Up', async (t) => {
const f = mounted(t, { view: true });
typedChat(f);
const reduce = globalThis.matchMedia;
globalThis.matchMedia = () => ({ matches: true });
t.after(() => (globalThis.matchMedia = reduce));
await f.settle();
$('#gev-chat-button').click();
await f.settle();
type('Fly to Tokyo');
$('#gev-chat-form').dispatchEvent(new Event('submit', { cancelable: true }));
await f.commands.session.start({ textOnly: true });
for (let i = 0; i < 5; i++) await f.settle();
const messages = () =>
[...globalThis.document.querySelectorAll('.gev-chat-message')].map(
(row) => `${row.dataset.role}:${row.querySelector('span').textContent}`,
);
assert.deepEqual(messages(), ['user:Fly to Tokyo']);
assert.equal($('#gev-chat-input').value, '');
f.instances[0].hooks.emit({
type: 'transcript',
role: 'assistant',
text: 'Flying to Tokyo.',
final: true,
});
await f.settle();
assert.deepEqual(messages(), [
'user:Fly to Tokyo',
'assistant:Flying to Tokyo.',
]);
assert.equal(f.ui.state.voicePanel.hidden, true);
key($('#gev-chat-input'), 'ArrowUp');
await f.settle();
assert.equal($('#gev-chat-input').value, 'Fly to Tokyo');
});
test('chat Settings keep chat context until the panel closes', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
$('#gev-chat-button').click();
await f.settle();
$('#gev-voice-settings-button').click();
await f.settle();
assert.equal(f.ui.state.voicePanel.context, 'chat');
assert.equal($('#gev-chat-panel').hidden, true);
assert.equal($('#gev-voice-control').dataset.assistantMode, 'chat');
assert.equal(active(), 'gev-chat-back');
key($('#gev-chat-back'), 'Escape');
await f.settle();
assert.equal(f.ui.state.voicePanel.context, null);
assert.equal($('#gev-chat-panel').hidden, false);
$('#gev-voice-settings-button').click();
await f.settle();
$('#gev-chat-back').click();
await f.settle();
assert.equal($('#gev-chat-panel').hidden, false);
assert.equal(f.ui.state.chat.backVisible, false);
});
test('chat Settings keep only the quick commands preference', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
for (const id of [
'typewriter',
'text-color',
'text-size',
'autocomplete',
'recent',
])
assert.equal(exists(`#gev-chat-${id}`), false);
assert.equal(exists('#gev-panel-default-position'), false);
assert.equal(exists('#gev-chat-quick'), true);
});
test('voice Settings step back to Preview with Back and Escape', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
f.commands.voicePanel.select('settings', { focus: true });
await f.settle();
assert.equal(active(), 'gev-voice-settings-tab');
$('#gev-voice-back').click();
await f.settle();
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
f.commands.voicePanel.select('settings');
key($('#gev-voice-panel'), 'Escape');
await f.settle();
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
key($('#gev-voice-panel'), 'Escape');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
});
test('Escape does not minimize chat', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
$('#gev-chat-button').click();
await f.settle();
key($('#gev-chat-input'), 'Escape');
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
assert.equal($('#gev-chat-panel').hidden, false);
});
function dockWing(t) {
const doc = globalThis.document;
const wing = doc.createElement('div');
wing.id = 'control-panel';
wing.className = 'panel-collapsible collapsed';
const toggle = doc.createElement('button');
toggle.className = 'dock-tray-toggle';
const tray = doc.createElement('div');
tray.className = 'dock-popover-content';
wing.append(toggle, tray);
doc.body.append(wing);
t.after(() => wing.remove());
return {
wing,
toggle,
open: () => wing.classList.remove('collapsed'),
close: () => wing.classList.add('collapsed'),
};
}
test('reaching for a dock wing minimizes a centred chat or voice panel', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
// The dock can mount after the layouts bind; they find it on use.
const wing = dockWing(t);
$('#gev-chat-button').click();
await f.settle();
wing.toggle.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
// Leaving the wing with no tray open ends the yield.
wing.wing.dispatchEvent(new PointerEvent('pointerleave'));
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
$('#gev-chat-button').click();
await f.settle();
$('#gev-chat-button').click();
await f.settle();
f.commands.voicePanel.select('preview');
await f.settle();
assert.equal($('#gev-voice-panel').dataset.minimized, undefined);
wing.toggle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await f.settle();
assert.equal($('#gev-voice-panel').dataset.minimized, 'true');
});
test('expanding a panel over an open tray docks it right without saving', async (t) => {
const f = mounted(t, { view: true });
// The view mount resets the body; the layouts bind on the next tick.
const wing = dockWing(t);
await f.settle();
$('#gev-chat-button').click();
await f.settle();
wing.toggle.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
wing.open();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
$('#gev-chat-restore').click();
await f.settle();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
assert.equal($('#gev-chat-panel').dataset.placement, 'docked');
assert.notEqual(globalThis.localStorage.getItem('gev.panel.docked'), 'true');
// The dock was only for the tray; it ends once the tray closes.
wing.close();
await f.settle();
await f.settle();
assert.equal(f.ui.state.layout.docked, false);
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
});
test('yielding to a dock tray leaves keyboard focus where it is', async (t) => {
const f = mounted(t, { view: true });
const wing = dockWing(t);
const field = globalThis.document.createElement('input');
globalThis.document.body.append(field);
t.after(() => field.remove());
await f.settle();
$('#gev-chat-button').click();
await f.settle();
for (const target of [$('#gev-chat-input'), field]) {
target.focus();
wing.toggle.dispatchEvent(
new PointerEvent('pointerover', { bubbles: true }),
);
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
assert.equal(globalThis.document.activeElement, target);
wing.wing.dispatchEvent(new PointerEvent('pointerleave'));
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
assert.equal(globalThis.document.activeElement, target);
}
});
test('a panel that yielded to a tray expands in the centre once it closes', async (t) => {
const f = mounted(t, { view: true });
const wing = dockWing(t);
await f.settle();
$('#gev-chat-button').click();
await f.settle();
wing.toggle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
wing.open();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
wing.close();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
assert.equal(f.ui.state.layout.yielded, null);
});
test('switching to voice while chat yields to a tray starts voice as its bar', async (t) => {
const f = mounted(t, { view: true });
const wing = dockWing(t);
await f.settle();
$('#gev-chat-button').click();
await f.settle();
wing.toggle.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
await f.settle();
$('#gev-chat-button').click();
await f.settle();
f.commands.voicePanel.select('preview');
await f.settle();
assert.equal($('#gev-voice-panel').dataset.minimized, 'true');
assert.equal(f.ui.state.layout.yielded, 'voicePanel');
});
test('undocking while a tray is open brings the panel back as its bar', async (t) => {
const f = mounted(t, { view: true });
const wing = dockWing(t);
await f.settle();
$('#gev-chat-button').click();
await f.settle();
$('#gev-chat-panel [data-layout-dock]').click();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.placement, 'docked');
wing.open();
await f.settle();
$('#gev-chat-panel [data-layout-dock]').click();
await f.settle();
await f.settle();
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
});
test('the voice panel close turns voice off', async (t) => {
const f = mounted(t, { view: true });
await f.commands.session.start();
f.commands.voicePanel.select('preview');
await f.settle();
$('#gev-voice-settings-close').click();
assert.equal(f.commands.session.isActive(), false);
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
});
test('closing chat Settings ends a typed session', async (t) => {
const f = mounted(t, { view: true });
typedChat(f);
await f.settle();
$('#gev-chat-button').click();
await f.commands.session.start({ textOnly: true });
await f.settle();
$('#gev-voice-settings-button').click();
await f.settle();
$('#gev-voice-settings-close').click();
await f.settle();
assert.equal(f.commands.session.isActive(), false);
assert.equal($('#gev-chat-panel').hidden, true);
});
test('opening chat stops a live voice session', async (t) => {
const f = mounted(t, { view: true });
await f.commands.session.start();
await f.settle();
assert.equal(f.commands.session.isActive(), true);
$('#gev-chat-button').click();
await f.settle();
assert.equal(f.commands.session.isActive(), false);
assert.equal($('#gev-chat-panel').hidden, false);
});
test('the mic button closes chat and minimize collapses voice to a bar', async (t) => {
const f = mounted(t, { view: true });
await f.settle();
$('#gev-chat-button').click();
await f.settle();
$('#gev-voice-button').click();
await f.settle();
assert.equal($('#gev-chat-panel').hidden, true);
assert.equal(f.commands.session.isActive(), true);
f.commands.voicePanel.select('preview');
await f.settle();
$('#gev-voice-panel [data-layout-minimize]').click();
await f.settle();
assert.equal($('#gev-voice-panel').dataset.minimized, 'true');
f.commands.voicePanel.select('preview');
await f.settle();
assert.equal($('#gev-voice-panel').dataset.minimized, undefined);
});
test('teardown inside sendText prevents appending the submitted message', async (t) => {
const f = mounted(t, { view: true });
f.instances[0].getStartOptions = () => ({ textOnly: true });
f.instances[0].sendText = () => {
f.commands.session.destroy();
return true;
};
await f.settle();
$('#gev-chat-button').click();
await f.settle();
type('Hello');
$('#gev-chat-form').dispatchEvent(new Event('submit', { cancelable: true }));
await f.commands.session.start({ textOnly: true });
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(
globalThis.document.querySelectorAll('.gev-chat-message').length,
0,
);
});