From 56f0d2e2030a0cc32ffbb207b0d2a8a0459366ef Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:21:00 -0700 Subject: [PATCH] fix: carry weather and fire freshness into tool answers get_weather dropped the proxy's stale status and get_active_fires dropped FIRMS staleness and failed satellite sources, so answers and the HUD caption read them as current. Both now report them, and the caption context marks such sections stale or degraded. --- src/tools/queries/brief.js | 8 +++-- src/tools/queries/environment.js | 13 +++++-- src/tools/queries/environment.test.mjs | 48 ++++++++++++++++++++++++++ src/tools/queries/hazards.js | 17 ++++++++- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index cf6161693..e1900873d 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -90,9 +90,11 @@ const AWARENESS_SECTIONS = [ function sectionFeedState(section) { if (section.unavailable) return 'unavailable'; const data = section.data || {}; - return data.freshness === 'stale' || data.stale === true - ? 'stale' - : 'nominal'; + if (data.freshness === 'stale' || data.stale === true) return 'stale'; + // Answered, but some of the feeds or satellites behind it did not. + if (data.missing_sources?.length || data.unavailable_feeds?.length) + return 'degraded'; + return 'nominal'; } /** diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 141a8e70c..fab605431 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -104,9 +104,18 @@ export const getWeather = defineTool({ signal, }); const row = weatherRow(payload?.weather); + // The proxy answers from its cache, marked stale, when a refresh fails. + const stale = payload?.status === 'stale'; return { - summary: `Weather at ${point.label}: ${describeWeather(row)}.`, - data: { location: point, weather: row }, + summary: + `Weather at ${point.label}: ${describeWeather(row)}` + + (stale ? ' (data may be stale).' : '.'), + data: { + location: point, + weather: row, + stale, + retrieved_at: payload?.retrievedAt ?? null, + }, }; }, }); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index b26c615bf..617d156f2 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -694,3 +694,51 @@ test('a fire perimeter enclosing the whole area is found', async () => { ['big'], ); }); + +test('stale weather and partial fire data reach answers and the HUD caption', async () => { + const sent = []; + const summary = { + async summarize(context) { + sent.push(context); + return { ok: true, status: 200, data: { summary: 'Austin STALE' } }; + }, + }; + const staleWeather = { + getConditions: async () => ({ status: 'stale', weather: conditions }), + }; + const fires = { + getSnapshot: async () => ({ + stale: false, + fetchedAt: Date.UTC(2026, 0, 1), + sources: [ + { source: 'VIIRS_NOAA20_NRT', ok: true }, + { source: 'VIIRS_SNPP_NRT', ok: false }, + ], + fires: [], + }), + }; + const catalog = catalogWith({ + weather: staleWeather, + summary, + places, + fires, + }); + const weatherResult = await catalog.call('get_weather', { + location: { place: 'Austin' }, + }); + assert.equal(weatherResult.data.stale, true); + assert.match(weatherResult.summary, /\(data may be stale\)\.$/); + const fireResult = await catalog.call('get_active_fires', { + area: { place: 'Austin' }, + }); + assert.deepEqual(fireResult.data.missing_sources, ['VIIRS_SNPP_NRT']); + assert.match(fireResult.summary, /\(no data from VIIRS_SNPP_NRT\)\.$/); + await catalog.call('get_hud_caption', { area: { place: 'Austin' } }); + assert.deepEqual( + sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]), + [ + ['weather', 'stale'], + ['fires', 'degraded'], + ], + ); +}); diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 15e80507a..c841d197b 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -78,9 +78,24 @@ export const getActiveFires = defineTool({ .filter((fire) => areaContains(area, fire)) .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); const result = capRows(fires, args.limit); + // The proxy serves its last good snapshot when a refresh fails, and marks + // satellites whose download failed. + const stale = snapshot.stale === true; + const missing = (snapshot.sources || []) + .filter((source) => source?.ok === false) + .map((source) => source.source); + const notes = [ + ...(stale ? ['data may be stale'] : []), + ...(missing.length ? [`no data from ${missing.join(', ')}`] : []), + ]; return { - summary: `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours.`, + summary: + `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + stale, + missing_sources: missing, + fetched_at: isoTime(snapshot.fetchedAt), ...result, rows: result.rows.map((fire) => ({ lat: fire.lat,