From fd5a1ef8569a313b249af271fe3d2e6f18817cfa Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:54:17 -0400 Subject: [PATCH] fix(tools): keep results when a timestamp is outside Date's range (#1045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isoTime returned toISOString() for any finite number, which throws past ±8.64e15 ms, so one bad record (e.g. a bike-share last_reported) failed the whole tool answer. Such timestamps are now null. The voice analyst's result rows had the same conversion and get the same guard. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 + src/tools/results.js | 4 +- src/tools/tool-timestamp-range.test.mjs | 59 +++++++++++++++++++++++++ src/voice/gevActions.js | 5 ++- src/voice/gevActions.test.mjs | 20 +++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/tools/tool-timestamp-range.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6051fb3aa..b72aeefd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range. + - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Frequently reused terrain batches retain their cached heights ahead of unused cells. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6ac31dd73..251b71a7e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -75,6 +75,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. +Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..f9af7af3d 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { - return Number.isFinite(ms) ? new Date(ms).toISOString() : null; + if (!Number.isFinite(ms)) return null; + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; } /** "1 earthquake", "3 earthquakes". */ diff --git a/src/tools/tool-timestamp-range.test.mjs b/src/tools/tool-timestamp-range.test.mjs new file mode 100644 index 000000000..7aeee737b --- /dev/null +++ b/src/tools/tool-timestamp-range.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isoTime } from './results.js'; +import { getBikeShare } from './queries/mobility.js'; +test('out-of-range finite timestamps are unknown rather than throwing', async () => { + for (const value of [1e30, -1e30, NaN, Infinity, null]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z'); + assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z'); + assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z'); + for (const value of [8640000000000001, -8640000000000001, -Infinity]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z'); + const station = { + station_id: 's1', + name: 'Fixture', + lat: 0, + lon: 0, + capacity: 4, + }; + const result = await getBikeShare.run( + { area: { bbox: [-1, -1, 1, 1] } }, + { + services: { + bikeshare: { + systems: [ + { + id: 'fixture', + city: 'Fixture', + centerLat: 0, + centerLon: 0, + loadRadiusKm: 30, + stationInformationUrl: 'information', + stationStatusUrl: 'status', + }, + ], + async getStations(url) { + return { + data: { + stations: + url === 'information' + ? [station] + : [ + { + station_id: 's1', + last_reported: 1e30, + num_bikes_available: 2, + }, + ], + }, + }; + }, + }, + }, + }, + ); + assert.equal(result.data.returned, 1); + assert.equal(result.data.rows[0].last_reported, null); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9e1e183d0..267f633ff 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4808,8 +4808,9 @@ function compactAnalystItem(r) { for (const [key, spec] of Object.entries(fields)) { if (!present(r[key]) || key in compact) continue; if (spec.type === 'time') { - const ms = Number(r[key]); - if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString(); + // A finite value can still be past Date's range, where toISOString throws. + const time = new Date(Number(r[key])); + if (Number.isFinite(time.getTime())) compact.time = time.toISOString(); } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..2a80b829c 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3459,6 +3459,26 @@ test('analyst_query refusals carry their code and the allowed values', async () assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/); }); +test('analyst_query keeps its answer when one record has an out-of-range time', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const now = Date.now(); + const earthquakes = { + getStats: () => ({ count: 2, lastUpdate: now }), + getAnalystRecords: () => [ + { id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now }, + { id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 }, + ], + }; + const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] }); + const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } }); + assert.equal(result.ok, true); + const q = result.items.find((item) => item.id === 'q'); + assert.match(q.time, /^\d{4}-\d\d-\d\dT/); + const r = result.items.find((item) => item.id === 'r'); + assert.ok(r, 'the record is still listed'); + assert.equal(r.time, undefined, 'an unrepresentable time is omitted'); +}); + test('spoken layer names from the manifest reach the newer layers', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const stub = () => ({ getStats: () => ({ count: 0 }) });