diff --git a/changelog.d/1488-hud-summary-language.md b/changelog.d/1488-hud-summary-language.md index 8cc0f8590..ec2eed198 100644 --- a/changelog.d/1488-hud-summary-language.md +++ b/changelog.d/1488-hud-summary-language.md @@ -1,5 +1,3 @@ -- Write the AI HUD summary in the interface language. The model now returns - the feed state as its own field, and the HUD checks it against its own and - adds its translated state tag, so a stale or partial view is never shown as - live. A partial layer no longer reads as off or nominal in the HUD, voice - and analyst provenance (German, @gekh, #1488). +- Write the AI HUD summary in the interface language, led by the HUD's own + translated feed-state warning. A partial layer no longer reads as off or + nominal in the HUD, voice and analyst answers (German, @gekh, #1488). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b8c6279f..325fd3580 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5255,8 +5255,11 @@ and unreachable upstream (502/504) separately from road geometry. ### AI HUD Summary (June 2026) - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). -- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. -- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- Input is the live basemap label context (place/street/nearby-place labels + enabled layers, the layer names as the user sees them) and `feedProvenance`; the model is instructed not to infer from coordinates. +- Language (October 2026): the HUD sends `locale`, which is part of the dedupe signature. The proxy keeps a shipped locale (English otherwise) and names the language in the instructions and at the start of the input; Japanese and Chinese get a 20-character phrase instead of five words. +- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary is shown after the HUD's own translated state tag (`STALE LIVE FLIGHTS | …`), which leads so the one-line readout never clips it. +- Output is sanitized to five words (20 characters for Japanese and Chinese); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer no longer reads as off and partial plus nominal reads partial (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live. - A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable. ### Place-search providers diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index d7b4b19cd..6986d9697 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,5 +1,6 @@ import { HUD_SUMMARY_FEED_STATES, + HUD_SUMMARY_MAX_UNSPACED_CHARS, HUD_SUMMARY_RESPONSE_FORMAT, hudSummaryInput, hudSummaryInstructions, @@ -21,7 +22,7 @@ function extractOpenAiResponseText(data) { .trim(); } -/** The structured reply, or nulls when the model did not follow the format. */ +/** Parse the summary and normalize the canonical feed state. */ function parseHudSummaryReply(text) { let reply = null; try { @@ -39,13 +40,22 @@ function parseHudSummaryReply(text) { } function toFiveWordHudSummary(value) { - return String(value || '') + const words = String(value || '') .replace(/[^\p{L}\p{N}\s-]/gu, ' ') .trim() .split(/\s+/) .filter(Boolean) .slice(0, 5) .join(' '); + // Japanese and Chinese have no spaces to count words by: cap characters. + if (!/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(words)) + return words; + const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + return [...graphemes.segment(words)] + .slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS) + .map(({ segment }) => segment) + .join('') + .trim(); } async function handleHudSummary(req, res) { diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js index 7a2041208..1e0daf01e 100644 --- a/src/data/layerSnapshot.js +++ b/src/data/layerSnapshot.js @@ -208,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) { return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`; }); const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`; + if (overall === 'partial') + return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`; return `${body}. Narrate that provenance with any count; do not describe this as live.`; } @@ -254,3 +256,35 @@ export function viewStateLayerRow(layer, options) { lastUpdate: snap.lastUpdate, }; } + +/** + * The feed state and up to two layer names of a provenance envelope that is + * not nominal, or null. Values stay English (`state` is a feed-state id, + * `names` are `layer.name`); the HUD translates them for display. + * @param {{ overall?: string|null, layers?: Array }|null} envelope + * @returns {{ state: string, names: string[] }|null} + */ +export function provenanceTag(envelope) { + const overall = envelope?.overall; + if (!overall || overall === 'nominal' || overall === 'off') return null; + const names = (envelope.layers || []) + .filter( + (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', + ) + .map((s) => String(s.name || s.id || 'LAYER')) + .slice(0, 2); + return { state: overall, names }; +} + +/** + * Require a matching feedState for warning states: a view that is not + * nominal falls back on a missing or different state. + * @param {{ feedState?: string|null }|null} response The proxy's reply. + * @param {{ overall?: string|null }|null} provenance The context's envelope. + * @returns {boolean} + */ +export function hudSummaryMatchesProvenance(response, provenance) { + const state = provenance?.overall; + if (!state || state === 'nominal' || state === 'off') return true; + return response?.feedState === state; +} diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs index a354a7a07..6fcb17b9c 100644 --- a/src/data/layerSnapshot.test.mjs +++ b/src/data/layerSnapshot.test.mjs @@ -132,6 +132,9 @@ test('partial is a feed-state of its own: above nominal, below every fault', () assert.equal(overall([partial, stale]), 'stale'); assert.equal(overall([stale, partial]), 'stale'); assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL'); + const note = feedProvenanceEnvelope([partial], { now: NOW }).note; + assert.match(note, /coverage is incomplete/); + assert.doesNotMatch(note, /do not describe this as live/); assert.equal( FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial && FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal, diff --git a/src/hud.js b/src/hud.js index ed1b5f07e..df649212a 100644 --- a/src/hud.js +++ b/src/hud.js @@ -782,7 +782,8 @@ export class IntelHUD { zone: localTag, }, ); - return provenance ? `${line} | ${provenance}` : line; + // The feed state leads: the one-line readout clips its end. + return provenance ? `${provenance} | ${line}` : line; } /** Stop a summary still typing out, leaving what it has typed so far. */ @@ -894,11 +895,12 @@ export class IntelHUD { throw new Error(data?.error || `HTTP ${response.status}`); } if (hudSummaryMatchesProvenance(data, context.feedProvenance)) { - // The warning is the HUD's own translated tag, not the model's words. + // The warning is the HUD's own translated tag, not the model's + // words, and it leads so the one-line readout never clips it. const tag = localizedProvenanceTag( provenanceTag(context.feedProvenance), ); - this._lastSummaryText = tag ? `${data.summary} | ${tag}` : data.summary; + this._lastSummaryText = tag ? `${tag} | ${data.summary}` : data.summary; } this._setSummaryText(this._lastSummaryText || fallbackText, animate); } catch (error) { @@ -959,18 +961,18 @@ export class IntelHUD { // A slower build for a view the camera has left must not replace the // newer view's lookup. if (revision === this._summaryRevision) this._placeLabels = labels; - const enabledLayers = - this._dataManager - ?.getAll?.() - ?.filter((layer) => layer.enabled) - .map((layer) => layer.name) || []; + const layerContext = hudSummaryLayerContext( + this._dataManager?.getAll?.() || [], + ); return { locale: getLocale(), placeLabels: labels.placeLabels, streetLabels: labels.streetLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels, - enabledLayerLabels: enabledLayers, - ...hudSummaryLayerContext(this._dataManager?.getAll?.() || []), + ...layerContext, + // The names the user sees; feedProvenance keeps the English ids. + enabledLayerLabels: + layerContext.enabledLayerLabels.map(localizeLayerName), }; } diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 50be07036..63efff42b 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -2,9 +2,15 @@ import { SNAPSHOT_FEED_STATES, feedProvenanceEnvelope, layerSnapshots, + provenanceTag, } from './data/layerSnapshot.js'; import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js'; +export { + hudSummaryMatchesProvenance, + provenanceTag, +} from './data/layerSnapshot.js'; + export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED'; /** @@ -54,11 +60,14 @@ export const HUD_SUMMARY_INSTRUCTIONS = [ 'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.', 'Prefer the clearest named place and include a relevant enabled layer only when useful.', 'Set feedState to feedProvenance.overall exactly as given, or none when it is null.', - 'If feedProvenance.overall is not nominal, the summary must not present the view as live or healthy.', + 'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.', 'Do not infer from coordinates or invent a place or feed state.', 'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.', ].join(' '); +/** Longest summary, in characters, for a script written without spaces. */ +export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20; + /** feedState values the model may return; none stands for a null overall. */ export const HUD_SUMMARY_FEED_STATES = Object.freeze([ ...SNAPSHOT_FEED_STATES, @@ -87,7 +96,19 @@ export function hudSummaryLocale(value) { } /** The language a request's summary is written in, named in English. */ +// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the +// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script. +const SUMMARY_LANGUAGES = Object.freeze({ + pt: 'Brazilian Portuguese', + 'zh-CN': 'Simplified Chinese', + 'zh-Hant': 'Chinese in Traditional characters (mainland wording)', +}); + +/** Scripts written without spaces, where five words do not apply. */ +const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/; + function summaryLanguage(tag) { + if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag]; try { return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag; } catch { @@ -96,8 +117,7 @@ function summaryLanguage(tag) { } /** - * The instructions for one request: English as before, otherwise one more - * sentence naming the language the five words are written in. + * Build the summary instructions for the requested locale. * @param {unknown} locale The `locale` the HUD sent. * @returns {string} */ @@ -105,7 +125,10 @@ export function hudSummaryInstructions(locale) { const tag = hudSummaryLocale(locale); if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS; const language = summaryLanguage(tag); - return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places and layers their usual ${language} names; feedState stays the English value.`; + const length = UNSPACED_LOCALES.test(tag) + ? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.` + : ''; + return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places their usual ${language} names and layers the names in enabledLayerLabels; feedState stays the English value.${length}`; } /** @@ -148,25 +171,6 @@ export function hudSummaryLayerContext(layers = [], options) { }; } -/** - * The feed state and up to two layer names of a provenance envelope that is - * not nominal, or null. Values stay English (`state` is a feed-state id, - * `names` are `layer.name`); the HUD translates them for display. - * @param {{ overall?: string|null, layers?: Array }|null} envelope - * @returns {{ state: string, names: string[] }|null} - */ -export function provenanceTag(envelope) { - const overall = envelope?.overall; - if (!overall || overall === 'nominal' || overall === 'off') return null; - const names = (envelope.layers || []) - .filter( - (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', - ) - .map((s) => String(s.name || s.id || 'LAYER')) - .slice(0, 2); - return { state: overall, names }; -} - /** * The feed-state tag for the HUD telemetry suffix when an enabled feed is not * nominal. @@ -178,16 +182,3 @@ export function hudTelemetryProvenance(layers = [], options) { const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); return provenanceTag(feedProvenanceEnvelope(snapshots, options)); } - -/** - * Show an AI summary only when its canonical feedState is the HUD's own: a - * view that is not nominal falls back on a missing or different state. - * @param {{ feedState?: string|null }|null} response The proxy's reply. - * @param {{ overall?: string|null }|null} provenance The context's envelope. - * @returns {boolean} - */ -export function hudSummaryMatchesProvenance(response, provenance) { - const state = provenance?.overall; - if (!state || state === 'nominal' || state === 'off') return true; - return response?.feedState === state; -} diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index ca31f988d..efa6ec581 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -5,6 +5,7 @@ import { Readable } from 'node:stream'; import { HUD_SUMMARY_FEED_STATES, HUD_SUMMARY_INSTRUCTIONS, + HUD_SUMMARY_MAX_UNSPACED_CHARS, HUD_SUMMARY_RESPONSE_FORMAT, HUD_SUMMARY_UNCONFIGURED_CODE, hudSummaryInput, @@ -163,9 +164,6 @@ test('the installed keyless HUD route stays successful after the voice quota is } finally { if (previousKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = previousKey; - if (previousLimit === undefined) - delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN; - else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit; } }); @@ -219,7 +217,14 @@ test('HUD summary instructions ask for the canonical feedState as its own field' HUD_SUMMARY_INSTRUCTIONS, /Set feedState to feedProvenance\.overall exactly/, ); - assert.match(HUD_SUMMARY_INSTRUCTIONS, /must not present the view as live/); + assert.match( + HUD_SUMMARY_INSTRUCTIONS, + /do not present the view as live or healthy/, + ); + assert.match( + HUD_SUMMARY_INSTRUCTIONS, + /partial, do not present the coverage as complete/, + ); assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [ 'summary', 'feedState', @@ -251,6 +256,32 @@ test('the HUD sends its UI locale inside the context it signs for dedupe', () => const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8'); assert.match(hud, /locale: getLocale\(\),\s+placeLabels:/); assert.match(hud, /const signature = JSON\.stringify\(context\);/); + // The model gets the layer names the user sees. + assert.match( + hud, + /enabledLayerLabels:\s+layerContext\.enabledLayerLabels\.map\(localizeLayerName\)/, + ); +}); + +test('the state tag leads the one-line summary so clipping cannot hide it', () => { + const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8'); + assert.match(hud, /tag \? `\$\{tag\} \| \$\{data\.summary\}`/); + assert.match(hud, /provenance \? `\$\{provenance\} \| \$\{line\}`/); +}); + +test('shipped catalogs get their exact language, and CJK a character limit', () => { + assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/); + assert.match( + hudSummaryInstructions('zh-Hant'), + /in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/, + ); + assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/); + assert.match( + hudSummaryInstructions('ja'), + new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`), + ); + assert.doesNotMatch(hudSummaryInstructions('es'), /at most/); + assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/); }); let hudSummaryClient = 0; @@ -259,10 +290,8 @@ async function postHudSummary(context, reply) { // A fresh address per request: the per-IP limiter outlives a single test. hudSummaryClient += 1; const previousKey = process.env.OPENAI_API_KEY; - const previousLimit = process.env.GEV_RATELIMIT_OPENAI_PER_MIN; const previousFetch = globalThis.fetch; process.env.OPENAI_API_KEY = 'test-key'; - process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '0'; let sent = null; globalThis.fetch = async (url, init) => { sent = JSON.parse(init.body); @@ -301,9 +330,6 @@ async function postHudSummary(context, reply) { globalThis.fetch = previousFetch; if (previousKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = previousKey; - if (previousLimit === undefined) - delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN; - else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit; } } @@ -332,6 +358,18 @@ test('an es request reaches the model in Spanish and returns the canonical state }); }); +test('a Japanese summary is capped in characters, not words', async () => { + const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています'; + const { response } = await postHudSummary( + { locale: 'ja', feedProvenance: { overall: 'stale' } }, + JSON.stringify({ summary: long, feedState: 'stale' }), + ); + assert.equal( + response.body.summary, + [...long].slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS).join(''), + ); +}); + test('a reply that skips the format carries no summary and no state', async () => { const { response } = await postHudSummary( { locale: 'ru', feedProvenance: { overall: 'stale' } }, @@ -346,17 +384,6 @@ test('a reply that skips the format carries no summary and no state', async () = assert.equal(unknown.response.body.feedState, null); }); -test('the HUD proxy builds its instructions from the shared contract', () => { - const local = readFileSync( - new URL('../server/providers/openai/hud-summary.js', import.meta.url), - 'utf8', - ); - assert.match(local, /hudSummaryInstructions\(context\?\.locale\)/); - assert.match(local, /hudSummaryInput\(context\)/); - assert.match(local, /HUD_SUMMARY_RESPONSE_FORMAT/); - assert.doesNotMatch(local, /enabled-layer text labels/); -}); - test("a localized summary is shown only with the HUD's own canonical state", () => { const stale = { overall: 'stale' }; assert.equal( diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index ba8e858dd..34d7f5998 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,7 +1,11 @@ /** Composite queries that combine other tools' answers for one area. */ import { suggestView } from '../views.js'; -import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js'; +import { + feedProvenanceEnvelope, + hudSummaryMatchesProvenance, + provenanceTag, +} from '../../data/layerSnapshot.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -232,6 +236,7 @@ export const getHudCaption = defineTool({ feedState: sectionFeedState(brief[key]), }), ); + const feedProvenance = feedProvenanceEnvelope(layers); const response = await context.services.summary.summarize( { placeLabels: [area.label], @@ -244,16 +249,29 @@ export const getHudCaption = defineTool({ name, feedState, })), - feedProvenance: feedProvenanceEnvelope(layers), + feedProvenance, }, { signal: context.signal }, ); - const caption = response?.data?.summary; - if (!response?.ok || typeof caption !== 'string' || !caption) + const words = response?.data?.summary; + if (!response?.ok || typeof words !== 'string' || !words) throw new ToolError('unavailable', 'The caption service did not answer'); + // As on the HUD: the warning is the app's own state tag, and a reply that + // names another state than the app's keeps only the place. + const tag = provenanceTag(feedProvenance); + const text = hudSummaryMatchesProvenance(response.data, feedProvenance) + ? words + : area.label; + const caption = tag + ? `${text} | ${[tag.state, ...tag.names].join(' ').toUpperCase()}` + : text; return { summary: caption, - data: { area: area.label, caption }, + data: { + area: area.label, + caption, + feedState: feedProvenance.overall, + }, }; }, }); diff --git a/src/tools/queries/hud-caption-feed-state.test.mjs b/src/tools/queries/hud-caption-feed-state.test.mjs new file mode 100644 index 000000000..c65b5436f --- /dev/null +++ b/src/tools/queries/hud-caption-feed-state.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +// The proxy returns the feed state as its own field ({ summary, feedState }); +// the five words no longer carry STALE, so get_hud_caption adds the app's own +// state tag and keeps only the place when the reply names another state. +async function staleCaption(reply) { + const conditions = { + observedAt: '2026-10-01T19:15:00.000Z', + temperatureC: 26.3, + apparentTemperatureC: 32, + precipitationMm: 0, + cloudCoverPct: 100, + windKph: 5.4, + windDirectionDeg: 318, + visibilityM: 15400, + weatherCode: 3, + }; + const places = { + resolve: async (name) => + name === 'Austin' + ? { + name: 'Austin, Texas', + bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 }, + } + : null, + }; + const staleWeather = { + getConditions: async () => ({ status: 'stale', weather: conditions }), + }; + let sent = null; + const summary = { + async summarize(context) { + sent = context; + return { ok: true, status: 200, data: { error: null, ...reply } }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { weather: staleWeather, summary, places }, + }); + const result = await catalog.call('get_hud_caption', { + area: { place: 'Austin' }, + }); + assert.equal(sent.feedProvenance.overall, 'stale'); + return result.data; +} + +test('get_hud_caption keeps the canonical feed state of a stale view', async () => { + const data = await staleCaption({ + summary: 'Austin weather across the area', + feedState: 'stale', + }); + assert.equal(data.feedState, 'stale'); + assert.equal(data.caption, 'Austin weather across the area | STALE WEATHER'); +}); + +test('get_hud_caption drops words whose feed state is not the app one', async () => { + const data = await staleCaption({ + summary: 'Austin live weather looks fine', + feedState: 'nominal', + }); + assert.equal(data.feedState, 'stale'); + assert.equal(data.caption, 'Austin, Texas | STALE WEATHER'); +});