mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge pull request #880 from kvnloo/feat/world-packet-boundary-clean-20260929
feat(agent): extract model-free World Packet boundary
This commit is contained in:
9 files changed
+548
-164
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
- Extract a reusable model-free World Packet from the existing voice
|
||||||
|
`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; voice retains the speech/card envelope at its own boundary.
|
||||||
|
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||||
@@ -496,6 +496,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
|
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
|
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.
|
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
|
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
|
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
|
the card: builder results show as built, and analyst results show a title
|
||||||
|
|||||||
+2
-1
@@ -277,6 +277,7 @@
|
|||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
"./tools/services": "./src/tools/services.js",
|
"./tools/services": "./src/tools/services.js",
|
||||||
"./view": "./src/view/index.js"
|
"./view": "./src/view/index.js",
|
||||||
|
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,6 +637,7 @@
|
|||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/realtimeBackend.js",
|
"src/voice/realtimeBackend.js",
|
||||||
"src/voice/realtimeBackend.test.mjs",
|
"src/voice/realtimeBackend.test.mjs",
|
||||||
|
"src/agent/worldPacket.test.mjs",
|
||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
"build/application-html.js",
|
"build/application-html.js",
|
||||||
"src/app/sourceSlot.js",
|
"src/app/sourceSlot.js",
|
||||||
|
|||||||
@@ -2221,6 +2221,7 @@
|
|||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/gevActions.js",
|
"src/voice/gevActions.js",
|
||||||
|
"src/agent/worldPacket.js",
|
||||||
"src/voice/speech.js",
|
"src/voice/speech.js",
|
||||||
"src/ui/layerNames.js",
|
"src/ui/layerNames.js",
|
||||||
"src/voice/earcon.js",
|
"src/voice/earcon.js",
|
||||||
@@ -3951,5 +3952,18 @@
|
|||||||
"src/i18n/marker.js"
|
"src/i18n/marker.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"agent-world-packet": {
|
||||||
|
"exports": ["./agent/world-packet"],
|
||||||
|
"modules": [
|
||||||
|
"src/agent/worldPacket.js",
|
||||||
|
"src/data/layerSnapshot.js",
|
||||||
|
"src/data/feedState.js",
|
||||||
|
"src/contextModePolicy.js",
|
||||||
|
"src/loadingFeedback.js",
|
||||||
|
"src/data/installationFeedback.js",
|
||||||
|
"src/i18n/marker.js"
|
||||||
|
],
|
||||||
|
"external": ["cesium"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
layerSnapshot,
|
||||||
|
layerSnapshots,
|
||||||
|
} from '../data/layerSnapshot.js';
|
||||||
|
import { contextModeWord } from '../contextModePolicy.js';
|
||||||
|
|
||||||
|
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||||
|
{ layerId: 'flights', kind: 'aircraft' },
|
||||||
|
{ layerId: 'military', kind: 'aircraft' },
|
||||||
|
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||||
|
{ layerId: 'satellites', kind: 'satellite' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||||
|
{ field: 'mode', emptyAs: 'off' },
|
||||||
|
{ field: 'entering', emptyAs: null },
|
||||||
|
{ field: 'priorMode', emptyAs: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||||
|
'context',
|
||||||
|
'contextRollback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate context-mode payloads into the public tool vocabulary while
|
||||||
|
* retaining the internal id beside each translated field.
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read tracked entities from their canonical layer owners. */
|
||||||
|
export 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 {
|
||||||
|
// A not-yet-ready layer contributes no tracked observation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the active Contacts window from its owning awareness layer. */
|
||||||
|
export function activeContactsWindow(dataManager) {
|
||||||
|
try {
|
||||||
|
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||||
|
return (
|
||||||
|
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the model-free fast World Packet from existing application owners.
|
||||||
|
*
|
||||||
|
* 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(options = {}) {
|
||||||
|
const {
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager,
|
||||||
|
sceneDirector = null,
|
||||||
|
now: requestedNow,
|
||||||
|
} = options ?? {};
|
||||||
|
// A default parameter only covers undefined; null or NaN would age every
|
||||||
|
// feed against the epoch and report stale data as "just now".
|
||||||
|
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
|
||||||
|
if (!viewer?.camera?.positionWC)
|
||||||
|
throw new TypeError('World Packet requires a viewer camera');
|
||||||
|
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||||
|
throw new TypeError('World Packet requires a data manager');
|
||||||
|
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||||
|
viewer.camera.positionWC,
|
||||||
|
);
|
||||||
|
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||||
|
// current enabled-only view keeps the model's context budget bounded.
|
||||||
|
const rows = dataManager.getAll();
|
||||||
|
const enabledRows = rows.filter((layer) => layer.enabled);
|
||||||
|
const contactsWindow = activeContactsWindow(dataManager);
|
||||||
|
const contextState =
|
||||||
|
typeof styleManager?.getContextModeState === 'function'
|
||||||
|
? withContextModeVocabulary(styleManager.getContextModeState())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action: 'get_current_view_state',
|
||||||
|
camera: {
|
||||||
|
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||||
|
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||||
|
heightM: cartographic.height,
|
||||||
|
},
|
||||||
|
style: styleManager?.activeStyle || 'normal',
|
||||||
|
context: contextState
|
||||||
|
? {
|
||||||
|
...contextState,
|
||||||
|
...(contactsWindow ? { contactsWindow } : {}),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
cockpit:
|
||||||
|
typeof styleManager?.getCockpitState === 'function'
|
||||||
|
? styleManager.getCockpitState()
|
||||||
|
: null,
|
||||||
|
controls:
|
||||||
|
typeof styleManager?.getControlState === 'function'
|
||||||
|
? styleManager.getControlState()
|
||||||
|
: null,
|
||||||
|
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||||
|
tracked: collectTrackedEntities(dataManager),
|
||||||
|
disabledLayerCount: rows.length - enabledRows.length,
|
||||||
|
// Preserve get_current_view_state's existing compact enabled-only shape.
|
||||||
|
layers: enabledRows.map((layer) => {
|
||||||
|
const snapshot = layerSnapshot(layer, { now });
|
||||||
|
return {
|
||||||
|
id: layer.id,
|
||||||
|
name: layer.name,
|
||||||
|
enabled: layer.enabled,
|
||||||
|
count: layer.stats?.count || 0,
|
||||||
|
error: layer.stats?.error || null,
|
||||||
|
feedState: snapshot.feedState,
|
||||||
|
source: snapshot.source,
|
||||||
|
lastUpdate: snapshot.lastUpdate,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
feedProvenance: feedProvenanceEnvelope(
|
||||||
|
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
|
||||||
|
{ now },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
trackedEntity: undefined,
|
||||||
|
clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
|
scene: {
|
||||||
|
canvas: {
|
||||||
|
clientWidth: 1200,
|
||||||
|
clientHeight: 800,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
|
globe: { getHeight: () => 0 },
|
||||||
|
tweens: [],
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
positionWC: position,
|
||||||
|
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||||
|
heading: 0,
|
||||||
|
pitch: Cesium.Math.toRadians(-45),
|
||||||
|
moveEnd: { addEventListener: () => () => {} },
|
||||||
|
cancelFlight() {},
|
||||||
|
lookAtTransform() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 3,
|
||||||
|
source: 'fixture',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const dataManager = {
|
||||||
|
layers: new Map([['traffic', { module: null }]]),
|
||||||
|
getAll: () => rows,
|
||||||
|
};
|
||||||
|
const styleManager = {
|
||||||
|
activeStyle: 'normal',
|
||||||
|
getContextModeState: () => ({ mode: 'flights' }),
|
||||||
|
getCockpitState: () => null,
|
||||||
|
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||||
|
};
|
||||||
|
return { viewer, dataManager, styleManager };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('World Packet preserves get_current_view_state parity', async () => {
|
||||||
|
const { viewer, dataManager, styleManager } = harness();
|
||||||
|
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||||
|
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||||
|
const voice = await runner('get_current_view_state');
|
||||||
|
|
||||||
|
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||||
|
assert.equal(packet.context.mode, 'contacts');
|
||||||
|
assert.equal(packet.context.modeInternal, 'flights');
|
||||||
|
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||||
|
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
|
||||||
|
const { viewer, dataManager, styleManager } = harness();
|
||||||
|
dataManager.getAll = () => [
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 0,
|
||||||
|
source: 'fixture',
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||||
|
assert.equal(packet.layers[0].count, 0);
|
||||||
|
assert.notEqual(packet.layers[0].feedState, 'nominal');
|
||||||
|
assert.notEqual(packet.feedProvenance.overall, 'nominal');
|
||||||
|
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
const rows = dataManager.getAll();
|
||||||
|
rows.push({
|
||||||
|
id: 'earthquakes',
|
||||||
|
name: 'Earthquakes',
|
||||||
|
enabled: false,
|
||||||
|
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||||
|
});
|
||||||
|
let reads = 0;
|
||||||
|
dataManager.getAll = () => {
|
||||||
|
reads += 1;
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||||
|
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
|
||||||
|
assert.equal(packet.disabledLayerCount, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
packet.layers.map((layer) => layer.id),
|
||||||
|
['traffic'],
|
||||||
|
);
|
||||||
|
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||||
|
|
||||||
|
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||||
|
const voice = await runner('get_current_view_state');
|
||||||
|
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
|
||||||
|
|
||||||
|
// Exercises every packet field: context vocabulary and Contacts window,
|
||||||
|
// cockpit, controls, scene playback, tracked families (one not ready),
|
||||||
|
// enabled and disabled layers, and an aged and an unavailable feed.
|
||||||
|
function pinnedHarness() {
|
||||||
|
const { viewer } = harness();
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 12,
|
||||||
|
source: 'fixture-adsb',
|
||||||
|
lastUpdate: PINNED_NOW - 90_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 0,
|
||||||
|
source: 'fixture',
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'earthquakes',
|
||||||
|
name: 'Earthquakes',
|
||||||
|
enabled: false,
|
||||||
|
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const module = (methods) => ({ module: methods });
|
||||||
|
const dataManager = {
|
||||||
|
layers: new Map([
|
||||||
|
[
|
||||||
|
'flights',
|
||||||
|
module({
|
||||||
|
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ais-live-vessels',
|
||||||
|
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'satellites',
|
||||||
|
module({
|
||||||
|
getTrackedInfo: () => {
|
||||||
|
throw new Error('not ready');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'military-awareness',
|
||||||
|
module({
|
||||||
|
getContextSnapshot: () => ({ snapshot: true }),
|
||||||
|
contactsWindowFromSnapshot: (snapshot) =>
|
||||||
|
snapshot ? { windowMin: 30, count: 4 } : null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
getAll: () => rows,
|
||||||
|
};
|
||||||
|
const styleManager = {
|
||||||
|
activeStyle: 'night-vision',
|
||||||
|
getContextModeState: () => ({
|
||||||
|
mode: 'flights',
|
||||||
|
entering: null,
|
||||||
|
priorMode: null,
|
||||||
|
}),
|
||||||
|
getCockpitState: () => ({ active: false }),
|
||||||
|
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||||
|
};
|
||||||
|
const sceneDirector = {
|
||||||
|
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
|
||||||
|
};
|
||||||
|
return { viewer, dataManager, styleManager, sceneDirector };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captured from main's get_current_view_state before the extraction
|
||||||
|
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
|
||||||
|
// minus the speech envelope. Key order is part of the contract.
|
||||||
|
const PRE_EXTRACTION_PAYLOAD = {
|
||||||
|
ok: true,
|
||||||
|
action: 'get_current_view_state',
|
||||||
|
camera: {
|
||||||
|
latitude: 30.259999999999994,
|
||||||
|
longitude: -97.74,
|
||||||
|
heightM: 499.99999999901877,
|
||||||
|
},
|
||||||
|
style: 'night-vision',
|
||||||
|
context: {
|
||||||
|
mode: 'contacts',
|
||||||
|
entering: null,
|
||||||
|
priorMode: null,
|
||||||
|
modeInternal: 'flights',
|
||||||
|
enteringInternal: null,
|
||||||
|
priorModeInternal: null,
|
||||||
|
contactsWindow: { windowMin: 30, count: 4 },
|
||||||
|
},
|
||||||
|
cockpit: { active: false },
|
||||||
|
controls: { hud: { visible: 'auto' } },
|
||||||
|
scenePlayback: { playing: false, sceneId: 'fixture' },
|
||||||
|
tracked: [
|
||||||
|
{
|
||||||
|
kind: 'aircraft',
|
||||||
|
layerId: 'flights',
|
||||||
|
icao24: 'abc123',
|
||||||
|
callsign: 'TEST1',
|
||||||
|
},
|
||||||
|
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
|
||||||
|
],
|
||||||
|
disabledLayerCount: 1,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: true,
|
||||||
|
count: 12,
|
||||||
|
error: null,
|
||||||
|
feedState: 'nominal',
|
||||||
|
source: 'fixture-adsb',
|
||||||
|
lastUpdate: 1792238310000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
count: 0,
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
feedState: 'unavailable',
|
||||||
|
source: 'fixture',
|
||||||
|
lastUpdate: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
feedProvenance: {
|
||||||
|
overall: 'unavailable',
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: true,
|
||||||
|
feedState: 'nominal',
|
||||||
|
source: 'fixture-adsb',
|
||||||
|
count: 12,
|
||||||
|
lastUpdate: 1792238310000,
|
||||||
|
ageSec: 90,
|
||||||
|
ageLabel: '1m ago',
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
feedState: 'unavailable',
|
||||||
|
source: 'fixture',
|
||||||
|
count: 0,
|
||||||
|
lastUpdate: 0,
|
||||||
|
ageSec: null,
|
||||||
|
ageLabel: null,
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertPinnedPayload(actual) {
|
||||||
|
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
|
||||||
|
// deepEqual ignores key order; the serialized form does not.
|
||||||
|
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
|
||||||
|
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
|
||||||
|
assertPinnedPayload(packet);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
|
||||||
|
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||||
|
const runner = createGevActionRunner(pinnedHarness());
|
||||||
|
const voice = await runner('get_current_view_state');
|
||||||
|
assertPinnedPayload(withoutVoicePresentation(voice));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
|
||||||
|
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||||
|
for (const now of [null, Number.NaN, '2026-10-17']) {
|
||||||
|
const packet = getWorldPacket({ ...pinnedHarness(), now });
|
||||||
|
const flights = packet.feedProvenance.layers.find(
|
||||||
|
(layer) => layer.id === 'flights',
|
||||||
|
);
|
||||||
|
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
|
||||||
|
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('World Packet rejects null options with the camera requirement', () => {
|
||||||
|
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
|||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { TRACKABLE_FAMILIES } from './agent/worldPacket.js';
|
||||||
import { setLocale } from './i18n/index.js';
|
import { setLocale } from './i18n/index.js';
|
||||||
import {
|
import {
|
||||||
COCKPIT_REFUSAL_NOUNS,
|
COCKPIT_REFUSAL_NOUNS,
|
||||||
@@ -68,10 +69,8 @@ const NOUN_LITERAL =
|
|||||||
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
||||||
|
|
||||||
function trackableKinds() {
|
function trackableKinds() {
|
||||||
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
|
// This is shared with voice; test the exported owner, not its file position.
|
||||||
const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/);
|
return TRACKABLE_FAMILIES.map(({ kind }) => kind);
|
||||||
assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js');
|
|
||||||
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
||||||
|
|||||||
+12
-159
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
layerSnapshot,
|
layerSnapshot,
|
||||||
layerSnapshots,
|
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
} from '../data/layerSnapshot.js';
|
} from '../data/layerSnapshot.js';
|
||||||
import { readLayerLifecycleSummary } from './layerSummary.js';
|
import { readLayerLifecycleSummary } from './layerSummary.js';
|
||||||
@@ -27,7 +26,6 @@ import {
|
|||||||
isContextRecordActive,
|
isContextRecordActive,
|
||||||
} from '../data/contextStore.js';
|
} from '../data/contextStore.js';
|
||||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||||
import { contextModeWord } from '../contextModePolicy.js';
|
|
||||||
import {
|
import {
|
||||||
createAnalystEngine,
|
createAnalystEngine,
|
||||||
normalizeAnalystSpec,
|
normalizeAnalystSpec,
|
||||||
@@ -48,6 +46,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
|||||||
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
||||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||||
|
import {
|
||||||
|
getWorldPacket,
|
||||||
|
withContextModeVocabulary,
|
||||||
|
activeContactsWindow,
|
||||||
|
TRACKABLE_FAMILIES,
|
||||||
|
} from '../agent/worldPacket.js';
|
||||||
|
|
||||||
const ALLOWED_STYLES = new Set([
|
const ALLOWED_STYLES = new Set([
|
||||||
'normal',
|
'normal',
|
||||||
@@ -119,64 +123,6 @@ const CONTEXT_MODE_ALIASES = new Map([
|
|||||||
['space-missions', 'space-missions'],
|
['space-missions', 'space-missions'],
|
||||||
['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 `<field>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([
|
const COCKPIT_ACTION_ALIASES = new Map([
|
||||||
['next', 'next'],
|
['next', 'next'],
|
||||||
['previous', 'previous'],
|
['previous', 'previous'],
|
||||||
@@ -246,14 +192,6 @@ const STACK_ALIASES = new Map([
|
|||||||
['road map', 'osm'],
|
['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([
|
const FRAME_TARGETS = new Map([
|
||||||
['flights', 'flights'],
|
['flights', 'flights'],
|
||||||
['planes', 'flights'],
|
['planes', 'flights'],
|
||||||
@@ -2553,26 +2491,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(
|
export async function getBasemapLabelContext(
|
||||||
viewer,
|
viewer,
|
||||||
service = defaultGeospatial,
|
service = defaultGeospatial,
|
||||||
@@ -3210,60 +3128,12 @@ function getCurrentViewState(
|
|||||||
dataManager,
|
dataManager,
|
||||||
sceneDirector = null,
|
sceneDirector = null,
|
||||||
) {
|
) {
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
return getWorldPacket({
|
||||||
viewer.camera.positionWC,
|
viewer,
|
||||||
);
|
styleManager,
|
||||||
return {
|
dataManager,
|
||||||
ok: true,
|
sceneDirector,
|
||||||
action: 'get_current_view_state',
|
});
|
||||||
camera: {
|
|
||||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
|
||||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
|
||||||
heightM: cartographic.height,
|
|
||||||
},
|
|
||||||
style: styleManager.activeStyle || 'normal',
|
|
||||||
context:
|
|
||||||
typeof styleManager.getContextModeState === 'function'
|
|
||||||
? {
|
|
||||||
...withContextModeVocabulary(styleManager.getContextModeState()),
|
|
||||||
// The numbers on the operator's Contacts panel, so a window/count
|
|
||||||
// question can be answered from what they are looking at.
|
|
||||||
...(activeContactsWindow(dataManager)
|
|
||||||
? { contactsWindow: activeContactsWindow(dataManager) }
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
cockpit:
|
|
||||||
typeof styleManager.getCockpitState === 'function'
|
|
||||||
? styleManager.getCockpitState()
|
|
||||||
: null,
|
|
||||||
controls:
|
|
||||||
typeof styleManager.getControlState === 'function'
|
|
||||||
? styleManager.getControlState()
|
|
||||||
: null,
|
|
||||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
|
||||||
tracked: collectTrackedEntities(dataManager),
|
|
||||||
// Enabled layers only: the full registry cost ~1k tokens per call and
|
|
||||||
// the model only reasons about what is on.
|
|
||||||
disabledLayerCount: dataManager.getAll().filter((layer) => !layer.enabled)
|
|
||||||
.length,
|
|
||||||
layers: dataManager
|
|
||||||
.getAll()
|
|
||||||
.filter((layer) => layer.enabled)
|
|
||||||
.map((layer) => ({
|
|
||||||
id: layer.id,
|
|
||||||
name: layer.name,
|
|
||||||
enabled: layer.enabled,
|
|
||||||
count: layer.stats?.count || 0,
|
|
||||||
error: layer.stats?.error || null,
|
|
||||||
feedState: layerSnapshot(layer).feedState,
|
|
||||||
source: layerSnapshot(layer).source,
|
|
||||||
lastUpdate: layerSnapshot(layer).lastUpdate,
|
|
||||||
})),
|
|
||||||
feedProvenance: feedProvenanceEnvelope(
|
|
||||||
layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEntityContext(
|
async function getEntityContext(
|
||||||
@@ -4635,23 +4505,6 @@ function clampNumber(value, min, max, fallback) {
|
|||||||
* ("which of those is closest?") scoped to the session.
|
* ("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
|
* 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
|
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so
|
||||||
|
|||||||
Reference in new issue
Block a user