Merge PR #1299 with current main for validation

This commit is contained in:
Sameh Khamis committed 2026-10-26 08:39:16 -07:00
commit aeee3652ce
39 files changed
+5274 -111

No files matched your search

+13
View File
@@ -178,6 +178,9 @@ const schemas = [
'global-context-panel',
'weather-panel',
'recent-imagery-panel',
'voice-settings',
'voice-preview',
'provider-settings',
],
},
open: {
@@ -187,6 +190,15 @@ const schemas = [
required: ['panelId', 'open'],
},
},
{
name: 'set_voice_inactivity_timeout',
parameters: {
type: 'object',
additionalProperties: false,
properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } },
required: ['minutes'],
},
},
{
name: 'set_context_mode',
parameters: {
@@ -223,6 +235,7 @@ const schemas = [
aircraftClass: {
type: 'string',
},
voicePanel: { type: 'string', enum: ['preview', 'settings'] },
},
required: ['action'],
},
+30 -5
View File
@@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
.digest('hex');
assert.equal(
digest,
// Re-derived for the voice layer manifest (generated layer enums and
// Re-derived for Provider Settings and explicit Visual Presets/Scenes routing,
// plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and
// aliases), point-and-ask (pointer sentinels, referent args) and the
// consolidated tool wording (each policy stated once); model-facing tools
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
@@ -41,8 +42,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Air quality (ECCC AQHI) extends the manifest.
// Globe rotation adds one motion; visual keyhole fit and explicit
// reset guidance/defaults update only move_camera and mode descriptions.
// Restore base descriptions; append only globe-rotation defaults and fit guidance.
'967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6',
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
// is consolidated without changing either feature's arguments.
'b2cd2decb77fb3f8f0ab65e7907c221580e21ee3e7e77581f1b52bf62126ae86',
);
});
@@ -100,12 +102,18 @@ test('metadata cannot add tools, fields, types or enum values', () => {
test('all legacy action arguments are byte-identical after removing the deliberate additions', () => {
const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter(
(tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name),
(tool) =>
![
'next_satellite_pass',
'set_cyber_sonar',
'set_voice_inactivity_timeout',
].includes(tool.name),
);
// Layer enums are generated from the voice layer manifest and pinned by
// layerManifest.test.mjs; the two shipped right-rail panels are additive.
const property = (name) =>
legacy.find((tool) => tool.name === name).parameters.properties;
delete property('control_cockpit').voicePanel;
delete property('set_layer_visibility').layerId.enum;
delete property('show_data_layers_menu').layerId.enum;
delete property('get_entity_context').layerId.enum;
@@ -123,7 +131,14 @@ test('all legacy action arguments are byte-identical after removing the delibera
motion.enum = motion.enum.filter((value) => value !== 'rotate_globe');
const panels = property('set_panel_open').panelId;
panels.enum = panels.enum.filter(
(id) => !['weather-panel', 'recent-imagery-panel'].includes(id),
(id) =>
![
'weather-panel',
'recent-imagery-panel',
'voice-settings',
'voice-preview',
'provider-settings',
].includes(id),
);
for (const tool of legacy) {
// Point-and-ask adds `referent` arguments and 'pointer' enum values.
@@ -228,3 +243,13 @@ test('globe reset guidance requires a separate explicit command after refusal',
assert.match(paragraph, /bare yes\/ok/);
assert.match(paragraph, /Do not ask a question/);
});
test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => {
const tool = GEV_REALTIME_TOOLS.find(
(tool) => tool.name === 'set_voice_inactivity_timeout',
);
for (const minutes of [0, 1, 5, 60])
assert.equal(validateArgs(tool.parameters, { minutes }).length, 0);
for (const minutes of [-1, 61, 1.5, '5'])
assert.ok(validateArgs(tool.parameters, { minutes }).length > 0);
});
+39 -6
View File
@@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js';
import { createRealtimeSession } from './realtimeSession.js';
import { createGeminiSession } from './geminiSession.js';
import { createProviderCommands } from './providerCommands.js';
import { createVoicePanelHost } from './panel/host.js';
/** Default composition; callers may supply another session adapter factory. */
export function createVoiceCommands(options = {}) {
const panelHost =
options.panel === true
? createVoicePanelHost({
storage: options.panelStorage,
clock: options.panelClock,
readCockpitState: options.styleManager?.getCockpitState
? () => options.styleManager.getCockpitState()
: undefined,
})
: null;
const bind = (settings) =>
bindVoiceCommands({
...settings,
...(panelHost
? {
panelHost,
nativePanelProvider: settings.createController
? null
: settings.createSession === createRealtimeSession
? 'openai'
: settings.createSession === createGeminiSession
? 'gemini'
: null,
}
: {}),
});
let commands;
// Generic hosts retain the existing default; standalone opts in explicitly.
if (!options.createSession && options.supportedProviders !== undefined)
return createProviderCommands(options, {
bind: bindVoiceCommands,
commands = createProviderCommands(options, {
bind,
factories: { openai: createRealtimeSession, gemini: createGeminiSession },
});
return bindVoiceCommands({
createSession: createRealtimeSession,
...options,
});
else commands = bind({ createSession: createRealtimeSession, ...options });
if (panelHost) {
const lifetime = commands.signal ?? commands.session?.signal;
lifetime?.addEventListener('abort', () => panelHost.destroy(), {
once: true,
});
if (lifetime?.aborted) panelHost.destroy();
}
return commands;
}
+73 -4
View File
@@ -27,7 +27,7 @@ export function voiceStatusLabel(status) {
}
/** Build the voice control independently of its connection backend. */
export function createVoiceControl({ reset = false } = {}) {
export function createVoiceControl({ reset = false, panel = false } = {}) {
let root = document.getElementById('gev-voice-control');
if (root && reset) {
root.remove();
@@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) {
<span>${t('Voice provider')}</span>
<select id="gev-voice-provider" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
</label>
${panel ? `<button id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" aria-label="${t('Voice Settings')}" aria-controls="gev-voice-panel" aria-expanded="false"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>` : ''}
<div class="gev-voice-heading">
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${voiceStatusLabel('idle')}</div>
<div class="gev-voice-cost">
${
panel
? ''
: `<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>`
}
</div>
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
@@ -65,13 +70,29 @@ export function createVoiceControl({ reset = false } = {}) {
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
</div>
${
panel
? `<section id="gev-voice-panel" class="gev-voice-panel" hidden aria-label="${t('Voice Settings')}">
<div class="gev-voice-panel-header">
<span class="gev-voice-panel-title">${t('VOICE')}</span>
<div class="gev-voice-panel-tabs" role="tablist" aria-label="${t('Voice Settings')}">
<button id="gev-voice-preview-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Preview')}" title="${t('Preview')}" aria-selected="true" aria-controls="gev-voice-preview-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">visibility</span></button>
<button id="gev-voice-settings-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Settings')}" title="${t('Settings')}" aria-selected="false" tabindex="-1" aria-controls="gev-voice-settings-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">settings</span></button>
</div>
<button id="gev-voice-settings-close" type="button" aria-label="${t('Close voice panel')}">×</button>
</div>
<div class="gev-voice-panel-body">
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab">
<p id="gev-voice-preview-empty">${t('Voice replies appear here.')}</p>`
: ''
}
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="${t('Voice assistant')}">
<div class="gev-voice-card-header">
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="${t('Dismiss voice card')}">×</button>
</div>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span id="gev-voice-card-user-who" class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">${t('THIS')}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">${ASSISTANT_NAME}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="${t('Plan')}" hidden></ol>
@@ -87,6 +108,40 @@ export function createVoiceControl({ reset = false } = {}) {
</div>
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true"></div>
</section>
${
panel
? `</section>
<section id="gev-voice-settings-panel" role="tabpanel" aria-labelledby="gev-voice-settings-tab" hidden>
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" checked aria-describedby="gev-voice-preview-note" /><span>${t('Show Voice Preview')}</span></label>
<p id="gev-voice-preview-note" class="gev-voice-settings-note"></p>
<div class="gev-voice-timeout-group">
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">${t('Voice inactivity timeout')}</label>
<div class="gev-voice-timeout-controls">
<select id="gev-voice-timeout-select" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status">
<option value="3">3 ${t('Minutes')}</option><option value="5" selected>5 ${t('Minutes')}</option><option value="10">10 ${t('Minutes')}</option><option value="15">15 ${t('Minutes')}</option>
<option value="custom">${t('Custom')}</option><option value="0">${t('None')}</option>
</select>
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">${t('Minutes')}</label>
<input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" value="5" aria-describedby="gev-voice-timeout-note" />
</div>
<p id="gev-voice-timeout-note" class="gev-voice-settings-note">${t('These preferences stay in this browser.')}</p>
<p id="gev-voice-timeout-status" class="gev-voice-settings-note" role="status" aria-live="polite"></p>
</div>
<div class="gev-voice-cost-settings">
<div class="gev-voice-cost-setting-row">
<span>${t('Voice model tier — applies next session')}</span>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
</div>
<div class="gev-voice-cost-setting-row">
<span>${t('Estimated session cost')}</span>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>
</section>
</div>
</section>`
: ''
}
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
<span>${t('VOICE SYSTEM ERROR')}</span>
@@ -114,6 +169,20 @@ export function createVoiceControl({ reset = false } = {}) {
}
return {
root,
voicePanel: root.querySelector('#gev-voice-panel'),
previewPanel: root.querySelector('#gev-voice-preview-panel'),
voiceSettingsPanel: root.querySelector('#gev-voice-settings-panel'),
previewTab: root.querySelector('#gev-voice-preview-tab'),
settingsTab: root.querySelector('#gev-voice-settings-tab'),
voiceSettingsButton: root.querySelector('#gev-voice-settings-button'),
voiceSettingsClose: root.querySelector('#gev-voice-settings-close'),
previewEmpty: root.querySelector('#gev-voice-preview-empty'),
voicePreviewInput: root.querySelector('#gev-voice-preview-enabled'),
voicePreviewNote: root.querySelector('#gev-voice-preview-note'),
voiceTimeoutSelect: root.querySelector('#gev-voice-timeout-select'),
voiceTimeoutInput: root.querySelector('#gev-voice-timeout-input'),
voiceTimeoutNote: root.querySelector('#gev-voice-timeout-note'),
voiceTimeoutStatus: root.querySelector('#gev-voice-timeout-status'),
providerField: root.querySelector('#gev-voice-provider-field'),
providerSelect: root.querySelector('#gev-voice-provider'),
button: root.querySelector('#gev-voice-button'),
+188
View File
@@ -74,8 +74,17 @@ const PANEL_ALIASES = new Map([
['styles', 'control-panel'],
['filters', 'control-panel'],
['visual styles', 'control-panel'],
['visual preset', 'control-panel'],
['visual presets', 'control-panel'],
['provider settings', 'provider-settings'],
['cctv', 'cctv-panel'],
['cameras', 'cctv-panel'],
['voice settings', 'voice-settings'],
['voice-settings', 'voice-settings'],
['voice preview', 'voice-preview'],
['voice-preview', 'voice-preview'],
['voice control', 'voice-preview'],
['voice controls', 'voice-preview'],
['radio', 'radio-panel'],
['internet radio', 'radio-panel'],
['radio stations', 'radio-panel'],
@@ -110,6 +119,7 @@ const PANEL_IDS = new Set([
'pp-toggles',
'weather-panel',
'recent-imagery-panel',
'provider-settings',
]);
const CONTEXT_MODE_ALIASES = new Map([
['off', 'off'],
@@ -243,6 +253,7 @@ export function createGevActionRunner({
searchNavigation = searchAndFlyTo,
speechBuilders = null,
deixis = null,
readVoicePanelOperation = null,
}) {
// Voice enable times and analyst follow-up memory belong to this runner.
// Speech builders turn results into say/display envelopes; callers extend
@@ -268,6 +279,81 @@ export function createGevActionRunner({
});
// Nested calls (select_nearest_aircraft composing layer + flight) use the
// raw runner; only the outer call gets a voice envelope.
const voiceUnavailable = (action) => ({
ok: false,
action,
applied: false,
unavailable: true,
error: 'Voice panel controls are unavailable in this host.',
say: 'Voice panel controls are unavailable in this host.',
});
async function showVoicePanel(
view,
open,
options,
operation = readVoicePanelOperation?.(),
) {
const current = () =>
!options.signal?.aborted &&
(!options.isCurrent || options.isCurrent()) &&
operation?.isCurrent();
if (!operation) return voiceUnavailable('set_panel_open');
if (!current())
return {
ok: false,
action: 'set_panel_open',
applied: false,
cancelled: true,
};
let exit = null;
if (open) {
if (typeof styleManager?.getCockpitState !== 'function')
return voiceUnavailable('set_panel_open');
if (styleManager.getCockpitState()?.active) {
if (typeof styleManager.controlCockpit !== 'function')
return voiceUnavailable('set_panel_open');
exit = await styleManager.controlCockpit('exit');
if (styleManager.getCockpitState()?.active)
return {
ok: false,
action: 'set_panel_open',
applied: false,
exit,
error: exit?.error || 'Cockpit did not exit.',
};
}
}
if (!current())
return {
ok: false,
action: 'set_panel_open',
applied: false,
cancelled: true,
exit,
cockpitExited: Boolean(exit && !styleManager.getCockpitState()?.active),
say:
exit && !styleManager.getCockpitState()?.active
? 'Cockpit exited; the voice panel was left as you last set it.'
: 'The voice panel was left as you last set it.',
};
const result = operation.show(view, open);
return {
...result,
action: 'set_panel_open',
panelId: view === 'settings' ? 'voice-settings' : 'voice-preview',
exit,
state: styleManager?.getCockpitState?.() ?? null,
say: !result.ok
? 'The voice panel was not changed.'
: result.settingsOpen
? 'Voice Settings is open.'
: result.panelOpen
? result.previewEnabled
? 'Voice Preview is open.'
: 'Voice control is open; Preview remains off.'
: 'The voice panel is closed.',
};
}
const runGevAction = async function runGevAction(
name,
rawArgs = {},
@@ -639,6 +725,25 @@ export function createGevActionRunner({
return { ok: true, action: 'set_visual_style', style };
}
if (name === 'set_voice_inactivity_timeout') {
const operation = readVoicePanelOperation?.();
if (!operation) return voiceUnavailable(name);
if (!current() || !operation.isCurrent())
return { ok: false, action: name, applied: false, cancelled: true };
const result = operation.setMinutes(args.minutes);
const duration =
result.minutes === 0 ? 'disabled' : `${result.minutes} minutes`;
return {
...result,
action: name,
say: !result.applied
? 'Voice inactivity was not changed.'
: result.persisted
? `Voice inactivity is ${duration}, saved in this browser.`
: `Voice inactivity is ${duration} for now, but could not be saved in this browser.`,
};
}
if (name === 'set_panel_open') {
const panelId = normalizePanelId(args.panelId || args.panel);
if (!panelId)
@@ -646,6 +751,70 @@ export function createGevActionRunner({
`Unknown panel: ${args.panelId || args.panel || 'missing'}`,
);
const open = args.open !== false;
if (panelId === 'voice-settings' || panelId === 'voice-preview')
return showVoicePanel(
panelId === 'voice-settings' ? 'settings' : 'preview',
open,
runOptions,
);
if (panelId === 'provider-settings') {
if (!current())
return {
ok: false,
action: name,
panelId,
applied: false,
cancelled: true,
};
const doc = globalThis.document;
const panel = doc?.getElementById?.('key-setup');
const button = open
? doc?.getElementById?.('key-setup-chip')
: panel?.querySelector?.('[data-key-setup-close]');
const concealed =
open &&
[
'ui-clean-view',
'recording-mode',
'cockpit-mode',
'scene-playback-mode',
].some((mode) => doc?.body?.classList?.contains(mode));
if (
concealed ||
panel?.dataset?.ready !== 'true' ||
typeof button?.click !== 'function'
) {
const error = concealed
? 'Provider Settings is hidden in the current view. Leave that view before opening it.'
: 'Provider Settings is unavailable in this runtime.';
return {
ok: false,
action: name,
panelId,
applied: false,
unavailable: true,
error,
say: error,
};
}
button.click();
// Opening unhides immediately; closing removes visible before its fade ends.
const applied = open
? !panel.hidden
: !panel.classList.contains('visible');
const say = applied
? `Provider Settings is ${open ? 'open' : 'closed'}.`
: 'Provider Settings did not change.';
return {
ok: applied,
action: name,
panelId,
applied,
open: applied ? open : !panel.hidden,
say,
...(!applied ? { error: say } : {}),
};
}
setPanelOpen(styleManager, panelId, open);
return { ok: true, action: 'set_panel_open', panelId, open };
}
@@ -734,6 +903,25 @@ export function createGevActionRunner({
error: `Unknown cockpit action: ${args.action || args.command || 'missing'}`,
};
}
if (args.voicePanel !== undefined) {
if (
action !== 'exit' ||
!['preview', 'settings'].includes(args.voicePanel)
)
return {
ok: false,
action: 'control_cockpit',
applied: false,
error: 'Voice panel reveal requires exit and a valid view.',
};
const result = await showVoicePanel(args.voicePanel, true, runOptions);
return {
...result,
action: 'control_cockpit',
cockpitAction: 'exit',
voicePanel: args.voicePanel,
};
}
const inferred = normalizeCockpitNavigationHints(rawAction);
const targetLayer = normalizeCockpitTargetLayer(
args.targetLayer || inferred.targetLayer || args.layer || args.layerId,
+7 -2
View File
@@ -50,12 +50,17 @@ export function initGevVoiceCommands(options) {
const referents = createReferentRegistry();
// The controller exists only after the runner, so its hooks bind late.
const deixis = { pointer, referents, cameraKey };
const commands = createVoiceCommands({
let commands;
commands = createVoiceCommands({
...options,
pointer,
referents,
runner: withToolCatalog(
createGevActionRunner({ ...options, deixis }),
createGevActionRunner({
...options,
deixis,
readVoicePanelOperation: () => commands?.captureVoicePanelOperation?.(),
}),
options.toolCatalog,
),
});
+178
View File
@@ -0,0 +1,178 @@
/**
* Private action accounting prototype. Keys describe invocations, never input
* provenance, successful effects, provider admission or physical settlement.
* The supplied runAction continues to own execution and currentness.
*/
export function createPanelActionObserver({
runAction,
onObservation,
signal,
}) {
if (typeof runAction !== 'function')
throw new TypeError('runAction is required');
const pending = new Map();
let disposed = false;
const publish = (type, record, details = {}) => {
try {
onObservation?.(
Object.freeze({
type,
key: record.key,
name: record.name,
pendingCount: pending.size,
...details,
}),
);
} catch {
// A presentation observer cannot change execution or its result.
}
};
const release = (record) => {
if (!pending.delete(record.key)) return false;
try {
record.signal?.removeEventListener('abort', record.abort);
} catch {
/* Observer cleanup only. */
}
return true;
};
const retireOne = (record, reason) => {
if (release(record)) publish('ownership-retired', record, { reason });
};
const retire = (reason = 'owner-retired') => {
for (const record of [...pending.values()])
retireOne(record, typeof reason === 'string' ? reason : 'owner-retired');
};
const dispose = () => {
if (disposed) return;
disposed = true;
try {
signal?.removeEventListener('abort', dispose);
} catch {
/* Observer cleanup only. */
}
retire('mount-retired');
};
signal?.addEventListener('abort', dispose, { once: true });
if (signal?.aborted) dispose();
function observedAction(...args) {
if (disposed) return Reflect.apply(runAction, this, args);
const record = {
key: Object.freeze({}),
name: typeof args[0] === 'string' ? args[0] : undefined,
};
const originalOptions = args[2];
let forwarded = args;
// Main supplies ordinary data options. Leave exotic/accessor options intact
// rather than evaluating getters or pretending all custom factories fit.
try {
const prototype =
originalOptions == null ? null : Object.getPrototypeOf(originalOptions);
const descriptors =
originalOptions == null
? {}
: Object.getOwnPropertyDescriptors(originalOptions);
const callerSignal = descriptors.signal?.value;
if (callerSignal instanceof AbortSignal) record.signal = callerSignal;
const dataOnly = Object.values(descriptors).every((d) => 'value' in d);
const originalProgress = descriptors.progress?.value;
if (
originalOptions !== undefined &&
originalOptions !== null &&
(prototype === Object.prototype || prototype === null) &&
dataOnly &&
(originalProgress == null || typeof originalProgress === 'function')
) {
const progressDescriptor = descriptors.progress;
delete descriptors.progress;
const options = Object.create(prototype, descriptors);
Object.defineProperty(options, 'progress', {
configurable: true,
enumerable: progressDescriptor?.enumerable ?? true,
writable: true,
value: function (...updates) {
const receiver = this === options ? originalOptions : this;
const returned =
originalProgress == null
? undefined
: Reflect.apply(originalProgress, receiver, updates);
if (pending.has(record.key)) {
// No raw mutable result/options/update object escapes to observers.
try {
const step = Object.getOwnPropertyDescriptor(
updates[0] ?? {},
'step',
)?.value;
const label = Object.getOwnPropertyDescriptor(
updates[0] ?? {},
'label',
)?.value;
if (typeof step === 'string')
publish('progress', record, {
step,
...(typeof label === 'string' ? { label } : {}),
});
} catch {
/* Private metadata inspection cannot change progress. */
}
}
return returned;
},
});
forwarded = [...args];
forwarded[2] = options;
}
} catch {
// Unsupported option introspection is not an execution failure.
}
if (!record.signal?.aborted) {
pending.set(record.key, record);
record.abort = () => retireOne(record, 'caller-aborted');
try {
record.signal?.addEventListener('abort', record.abort, { once: true });
} catch {
retireOne(record, 'unobserved-signal');
}
if (pending.has(record.key)) publish('attempt-started', record);
}
const settle = (outcome) => {
if (release(record)) publish('attempt-settled', record, { outcome });
};
let result;
try {
result = Reflect.apply(runAction, this, forwarded);
} catch (error) {
settle('synchronous-throw');
throw error;
}
// The actual common session returns a same-realm native Promise. Do not
// assimilate arbitrary thenables, read their .then getter or invent their
// settlement; custom return values still reach the caller unchanged.
try {
if (result instanceof Promise) {
Promise.prototype.then.call(
result,
() => settle('fulfilled'),
() => settle('rejected'),
);
} else if (
result === null ||
!['object', 'function'].includes(typeof result)
) {
settle('synchronous-return');
} else retireOne(record, 'unobserved-return-kind');
} catch {
retireOne(record, 'unobserved-return-kind');
}
return result;
}
return {
runAction: observedAction,
retire,
dispose,
get pendingCount() {
return pending.size;
},
};
}
+498
View File
@@ -0,0 +1,498 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getEventListeners } from 'node:events';
import { createPanelActionObserver } from './actionObserver.js';
import { createVoiceSession } from '../session.js';
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
test('sync value/throw, receiver, arguments and opaque unsupported result stay exact', () => {
const receiver = {},
args = {},
options = { extra: 7 };
const error = Error('original');
let calls = 0;
const events = [];
const observer = createPanelActionObserver({
onObservation: (event) => events.push(event),
runAction(name, received, opts, tail) {
assert.equal(this, receiver);
assert.equal(received, args);
assert.equal(opts.extra, 7);
assert.equal(tail, 'tail');
calls++;
if (name === 'throw') throw error;
return 42;
},
});
assert.equal(
observer.runAction.call(receiver, 'value', args, options, 'tail'),
42,
);
assert.throws(
() => observer.runAction.call(receiver, 'throw', args, options, 'tail'),
(e) => e === error,
);
assert.equal(calls, 2);
assert.equal(observer.pendingCount, 0);
assert.deepEqual(
events.filter((e) => e.type === 'attempt-settled').map((e) => e.outcome),
['synchronous-return', 'synchronous-throw'],
);
const opaque = Object.defineProperty({}, 'then', {
get() {
throw Error('must not read then');
},
});
const another = createPanelActionObserver({ runAction: () => opaque });
assert.equal(another.runAction('opaque'), opaque);
assert.equal(another.pendingCount, 0);
});
test('native Promise identity, fulfillment object and rejection object are preserved', async () => {
for (const reject of [false, true]) {
const d = deferred(),
value = { exact: true },
events = [];
const observer = createPanelActionObserver({
runAction: () => d.promise,
onObservation: (e) => events.push(e),
});
const returned = observer.runAction('promise');
assert.equal(returned, d.promise);
assert.equal(observer.pendingCount, 1);
if (reject) {
d.reject(value);
await assert.rejects(returned, (e) => e === value);
} else {
d.resolve(value);
assert.equal(await returned, value);
}
assert.equal(observer.pendingCount, 0);
assert.equal(events.at(-1).outcome, reject ? 'rejected' : 'fulfilled');
assert.equal('result' in events.at(-1), false);
assert.equal('error' in events.at(-1), false);
}
});
test('null/reused callIds have independent keys and settlement cannot retire a sibling', async () => {
const a = deferred(),
b = deferred(),
events = [];
const observer = createPanelActionObserver({
runAction: (name) => (name === 'a' ? a.promise : b.promise),
onObservation: (e) => events.push(e),
});
const pa = observer.runAction('a', {}, { callId: null });
const pb = observer.runAction('b', {}, { callId: null });
const starts = events.filter((e) => e.type === 'attempt-started');
assert.notEqual(starts[0].key, starts[1].key);
assert.equal(observer.pendingCount, 2);
b.resolve('b');
await pb;
assert.equal(observer.pendingCount, 1);
assert.equal(events.at(-1).key, starts[1].key);
a.resolve('a');
await pa;
assert.equal(observer.pendingCount, 0);
});
test('progress preserves public-before-original-before-private order, receiver and option references', async () => {
const order = [],
signal = new AbortController().signal,
symbol = Symbol('unknown');
let currentCalls = 0;
const isCurrent = () => {
currentCalls++;
return true;
};
const options = {
signal,
isCurrent,
progress(update) {
assert.equal(this, options);
order.push('original');
return update;
},
};
Object.defineProperty(options, 'hidden', { value: 8 });
options[symbol] = 9;
const custom = {};
const observer = createPanelActionObserver({
onObservation(e) {
if (e.type === 'progress') order.push('private');
},
runAction(name, args, received) {
assert.equal(received.signal, signal);
assert.equal(received.isCurrent, isCurrent);
assert.equal(received.hidden, 8);
assert.equal(received[symbol], 9);
assert.equal(
Object.getOwnPropertyDescriptor(received, 'hidden').enumerable,
false,
);
order.push('public');
assert.equal(received.progress({ step: 'running' }).step, 'running');
return Promise.resolve();
},
});
await observer.runAction('action', {}, options);
assert.deepEqual(order, ['public', 'original', 'private']);
assert.equal(currentCalls, 0);
const explicit = createPanelActionObserver({
runAction(name, args, received) {
received.progress.call(custom, { step: 'running' });
return 0;
},
});
let originalReceiver;
explicit.runAction(
'explicit',
{},
{
progress() {
originalReceiver = this;
},
},
);
assert.equal(originalReceiver, custom);
});
test('observer faults and private metadata faults cannot replace original callback errors', async () => {
const originalError = Error('callback');
const observer = createPanelActionObserver({
onObservation() {
throw Error('observer');
},
runAction(name, args, options) {
options.progress({ step: 'work' });
return Promise.resolve('ok');
},
});
await assert.rejects(
Promise.resolve().then(() =>
observer.runAction(
'error',
{},
{
progress() {
throw originalError;
},
},
),
),
(e) => e === originalError,
);
const hostile = new Proxy(
{},
{
getOwnPropertyDescriptor() {
throw Error('private read');
},
},
);
const safe = createPanelActionObserver({
runAction(name, args, options) {
options.progress(hostile);
return Promise.resolve('unchanged');
},
onObservation() {
throw Error('observer');
},
});
assert.equal(await safe.runAction('safe', {}, {}), 'unchanged');
});
test('accessor/custom options are forwarded untouched without extra getter/currentness probes', async () => {
let reads = 0,
predicates = 0;
const options = Object.defineProperty(
{
isCurrent() {
predicates++;
return true;
},
},
'progress',
{
get() {
reads++;
return () => {};
},
},
);
const observer = createPanelActionObserver({
runAction(name, args, received) {
assert.equal(received, options);
received.progress({});
received.isCurrent();
return Promise.resolve();
},
});
await observer.runAction('accessor', {}, options);
assert.equal(reads, 1);
assert.equal(predicates, 1);
assert.equal(observer.pendingCount, 0);
});
test('real caller abort retires ownership only; late A cannot release or report over B', async () => {
const a = deferred(),
b = deferred(),
caller = new AbortController(),
events = [],
callbacks = [];
let oldOptions;
const observer = createPanelActionObserver({
onObservation: (e) => events.push(e),
runAction(name, args, options) {
if (name === 'a') oldOptions = options;
return name === 'a' ? a.promise : b.promise;
},
});
const pa = observer.runAction(
'a',
{},
{ signal: caller.signal, progress: () => callbacks.push('original') },
);
caller.abort();
assert.equal(observer.pendingCount, 0);
assert.equal(events.at(-1).type, 'ownership-retired');
assert.equal(
events.some((e) => e.type === 'attempt-settled'),
false,
);
const pb = observer.runAction('b');
const count = events.length;
oldOptions.progress({ step: 'late' });
a.resolve('a');
await pa;
assert.deepEqual(callbacks, ['original']);
assert.equal(events.length, count);
assert.equal(observer.pendingCount, 1);
b.resolve('b');
await pb;
assert.equal(observer.pendingCount, 0);
assert.equal(getEventListeners(caller.signal, 'abort').length, 0);
});
test('already-aborted caller and disposed mount still invoke original exactly once', async () => {
const caller = new AbortController(),
mount = new AbortController();
caller.abort();
let calls = 0;
const events = [];
const observer = createPanelActionObserver({
signal: mount.signal,
onObservation: (e) => events.push(e),
runAction: () => {
calls++;
return Promise.resolve('original');
},
});
assert.equal(
await observer.runAction('aborted', {}, { signal: caller.signal }),
'original',
);
assert.equal(observer.pendingCount, 0);
mount.abort();
assert.equal(await observer.runAction('disposed'), 'original');
assert.equal(calls, 2);
assert.equal(events.length, 0);
assert.equal(getEventListeners(mount.signal, 'abort').length, 0);
});
test('reentrant start retirement retains only fresh ownership and never skips execution', async () => {
const a = deferred(),
b = deferred(),
called = [];
let observer, pb;
observer = createPanelActionObserver({
runAction(name) {
called.push(name);
return name === 'a' ? a.promise : b.promise;
},
onObservation(e) {
if (e.type === 'attempt-started' && e.name === 'a') {
observer.retire('actual-interruption');
pb = observer.runAction('b');
}
},
});
const pa = observer.runAction('a');
assert.deepEqual(called, ['b', 'a']);
assert.equal(observer.pendingCount, 1);
a.resolve();
await pa;
assert.equal(observer.pendingCount, 1);
b.resolve();
await pb;
assert.equal(observer.pendingCount, 0);
});
test('actual pinned-main common session: private invocation stream is separate from public events', async (t) => {
const publicEvents = [],
privateEvents = [],
order = [];
let hooks, observer;
const result = { ok: true, unchanged: true };
const session = createVoiceSession({
runner: async (name, args, options) => {
assert.equal(options.isCurrent(), true);
options.progress({ step: 'actual', label: 'Actual' });
return result;
},
createAdapter(h) {
hooks = h;
observer = createPanelActionObserver({
runAction: h.runAction,
signal: h.signal,
onObservation(e) {
privateEvents.push(e);
if (e.type === 'progress') order.push('private');
},
});
return {
start() {
h.emit({ type: 'state', state: 'listening' });
},
stop() {},
sendText() {},
sendMapEvent() {},
};
},
});
t.after(() => session.destroy());
session.subscribe((e) => {
publicEvents.push(e);
if (e.type === 'progress') order.push('public');
});
await session.start();
assert.equal(
await observer.runAction(
'actual',
{},
{
progress() {
order.push('caller');
},
},
),
result,
);
assert.deepEqual(order, ['public', 'caller', 'private']);
const call = publicEvents.find((e) => e.type === 'action-call');
assert.deepEqual(call, {
type: 'action-call',
name: 'actual',
callId: null,
arguments: {},
});
assert.equal(
publicEvents.some((e) => e.type === 'action-settled'),
false,
);
assert.equal(privateEvents.at(-1).type, 'attempt-settled');
assert.equal(observer.pendingCount, 0);
assert.equal(hooks.signal.aborted, false);
});
test('actual common interruption aborts resources; injected real-lifecycle observation retires uncooperative A', async (t) => {
const a = deferred(),
b = deferred(),
events = [];
let hooks, observer, aSignal;
const session = createVoiceSession({
runner: (name, args, options) => {
if (name === 'a') aSignal = options.signal;
return name === 'a' ? a.promise : b.promise;
},
createAdapter(h) {
hooks = h;
observer = createPanelActionObserver({
runAction: h.runAction,
signal: h.signal,
onObservation: (e) => events.push(e),
});
return {
start() {
h.emit({ type: 'state', state: 'listening' });
},
stop() {},
sendText() {},
sendMapEvent() {},
};
},
});
t.after(() => session.destroy());
await session.start();
const pa = observer.runAction('a');
const rejection = assert.rejects(pa, { name: 'AbortError' });
hooks.emit({ type: 'interruption', reason: 'actual-common-event' });
assert.equal(aSignal.aborted, true);
// This bridge is explicitly supplied by the test, not automatically inferred.
observer.retire('observed-common-interruption');
assert.equal(observer.pendingCount, 0);
const pb = observer.runAction('b');
const count = events.length;
a.resolve({ ok: true });
await rejection;
assert.equal(events.length, count);
assert.equal(observer.pendingCount, 1);
b.resolve({ ok: true });
await pb;
assert.equal(observer.pendingCount, 0);
});
test('null-prototype option descriptors survive instrumentation; mount abort does not fabricate settlement', async () => {
const mount = new AbortController(),
d = deferred(),
events = [],
originalProgress = [];
const options = Object.assign(Object.create(null), {
progress: () => originalProgress.push('original'),
});
let received;
const observer = createPanelActionObserver({
signal: mount.signal,
onObservation: (e) => events.push(e),
runAction(name, args, opts) {
received = opts;
return d.promise;
},
});
const promise = observer.runAction('pending', {}, options);
assert.equal(Object.getPrototypeOf(received), null);
mount.abort();
assert.equal(observer.pendingCount, 0);
assert.equal(events.at(-1).type, 'ownership-retired');
const count = events.length;
received.progress({ step: 'late' });
d.resolve('done');
assert.equal(await promise, 'done');
assert.deepEqual(originalProgress, ['original']);
assert.equal(events.length, count);
assert.equal(getEventListeners(mount.signal, 'abort').length, 0);
});
test('omitted and explicit undefined options keep original arity and values', () => {
const calls = [];
const observer = createPanelActionObserver({
runAction: function (...args) {
calls.push(args);
return 1;
},
});
assert.equal(observer.runAction('omitted', {}), 1);
assert.equal(observer.runAction('explicit', {}, undefined), 1);
assert.equal(calls[0].length, 2);
assert.equal(calls[1].length, 3);
assert.equal(calls[0][2], undefined);
assert.equal(calls[1][2], undefined);
});
+304
View File
@@ -0,0 +1,304 @@
import { t } from '../../i18n/index.js';
import { createManualVoicePanel } from './manualPanel.js';
import {
createVoicePreferences,
bindPreferenceControls,
} from './preferences.js';
import { createPanelActionObserver } from './actionObserver.js';
import { observeNativeActivity } from './nativeActivity.js';
import { createVoiceInactivity } from './inactivity.js';
/** One UI/preference owner across provider mounts; no semantic voice routes. */
export function createVoicePanelHost({
storage,
clock = {},
readCockpitState = () => ({
active:
globalThis.document?.body?.classList?.contains('cockpit-mode') === true,
}),
} = {}) {
const preferences = createVoicePreferences({ storage });
let current = null;
let disposed = false;
let saved = { view: 'preview', panelOpen: false, automatic: true };
function observeActions(hooks) {
const observer = createPanelActionObserver({
runAction: hooks.runAction,
signal: hooks.signal,
onObservation: () => {
if (current?.observer === observer) current.workChanged();
},
});
return observer;
}
function mount({ ui, card, session, observer, nativeProvider }) {
if (disposed || !ui.voicePanel || !card?.setEnabled || !card?.setPresented)
return null;
current?.destroy();
let dead = false;
let revision = 0;
let operationEpoch = 0;
let operationWrite = false;
let panel;
let idle;
let refreshQueued = false;
let automatic = saved.automatic;
let inputBusy = false;
let finalTranscript = null;
const presentationLifetime = new AbortController();
const rearm = () => {
if (!owns() || !session.isActive()) return;
automatic = true;
saved.automatic = true;
card.rearm?.();
// Unknown IDs cannot distinguish a fresh identical answer after input.
if (!finalTranscript?.id) finalTranscript = null;
};
const owns = () =>
!dead && !disposed && current === binding && !session.disposed;
const remember = ({ automatic: auto = false } = {}) => {
if (!auto) {
revision++;
automatic = false;
}
if (panel) {
const value = panel.snapshot();
saved = { view: value.view, panelOpen: value.panelOpen, automatic };
}
};
const renderActivity = () => {
if (ui.voiceTimeoutStatus)
ui.voiceTimeoutStatus.textContent =
nativeProvider === 'openai' || nativeProvider === 'gemini'
? ''
: t('Automatic stop is unavailable for this voice connection.');
};
const changed = () => {
if (!owns()) return;
const busy = activity.snapshot().inputBusy;
if (busy && !inputBusy) rearm();
inputBusy = busy;
idle?.update({ activity: true });
renderActivity();
};
const activity = observeNativeActivity({
adapter: session.adapter,
nativeProvider,
signal: session.signal,
onChange: changed,
onInput: rearm,
});
const closeAfterStop = (ticket) => {
if (owns() && ticket === revision && !session.isActive()) panel.close();
};
const binding = {
observer,
get panel() {
return panel;
},
stopTicket: () => revision,
closeAfterStop,
workChanged: changed,
captureOperation() {
const stamp = revision;
const epoch = operationEpoch;
const isCurrent = () =>
owns() && stamp === revision && epoch === operationEpoch;
const cancelled = () => ({
ok: false,
applied: false,
cancelled: true,
});
return {
isCurrent,
setMinutes(value) {
if (!isCurrent()) return cancelled();
if (typeof value !== 'number' || !Number.isInteger(value))
return {
ok: false,
applied: false,
changed: false,
persisted: null,
reason: 'invalid-value',
minutes: preferences.snapshot().minutes,
};
let result;
operationWrite = true;
try {
result = preferences.setMinutes(value);
} finally {
operationWrite = false;
}
return {
ok: result.ok,
applied: result.applied,
changed: result.changed,
persisted: result.persisted,
minutes: result.minutes,
...(result.reason ? { reason: result.reason } : {}),
};
},
show(view, open = true) {
if (!isCurrent()) return cancelled();
const result = open ? panel.select(view) : panel.close(view);
return { ...result, applied: result.ok };
},
};
},
handle(event) {
if (!owns()) return;
if (event.type !== 'state' && !session.isActive()) return;
if (
event.type === 'state' &&
['connecting', 'idle', 'error'].includes(event.state)
) {
finalTranscript = null;
if (event.state !== 'connecting') saved.automatic = automatic = false;
}
if (
event.type === 'interruption' &&
!event.preserveAdvisoryCaption &&
['user-speech', 'user-text', 'push-to-talk'].includes(event.reason)
)
rearm();
if (
event.type === 'transcript' &&
event.role === 'assistant' &&
event.text
) {
const id = event.responseId || event.itemId || null;
if (
finalTranscript &&
(id ? id === finalTranscript.id : !finalTranscript.id) &&
((!event.final && id) ||
(event.final && event.text === finalTranscript.text))
)
return;
if (event.final) finalTranscript = { id, text: event.text };
if (
automatic &&
preferences.snapshot().previewEnabled &&
!readCockpitState()?.active
)
panel.select('preview', { automatic: true });
}
// A retired native boundary is not fresh input and must not revive
// dismissal or erase the advisory caption it explicitly preserves.
if (!(event.type === 'interruption' && event.preserveAdvisoryCaption))
card.handle(event);
if (
event.type === 'interruption' ||
(event.type === 'state' &&
['connecting', 'idle', 'error'].includes(event.state))
) {
operationEpoch++;
observer.retire('session-boundary');
}
activity.handle(event);
idle?.update();
// Native controllers finish processing after forwarding some events.
// One guarded microtask refresh reads the final current local state.
if (!refreshQueued) {
refreshQueued = true;
queueMicrotask(() => {
refreshQueued = false;
if (!owns()) return;
activity.handle({ type: 'refresh' });
idle?.update();
});
}
},
snapshot: () => ({
...panel.snapshot(),
preferences: preferences.snapshot(),
activity: activity.snapshot(),
inactivity: idle.snapshot(),
}),
destroy() {
if (dead) return;
dead = true;
presentationLifetime.abort();
idle?.destroy();
activity.destroy();
fields.dispose();
observer.dispose();
panel.destroy();
session.signal.removeEventListener('abort', binding.destroy);
if (current === binding) current = null;
},
};
current = binding;
panel = createManualVoicePanel({
ui,
card,
getPreviewEnabled: () => preferences.snapshot().previewEnabled,
getCockpitActive: () => readCockpitState()?.active === true,
copy: {
previewEmpty: t('Voice replies appear here.'),
previewDisabled: t('Preview is off.'),
},
onPresentationIntent: remember,
});
const fields = bindPreferenceControls({
ui,
preferences,
copy: {
previewNote: '',
timeoutNote: t('These preferences stay in this browser.'),
notSaved: t('Your choice applies now, but could not be saved.'),
},
onChange: (state) => {
if (!owns()) return;
if (!operationWrite) revision++;
card.setEnabled(state.previewEnabled);
panel.sync();
idle?.setMinutes(state.minutes);
},
});
globalThis.window?.addEventListener(
'gev:cockpit-mode-changed',
() => {
if (owns()) panel.sync();
},
{ signal: presentationLifetime.signal },
);
ui.root?.querySelector('#gev-voice-card-dismiss')?.addEventListener(
'click',
() => {
remember();
if (panel.snapshot().panelOpen) ui.previewTab?.focus();
},
{ signal: presentationLifetime.signal },
);
card.setEnabled(preferences.snapshot().previewEnabled);
panel.sync();
idle = createVoiceInactivity({
readActivity: activity.snapshot,
readPending: () => observer.pendingCount,
minutes: preferences.snapshot().minutes,
signal: session.signal,
...clock,
onExpire: () => {
if (!owns()) return;
const ticket = revision;
session.stop();
closeAfterStop(ticket);
},
});
if (saved.panelOpen) panel.select(saved.view, { automatic: true });
renderActivity();
session.signal.addEventListener('abort', binding.destroy, { once: true });
if (session.signal.aborted) binding.destroy();
return binding;
}
return {
observeActions,
mount,
snapshot: () => preferences.snapshot(),
destroy() {
if (disposed) return;
current?.destroy();
disposed = true;
},
};
}
+756
View File
@@ -0,0 +1,756 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVoiceControl } from '../control.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 { installPanelDom, controlledClock } from './testDom.mjs';
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;
};
}
function mounted(t, options = {}) {
const doc = installPanelDom(t);
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),
panelHost: host,
nativePanelProvider: options.native
? (options.nativeProvider ?? 'gemini')
: null,
});
t.after(() => {
try {
commands.session.destroy();
} catch {}
host.destroy();
});
return { doc, clock, host, instances, commands, ui: commands.ui };
}
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', (t) => {
const doc = installPanelDom(t);
const baseline = createVoiceControl();
assert.equal(baseline.voicePanel, null);
const ui = createVoiceControl({ reset: true, panel: true });
assert.equal(doc.getElementById('gev-voice-panel'), ui.voicePanel);
assert.equal(
ui.voiceSettingsButton.getAttribute('aria-controls'),
ui.voicePanel.id,
);
assert.equal(ui.previewTab.getAttribute('aria-controls'), ui.previewPanel.id);
assert.equal(
ui.settingsTab.getAttribute('aria-controls'),
ui.voiceSettingsPanel.id,
);
assert.equal(ui.previewPanel.contains(ui.card), true);
assert.equal(
ui.voicePanel.contains(ui.root.querySelector('.gev-voice-error-tray')),
false,
);
assert.equal(ui.root.querySelectorAll('#gev-voice-card').length, 1);
});
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', (t) => {
const { ui, commands, doc } = mounted(t);
ui.voiceSettingsButton.fire('click');
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
ui.voiceSettingsButton.fire('click');
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
ui.settingsTab.fire('click');
ui.settingsTab.fire('keydown', { key: 'End' });
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
ui.settingsTab.fire('click');
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
commands.voicePanel.select('preview');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
final: true,
text: 'A visible reply',
});
const dismiss = ui.root.querySelector('#gev-voice-card-dismiss');
dismiss.focus();
dismiss.fire('click');
assert.equal(ui.card.hidden, true);
assert.equal(commands.voicePanel.snapshot().panelOpen, true);
assert.equal(doc.activeElement?.id, ui.previewTab.id);
ui.voicePanel.fire('keydown', { key: 'Escape', target: doc.activeElement });
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
commands.voicePanel.select('settings');
ui.voicePreviewInput.checked = false;
ui.voicePreviewInput.fire('change');
commands.voicePanel.select('preview');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
final: true,
text: 'A hidden reply',
});
assert.equal(ui.card.hidden, true);
assert.equal(ui.previewEmpty.textContent, 'Preview is off.');
ui.previewTab.focus();
ui.voicePanel.fire('keydown', { key: 'Escape' });
assert.equal(doc.activeElement, ui.voiceSettingsButton);
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 doc = installPanelDom(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 }),
factories: { openai: factory(instances), gemini: factory(instances) },
storage,
},
);
t.after(() => {
commands.destroy();
host.destroy();
});
const old = commands.ui;
commands.voicePanel.select('settings');
old.voicePreviewInput.checked = false;
old.voicePreviewInput.fire('change');
old.voiceTimeoutInput.value = '7';
old.voiceTimeoutSelect.value = 'custom';
old.voiceTimeoutSelect.fire('change');
const preferenceWrites = () =>
writes.filter(([key]) => key.startsWith('godsEyeView.voice.')).length;
assert.equal(preferenceWrites(), 2);
old.providerSelect.value = 'gemini';
old.providerSelect.fire('change');
assert.notEqual(commands.ui, old);
assert.equal(old.root.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.voicePreviewInput.checked, false);
assert.match(commands.ui.voiceTimeoutNote.textContent, /could not be saved/);
assert.equal(preferenceWrites(), 2);
assert.equal(doc.activeElement, commands.ui.providerSelect);
commands.ui.voicePreviewInput.checked = true;
commands.ui.voicePreviewInput.fire('change');
commands.voicePanel.close();
await commands.start();
const retired = instances[1];
commands.ui.providerSelect.value = 'openai';
commands.ui.providerSelect.fire('change');
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.button.fire('click');
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.button.fire('click'),
(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.button.fire('click');
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');
f.ui.voiceTimeoutInput.value = '1';
f.ui.voiceTimeoutSelect.value = 'custom';
f.ui.voiceTimeoutSelect.fire('change');
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.voiceTimeoutSelect.value = '0';
native.ui.voiceTimeoutSelect.fire('change');
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.voiceTimeoutStatus.textContent, /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();
f.ui.voiceTimeoutInput.value = '1';
f.ui.voiceTimeoutSelect.value = 'custom';
f.ui.voiceTimeoutSelect.fire('change');
f.instances[0].controller._turns.observeSpeech({ type: 'response.created' });
assert.equal(f.ui.voiceTimeoutStatus.textContent, '');
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) => {
installPanelDom(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);
});
async function directActions(
t,
{ storage = null, delayExit = false, exposePanel = true } = {},
) {
installPanelDom(t);
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.voicePreviewInput.checked = false;
f.ui.voicePreviewInput.fire('change');
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' });
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,
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');
f.ui.previewTab.focus();
cockpit = true;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
f.commands.voicePanel.select('settings', { focus: true });
assert.equal(f.doc.activeElement, f.ui.settingsTab);
f.commands.voicePanel.select('preview', { focus: true });
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
const outside = f.doc.createElement('button');
f.doc.body.appendChild(outside);
outside.focus();
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.doc.activeElement, outside);
assert.equal(f.commands.voicePanel.snapshot().previewVisible, false);
reply('Suppressed in Cockpit');
assert.equal(f.ui.voicePanel.hidden, true);
f.commands.voicePanel.close();
cockpit = false;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.voicePanel.hidden, true);
f.ui.voicePreviewInput.checked = false;
f.ui.voicePreviewInput.fire('change');
emit({ type: 'interruption', reason: 'user-speech' });
reply('Preference stays off');
assert.equal(f.ui.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;
const chip = doc.createElement('button');
chip.id = 'key-setup-chip';
const panel = doc.createElement('aside');
panel.id = 'key-setup';
panel.hidden = true;
const close = doc.createElement('button');
panel.appendChild(close);
doc.body.append(chip, panel);
const query = panel.querySelector.bind(panel);
panel.querySelector = (selector) =>
selector === '[data-key-setup-close]' ? close : query(selector);
chip.click = () => chip.fire('click');
close.click = () => close.fire('click');
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');
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(
panel.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(panel.hidden, true);
assert.equal((await request(true)).ok, true);
assert.equal(panel.hidden, false);
assert.equal(panel.classList.contains('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(panel.classList.contains('visible'), false);
t.mock.timers.tick(400);
assert.equal(panel.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',
);
});
+77
View File
@@ -0,0 +1,77 @@
/** Local inactivity only. Unknown activity never authorizes expiry. */
export function createVoiceInactivity({
readActivity,
readPending,
minutes = 5,
onExpire,
signal,
setTimer = setTimeout,
clearTimer = clearTimeout,
now = Date.now,
}) {
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 60) minutes = 5;
let disposed = false,
timer = null,
deadline = null,
eligible = false,
epoch = 0;
function cancel() {
epoch++;
if (timer !== null) clearTimer(timer);
timer = deadline = null;
}
function quiet() {
const a = readActivity();
return (
a.known &&
a.connected &&
!a.held &&
!a.inputBusy &&
!a.outputBusy &&
!a.generationBusy &&
!readPending()
);
}
function arm() {
cancel();
if (!minutes || disposed) return;
const id = epoch;
deadline = now() + minutes * 60000;
timer = setTimer(() => {
if (disposed || id !== epoch) return;
timer = deadline = null;
eligible = quiet();
if (eligible) onExpire?.();
}, minutes * 60000);
}
function update({ activity = false } = {}) {
if (disposed) return;
const next = quiet();
if (!next) cancel();
else if (!eligible || activity) arm();
eligible = next;
}
function setMinutes(value) {
if (!Number.isInteger(value) || value < 0 || value > 60) return false;
if (disposed) return false;
minutes = value;
eligible = false;
update();
return true;
}
function destroy() {
if (disposed) return;
disposed = true;
cancel();
signal?.removeEventListener('abort', destroy);
}
signal?.addEventListener('abort', destroy, { once: true });
if (signal?.aborted) destroy();
update();
return {
update,
setMinutes,
snapshot: () => ({ minutes, deadline, eligible, disposed }),
destroy,
};
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVoiceInactivity } from './inactivity.js';
function fixture() {
let activity = {
known: true,
connected: true,
held: false,
inputBusy: false,
outputBusy: false,
generationBusy: false,
};
let pending = 0,
expired = 0,
time = 100,
serial = 0;
const timers = new Map();
const idle = createVoiceInactivity({
readActivity: () => activity,
readPending: () => pending,
onExpire: () => expired++,
now: () => time,
setTimer: (fn, delay) => {
timers.set(++serial, { fn, delay });
return serial;
},
clearTimer: (id) => timers.delete(id),
});
return {
idle,
activity,
timers,
setPending: (n) => (pending = n),
expired: () => expired,
fire: () => {
const entry = [...timers][0];
timers.delete(entry[0]);
entry[1].fn();
},
tick: (n) => (time += n),
};
}
test('one clock; quiet arms default five minutes; busy blocks and quiescence rearms', () => {
const f = fixture();
assert.equal([...f.timers.values()][0].delay, 300000);
assert.equal(f.idle.snapshot().deadline, 300100);
f.activity.outputBusy = true;
f.idle.update();
assert.equal(f.timers.size, 0);
f.activity.outputBusy = false;
f.tick(200);
f.idle.update();
assert.equal(f.idle.snapshot().deadline, 300300);
f.fire();
assert.equal(f.expired(), 1);
});
test('actual activity resets but silent unchanged snapshots do not', () => {
const f = fixture();
const deadline = f.idle.snapshot().deadline;
f.tick(1000);
f.idle.update();
assert.equal(f.idle.snapshot().deadline, deadline);
f.idle.update({ activity: true });
assert.equal(f.idle.snapshot().deadline, deadline + 1000);
});
test('None cancels; invalid values cannot replace setting; explicit retry rearms', () => {
const f = fixture();
assert.equal(f.idle.setMinutes(0), true);
assert.equal(f.timers.size, 0);
for (const bad of [1.5, -1, 61, '5', NaN])
assert.equal(f.idle.setMinutes(bad), false);
assert.equal(f.idle.setMinutes(1), true);
assert.equal([...f.timers.values()][0].delay, 60000);
});
test('expiry rereads held, unknown and pending counts despite missing update', () => {
for (const kind of ['held', 'unknown', 'pending']) {
const f = fixture();
if (kind === 'held') f.activity.held = true;
if (kind === 'unknown') f.activity.known = false;
if (kind === 'pending') f.setPending(2);
f.fire();
assert.equal(f.expired(), 0);
}
});
test('pending siblings and held-before-connect forbid timer; stale callback cannot expire', () => {
const f = fixture();
const stale = [...f.timers.values()][0].fn;
f.setPending(2);
f.idle.update();
stale();
assert.equal(f.expired(), 0);
f.setPending(1);
f.idle.update();
assert.equal(f.timers.size, 0);
f.setPending(0);
f.activity.connected = false;
f.activity.held = true;
f.idle.update();
assert.equal(f.timers.size, 0);
f.activity.connected = true;
f.idle.update();
assert.equal(f.timers.size, 0);
f.activity.held = false;
f.idle.update();
assert.equal(f.timers.size, 1);
f.idle.destroy();
assert.equal(f.timers.size, 0);
});
+182
View File
@@ -0,0 +1,182 @@
const ROW_GRACE_MS = 700;
/** Manual presentation only. Session and action events belong to the host. */
export function createManualVoicePanel({
ui,
card,
getPreviewEnabled,
getCockpitActive = () => false,
copy,
onPresentationIntent,
}) {
if (!ui?.voicePanel || typeof getPreviewEnabled !== 'function')
throw new TypeError(
'A voice panel and Preview preference reader are required.',
);
if (
typeof copy?.previewEmpty !== 'string' ||
typeof copy?.previewDisabled !== 'string'
)
throw new TypeError('Localized Preview messages are required.');
const lifetime = new AbortController();
const panel = ui.voicePanel;
const tabs = [ui.previewTab, ui.settingsTab];
const doc = panel.ownerDocument;
let disposed = false;
let open = false;
let view = 'preview';
const enabled = () => getPreviewEnabled() === true;
const suppressed = () => view === 'preview' && getCockpitActive() === true;
const visible = (element) =>
Boolean(
element &&
!element.hidden &&
element.isConnected !== false &&
element.checkVisibility?.({
opacityProperty: true,
visibilityProperty: true,
}) !== false,
);
const snapshot = () => ({
view,
panelOpen: !disposed && open,
settingsOpen: !disposed && open && view === 'settings',
previewEnabled: enabled(),
previewVisible:
!disposed &&
open &&
view === 'preview' &&
!suppressed() &&
enabled() &&
visible(ui.card),
});
const sync = () => {
if (disposed) return;
const hidePanel = !open || suppressed();
const restoreFocus = hidePanel && panel.contains?.(doc?.activeElement);
panel.hidden = hidePanel;
panel.dataset.view = view;
if (ui.previewPanel)
ui.previewPanel.hidden = !open || view !== 'preview' || suppressed();
if (ui.voiceSettingsPanel)
ui.voiceSettingsPanel.hidden = !open || view !== 'settings';
ui.voiceSettingsButton?.setAttribute(
'aria-expanded',
String(open && view === 'settings'),
);
tabs.forEach((tab, index) => {
if (!tab) return;
const selected = view === (index === 0 ? 'preview' : 'settings');
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
});
card?.setPresented?.(open && view === 'preview' && !suppressed());
if (ui.previewEmpty) {
ui.previewEmpty.hidden = Boolean(ui.card && !ui.card.hidden);
ui.previewEmpty.textContent = enabled()
? copy.previewEmpty
: copy.previewDisabled;
}
if (restoreFocus)
[ui.voiceSettingsButton, ui.button].find(visible)?.focus?.();
};
const refuse = (reason) => ({
ok: false,
changed: false,
reason,
...snapshot(),
});
const select = (next, { focus = false, automatic = false } = {}) => {
if (disposed) return refuse('disposed');
if (!['preview', 'settings'].includes(next)) return refuse('unknown-view');
const changed = !open || view !== next;
const outgoing =
view === 'preview' ? ui.previewPanel : ui.voiceSettingsPanel;
const transferFocus = changed && outgoing?.contains?.(doc?.activeElement);
open = true;
view = next;
onPresentationIntent?.({ automatic });
sync();
const tab = tabs[next === 'preview' ? 0 : 1];
if ((focus || transferFocus) && visible(panel) && visible(tab))
tab.focus?.();
return { ok: true, changed, ...snapshot() };
};
const close = (onlyView) => {
if (disposed) return refuse('disposed');
if (onlyView && !['preview', 'settings'].includes(onlyView))
return refuse('unknown-view');
if (onlyView && onlyView !== view)
return { ok: true, changed: false, ...snapshot() };
const changed = open;
open = false;
onPresentationIntent?.();
sync();
return { ok: true, changed, ...snapshot() };
};
const listen = (element, type, handler) =>
element?.addEventListener(type, handler, { signal: lifetime.signal });
const toggleSettings = () =>
open && view === 'settings'
? close('settings')
: select('settings', { focus: true });
try {
listen(ui.voiceSettingsButton, 'click', toggleSettings);
tabs.forEach((tab, index) => {
listen(tab, 'click', () =>
select(index === 0 ? 'preview' : 'settings', { focus: true }),
);
listen(tab, 'keydown', (event) => {
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? 1
: ['ArrowLeft', 'ArrowRight'].includes(event.key)
? 1 - index
: null;
if (next === null) return;
event.preventDefault();
select(next === 0 ? 'preview' : 'settings', { focus: true });
});
});
listen(ui.voiceSettingsClose, 'click', () => close());
listen(panel, 'keydown', (event) => {
if (event.key !== 'Escape' || !open) return;
event.preventDefault();
close();
});
// Keep the raised row reachable across pinned trays without intercepting
// their controls. The same lifetime owns the listeners and pending timer.
let graceTimer;
const endGrace = () => {
clearTimeout(graceTimer);
if (ui.root) delete ui.root.dataset.rowGrace;
};
listen(ui.root, 'pointerleave', (event) => {
if (event.pointerType === 'touch') return;
endGrace();
ui.root.dataset.rowGrace = 'true';
graceTimer = setTimeout(endGrace, ROW_GRACE_MS);
});
listen(ui.root, 'pointerenter', endGrace);
lifetime.signal.addEventListener('abort', endGrace, { once: true });
sync();
} catch (error) {
lifetime.abort();
throw error;
}
return {
select,
close,
sync,
snapshot,
destroy() {
if (disposed) return;
open = false;
sync();
disposed = true;
lifetime.abort();
},
};
}
+198
View File
@@ -0,0 +1,198 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createManualVoicePanel } from './manualPanel.js';
function fixture(t) {
const doc = { activeElement: null };
const node = () =>
Object.assign(new EventTarget(), {
hidden: false,
isConnected: true,
dataset: {},
attributes: {},
ownerDocument: doc,
setAttribute(name, value) {
this.attributes[name] = value;
},
focus() {
doc.activeElement = this;
},
contains(other) {
return other === this;
},
checkVisibility() {
return !this.hidden;
},
});
const ui = Object.fromEntries(
[
'root',
'voicePanel',
'previewPanel',
'voiceSettingsPanel',
'previewTab',
'settingsTab',
'voiceSettingsButton',
'voiceSettingsClose',
'previewEmpty',
'card',
'button',
].map((key) => [key, node()]),
);
ui.voicePanel.contains = (element) =>
Object.values(ui).includes(element) &&
![ui.root, ui.voiceSettingsButton, ui.button].includes(element);
let enabled = true,
content = false,
intents = 0;
const card = {
setPresented(value) {
this.presented = value;
ui.card.hidden = !(value && enabled && content);
},
};
const copy = {
previewEmpty: 'Habla para ver resultados.',
previewDisabled: 'Vista previa desactivada.',
};
const panel = createManualVoicePanel({
ui,
card,
getPreviewEnabled: () => enabled,
copy,
onPresentationIntent: () => intents++,
});
t.after(() => panel.destroy());
return {
ui,
panel,
doc,
card,
copy,
intents: () => intents,
enabled(value) {
enabled = value;
panel.sync();
},
content(value) {
content = value;
panel.sync();
},
};
}
const click = (element) => element.dispatchEvent(new Event('click'));
function key(element, value) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value });
element.dispatchEvent(event);
return event;
}
test('Settings launcher toggles while tab and programmatic selection remain idempotent', (t) => {
const f = fixture(t);
assert.equal(f.panel.snapshot().panelOpen, false);
click(f.ui.voiceSettingsButton);
assert.equal(f.panel.snapshot().settingsOpen, true);
assert.equal(f.doc.activeElement, f.ui.settingsTab);
assert.equal(f.ui.voiceSettingsButton.attributes['aria-expanded'], 'true');
click(f.ui.voiceSettingsButton);
assert.equal(f.panel.snapshot().panelOpen, false);
click(f.ui.settingsTab);
assert.equal(f.panel.snapshot().settingsOpen, true);
assert.equal(f.panel.select('settings').changed, false);
assert.equal(f.panel.snapshot().settingsOpen, true);
click(f.ui.settingsTab);
assert.equal(f.panel.snapshot().settingsOpen, true);
assert.equal(f.panel.select('unknown').reason, 'unknown-view');
});
test('tab keyboard navigation and Escape preserve focus and accessibility state', (t) => {
const f = fixture(t);
click(f.ui.voiceSettingsButton);
assert.equal(key(f.ui.settingsTab, 'ArrowLeft').defaultPrevented, true);
assert.equal(f.panel.snapshot().view, 'preview');
assert.equal(f.doc.activeElement, f.ui.previewTab);
assert.equal(f.ui.previewTab.tabIndex, 0);
assert.equal(f.ui.settingsTab.tabIndex, -1);
assert.equal(f.ui.previewTab.attributes['aria-selected'], 'true');
key(f.ui.previewTab, 'End');
assert.equal(f.panel.snapshot().view, 'settings');
key(f.ui.settingsTab, 'Home');
assert.equal(f.panel.snapshot().view, 'preview');
assert.equal(key(f.ui.voicePanel, 'Escape').defaultPrevented, true);
assert.equal(f.panel.snapshot().panelOpen, false);
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
assert.equal(key(f.ui.voicePanel, 'Escape').defaultPrevented, false);
});
test('manual switching transfers hidden-panel focus without stealing unrelated focus', (t) => {
const f = fixture(t);
f.panel.select('settings');
f.ui.voiceSettingsPanel.focus();
f.panel.select('preview');
assert.equal(f.doc.activeElement, f.ui.previewTab);
const unrelated = (f.doc.activeElement = {});
f.panel.select('settings');
assert.equal(f.doc.activeElement, unrelated);
assert.equal(f.panel.close('preview').changed, false);
assert.equal(f.panel.snapshot().settingsOpen, true);
});
test('manual Preview selection never enables a disabled preference and uses injected copy', (t) => {
const f = fixture(t);
f.enabled(false);
f.content(true);
f.panel.select('preview');
assert.equal(f.panel.snapshot().previewEnabled, false);
assert.equal(f.panel.snapshot().previewVisible, false);
assert.equal(f.ui.previewEmpty.textContent, f.copy.previewDisabled);
assert.equal(f.ui.previewEmpty.hidden, false);
f.enabled(true);
assert.equal(f.panel.snapshot().previewVisible, true);
assert.equal(f.ui.previewEmpty.hidden, true);
f.content(false);
assert.equal(f.ui.previewEmpty.textContent, f.copy.previewEmpty);
});
test('disposal hides owned presentation and removes listeners without destroying the card', (t) => {
const f = fixture(t);
f.panel.select('settings');
const count = f.intents();
f.panel.destroy();
f.panel.destroy();
click(f.ui.voiceSettingsButton);
click(f.ui.settingsTab);
assert.equal(f.intents(), count);
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.card.presented, false);
assert.equal(f.panel.select('preview').reason, 'disposed');
assert.equal(f.panel.close().reason, 'disposed');
});
test('pointer grace expires, resets on reentry, ignores touch and clears on disposal', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = fixture(t);
const pointer = (type, pointerType = 'mouse') => {
const event = new Event(type);
Object.defineProperty(event, 'pointerType', { value: pointerType });
f.ui.root.dispatchEvent(event);
};
pointer('pointerleave');
t.mock.timers.tick(699);
assert.equal(f.ui.root.dataset.rowGrace, 'true');
pointer('pointerenter');
assert.equal(f.ui.root.dataset.rowGrace, undefined);
pointer('pointerleave');
t.mock.timers.tick(699);
assert.equal(f.ui.root.dataset.rowGrace, 'true');
t.mock.timers.tick(1);
assert.equal(f.ui.root.dataset.rowGrace, undefined);
pointer('pointerleave', 'touch');
assert.equal(f.ui.root.dataset.rowGrace, undefined);
pointer('pointerleave');
f.panel.destroy();
assert.equal(f.ui.root.dataset.rowGrace, undefined);
pointer('pointerleave');
t.mock.timers.tick(700);
assert.equal(f.ui.root.dataset.rowGrace, undefined);
});
+212
View File
@@ -0,0 +1,212 @@
/** Inspected native capabilities only; scheduled/reported output is not acoustics. */
export function observeNativeActivity({
adapter,
nativeProvider,
signal,
onChange,
onInput,
}) {
const native = nativeProvider === 'openai' || nativeProvider === 'gemini';
// Do not even inspect custom controller properties.
const controller = native ? adapter.controller : null;
const input = native
? nativeProvider === 'gemini'
? controller.input
: controller._input
: null;
let dead = false,
connected = false,
inputBusy = false;
function cleanNativeOutput() {
if (nativeProvider === 'gemini') return true;
if (nativeProvider !== 'openai') return false;
const turns = controller._turns;
return (
['idle', 'connecting', 'error'].includes(controller.status) &&
controller.dc == null &&
!turns.assistantAudioPlaying &&
!turns.responseActive &&
!turns.responseCreatePending &&
!turns.userTurnPending &&
!turns.activeResponseId &&
!turns.progressResponse
);
}
let outputKnown = cleanNativeOutput();
let audio = null,
version = 0;
const restores = [];
function snapshot() {
const turns = nativeProvider === 'openai' ? controller?._turns : controller;
return {
known: native && outputKnown && !dead,
connected: connected && !dead,
held: Boolean(input?.pushToTalkKeyHeld),
inputBusy: Boolean(
inputBusy ||
(nativeProvider === 'gemini' && controller?.userActivityOpen),
),
outputBusy: Boolean(
nativeProvider === 'gemini'
? audio && audio === controller.audio && audio.pending
: turns?.assistantAudioPlaying,
),
generationBusy: Boolean(
turns?.responseActive ||
turns?.responseCreatePending ||
(nativeProvider === 'openai' && turns?.userTurnPending) ||
(nativeProvider === 'gemini' &&
(turns?.replyPending ||
turns?.retiredReplyPending ||
turns?.deferredInput ||
turns?.automaticTurnOpen ||
turns?.pendingTools ||
turns?.controlQueue?.length)),
),
};
}
function notify() {
if (!dead) {
try {
onChange?.(snapshot());
} catch {}
}
}
function wrap(object, key, after) {
const original = object?.[key];
if (typeof original !== 'function') return;
function wrapped(...args) {
const stamp = version;
const result = Reflect.apply(original, this, args);
if (!dead && stamp === version) {
try {
after?.(args, result);
} catch {}
}
return result;
}
object[key] = wrapped;
restores.push(() => {
if (object[key] === wrapped) object[key] = original;
});
}
function handle(event) {
if (dead || !native) return;
if (event?.type === 'state') {
const state = event.state;
const wasConnected = connected;
connected = !['idle', 'connecting', 'error'].includes(state);
// Connecting has multiple native phases around audio allocation. Retire
// a previous connected generation once, not each phase of this start.
if (
state === 'idle' ||
state === 'error' ||
(state === 'connecting' && wasConnected)
) {
version++;
audio = null;
inputBusy = false;
outputKnown = cleanNativeOutput();
}
}
notify();
}
if (native) {
wrap(controller, 'sendTextCommand', ([text], result) => {
if (String(text || '').trim() && result !== false) onInput?.();
});
for (const key of [
'setMicrophoneEnabled',
'releasePushToTalkKey',
'resetSession',
])
wrap(input, key, notify);
if (nativeProvider === 'openai') {
wrap(controller._turns, 'observeSpeech', ([event]) => {
if (event?.type === 'input_audio_buffer.speech_started')
inputBusy = true;
if (event?.type === 'input_audio_buffer.speech_stopped')
inputBusy = false;
// A previous turn's drain does not prove the next transport emits output events.
if (event?.type === 'response.created') outputKnown = false;
if (
event?.type === 'output_audio_buffer.started' ||
event?.type === 'output_audio_buffer.cleared'
)
outputKnown = true;
notify();
});
} else {
wrap(controller, 'handleMessage', notify);
const original = controller.createAudio;
function createAudio(...args) {
const stamp = version;
const options = args[0];
let current;
const decorated = { ...options };
for (const key of [
'onAudioStart',
'onAudioEnd',
'onAudioDiscard',
'onDrain',
]) {
const callback = options?.[key];
decorated[key] = function (...values) {
const result =
typeof callback === 'function'
? Reflect.apply(
callback,
this === decorated ? options : this,
values,
)
: undefined;
if (
!dead &&
stamp === version &&
audio === current &&
controller.audio === current
) {
if (
key === 'onAudioStart' &&
options.pushToTalk &&
controller.captureOwner?.admitted
)
inputBusy = true;
if (key === 'onAudioEnd' || key === 'onAudioDiscard')
inputBusy = false;
notify();
}
return result;
};
}
const forwarded = args.slice();
forwarded[0] = decorated;
current = Reflect.apply(original, this, forwarded);
if (!dead && stamp === version) {
audio = current;
inputBusy = false;
for (const key of ['play', 'clearPlayback', 'close'])
wrap(current, key, () => {
if (audio === current && controller.audio === current) notify();
});
}
return current;
}
controller.createAudio = createAudio;
restores.push(() => {
if (controller.createAudio === createAudio)
controller.createAudio = original;
});
}
}
function destroy() {
if (dead) return;
dead = true;
version++;
for (const restore of restores.reverse()) restore();
signal?.removeEventListener('abort', destroy);
}
signal?.addEventListener('abort', destroy, { once: true });
if (signal?.aborted) destroy();
return { snapshot, handle, destroy };
}
+473
View File
@@ -0,0 +1,473 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVoiceInactivity } from './inactivity.js';
import { RealtimeTurns } from '../realtimeTurns.js';
import { observeNativeActivity } from './nativeActivity.js';
// Deliberately small native-shaped fixtures exercise wrapper contracts, not devices.
function gemini(onChange = () => {}) {
const callbacks = [];
const input = {
pushToTalkKeyHeld: false,
setMicrophoneEnabled() {},
releasePushToTalkKey() {
this.pushToTalkKeyHeld = false;
},
resetSession() {
this.pushToTalkKeyHeld = false;
},
};
const c = {
input,
captureOwner: { admitted: false },
createAudio(opts) {
callbacks.push(opts);
return {
pending: 0,
play() {
this.pending++;
return 42;
},
clearPlayback() {
this.pending = 0;
},
close() {
this.pending = 0;
},
};
},
handleMessage() {},
};
const observer = observeNativeActivity({
adapter: { controller: c },
nativeProvider: 'gemini',
onChange,
});
const start = () => {
observer.handle({ type: 'state', state: 'connecting' });
c.audio = c.createAudio({ pushToTalk: true });
observer.handle({ type: 'state', state: 'listening' });
return c.audio;
};
return { c, input, observer, callbacks, start };
}
test('custom host never inspected, including hostile controller getter', () => {
const adapter = {
get controller() {
throw Error('must not inspect');
},
};
const o = observeNativeActivity({ adapter, nativeProvider: null });
assert.equal(o.snapshot().known, false);
o.handle({ type: 'state', state: 'listening' });
o.destroy();
});
test('PTT hold is direct before connect; silent automatic stream is not admitted speech', () => {
const f = gemini();
f.input.pushToTalkKeyHeld = true;
assert.equal(f.observer.snapshot().held, true);
assert.equal(f.observer.snapshot().connected, false);
f.start();
f.callbacks[0].onAudioStart({});
assert.equal(f.observer.snapshot().inputBusy, false);
f.c.captureOwner.admitted = true;
f.callbacks[0].onAudioStart({});
assert.equal(f.observer.snapshot().inputBusy, true);
f.callbacks[0].onAudioDiscard();
f.input.releasePushToTalkKey();
assert.equal(f.observer.snapshot().inputBusy, false);
assert.equal(f.observer.snapshot().held, false);
});
test('queue-to-drain, clear and close use current instance, without an acoustic claim', () => {
const f = gemini();
const a = f.start();
assert.equal(a.play(), 42);
assert.equal(f.observer.snapshot().outputBusy, true);
a.clearPlayback();
assert.equal(f.observer.snapshot().outputBusy, false);
a.play();
a.close();
assert.equal(f.observer.snapshot().outputBusy, false);
a.play();
a.pending = 0;
f.callbacks[0].onDrain();
assert.equal(f.observer.snapshot().outputBusy, false);
});
test('same-mount restart old callbacks cannot clear new admitted capture/output', () => {
const f = gemini();
f.start();
const old = f.callbacks[0];
const b = f.start();
f.c.captureOwner.admitted = true;
f.callbacks[1].onAudioStart();
b.play();
old.onAudioDiscard();
old.onDrain();
assert.equal(f.observer.snapshot().inputBusy, true);
assert.equal(f.observer.snapshot().outputBusy, true);
});
test('native callbacks original first with receiver, arguments, return and thrown identity; observation failure isolated', () => {
const f = gemini(() => {
throw Error('observer');
});
const receiver = {};
const argument = {};
let seen;
f.c.audio = f.c.createAudio({
pushToTalk: true,
onAudioEnd: function (...args) {
seen = [this, args];
return argument;
},
});
const cb = f.callbacks[0];
assert.equal(cb.onAudioEnd.call(receiver, argument), argument);
assert.equal(seen[0], receiver);
assert.equal(seen[1][0], argument);
const error = Error('same');
f.c.audio = f.c.createAudio({
onDrain() {
throw error;
},
});
assert.throws(
() => f.callbacks[1].onDrain(),
(e) => e === error,
);
});
test('reentrant Stop in original callback suppresses stale post-callback observation; teardown preserves newer replacement', () => {
let count = 0;
const f = gemini(() => count++);
f.c.audio = f.c.createAudio({
onDrain() {
f.observer.handle({ type: 'state', state: 'idle' });
},
});
const before = count;
f.callbacks[0].onDrain();
assert.equal(count, before + 1);
assert.equal(f.observer.snapshot().connected, false);
const newer = () => {};
f.input.releasePushToTalkKey = newer;
f.observer.destroy();
assert.equal(f.input.releasePushToTalkKey, newer);
});
test('OpenAI response.done does not drain output; absent lifecycle remains unknown', () => {
const turns = {
assistantAudioPlaying: false,
responseActive: false,
observeSpeech(e) {
if (e.type === 'output_audio_buffer.started')
this.assistantAudioPlaying = true;
if (
e.type === 'output_audio_buffer.stopped' ||
e.type === 'output_audio_buffer.cleared'
)
this.assistantAudioPlaying = false;
},
};
const c = { _turns: turns, _input: { pushToTalkKeyHeld: false } };
const original = turns.observeSpeech;
const o = observeNativeActivity({
adapter: { controller: c },
nativeProvider: 'openai',
});
o.handle({ type: 'state', state: 'listening' });
assert.equal(o.snapshot().known, false);
turns.observeSpeech({ type: 'output_audio_buffer.started' });
assert.equal(o.snapshot().known, true);
turns.responseActive = false;
o.handle({ type: 'completion' });
assert.equal(o.snapshot().outputBusy, true);
turns.observeSpeech({ type: 'output_audio_buffer.cleared' });
assert.equal(o.snapshot().outputBusy, false);
o.destroy();
assert.equal(turns.observeSpeech, original);
});
test('method receiver/args/result and throw are exact; abort restores wrappers', () => {
const abort = new AbortController();
const result = Promise.resolve();
let seen;
const error = Error('original');
const input = {
setMicrophoneEnabled(...args) {
seen = [this, args];
if (args[0] === 'throw') throw error;
return result;
},
};
const original = input.setMicrophoneEnabled;
const c = {
input,
createAudio() {
return {};
},
handleMessage() {},
};
const o = observeNativeActivity({
adapter: { controller: c },
nativeProvider: 'gemini',
signal: abort.signal,
onChange() {
throw Error('observe');
},
});
assert.equal(input.setMicrophoneEnabled(1, 2), result);
assert.equal(seen[0], input);
assert.deepEqual(seen[1], [1, 2]);
assert.throws(
() => input.setMicrophoneEnabled('throw'),
(e) => e === error,
);
abort.abort();
assert.equal(input.setMicrophoneEnabled, original);
assert.equal(o.snapshot().known, false);
});
test('options-method callback receiver maps to original options, explicit receiver is preserved', () => {
const f = gemini();
let receiver;
const options = {
onDrain() {
receiver = this;
return 17;
},
};
f.c.audio = f.c.createAudio(options);
assert.equal(f.callbacks[0].onDrain(), 17);
assert.equal(receiver, options);
const explicit = {};
f.callbacks[0].onDrain.call(explicit);
assert.equal(receiver, explicit);
});
test('actual native RealtimeTurns output lifecycle remains busy beyond generation', () => {
const turns = new RealtimeTurns({});
const observer = observeNativeActivity({
adapter: { controller: { _turns: turns, _input: {} } },
nativeProvider: 'openai',
});
observer.handle({ type: 'state', state: 'listening' });
turns.observeSpeech({ type: 'output_audio_buffer.started' });
turns.responseActive = false;
observer.handle({ type: 'completion' });
assert.equal(observer.snapshot().outputBusy, true);
turns.observeSpeech({ type: 'output_audio_buffer.stopped' });
assert.equal(observer.snapshot().outputBusy, false);
turns.observeSpeech({ type: 'response.created' });
turns.observeSpeech({ type: 'output_audio_buffer.stopped' });
assert.equal(observer.snapshot().known, false);
observer.destroy();
});
test('verified clean native mount can arm initial idle, eventless response invalidates it', () => {
const turns = new RealtimeTurns({});
const controller = { status: 'idle', dc: null, _turns: turns, _input: {} };
const observer = observeNativeActivity({
adapter: { controller },
nativeProvider: 'openai',
});
let armed = 0,
cleared = 0;
const idle = createVoiceInactivity({
readActivity: observer.snapshot,
readPending: () => 0,
setTimer() {
armed++;
return 1;
},
clearTimer() {
cleared++;
},
});
assert.equal(observer.snapshot().known, true);
assert.equal(armed, 0);
controller.status = 'listening';
controller.dc = { readyState: 'open' };
observer.handle({ type: 'state', state: 'listening' });
idle.update();
assert.equal(armed, 1);
turns.observeSpeech({ type: 'response.created' });
idle.update();
assert.equal(observer.snapshot().known, false);
assert.equal(cleared, 1);
turns.responseActive = false;
observer.handle({ type: 'completion' });
idle.update();
assert.equal(armed, 1);
assert.equal(observer.snapshot().known, false);
controller.status = 'idle';
controller.dc = null;
turns.reset();
observer.handle({ type: 'state', state: 'idle' });
assert.equal(observer.snapshot().known, true);
idle.destroy();
observer.destroy();
});
test('attaching to already active native transport never assumes empty playback', () => {
const turns = new RealtimeTurns({});
for (const controller of [
{ status: 'listening', dc: {}, _turns: turns, _input: {} },
{ status: 'idle', dc: {}, _turns: turns, _input: {} },
]) {
const observer = observeNativeActivity({
adapter: { controller },
nativeProvider: 'openai',
});
assert.equal(observer.snapshot().known, false);
observer.destroy();
}
turns.responseActive = true;
const observer = observeNativeActivity({
adapter: {
controller: { status: 'idle', dc: null, _turns: turns, _input: {} },
},
nativeProvider: 'openai',
});
assert.equal(observer.snapshot().known, false);
observer.destroy();
});
test('Gemini queue-only work blocks idle, queue drain rearms, expiry rereads delayed queue', () => {
const f = gemini();
f.start();
f.c.controlQueue = [];
let callback,
arms = 0,
clears = 0,
expired = 0;
const idle = createVoiceInactivity({
readActivity: f.observer.snapshot,
readPending: () => 0,
onExpire() {
expired++;
},
setTimer(fn) {
callback = fn;
return ++arms;
},
clearTimer() {
clears++;
},
});
assert.equal(arms, 1);
f.c.controlQueue.push({});
assert.equal(f.observer.snapshot().generationBusy, true);
idle.update();
assert.equal(clears, 1);
f.c.controlQueue.shift();
assert.equal(f.observer.snapshot().generationBusy, false);
idle.update();
assert.equal(arms, 2);
// A queue may change after the last event-driven snapshot and before the deadline.
f.c.controlQueue.push({});
callback();
assert.equal(expired, 0);
f.c.controlQueue = [];
idle.update();
assert.equal(arms, 3);
callback();
assert.equal(expired, 1);
idle.destroy();
f.observer.destroy();
});
test('Gemini presentation-only pending does not block idle; retired/deferred work still does', () => {
const f = gemini();
f.start();
f.c.userTurnPending = true;
assert.equal(f.observer.snapshot().generationBusy, false);
f.c.retiredReplyPending = true;
assert.equal(f.observer.snapshot().generationBusy, true);
f.c.retiredReplyPending = false;
f.c.deferredInput = {};
assert.equal(f.observer.snapshot().generationBusy, true);
f.c.deferredInput = null;
assert.equal(f.observer.snapshot().generationBusy, false);
f.observer.destroy();
});
test('actual Gemini startup phases keep playback observation and permit quiet open-mic expiry', async () => {
const { harness } = await import('../../testSupport/geminiNativeHarness.mjs');
let observer,
idle,
deadline,
expiries = 0;
const h = harness({ emit: (event) => observer?.handle(event) });
observer = observeNativeActivity({
adapter: { controller: h.c },
nativeProvider: 'gemini',
onChange: () => idle?.update(),
});
idle = createVoiceInactivity({
readActivity: observer.snapshot,
readPending: () => 0,
setTimer(fn) {
deadline = fn;
return fn;
},
clearTimer() {},
onExpire() {
expiries++;
},
});
try {
await h.c.start();
assert.equal(h.c.userTurnPending, true);
assert.equal(idle.snapshot().eligible, true);
const beforeOutput = deadline;
h.sockets[0].message({
serverContent: {
modelTurn: {
parts: [
{
inlineData: { data: 'output', mimeType: 'audio/pcm;rate=24000' },
},
],
},
turnComplete: true,
},
});
assert.equal(observer.snapshot().outputBusy, true);
assert.equal(idle.snapshot().eligible, false);
beforeOutput();
assert.equal(expiries, 0);
h.captures[0].drain();
assert.equal(observer.snapshot().outputBusy, false);
assert.equal(idle.snapshot().eligible, true);
deadline();
assert.equal(expiries, 1);
} finally {
idle.destroy();
observer.destroy();
h.c.stop({ removeUi: true });
}
});
test('native typed input observation preserves receiver/result, refuses failed submissions and retires old wrapper', () => {
let inputs = 0;
const controller = {
input: {},
handleMessage() {},
createAudio() {},
sendTextCommand(text) {
assert.equal(this, controller);
if (text === 'throw') throw new Error('rejected');
return text === 'reject' ? false : undefined;
},
};
const original = controller.sendTextCommand;
const observer = observeNativeActivity({
adapter: { controller },
nativeProvider: 'gemini',
onInput: () => inputs++,
});
const old = controller.sendTextCommand;
assert.equal(controller.sendTextCommand('typed request'), undefined);
controller.sendTextCommand('reject');
controller.sendTextCommand('');
assert.throws(() => controller.sendTextCommand('throw'), /rejected/);
assert.equal(inputs, 1);
observer.destroy();
assert.equal(controller.sendTextCommand, original);
old.call(controller, 'late');
assert.equal(inputs, 1);
});
+194
View File
@@ -0,0 +1,194 @@
export const VOICE_PREVIEW_STORAGE_KEY = 'godsEyeView.voice.previewEnabled';
export const VOICE_TIMEOUT_STORAGE_KEY = 'godsEyeView.voice.inactivityMinutes';
const PRESETS = ['3', '5', '10', '15'];
function parseMinutes(value) {
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
if (typeof value === 'string' && !/^\d+$/.test(value)) return undefined;
const number = Number(value);
return Number.isInteger(number) && number >= 0 && number <= 60
? number
: undefined;
}
/** Outer, browser-local live state. Construction never writes storage. */
export function createVoicePreferences({ storage } = {}) {
const listeners = new Set();
const target = () =>
storage === undefined ? globalThis.localStorage : storage;
const read = (key) => {
try {
return target()?.getItem?.(key);
} catch {
return undefined;
}
};
const rawMinutes = read(VOICE_TIMEOUT_STORAGE_KEY);
let state = {
previewEnabled: read(VOICE_PREVIEW_STORAGE_KEY) !== 'false',
minutes: rawMinutes === 'none' ? 0 : (parseMinutes(rawMinutes) ?? 5),
previewPersisted: null,
timeoutPersisted: null,
};
const snapshot = () => ({ ...state });
function apply(field, status, key, value, serialized) {
const changed = state[field] !== value;
let persisted = false;
state = { ...state, [field]: value };
try {
const destination = target();
if (
typeof destination?.setItem === 'function' &&
typeof destination?.getItem === 'function'
) {
destination.setItem(key, serialized);
persisted = destination.getItem(key) === serialized;
}
} catch {
/* The applied live value remains authoritative across mounts. */
}
state = { ...state, [status]: persisted };
const result = {
ok: true,
applied: true,
changed,
persisted,
...snapshot(),
};
for (const listener of [...listeners]) listener(snapshot());
return result;
}
const invalid = () => ({
ok: false,
applied: false,
changed: false,
persisted: null,
reason: 'invalid-value',
...snapshot(),
});
return {
snapshot,
setPreviewEnabled(value) {
return typeof value === 'boolean'
? apply(
'previewEnabled',
'previewPersisted',
VOICE_PREVIEW_STORAGE_KEY,
value,
String(value),
)
: invalid();
},
setMinutes(value) {
const parsed = parseMinutes(value);
return parsed === undefined
? invalid()
: apply(
'minutes',
'timeoutPersisted',
VOICE_TIMEOUT_STORAGE_KEY,
parsed,
parsed === 0 ? 'none' : String(parsed),
);
},
subscribe(listener) {
if (typeof listener !== 'function')
throw new TypeError('Preference listener required');
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
/** Mount-local controls, backed by an independently owned preference model. */
export function bindPreferenceControls({ ui, preferences, copy, onChange }) {
const {
voicePreviewInput: preview,
voicePreviewNote: previewNote,
voiceTimeoutSelect: select,
voiceTimeoutInput: input,
voiceTimeoutNote: timeoutNote,
} = ui;
let disposed = false;
const initial = preferences.snapshot();
const displayed = parseMinutes(input?.value);
let custom =
!PRESETS.includes(String(initial.minutes)) && initial.minutes > 0
? initial.minutes
: displayed > 0
? displayed
: 5;
function render(state) {
if (disposed) return;
if (preview) preview.checked = state.previewEnabled;
if (previewNote)
previewNote.textContent = `${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`;
if (timeoutNote)
timeoutNote.textContent = `${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`;
const value = String(state.minutes);
const customSelected = state.minutes > 0 && !PRESETS.includes(value);
if (customSelected) custom = state.minutes;
if (select)
select.value =
state.minutes === 0 ? '0' : customSelected ? 'custom' : value;
if (input) {
input.value = String(custom);
input.hidden = !customSelected;
}
}
const unsubscribe = preferences.subscribe((state) => {
render(state);
onChange?.(state);
});
const previewChange = () => {
if (!disposed && !preferences.setPreviewEnabled(preview.checked).ok)
render(preferences.snapshot());
};
const selectChange = () => {
if (disposed) return;
const value = select.value;
if (value === 'custom') {
const parsed = parseMinutes(input?.value);
if (parsed === undefined || parsed === 0) {
render(preferences.snapshot());
return;
}
custom = parsed;
preferences.setMinutes(parsed);
// A custom value may equal a preset: retain the user's explicit editor choice.
select.value = 'custom';
if (input) input.hidden = false;
} else if (value === '0' || PRESETS.includes(value))
preferences.setMinutes(value);
else render(preferences.snapshot());
};
const inputChange = () => {
if (disposed) return;
const parsed = parseMinutes(input.value);
if (parsed === undefined || parsed === 0) {
render(preferences.snapshot());
return;
}
custom = parsed;
preferences.setMinutes(parsed);
if (select) select.value = 'custom';
input.hidden = false;
};
const bindings = [
[preview, previewChange],
[select, selectChange],
[input, inputChange],
];
for (const [element, listener] of bindings)
element?.addEventListener('change', listener);
render(initial);
return {
dispose() {
if (disposed) return;
disposed = true;
unsubscribe();
for (const [element, listener] of bindings)
element?.removeEventListener('change', listener);
},
};
}
+272
View File
@@ -0,0 +1,272 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createVoicePreferences,
bindPreferenceControls,
VOICE_PREVIEW_STORAGE_KEY as P,
VOICE_TIMEOUT_STORAGE_KEY as T,
} from './preferences.js';
function storage(entries = {}, mode = 'normal') {
const values = new Map(Object.entries(entries));
const writes = [];
return {
writes,
values,
getItem(key) {
if (mode === 'readthrow') throw Error('blocked');
return values.get(key) ?? null;
},
setItem(key, value) {
writes.push([key, value]);
if (mode === 'throw') throw Error('blocked');
if (mode !== 'noop' && mode !== 'readthrow')
values.set(key, mode === 'mismatch' ? `${value}!` : value);
},
};
}
class Element {
value = '';
checked = false;
textContent = '';
hidden = false;
listeners = new Set();
addEventListener(type, listener) {
assert.equal(type, 'change');
this.listeners.add(listener);
}
removeEventListener(type, listener) {
assert.equal(type, 'change');
this.listeners.delete(listener);
}
change(value) {
if (typeof value === 'boolean') this.checked = value;
else this.value = value;
for (const listener of [...this.listeners]) listener();
}
}
function controls(custom = '7') {
const ui = Object.fromEntries(
[
'voicePreviewInput',
'voicePreviewNote',
'voiceTimeoutSelect',
'voiceTimeoutInput',
'voiceTimeoutNote',
].map((key) => [key, new Element()]),
);
ui.voiceTimeoutInput.value = custom;
return ui;
}
const copy = {
previewNote: 'Preview local',
timeoutNote: 'Provider limits still apply',
notSaved: 'Not saved',
};
function bind(ui, preferences, onChange) {
return bindPreferenceControls({ ui, preferences, copy, onChange });
}
test('defaults and restore are passive; legacy None and stored custom restore', () => {
for (const [entries, minutes, previewEnabled] of [
[{}, 5, true],
[{ [P]: 'false', [T]: 'none' }, 0, false],
[{ [T]: '17' }, 17, true],
[{ [T]: 'broken' }, 5, true],
]) {
const disk = storage(entries);
const prefs = createVoicePreferences({ storage: disk });
assert.deepEqual(prefs.snapshot(), {
previewEnabled,
minutes,
previewPersisted: null,
timeoutPersisted: null,
});
const ui = controls();
bind(ui, prefs).dispose();
assert.deepEqual(disk.writes, []);
}
});
test('exact valid values, presets, None and serialized readback', () => {
const disk = storage();
const prefs = createVoicePreferences({ storage: disk });
for (const value of [3, 5, 10, 15, '1', '60', 0]) {
const result = prefs.setMinutes(value);
assert.equal(result.ok, true);
assert.equal(result.applied, true);
assert.equal(result.persisted, true);
assert.equal(result.minutes, Number(value));
assert.deepEqual(disk.writes.at(-1), [
T,
Number(value) === 0 ? 'none' : String(Number(value)),
]);
}
assert.equal(prefs.setPreviewEnabled(false).previewEnabled, false);
assert.deepEqual(disk.writes.at(-1), [P, 'false']);
});
test('fractional, malformed, coercible and out-of-range inputs never write or notify', () => {
const disk = storage();
const prefs = createVoicePreferences({ storage: disk });
let notices = 0;
prefs.subscribe(() => notices++);
const before = prefs.snapshot();
for (const value of [
null,
undefined,
true,
{},
[],
'',
' ',
' 3',
'3 ',
'3.0',
'1e1',
'0x10',
'none',
-1,
61,
3.2,
NaN,
Infinity,
]) {
const result = prefs.setMinutes(value);
assert.equal(result.ok, false, String(value));
assert.equal(result.applied, false);
assert.equal(result.changed, false);
assert.deepEqual(prefs.snapshot(), before);
}
for (const value of [0, 'true', null])
assert.equal(prefs.setPreviewEnabled(value).ok, false);
assert.equal(notices, 0);
assert.deepEqual(disk.writes, []);
});
for (const mode of ['throw', 'noop', 'mismatch', 'readthrow', 'missing']) {
test(`storage ${mode} preserves live value and exposes unsaved state across A → B`, () => {
const disk = mode === 'missing' ? null : storage({}, mode);
const prefs = createVoicePreferences({ storage: disk });
const a = controls();
const bindingA = bind(a, prefs);
a.voicePreviewInput.change(false);
a.voiceTimeoutSelect.change('custom');
a.voiceTimeoutInput.change('19');
assert.deepEqual(prefs.snapshot(), {
previewEnabled: false,
minutes: 19,
previewPersisted: false,
timeoutPersisted: false,
});
bindingA.dispose();
const writeCount = disk?.writes.length;
const b = controls();
const bindingB = bind(b, prefs);
assert.equal(b.voicePreviewInput.checked, false);
assert.equal(b.voiceTimeoutInput.value, '19');
assert.match(b.voicePreviewNote.textContent, /Not saved/);
assert.match(b.voiceTimeoutNote.textContent, /Not saved/);
assert.equal(disk?.writes.length, writeCount);
a.voicePreviewInput.change(true);
a.voiceTimeoutSelect.change('3');
assert.equal(prefs.snapshot().minutes, 19);
assert.equal(prefs.snapshot().previewEnabled, false);
bindingB.dispose();
});
}
test('same-value retry really writes and clears warnings after storage recovers', () => {
const disk = storage();
const original = disk.setItem;
disk.setItem = () => {
throw Error('blocked');
};
const prefs = createVoicePreferences({ storage: disk });
const ui = controls();
bind(ui, prefs);
prefs.setPreviewEnabled(false);
prefs.setMinutes(12);
assert.match(ui.voiceTimeoutNote.textContent, /Not saved/);
disk.setItem = original;
const timeout = prefs.setMinutes(12);
const preview = prefs.setPreviewEnabled(false);
assert.equal(timeout.changed, false);
assert.equal(preview.changed, false);
assert.equal(timeout.persisted, true);
assert.equal(preview.persisted, true);
assert.deepEqual(disk.writes, [
[T, '12'],
[P, 'false'],
]);
assert.equal(ui.voiceTimeoutNote.textContent, copy.timeoutNote);
assert.equal(ui.voicePreviewNote.textContent, copy.previewNote);
});
test('Custom commits displayed valid value immediately, even when equal to preset', () => {
const disk = storage();
const prefs = createVoicePreferences({ storage: disk });
const ui = controls('10');
bind(ui, prefs);
assert.equal(disk.writes.length, 0);
ui.voiceTimeoutSelect.change('custom');
assert.equal(prefs.snapshot().minutes, 10);
assert.equal(ui.voiceTimeoutSelect.value, 'custom');
assert.equal(ui.voiceTimeoutInput.hidden, false);
assert.deepEqual(disk.writes, [[T, '10']]);
ui.voiceTimeoutInput.change('23');
ui.voiceTimeoutSelect.change('5');
assert.equal(ui.voiceTimeoutInput.value, '23');
assert.equal(ui.voiceTimeoutInput.hidden, true);
ui.voiceTimeoutSelect.change('custom');
assert.equal(prefs.snapshot().minutes, 23);
assert.equal(ui.voiceTimeoutInput.value, '23');
});
test('invalid control edits restore valid state without writes; None remains distinct', () => {
const disk = storage({ [T]: '17' });
const prefs = createVoicePreferences({ storage: disk });
const ui = controls();
bind(ui, prefs);
assert.equal(ui.voiceTimeoutInput.value, '17');
for (const value of ['1.5', '', '61', '0']) {
ui.voiceTimeoutInput.change(value);
assert.equal(ui.voiceTimeoutInput.value, '17');
assert.equal(prefs.snapshot().minutes, 17);
}
ui.voiceTimeoutSelect.change('malformed');
assert.equal(ui.voiceTimeoutSelect.value, 'custom');
assert.equal(disk.writes.length, 0);
ui.voiceTimeoutSelect.change('0');
assert.equal(prefs.snapshot().minutes, 0);
assert.equal(ui.voiceTimeoutInput.hidden, true);
ui.voiceTimeoutInput.value = 'bad';
ui.voiceTimeoutSelect.change('custom');
assert.equal(ui.voiceTimeoutSelect.value, '0');
assert.equal(prefs.snapshot().minutes, 0);
assert.deepEqual(disk.writes, [[T, 'none']]);
});
test('model snapshots cannot mutate state; unsubscribe and binder disposal release ownership', () => {
const prefs = createVoicePreferences({ storage: storage() });
const ui = controls();
let calls = 0;
const binding = bind(ui, prefs, () => calls++);
const snap = prefs.snapshot();
snap.minutes = 41;
assert.equal(prefs.snapshot().minutes, 5);
const unsub = prefs.subscribe((state) => {
state.minutes = 22;
});
prefs.setMinutes(11);
assert.equal(calls, 1);
assert.equal(prefs.snapshot().minutes, 11);
unsub();
binding.dispose();
binding.dispose();
prefs.setMinutes(12);
assert.equal(calls, 1);
assert.equal(ui.voiceTimeoutInput.value, '11');
for (const element of Object.values(ui))
assert.equal(element.listeners.size, 0);
});
+204
View File
@@ -0,0 +1,204 @@
// Minimal DOM fixture for actual control/card/binder composition, not layout proof.
class Element {
constructor(doc, tag = 'div') {
Object.assign(this, {
ownerDocument: doc,
tagName: tag.toUpperCase(),
children: [],
attributes: new Map(),
dataset: {},
hidden: false,
value: '',
checked: false,
listeners: new Map(),
style: { setProperty() {}, removeProperty() {} },
});
this.classList = {
add: (...names) => names.forEach((n) => this.classes.add(n)),
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
contains: (name) => this.classes.has(name),
};
this.classes = new Set();
}
set id(value) {
this.attributes.set('id', value);
}
get id() {
return this.attributes.get('id') || '';
}
set className(value) {
this.classes = new Set(value.split(/\s+/).filter(Boolean));
}
get className() {
return [...this.classes].join(' ');
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.className = value;
if (name === 'hidden') this.hidden = true;
if (name === 'value') this.value = value;
if (name.startsWith('data-'))
this.dataset[
name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
] = value;
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
get isConnected() {
return (
this === this.ownerDocument.body || Boolean(this.parentNode?.isConnected)
);
}
get parentElement() {
return this.parentNode;
}
set innerHTML(html) {
this.replaceChildren();
const stack = [this];
const voids = new Set(['INPUT', 'IMG', 'BR', 'HR']);
for (const match of html.matchAll(/<(\/?)([a-z][\w-]*)\b([^>]*)>/gi)) {
if (match[1]) {
if (stack.length > 1) stack.pop();
continue;
}
const node = new Element(this.ownerDocument, match[2]);
for (const attr of match[3].matchAll(/([^\s=\/]+)(?:="([^"]*)")?/g))
node.setAttribute(attr[1], attr[2] ?? '');
stack.at(-1).appendChild(node);
if (!voids.has(node.tagName)) stack.push(node);
}
}
set textContent(value) {
this.text = String(value);
}
get textContent() {
return this.text ?? '';
}
appendChild(node) {
node.remove();
this.children.push(node);
node.parentNode = this;
return node;
}
append(...nodes) {
nodes.forEach((node) => this.appendChild(node));
}
insertBefore(node, before) {
node.remove();
const at = this.children.indexOf(before);
this.children.splice(at < 0 ? this.children.length : at, 0, node);
node.parentNode = this;
}
replaceChildren(...nodes) {
for (const child of this.children) child.parentNode = null;
this.children = [];
this.append(...nodes);
}
remove() {
if (this.parentNode)
this.parentNode.children = this.parentNode.children.filter(
(n) => n !== this,
);
this.parentNode = null;
}
contains(node) {
return node === this || this.children.some((child) => child.contains(node));
}
matches(selector) {
if (selector.startsWith('#')) return this.id === selector.slice(1);
if (selector.startsWith('.')) return this.classes.has(selector.slice(1));
return this.tagName === selector.toUpperCase();
}
querySelectorAll(selector) {
return this.children.flatMap((n) => [
...(n.matches(selector) ? [n] : []),
...n.querySelectorAll(selector),
]);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
checkVisibility() {
return (
this.isConnected &&
!this.hidden &&
(!this.parentNode || this.parentNode.checkVisibility())
);
}
focus() {
this.ownerDocument.activeElement = this;
}
addEventListener(type, listener, options = {}) {
if (options.signal?.aborted) return;
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
if (options.signal)
options.signal.addEventListener(
'abort',
() => this.removeEventListener(type, listener),
{ once: true },
);
}
removeEventListener(type, listener) {
this.listeners.get(type)?.delete(listener);
}
fire(type, fields = {}) {
const event = {
type,
target: this,
preventDefault() {
this.defaultPrevented = true;
},
...fields,
};
for (const listener of [...(this.listeners.get(type) || [])])
listener.call(this, event);
return event;
}
}
const installed = new WeakMap();
export function installPanelDom(t) {
if (installed.has(t)) return installed.get(t);
const previous = {
window: globalThis.window,
document: globalThis.document,
Element: globalThis.Element,
};
const doc = new EventTarget();
doc.body = new Element(doc, 'body');
doc.documentElement = new Element(doc, 'html');
doc.documentElement.lang = 'en';
doc.activeElement = doc.body;
doc.createElement = (tag) => new Element(doc, tag);
doc.getElementById = (id) => doc.body.querySelector(`#${id}`);
doc.querySelector = (selector) => doc.body.querySelector(selector);
const win = new EventTarget();
globalThis.window = win;
globalThis.document = doc;
globalThis.Element = Element;
installed.set(t, doc);
t.after(() => Object.assign(globalThis, previous));
return doc;
}
export 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();
},
};
}
+45 -9
View File
@@ -36,15 +36,24 @@ export function createVoiceCommands({
pointer = null,
referents = null,
createReticle = createDefaultReticle,
panelHost = null,
nativePanelProvider = null,
}) {
if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true });
const ui = createControl({ reset: true });
const ui = createControl({
reset: true,
...(panelHost ? { panel: true } : {}),
});
let actionObserver;
let panelMount;
const session = createVoiceSession({
runner,
signal,
createAdapter: (hooks) =>
createSession({
createAdapter: (hooks) => {
if (panelHost) actionObserver = panelHost.observeActions(hooks);
return createSession({
...hooks,
...(actionObserver ? { runAction: actionObserver.runAction } : {}),
runner,
ui,
dataManager,
@@ -53,7 +62,8 @@ export function createVoiceCommands({
createController,
pointer,
radioLayer: dataManager?.layers?.get('radio')?.module || null,
}),
});
},
});
const adapter = session.adapter;
const capabilities = adapter.capabilities || {};
@@ -117,11 +127,31 @@ export function createVoiceCommands({
});
// Captions, plan and result card nested in the control; it only reads
// provider-neutral session events.
const card = ui.card ? createCard({ root: ui.root }) : null;
const updateCard = card
? session.subscribe((event) => card.handle(event))
const card = ui.card
? createCard({
root: ui.root,
...(panelHost ? { onRender: () => panelMount?.panel.sync() } : {}),
})
: null;
panelMount = panelHost?.mount({
ui,
card,
session,
observer: actionObserver,
nativeProvider: nativePanelProvider,
});
const updatePanel = panelMount ? session.subscribe(panelMount.handle) : null;
const updateCard =
card && !panelMount
? session.subscribe((event) => card.handle(event))
: null;
controls.voiceCard = card;
if (panelMount) {
controls.voicePanel = panelMount.panel;
controls.captureVoicePanelOperation = panelMount.captureOperation;
controls.getVoicePanelState = panelMount.snapshot;
controls.getVoicePreferences = panelHost.snapshot;
}
// Point-and-ask: the reticle follows turn events; pointer and referents are
// session-scoped, so a session that ends forgets both.
const reticle = pointer ? createReticle(pointer) : null;
@@ -151,8 +181,11 @@ export function createVoiceCommands({
);
const buttonHandler = () => {
if (adapter.ignoreButtonClick?.()) return;
if (session.isActive()) session.stop();
else void session.start({ pushToTalk: false });
if (session.isActive()) {
const ticket = panelMount?.stopTicket();
session.stop();
panelMount?.closeAfterStop(ticket);
} else void session.start({ pushToTalk: false });
};
ui.button.addEventListener('click', buttonHandler);
let cleaned = false;
@@ -165,6 +198,9 @@ export function createVoiceCommands({
annotationUnsubscribe?.();
updateStatus();
updateCard?.();
updatePanel?.();
panelMount?.destroy();
actionObserver?.dispose();
updatePointer();
forwardEvents?.();
reticle?.destroy();
+1
View File
@@ -848,6 +848,7 @@ const STEP_TITLES = Object.freeze({
zoom_to_globe: msg('Zoom out to globe'),
show_data_layers_menu: msg('Open data layers'),
set_panel_open: msg('Open or close a panel'),
set_voice_inactivity_timeout: msg('Voice inactivity timeout'),
set_context_mode: msg('Change context mode'),
control_cockpit: msg('Control cockpit'),
set_visual_style: msg('Change visual style'),
+56 -3
View File
@@ -13,6 +13,7 @@ const ELEMENT_IDS = Object.freeze({
dismiss: 'gev-voice-card-dismiss',
user: 'gev-voice-card-user',
userText: 'gev-voice-card-user-text',
userWho: 'gev-voice-card-user-who',
pointer: 'gev-voice-card-pointer',
pointerKind: 'gev-voice-card-pointer-kind',
pointerText: 'gev-voice-card-pointer-text',
@@ -70,8 +71,9 @@ export class VoiceCardControls {
idleMs = VOICE_CARD_IDLE_MS,
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (id) => clearTimeout(id),
onRender,
}) {
Object.assign(this, { idleMs, setTimer, clearTimer });
Object.assign(this, { idleMs, setTimer, clearTimer, onRender });
this.elements = Object.fromEntries(
Object.entries(ELEMENT_IDS).map(([key, id]) => [
key,
@@ -79,6 +81,10 @@ export class VoiceCardControls {
]),
);
this.state = initialVoiceCardState();
this.enabled = true;
this.presented = true;
this.announcementRevision = 0;
this.consumedPresentationAnnouncement = -1;
this.hideTimer = null;
this.destroyed = false;
this.listeners = new AbortController();
@@ -96,17 +102,53 @@ export class VoiceCardControls {
if (this.destroyed || !this.elements.card) return;
const next = reduceVoiceCard(this.state, event);
if (next === this.state) return;
if (
next.announce !== this.state.announce ||
(next.announce &&
((event.type === 'transcript' &&
event.role === 'assistant' &&
event.final) ||
(event.type === 'action-result' &&
next.result !== this.state.result) ||
(event.type === 'pointer' &&
next.pointer &&
next.pointer !== this.state.pointer)))
)
this.announcementRevision++;
this.state = next;
this.render();
if (this.state.visible) this.scheduleIdle();
else this.cancelIdle();
}
/** A local submission allows future output after dismissal; keep its content. */
rearm() {
if (this.destroyed || !this.elements.card) return;
this.state = { ...this.state, dismissed: false };
}
/** An annotation outline resolved or failed after its tool result. */
outline(event) {
this.handle({ type: 'outline', id: event?.id, status: event?.status });
}
/** Change presentation only; session events and the idle deadline continue. */
setEnabled(enabled) {
if (this.destroyed || !this.elements.card) return;
this.enabled = Boolean(enabled);
this.render();
}
/** View selection changes presentation without changing the saved preference. */
setPresented(presented) {
if (this.destroyed || !this.elements.card || this.presented === presented)
return;
if (!presented)
this.consumedPresentationAnnouncement = this.announcementRevision;
this.presented = presented;
this.render();
}
scheduleIdle() {
this.cancelIdle();
this.hideTimer = this.setTimer(() => {
@@ -129,12 +171,13 @@ export class VoiceCardControls {
render() {
const el = this.elements;
const view = voiceCardView(this.state);
const view = this.snapshot();
setHidden(el.card, !view.visible);
el.card.dataset.busy = String(view.busy);
setText(el.phase, view.phase);
setHidden(el.user, !view.user.visible);
setText(el.userText, view.user.text);
setText(el.userWho, view.user.label);
setHidden(el.pointer, !view.pointer.visible);
setText(el.pointerKind, view.pointer.kind);
setText(el.pointerText, view.pointer.text);
@@ -186,11 +229,21 @@ export class VoiceCardControls {
node.textContent = note;
});
setText(el.live, view.announce);
this.onRender?.();
}
/** Test/diagnostic view of what the card shows. */
snapshot() {
return voiceCardView(this.state);
const view = voiceCardView(this.state);
return this.enabled && this.presented
? {
...view,
announce:
this.consumedPresentationAnnouncement === this.announcementRevision
? ''
: view.announce,
}
: { ...view, visible: false, announce: '' };
}
/** Idempotent: removes listeners and the idle timer; markup stays owned by the control. */
+464 -46
View File
@@ -3,13 +3,17 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { VoiceCardControls } from './voiceCard.js';
import { getLocale, setLocale } from '../i18n/index.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
const controlSource = readFileSync(new URL('./control.js', import.meta.url), 'utf8');
const controlSource = readFileSync(
new URL('./control.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../../style.css', import.meta.url));
function fold(events) {
@@ -19,11 +23,37 @@ function fold(events) {
test('presentation: a slow annotate turn shows captions, plan steps and the result card', () => {
const state = fold([
{ type: 'interruption' },
{ type: 'transcript', role: 'user', text: 'Annotate the Texas State Capitol', final: true },
{ type: 'transcript', role: 'assistant', text: 'Finding the Capitol, ', responseId: 'r1' },
{ type: 'transcript', role: 'assistant', text: 'then outlining it.', responseId: 'r1' },
{ type: 'action-call', name: 'annotate_map', callId: 'call_a', arguments: { annotations: [{ target: 'Texas State Capitol' }] } },
{ type: 'progress', name: 'annotate_map', callId: 'call_a', step: 'resolve', label: 'Texas State Capitol' },
{
type: 'transcript',
role: 'user',
text: 'Annotate the Texas State Capitol',
final: true,
},
{
type: 'transcript',
role: 'assistant',
text: 'Finding the Capitol, ',
responseId: 'r1',
},
{
type: 'transcript',
role: 'assistant',
text: 'then outlining it.',
responseId: 'r1',
},
{
type: 'action-call',
name: 'annotate_map',
callId: 'call_a',
arguments: { annotations: [{ target: 'Texas State Capitol' }] },
},
{
type: 'progress',
name: 'annotate_map',
callId: 'call_a',
step: 'resolve',
label: 'Texas State Capitol',
},
]);
let view = voiceCardView(state);
assert.equal(view.visible, true);
@@ -31,9 +61,16 @@ test('presentation: a slow annotate turn shows captions, plan steps and the resu
assert.equal(view.phase, 'WORKING');
assert.equal(view.user.text, 'Annotate the Texas State Capitol');
assert.equal(view.assistant.text, 'Finding the Capitol, then outlining it.');
assert.deepEqual(view.steps.map(({ label, status, detail }) => ({ label, status, detail })), [
{ label: 'Mark Texas State Capitol', status: 'running', detail: 'Finding places: Texas State Capitol' },
]);
assert.deepEqual(
view.steps.map(({ label, status, detail }) => ({ label, status, detail })),
[
{
label: 'Mark Texas State Capitol',
status: 'running',
detail: 'Finding places: Texas State Capitol',
},
],
);
const done = [
{
type: 'action-result',
@@ -43,7 +80,12 @@ test('presentation: a slow annotate turn shows captions, plan steps and the resu
ok: true,
outlinePending: true,
items: [{ ok: true, id: 'anno-1', outlinePending: true }],
display: { title: 'Marked 1 place', lines: ['Texas State Capitol'], notes: ['Tracing outlines'], sources: [{ label: 'OpenStreetMap' }] },
display: {
title: 'Marked 1 place',
lines: ['Texas State Capitol'],
notes: ['Tracing outlines'],
sources: [{ label: 'OpenStreetMap' }],
},
referents: [{ n: 1, id: 'anno-1', label: 'Texas State Capitol' }],
},
},
@@ -53,35 +95,80 @@ test('presentation: a slow annotate turn shows captions, plan steps and the resu
assert.equal(view.phase, 'DONE');
assert.equal(view.steps[0].detail, 'Outline traced');
assert.equal(view.result.title, 'Marked 1 place');
assert.deepEqual(view.result.referents, [{ n: 1, label: 'Texas State Capitol' }]);
assert.deepEqual(view.result.notes, ['Tracing outlines', 'Source: OpenStreetMap']);
assert.equal(view.announce, 'Marked 1 place', 'the live region carries the result title');
assert.deepEqual(view.result.referents, [
{ n: 1, label: 'Texas State Capitol' },
]);
assert.deepEqual(view.result.notes, [
'Tracing outlines',
'Source: OpenStreetMap',
]);
assert.equal(
view.announce,
'Marked 1 place',
'the live region carries the result title',
);
});
test('presentation: silent lookups, dismissal and session end', () => {
const lookup = fold([
{ type: 'interruption' },
{ type: 'action-call', name: 'get_current_view_state', arguments: {} },
{ type: 'action-result', name: 'get_current_view_state', result: { ok: true, schedule: 'silent', display: { title: 'View state' } } },
{
type: 'action-result',
name: 'get_current_view_state',
result: {
ok: true,
schedule: 'silent',
display: { title: 'View state' },
},
},
]);
assert.equal(voiceCardView(lookup).result.visible, false, 'a silent lookup does not take the result card');
assert.equal(
voiceCardView(lookup).result.visible,
false,
'a silent lookup does not take the result card',
);
const dismissed = reduceVoiceCard(lookup, { type: 'dismiss' });
assert.equal(voiceCardView(dismissed).visible, false);
const stillDismissed = reduceVoiceCard(dismissed, { type: 'transcript', role: 'assistant', text: 'Hi', final: true });
assert.equal(voiceCardView(stillDismissed).visible, false, 'dismissed until the next turn');
assert.equal(voiceCardView(reduceVoiceCard(stillDismissed, { type: 'interruption' })).visible, true);
assert.deepEqual(reduceVoiceCard(lookup, { type: 'state', state: 'idle' }), initialVoiceCardState());
assert.equal(reduceVoiceCard(lookup, { type: 'turn-metrics' }), lookup, 'unrelated events are no-ops');
const stillDismissed = reduceVoiceCard(dismissed, {
type: 'transcript',
role: 'assistant',
text: 'Hi',
final: true,
});
assert.equal(
voiceCardView(stillDismissed).visible,
false,
'dismissed until the next turn',
);
assert.equal(
voiceCardView(reduceVoiceCard(stillDismissed, { type: 'interruption' }))
.visible,
true,
);
assert.deepEqual(
reduceVoiceCard(lookup, { type: 'state', state: 'idle' }),
initialVoiceCardState(),
);
assert.equal(
reduceVoiceCard(lookup, { type: 'turn-metrics' }),
lookup,
'unrelated events are no-ops',
);
});
test('markup: the card nests inside the voice control with every element and a polite live region', () => {
const root = controlSource.indexOf("root.id = 'gev-voice-control'");
const card = controlSource.indexOf('id="gev-voice-card"');
assert.ok(root >= 0 && card > root, 'the card is part of the voice control markup');
assert.ok(
root >= 0 && card > root,
'the card is part of the voice control markup',
);
for (const id of [
'gev-voice-card-phase',
'gev-voice-card-dismiss',
'gev-voice-card-user-text',
'gev-voice-card-user-who',
'gev-voice-card-assistant-text',
'gev-voice-card-plan',
'gev-voice-card-result-title',
@@ -92,11 +179,22 @@ test('markup: the card nests inside the voice control with every element and a p
]) {
assert.match(controlSource, new RegExp(`id="${id}"`), `${id} is missing`);
}
assert.match(controlSource, /id="gev-voice-card-live"[^>]*aria-live="polite"/);
assert.match(
controlSource,
/id="gev-voice-card-live"[^>]*aria-live="polite"/,
);
assert.match(controlSource, /aria-label="\$\{t\('Dismiss voice card'\)\}"/);
assert.match(css, /#gev-voice-control \.gev-voice-card \{/);
assert.match(css, /body\.recording-mode #gev-voice-control/, 'recording mode hides the control, card included');
assert.match(css, /body\.ui-clean-view #command-dock/, 'Clean-UI hides the dock, card included');
assert.match(
css,
/body\.recording-mode #gev-voice-control/,
'recording mode hides the control, card included',
);
assert.match(
css,
/body\.ui-clean-view #command-dock/,
'Clean-UI hides the dock, card included',
);
});
function fakeRoot() {
@@ -111,7 +209,12 @@ function fakeRoot() {
ownerDocument: null,
addEventListener(type, handler, options) {
listeners.set(type, handler);
options?.signal?.addEventListener('abort', () => listeners.delete(type));
options?.signal?.addEventListener('abort', () =>
listeners.delete(type),
);
},
removeEventListener(type, handler) {
if (listeners.get(type) === handler) listeners.delete(type);
},
fire: (type) => listeners.get(type)?.({}),
listenerCount: () => listeners.size,
@@ -128,6 +231,7 @@ function fakeRoot() {
const nodes = new Map();
const doc = { createElement: (tag) => ({ ...make(tag), tag }) };
const root = {
...make('gev-voice-control'),
querySelector(selector) {
const id = selector.slice(1);
if (!nodes.has(id)) {
@@ -160,12 +264,26 @@ test('controls: render, idle hide, dismiss and idempotent teardown', () => {
});
assert.equal(nodes.get('gev-voice-card').hidden, true, 'starts hidden');
card.handle({ type: 'interruption' });
card.handle({ type: 'action-call', name: 'fly_to_location', arguments: { query: 'Tokyo' } });
card.handle({ type: 'action-result', name: 'fly_to_location', result: { ok: true, display: { title: 'Tokyo', lines: [], chips: [{ label: 'stale' }] } } });
card.handle({
type: 'action-call',
name: 'fly_to_location',
arguments: { query: 'Tokyo' },
});
card.handle({
type: 'action-result',
name: 'fly_to_location',
result: {
ok: true,
display: { title: 'Tokyo', lines: [], chips: [{ label: 'stale' }] },
},
});
assert.equal(nodes.get('gev-voice-card').hidden, false);
assert.equal(nodes.get('gev-voice-card-result-title').textContent, 'Tokyo');
assert.equal(nodes.get('gev-voice-card-plan').children.length, 1);
assert.equal(nodes.get('gev-voice-card-chips').children[0].textContent, 'stale');
assert.equal(
nodes.get('gev-voice-card-chips').children[0].textContent,
'stale',
);
assert.equal(timers.size, 1, 'one idle timer at a time');
[...timers.values()][0]();
assert.equal(nodes.get('gev-voice-card').hidden, true, 'hides when idle');
@@ -177,7 +295,11 @@ test('controls: render, idle hide, dismiss and idempotent teardown', () => {
assert.equal(timers.size, 0);
assert.equal(nodes.get('gev-voice-card-dismiss').listenerCount(), 0);
card.handle({ type: 'interruption' });
assert.equal(nodes.get('gev-voice-card').hidden, true, 'a destroyed card ignores late events');
assert.equal(
nodes.get('gev-voice-card').hidden,
true,
'a destroyed card ignores late events',
);
});
test('controls: a control without card markup is inert', () => {
@@ -189,17 +311,41 @@ test('controls: a control without card markup is inert', () => {
test('presentation: concurrent calls of one tool update their own steps', () => {
const state = fold([
{ type: 'interruption' },
{ type: 'action-call', name: 'fly_to_location', callId: 'c1', arguments: { query: 'Tokyo' } },
{ type: 'action-call', name: 'fly_to_location', callId: 'c2', arguments: { query: 'Paris' } },
{
type: 'action-call',
name: 'fly_to_location',
callId: 'c1',
arguments: { query: 'Tokyo' },
},
{
type: 'action-call',
name: 'fly_to_location',
callId: 'c2',
arguments: { query: 'Paris' },
},
// Out of order: the first call finishes last.
{ type: 'action-result', name: 'fly_to_location', callId: 'c2', result: { ok: false, error: 'x' } },
{ type: 'progress', name: 'fly_to_location', callId: 'c1', step: 'search', label: 'Tokyo' },
{
type: 'action-result',
name: 'fly_to_location',
callId: 'c2',
result: { ok: false, error: 'x' },
},
{
type: 'progress',
name: 'fly_to_location',
callId: 'c1',
step: 'search',
label: 'Tokyo',
},
]);
const steps = voiceCardView(state).steps;
assert.deepEqual(steps.map(({ label, status }) => [label, status]), [
['Fly to Tokyo', 'running'],
['Fly to Paris', 'failed'],
]);
assert.deepEqual(
steps.map(({ label, status }) => [label, status]),
[
['Fly to Tokyo', 'running'],
['Fly to Paris', 'failed'],
],
);
assert.equal(steps[0].detail, 'Looking up place: Tokyo');
});
@@ -207,27 +353,299 @@ test('presentation: stale and unmatched events never reopen the card', () => {
const ended = fold([
{ type: 'interruption' },
{ type: 'action-call', name: 'annotate_map', callId: 'c1', arguments: {} },
{ type: 'action-result', name: 'annotate_map', callId: 'c1', result: { ok: true, outlinePending: true, items: [{ ok: true, id: 'anno-9', outlinePending: true }] } },
{
type: 'action-result',
name: 'annotate_map',
callId: 'c1',
result: {
ok: true,
outlinePending: true,
items: [{ ok: true, id: 'anno-9', outlinePending: true }],
},
},
{ type: 'state', state: 'idle' },
]);
for (const late of [
{ type: 'outline', id: 'anno-9', status: 'resolved' },
{ type: 'outline', status: 'resolved' },
{ type: 'progress', name: 'annotate_map', callId: 'c1', step: 'resolve' },
{ type: 'action-result', name: 'annotate_map', callId: 'c1', result: { ok: true, display: { title: 'Marked 1 place' } } },
{
type: 'action-result',
name: 'annotate_map',
callId: 'c1',
result: { ok: true, display: { title: 'Marked 1 place' } },
},
]) {
assert.equal(reduceVoiceCard(ended, late), ended, `${late.type} after stop is a no-op`);
assert.equal(
reduceVoiceCard(ended, late),
ended,
`${late.type} after stop is a no-op`,
);
}
const live = fold([
{ type: 'interruption' },
{ type: 'action-call', name: 'annotate_map', callId: 'c1', arguments: {} },
{ type: 'action-result', name: 'annotate_map', callId: 'c1', result: { ok: true, outlinePending: true, items: [{ ok: true, id: 'anno-1', outlinePending: true }, { ok: true, id: 'anno-2', outlinePending: true }] } },
{
type: 'action-result',
name: 'annotate_map',
callId: 'c1',
result: {
ok: true,
outlinePending: true,
items: [
{ ok: true, id: 'anno-1', outlinePending: true },
{ ok: true, id: 'anno-2', outlinePending: true },
],
},
},
{ type: 'dismiss' },
]);
assert.equal(reduceVoiceCard(live, { type: 'outline', id: 'anno-7', status: 'resolved' }), live, 'another mark is ignored');
const first = reduceVoiceCard(live, { type: 'outline', id: 'anno-1', status: 'resolved' });
assert.equal(voiceCardView(first).steps[0].detail, 'Tracing outline', 'one of two outlines still pending');
assert.equal(voiceCardView(first).visible, false, 'a dismissed card stays hidden');
const both = reduceVoiceCard(first, { type: 'outline', id: 'anno-2', status: 'failed' });
assert.equal(
reduceVoiceCard(live, {
type: 'outline',
id: 'anno-7',
status: 'resolved',
}),
live,
'another mark is ignored',
);
const first = reduceVoiceCard(live, {
type: 'outline',
id: 'anno-1',
status: 'resolved',
});
assert.equal(
voiceCardView(first).steps[0].detail,
'Tracing outline',
'one of two outlines still pending',
);
assert.equal(
voiceCardView(first).visible,
false,
'a dismissed card stays hidden',
);
const both = reduceVoiceCard(first, {
type: 'outline',
id: 'anno-2',
status: 'failed',
});
assert.equal(voiceCardView(both).steps[0].detail, 'Outline unavailable');
});
test('presentation controls preserve real reducer content and the existing 14-second idle deadline', () => {
const { root, nodes } = fakeRoot();
const timers = new Map();
let next = 0,
renders = 0;
const card = new VoiceCardControls({
root,
setTimer(fn, ms) {
const id = ++next;
timers.set(id, { fn, ms });
return id;
},
clearTimer(id) {
timers.delete(id);
},
onRender() {
renders++;
},
});
card.handle({ type: 'interruption' });
card.handle({
type: 'transcript',
role: 'assistant',
text: 'A current answer',
final: true,
responseId: 'r1',
});
const content = card.state;
const timer = [...timers.keys()][0];
assert.equal(timers.get(timer).ms, 14000);
card.setPresented(false);
assert.equal(card.snapshot().visible, false);
assert.equal(nodes.get('gev-voice-card-live').textContent, '');
card.setPresented(true);
assert.equal(card.snapshot().visible, true);
assert.equal(card.snapshot().assistant.text, 'A current answer');
assert.equal(
card.snapshot().announce,
'',
'returning to a tab does not repeat an already presented announcement',
);
assert.equal(card.state, content);
assert.equal(
[...timers.keys()][0],
timer,
'presentation switches do not reset idle',
);
card.setEnabled(false);
card.handle({
type: 'transcript',
role: 'assistant',
text: 'A later answer',
final: true,
responseId: 'r2',
});
assert.equal(card.snapshot().visible, false);
card.setEnabled(true);
assert.equal(card.snapshot().assistant.text, 'A later answer');
assert.equal(card.snapshot().announce, 'A later answer');
assert.ok(renders > 4);
const idle = [...timers.values()][0];
idle.fn();
assert.equal(
card.snapshot().visible,
false,
'original idle still ends content presentation',
);
card.destroy();
const lastRenders = renders;
card.setPresented(false);
card.setEnabled(false);
assert.equal(renders, lastRenders);
});
test('a new hidden reply with identical words is still a fresh announcement', () => {
const { root } = fakeRoot();
const card = new VoiceCardControls({
root,
setTimer: () => 1,
clearTimer() {},
});
card.handle({ type: 'interruption' });
card.handle({
type: 'transcript',
role: 'assistant',
text: 'Same words',
final: true,
responseId: 'r1',
});
card.setPresented(false);
card.handle({
type: 'transcript',
role: 'assistant',
text: 'Same words',
final: true,
responseId: 'r2',
});
card.setPresented(true);
assert.equal(card.snapshot().announce, 'Same words');
card.destroy();
});
test('additive presentation gates retain the current localized reducer labels', () => {
const previous = getLocale();
try {
setLocale('es');
const { root } = fakeRoot();
const card = new VoiceCardControls({
root,
setTimer: () => 1,
clearTimer() {},
});
card.handle({ type: 'interruption' });
card.handle({
type: 'action-call',
name: 'annotate_map',
callId: 'a',
arguments: {},
});
assert.equal(card.snapshot().phase, 'EN CURSO');
const steps = card.state.steps;
const result = card.state.result;
const who = root.querySelector('#gev-voice-card-user-who');
card.handle({ type: 'transcript', role: 'user', text: 'Same words', attribution: 'unverified' });
assert.equal(card.state.userAttribution, 'unverified');
assert.equal(card.snapshot().user.label, 'Habla reciente');
assert.equal(who.textContent, 'Habla reciente');
card.handle({ type: 'transcript', role: 'user', text: 'Same words' });
assert.equal(card.state.userAttribution, null);
assert.equal(card.snapshot().user.label, 'TÚ');
assert.equal(who.textContent, 'TÚ');
assert.equal(card.state.steps, steps);
assert.equal(card.state.result, result);
card.setPresented(false);
card.setPresented(true);
assert.equal(card.snapshot().phase, 'EN CURSO');
card.setEnabled(false);
card.setEnabled(true);
assert.equal(card.snapshot().phase, 'EN CURSO');
card.destroy();
} finally {
setLocale(previous);
}
});
test('manual panel and preference compose with the actual card without changing voice content or enabling Preview', async () => {
const { createManualVoicePanel } = await import('./panel/manualPanel.js');
const { createVoicePreferences, bindPreferenceControls } =
await import('./panel/preferences.js');
const { root } = fakeRoot();
let panel;
const card = new VoiceCardControls({
root,
setTimer: () => 1,
clearTimer() {},
onRender: () => panel?.sync(),
});
const ui = {
root,
card: root.querySelector('#gev-voice-card'),
voicePanel: root.querySelector('#panel'),
previewPanel: root.querySelector('#preview-panel'),
voiceSettingsPanel: root.querySelector('#settings-panel'),
previewEmpty: root.querySelector('#empty'),
voicePreviewInput: root.querySelector('#preview-input'),
voicePreviewNote: root.querySelector('#preview-note'),
};
const preference = createVoicePreferences({ storage: null });
const fields = bindPreferenceControls({
ui,
preferences: preference,
copy: {
previewNote: 'Mostrar respuestas.',
timeoutNote: '',
notSaved: 'No guardado.',
},
onChange: (state) => {
card.setEnabled(state.previewEnabled);
panel?.sync();
},
});
card.setEnabled(preference.snapshot().previewEnabled);
panel = createManualVoicePanel({
ui,
card,
getPreviewEnabled: () => preference.snapshot().previewEnabled,
copy: { previewEmpty: 'Sin respuesta.', previewDisabled: 'Desactivada.' },
});
card.handle({ type: 'interruption' });
card.handle({
type: 'transcript',
role: 'assistant',
text: 'Actual current answer',
final: true,
});
const content = card.state;
assert.equal(
card.snapshot().visible,
false,
'manual panel stays closed despite content',
);
panel.select('preview');
assert.equal(card.snapshot().assistant.text, 'Actual current answer');
assert.equal(card.snapshot().visible, true);
preference.setPreviewEnabled(false);
assert.equal(card.snapshot().visible, false);
assert.equal(ui.previewEmpty.textContent, 'Desactivada.');
panel.select('settings');
panel.select('preview');
assert.equal(preference.snapshot().previewEnabled, false);
assert.equal(card.snapshot().visible, false);
assert.equal(card.state, content);
fields.dispose();
panel.destroy();
card.destroy();
});
+12 -1
View File
@@ -44,6 +44,7 @@ export function initialVoiceCardState() {
visible: false,
dismissed: false,
user: null,
userAttribution: null,
pointer: null,
assistant: null,
assistantResponse: null,
@@ -69,6 +70,7 @@ function newTurn(state) {
visible: true,
dismissed: false,
user: '',
userAttribution: null,
pointer: null,
assistant: null,
assistantResponse: null,
@@ -143,7 +145,14 @@ export function reduceVoiceCard(state, event) {
case 'transcript': {
const text = event.text || '';
if (event.role === 'user') {
return text ? reveal({ ...state, user: caption(text) }) : state;
return text
? reveal({
...state,
user: caption(text),
userAttribution:
event.attribution === 'unverified' ? 'unverified' : null,
})
: state;
}
if (event.role !== 'assistant' || !text) return state;
const sameResponse =
@@ -287,6 +296,8 @@ export function voiceCardView(state) {
busy: running,
phase: running ? t('WORKING') : state.steps.length ? t('DONE') : '',
user: {
label:
state.userAttribution === 'unverified' ? t('Recent speech') : t('YOU'),
visible: state.user !== null,
text: state.user || '…',
},