Files
gods-eye-view/src/voice/integrationContract.test.mjs
T
manjunath22466 095981eee7 fix(voice): preserve analyst result and tracking contracts
keep contacts counts, rows, scope and provenance on one retained snapshot

report capped cohorts as lower bounds

separate sibling tool cancellation from newer user turns

align track_entity validation across runtime and benchmark paths
2026-10-02 11:15:31 +05:30

360 lines
12 KiB
JavaScript

// Cross-feature voice contracts, driven through the real action runner:
// one result contract (engine fields → model payload → voice card), result-set
// ownership for follow-ups, conversation lifetime, and progress narration.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
import { createNarrationScheduler } from './narration.js';
import { createVoiceCommands } from './sessionCommands.js';
import { harness, tick } from './voiceWorkflowHarness.mjs';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { attachVoiceResult } from './speech.js';
/** Run one call and fold it through the real voice card. */
async function throughCard(runner, name, args, card = initialVoiceCardState()) {
const callId = `call-${name}-${Math.random()}`;
let state = reduceVoiceCard(card, { type: 'interruption' });
state = reduceVoiceCard(state, {
type: 'action-call',
name,
callId,
arguments: args,
});
const result = await runner(name, args);
state = reduceVoiceCard(state, {
type: 'action-result',
name,
callId,
result,
});
const shown = voiceCardView(state);
return { result, view: shown.result, steps: shown.steps };
}
// ── 1. One result contract ────────────────────────────────────────────────
test('an analyst count reaches the model and the card with its scope and caveats', async () => {
const { runner } = harness();
const { result, view } = await throughCard(runner, 'analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
assert.equal(result.ok, true);
assert.equal(result.complete, true, 'complete survives the projection');
assert.equal(result.say, '3 aircraft anywhere in the loaded data.');
assert.equal(view.visible, true, 'the card shows an analyst result');
assert.equal(view.title, '3 aircraft anywhere in the loaded data');
assert.ok(
!view.lines.includes('anywhere in the loaded data'),
'the scope is in the title, not repeated as a line',
);
assert.deepEqual(
view.referents.map((r) => r.label),
['UAL1', 'DAL2', 'SWA3'],
);
});
test('long analyst scope text is bounded identically for speech and card', () => {
const result = attachVoiceResult('analyst_query', {
ok: true,
action: 'analyst_query',
count: 12,
complete: true,
scopeLabel:
'over the extraordinarily long alpine administrative research corridor with a deliberately verbose official name',
coverage: { layersQueried: [{ layerKey: 'flights' }] },
});
const started = reduceVoiceCard(initialVoiceCardState(), {
type: 'action-call',
name: 'analyst_query',
callId: 'long-scope',
arguments: {},
});
const card = reduceVoiceCard(started, {
type: 'action-result',
name: 'analyst_query',
callId: 'long-scope',
result,
});
const title = voiceCardView(card).result.title;
assert.equal(result.say, title);
assert.ok(title.length <= 64);
assert.match(title, /…$/);
});
test('a capped count is a floor everywhere: model, card and caveat', async () => {
const rows = Array.from({ length: 250_001 }, (_, i) => ({
id: `F${i}`,
lat: 10,
lon: 20,
}));
const flights = {
getStats: () => ({ count: 400_000, lastUpdate: Date.now() }),
getAnalystRecords: (max) => rows.slice(0, max),
};
const { runner } = harness({ flights });
const { result, view } = await throughCard(runner, 'analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
limit: 1,
});
assert.equal(result.complete, false);
assert.match(result.say, /^At least 250,000 aircraft/);
assert.match(view.title, /^At least 250,000 aircraft/);
assert.ok(
view.notes.some((note) => /counted 250,000 of 400,000/.test(note)),
'the cap is on screen',
);
});
test('a partial answer names the layers it could not read', async () => {
const { runner } = harness();
const { result, view } = await throughCard(runner, 'analyst_query', {
layers: ['flights', 'earthquakes'],
scope: { kind: 'anywhere' },
});
assert.equal(result.partial, true);
assert.match(result.say, /Partial; earthquakes not answered/);
assert.deepEqual(result.unanswered, ['earthquakes']);
assert.ok(view.chips.includes('partial'));
assert.ok(view.notes.includes('Not answered: earthquakes'));
});
test('"the second one" is the second row the card lists', async () => {
const { runner, referents } = harness();
await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
assert.equal(referents.get(2).label, 'DAL2');
assert.equal(referents.get(-1).label, 'SWA3');
});
test('numbered and last referents are exactly the five rows the card displays', async () => {
const flights = {
getStats: () => ({ count: 6, lastUpdate: Date.now() }),
getAnalystRecords: () =>
Array.from({ length: 6 }, (_, index) => ({
id: `F${index + 1}`,
icao24: `f${index + 1}`,
callsign: `CALL${index + 1}`,
lat: 30,
lon: -97,
})),
};
const { runner, referents } = harness({ flights });
const { view } = await throughCard(runner, 'analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
limit: 6,
});
assert.deepEqual(
view.referents.map((entry) => entry.label),
['CALL1', 'CALL2', 'CALL3', 'CALL4', 'CALL5'],
);
assert.equal(referents.get(6), null);
assert.equal(referents.get(-1)?.label, 'CALL5');
});
test('a follow-up naming other layers than the last answer is refused, not answered from it', async () => {
const { runner } = harness();
await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
const mismatch = await runner('analyst_query', {
layers: ['earthquakes'],
followUp: true,
});
assert.equal(mismatch.ok, false);
assert.equal(mismatch.code, 'FOLLOW_UP_MISMATCH');
assert.match(mismatch.error, /flights, not earthquakes/);
});
// ── 2. Conversation lifetime ──────────────────────────────────────────────
test('a mic restart forgets the last answer and in-flight writes', async () => {
const h = harness();
await h.runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
// A query in flight when the session ends must not seed the new session.
const late = h.runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
h.runner.resetConversation();
await late;
const followUp = await h.runner('analyst_query', {
layers: ['flights'],
followUp: true,
});
assert.equal(followUp.code, 'NO_RESULT_CONTEXT');
});
test('the voice controls reset the runner on stop and dispose it on removal', async () => {
const previous = globalThis.window;
globalThis.window = {};
try {
const h = harness();
const button = new EventTarget();
button.setAttribute = () => {};
const ui = {
button,
root: { dataset: {}, remove() {} },
status: {},
detail: {},
};
const lifetime = new AbortController();
let emitState;
const controls = createVoiceCommands({
runner: h.runner,
signal: lifetime.signal,
createControl: () => ui,
createCard: () => null,
createSession({ emit }) {
emitState = (state) => emit({ type: 'state', state });
return {
async start() {
emitState('listening');
},
stop() {
emitState('idle');
},
sendText() {},
sendMapEvent() {},
};
},
});
await controls.session.start();
await h.runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
controls.session.stop();
const after = await h.runner('analyst_query', {
layers: ['flights'],
followUp: true,
});
assert.equal(after.code, 'NO_RESULT_CONTEXT', 'stop cleared the memory');
lifetime.abort();
const removed = await h.runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
assert.equal(removed.cancelled, true, 'a disposed runner does nothing');
} finally {
globalThis.window = previous;
}
});
// ── 3. Progress narration through the scheduler ───────────────────────────
function fakeClock() {
let now = 0;
const timers = [];
return {
now: () => now,
setTimer(fn, ms) {
const timer = { fn, at: now + ms, done: false };
timers.push(timer);
return timer;
},
clearTimer(timer) {
if (timer) timer.done = true;
},
advance(ms) {
const until = now + ms;
for (;;) {
const next = timers
.filter((t) => !t.done && t.at <= until)
.sort((a, b) => a.at - b.at)[0];
if (!next) break;
now = next.at;
next.done = true;
next.fn();
}
now = until;
},
};
}
/**
* Start one tool under the real scheduler, let its workflow report its
* phases, and read what the scheduler says once the first line is due (the
* tool is still counted as running, as a slow call would be).
*/
async function narrated(name, args, whileRunning = async () => {}, on) {
const clock = fakeClock();
const spoken = [];
const scheduler = createNarrationScheduler({
speak: (line) => spoken.push(line),
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
const h = harness(on ? { on } : {});
scheduler.userTurnEnded();
scheduler.toolStarted('c1', { name, label: 'x' });
const pending = h.runner(name, args, {
progress: (update) => scheduler.progress('c1', update),
});
await tick();
await whileRunning(h);
await pending;
clock.advance(1600);
scheduler.toolFinished('c1');
return spoken;
}
test('a slow annotate_map call narrates its real phase', async () => {
assert.deepEqual(
await narrated('annotate_map', {
annotations: [{ type: 'pin', target: 'Ferry Building' }],
}),
['Finding Ferry Building on the map.'],
);
});
// ── 4. One statement per policy ───────────────────────────────────────────
test('the cloud prompt states precedence and caveat speech once', () => {
const lines = realtimeInstructions().split('\n');
const starting = (prefix) => lines.filter((line) => line.startsWith(prefix));
assert.equal(starting('WHERE,').length, 1, 'one precedence rule');
const where = starting('WHERE,')[0];
const order = [
'a place the user names',
'explicit pointing',
'Contacts subject',
'the current view',
];
const at = order.map((phrase) => where.indexOf(phrase));
assert.ok(
at.every((i, n) => i >= 0 && (n === 0 || i > at[n - 1])),
'named → pointing → Contacts/selection → view',
);
const tools = JSON.stringify(GEV_REALTIME_TOOLS);
// Caveat speech is one policy: lower bounds and partial answers are spoken.
const caveatRules = lines.filter((line) => /caveat/i.test(line));
assert.equal(caveatRules.length, 1, caveatRules.join('\n'));
assert.match(
caveatRules[0],
/speak a caveat only when it changes the answer/,
);
assert.match(caveatRules[0], /Lower bounds \("at least 500"\)/);
assert.doesNotMatch(tools, /mention display\.caveat/);
assert.doesNotMatch(
realtimeInstructions(),
/"approximately"\/"roughly" for a tool number/,
);
});