From 1c4c903ac61ba014259d4dbd74bee9910acc7ad7 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:12:46 -0500 Subject: [PATCH] fix(agent): align World Packet with reviewer feedback and voice parity Reuse one context/contacts/tracking owner in voice and World Packet. Compare voice outputs after stripping presentation fields; remove the unconsumed fingerprint API pending an actual caller. Document the exported packet boundary and reject null options safely. Review guidance: asturksever on origin #880; Windows parity diagnosis by quanticsoul4772. Existing source ownership retained. --- changelog.d/880-world-packet-boundary.md | 2 +- docs/CURRENT-STATE.md | 5 + src/agent/worldPacket.js | 48 ++-------- src/agent/worldPacket.test.mjs | 48 +++------- src/voice/gevActions.js | 112 ++--------------------- 5 files changed, 34 insertions(+), 181 deletions(-) diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md index f1bedb614..7ee574e35 100644 --- a/changelog.d/880-world-packet-boundary.md +++ b/changelog.d/880-world-packet-boundary.md @@ -2,5 +2,5 @@ `get_current_view_state` owner without restoring disabled layers to the model context. Keep the enabled-only payload, disabled-layer count, source provenance, action result and view-state contract; read the layer roster once - per packet. The semantic fingerprint is for change detection, not authority. + per packet; voice retains the speech/card envelope at its own boundary. Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..7a00217b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -474,6 +474,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft, get_entity_context, annotate_map and analyst_query. Analyst speech and the card share one deterministic count/scope headline; lower bounds use "At least" and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only. +The model-free `gods-eye-view/agent/world-packet` export exposes +`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to +non-voice consumers. It reads the same enabled-only state and feed +provenance before voice adds speech/card fields, with no new network requests +or mutations. The voice card nested in the mic control (`voiceCard.js`) shows captions, plan steps and the result. `resultDisplay.js` is the one adapter from a result to the card: builder results show as built, and analyst results show a title diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index 7315656b6..0987a7fce 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -6,7 +6,7 @@ import { } from '../data/layerSnapshot.js'; import { contextModeWord } from '../contextModePolicy.js'; -const TRACKABLE_FAMILIES = Object.freeze([ +export const TRACKABLE_FAMILIES = Object.freeze([ { layerId: 'flights', kind: 'aircraft' }, { layerId: 'military', kind: 'aircraft' }, { layerId: 'ais-live-vessels', kind: 'vessel' }, @@ -24,37 +24,6 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ 'contextRollback', ]); -const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']); - -function semanticPacketValue(value) { - if (Array.isArray(value)) return value.map(semanticPacketValue); - if (!value || typeof value !== 'object') return value; - const out = {}; - for (const key of Object.keys(value).sort()) { - if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue; - out[key] = semanticPacketValue(value[key]); - } - return out; -} - -/** - * Stable semantic fingerprint for change detection, not authentication. - * - * Presentation-only narration/age labels are excluded. Feed state, source, - * lastUpdate, camera, context, tracked entities, and other semantic state stay - * inside the digest. - */ -export function semanticWorldPacketFingerprint(packet) { - const canonical = JSON.stringify(semanticPacketValue(packet)); - let hash = 0xcbf29ce484222325n; - const prime = 0x100000001b3n; - for (let i = 0; i < canonical.length; i += 1) { - hash ^= BigInt(canonical.charCodeAt(i)); - hash = BigInt.asUintN(64, hash * prime); - } - return `fnv1a64:${hash.toString(16).padStart(16, '0')}`; -} - /** * Translate context-mode payloads into the public tool vocabulary while * retaining the internal id beside each translated field. @@ -124,13 +93,14 @@ export function activeContactsWindow(dataManager) { * This performs no network requests and intentionally mirrors the historical * get_current_view_state payload so voice can delegate without behavior drift. */ -export function getWorldPacket({ - viewer, - styleManager, - dataManager, - sceneDirector = null, - now = Date.now(), -} = {}) { +export function getWorldPacket(options = {}) { + const { + viewer, + styleManager, + dataManager, + sceneDirector = null, + now = Date.now(), + } = options ?? {}; if (!viewer?.camera?.positionWC) throw new TypeError('World Packet requires a viewer camera'); if (!dataManager || typeof dataManager.getAll !== 'function') diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs index 342af34c3..c4d0200ba 100644 --- a/src/agent/worldPacket.test.mjs +++ b/src/agent/worldPacket.test.mjs @@ -2,12 +2,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { - getWorldPacket, - semanticWorldPacketFingerprint, -} from './worldPacket.js'; +import { getWorldPacket } from './worldPacket.js'; import { createGevActionRunner } from '../voice/gevActions.js'; +function withoutVoicePresentation(result) { + const packet = { ...result }; + delete packet.say; + delete packet.display; + delete packet.schedule; + return packet; +} + function harness() { const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); const viewer = { @@ -64,7 +69,7 @@ test('World Packet preserves get_current_view_state parity', async () => { const runner = createGevActionRunner({ viewer, dataManager, styleManager }); const voice = await runner('get_current_view_state'); - assert.deepEqual(voice, packet); + assert.deepEqual(withoutVoicePresentation(voice), packet); assert.equal(packet.context.mode, 'contacts'); assert.equal(packet.context.modeInternal, 'flights'); assert.equal(packet.layers[0].feedState, 'nominal'); @@ -94,20 +99,6 @@ test('World Packet keeps stale/unavailable feed semantics distinct from empty', assert.match(packet.feedProvenance.note, /fixture unavailable/i); }); -test('semantic fingerprint ignores narration prose but changes with observed state', () => { - const { viewer, dataManager, styleManager } = harness(); - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - const original = semanticWorldPacketFingerprint(packet); - - const proseOnly = structuredClone(packet); - proseOnly.feedProvenance.note = 'different generated narration'; - assert.equal(semanticWorldPacketFingerprint(proseOnly), original); - - const changed = structuredClone(packet); - changed.layers[0].count += 1; - assert.notEqual(semanticWorldPacketFingerprint(changed), original); -}); - // The 2026-10 main voice contract omits disabled layers from the model payload. test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { const { viewer, dataManager, styleManager } = harness(); @@ -134,22 +125,9 @@ test('World Packet and voice preserve enabled-only payload and count disabled la const runner = createGevActionRunner({ viewer, dataManager, styleManager }); const voice = await runner('get_current_view_state'); - assert.deepEqual(voice, packet); + assert.deepEqual(withoutVoicePresentation(voice), packet); }); -test('World Packet fingerprints distinguish enabled state but ignore narrative churn', () => { - const { viewer, dataManager, styleManager } = harness(); - const first = getWorldPacket({ viewer, dataManager, styleManager }); - const prose = structuredClone(first); - prose.feedProvenance.note = 'new narration only'; - assert.equal( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(prose), - ); - const hidden = structuredClone(first); - hidden.disabledLayerCount += 1; - assert.notEqual( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(hidden), - ); +test('World Packet rejects null options with the camera requirement', () => { + assert.throws(() => getWorldPacket(null), /requires a viewer camera/); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 2f7ea7824..c3833e571 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,6 +1,5 @@ import { layerSnapshot, - layerSnapshots, feedProvenanceEnvelope, } from '../data/layerSnapshot.js'; import { readLayerLifecycleSummary } from './layerSummary.js'; @@ -26,7 +25,6 @@ import { isContextRecordActive, } from '../data/contextStore.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; -import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine, normalizeAnalystSpec, @@ -47,7 +45,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; -import { getWorldPacket } from '../agent/worldPacket.js'; +import { + getWorldPacket, + withContextModeVocabulary, + activeContactsWindow, + TRACKABLE_FAMILIES, +} from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -119,64 +122,6 @@ const CONTEXT_MODE_ALIASES = new Map([ ['space-missions', 'space-missions'], ['missions', 'space-missions'], ]); -/** - * Every model-readable field that carries a context-mode id, and what an - * absent value means for each. - * - * `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode` - * are absent when there is no such mode at all — calling those 'off' would - * assert a state that does not exist. - */ -const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ - { field: 'mode', emptyAs: 'off' }, - { field: 'entering', emptyAs: null }, - { field: 'priorMode', emptyAs: null }, -]); - -/** Nested results that are themselves context-mode payloads the model reads. */ -const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ - 'context', - 'contextRollback', -]); - -/** - * Report a context-mode payload in the tools' own vocabulary. - * - * `set_context_mode` accepts 'contacts' while the mode's internal id is - * 'flights'. Reporting the internal id back made the model read - * `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts - * window counts sitting in the very same payload (owner field session - * 2026-08-21). Secondary fields and nested transition/rollback results are - * translated too — one leaked internal id is enough to recreate the confusion, - * and a rollback result is exactly what the model reads when something went - * wrong. Each internal id is kept alongside as `Internal` for anything - * reasoning about layers. - * @param {object|null|undefined} state Any payload carrying context-mode fields. - * @returns {object|null|undefined} The same payload, modes translated. - */ -function withContextModeVocabulary(state) { - if (!state || typeof state !== 'object') return state; - let out = state; - const mutable = () => { - if (out === state) out = { ...state }; - return out; - }; - for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { - if (!(field in state)) continue; - const internal = state[field] ?? null; - const target = mutable(); - target[field] = contextModeWord(internal, { emptyAs }); - target[`${field}Internal`] = internal; - } - for (const field of NESTED_CONTEXT_RESULT_FIELDS) { - const nested = state[field]; - if (!nested || typeof nested !== 'object') continue; - const translated = withContextModeVocabulary(nested); - if (translated !== nested) mutable()[field] = translated; - } - return out; -} - const COCKPIT_ACTION_ALIASES = new Map([ ['next', 'next'], ['previous', 'previous'], @@ -246,14 +191,6 @@ const STACK_ALIASES = new Map([ ['road map', 'osm'], ]); -/** Search order for track_entity across entity layer families. */ -const TRACKABLE_FAMILIES = [ - { layerId: 'flights', kind: 'aircraft' }, - { layerId: 'military', kind: 'aircraft' }, - { layerId: 'ais-live-vessels', kind: 'vessel' }, - { layerId: 'satellites', kind: 'satellite' }, -]; - const FRAME_TARGETS = new Map([ ['flights', 'flights'], ['planes', 'flights'], @@ -2553,26 +2490,6 @@ function runManagedVoiceNavigation( }; } -/** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -4599,23 +4516,6 @@ function clampNumber(value, min, max, fallback) { * ("which of those is closest?") scoped to the session. */ -/** - * The Contacts panel's own counts, or null when Contacts has no subject. - * Read through the awareness snapshot the panel renders, so the two cannot - * diverge no matter which surface asks. - * @returns {object|null} Panel-equivalent window counts. - */ -function activeContactsWindow(dataManager) { - try { - const layer = dataManager?.layers?.get('military-awareness')?.module; - return ( - layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null - ); - } catch { - return null; - } -} - /** * Most records one layer hands a single query. Above every loaded feed seen * so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so