diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2a5bb7f90..8c1de04fe 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5255,10 +5255,10 @@ 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 layer names as the user sees them) and `feedProvenance`; the model is instructed not to infer from coordinates. +- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) and `feedProvenance`, with every layer name as the user sees it; outside English the narration note is left out, since a small model copies its English words. The model is instructed not to infer from coordinates. A view with no labels and no enabled layer is not sent: the deterministic line is shown. - 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 and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout clips neither the warning nor the text. `get_hud_caption` builds its caption the same way and returns `feedState`. -- Output is sanitized to five words (20 characters when the request locale is Japanese or Chinese, whatever script the words use); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- 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 and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout never clips the warning; the text and then the layer names may be cut off on narrow layouts. Outside English a reply that echoes a feed-state id or an English layer name the UI translates falls back too. `get_hud_caption` builds its caption the same way and returns `feedState`. +- Output is sanitized to five words, or for a Japanese or Chinese request locale to 20 characters (stepping back to a space rather than splitting a Latin word); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update; a camera move during typing shows the deterministic line instead of the fragment. - `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. diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 1384537a0..1369de10f 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -45,18 +45,24 @@ function toFiveWordHudSummary(value, unspaced) { .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. + .filter(Boolean); // Other languages keep five words, even around a Han place name. - if (!unspaced) 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(); + if (!unspaced) return words.slice(0, 5).join(' '); + // Japanese and Chinese have no spaces to count words by: cap characters, + // stepping back to a space rather than cutting a Latin word in two. + const text = words.join(' '); + const graphemes = [ + ...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), + ].map(({ segment }) => segment); + if (graphemes.length <= HUD_SUMMARY_MAX_UNSPACED_CHARS) return text; + const head = graphemes.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS); + const next = graphemes[HUD_SUMMARY_MAX_UNSPACED_CHARS]; + const inWord = (g) => /^[\p{Script=Latin}\p{N}]/u.test(g ?? ''); + if (inWord(head.at(-1)) && inWord(next)) { + const space = head.lastIndexOf(' '); + if (space > 0) return head.slice(0, space).join('').trim(); + } + return head.join('').trim(); } async function handleHudSummary(req, res) { diff --git a/src/hud.js b/src/hud.js index 4c969e38b..c26d8df17 100644 --- a/src/hud.js +++ b/src/hud.js @@ -27,6 +27,7 @@ import { } from './data/geoid.js'; import { getBasemapLabelContext } from './voice/gevActions.js'; import { + hudSummaryHasEnglish, hudSummaryMatchesProvenance, hudSummaryLayerContext, hudTelemetryProvenance, @@ -237,12 +238,17 @@ export class IntelHUD { // the camera has moved to. this._onCameraMoveStart = () => { this._markSummaryDirty(); - if (this._summaryRequest || this._summaryTypingInterval) { + const typing = Boolean(this._summaryTypingInterval); + if (this._summaryRequest || typing) { // Cancelled requests and partially typed replies are not reusable answers. this._lastSummarySignature = null; } this._summaryRequest?.abort(); this._stopSummaryTyping(); + // A half-typed reply would stay until moveEnd, which a camera that + // keeps moving may not send for a long time: show the plain line. + if (typing && this._latestMetrics) + this._setSummaryText(this._composeSummary(), false); }; this._onCameraMoveEnd = () => { this._markSummaryDirty(); @@ -856,6 +862,19 @@ export class IntelHUD { return; } if (revision !== this._summaryRevision) return; + // Nothing to describe (no place labels, no enabled layer): the model only + // answers about the empty input, so the plain line is shown instead. + const described = [ + context.placeLabels, + context.streetLabels, + context.nearbyPlaceLabels, + context.enabledLayerLabels, + ].some((list) => list?.length); + if (!described) { + this._summaryDirty = false; + this._setSummaryText(fallbackText, animate); + return; + } const signature = JSON.stringify(context); if (!force && signature === this._lastSummarySignature) { this._summaryDirty = false; @@ -892,7 +911,14 @@ export class IntelHUD { if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - if (hudSummaryMatchesProvenance(data, context.feedProvenance)) { + if ( + hudSummaryMatchesProvenance(data, context.feedProvenance) && + !hudSummaryHasEnglish( + data.summary, + context.locale, + context.translatedLayerNames, + ) + ) { // The warning is the HUD's own translated state, not the model's words. this._lastSummaryText = withProvenance( data.summary, @@ -958,23 +984,40 @@ 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 locale = getLocale(); const layerContext = hudSummaryLayerContext( this._dataManager?.getAll?.() || [], ); - return { - locale: getLocale(), + const named = (layer) => ({ + ...layer, + name: localizeLayerName(layer.name), + }); + const { feedProvenance } = layerContext; + const context = { + locale, placeLabels: labels.placeLabels, streetLabels: labels.streetLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels, ...layerContext, - // The names the user sees; feedProvenance keeps the English ids. + // The model sees only the layer names the user sees. The English note + // goes too: a small model copies its words. `overall` stays the id. enabledLayerLabels: layerContext.enabledLayerLabels.map(localizeLayerName), - enabledLayers: layerContext.enabledLayers.map((layer) => ({ - ...layer, - name: localizeLayerName(layer.name), - })), + enabledLayers: layerContext.enabledLayers.map(named), + feedProvenance: { + ...feedProvenance, + layers: feedProvenance.layers.map(named), + note: locale === 'en' ? feedProvenance.note : undefined, + }, }; + // Not sent and not part of the signature: the English names a reply in + // another language must not echo. + Object.defineProperty(context, 'translatedLayerNames', { + value: layerContext.enabledLayerLabels.filter( + (name) => localizeLayerName(name) !== name, + ), + }); + return context; } _markSummaryDirty() { diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index 18ed56f92..3bb0f2060 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -304,7 +304,7 @@ test('a summary asked for one view is dropped once the camera moves', async () = try { hud = new IntelHUD(env.viewer, { summaryService }); let place = 'Yellowstone'; - hud._summaryContext = async () => ({ place }); + hud._summaryContext = async () => ({ placeLabels: [place] }); const summary = () => env.elements.get('hud-summary').textContent; const settle = () => new Promise((resolve) => setImmediate(resolve)); hud._updateCameraData(); @@ -330,7 +330,7 @@ test('a summary asked for one view is dropped once the camera moves', async () = const moving = hud._updateSummary(false); await settle(); assert.equal(pending.length, 2, 'the move marked the summary dirty'); - assert.deepEqual(pending[1].context, { place: 'Atlantic' }); + assert.deepEqual(pending[1].context, { placeLabels: ['Atlantic'] }); pending[1].reply('North Atlantic Ocean'); await moving; assert.equal(summary(), 'North Atlantic Ocean'); @@ -363,7 +363,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex let hud; try { hud = new IntelHUD(env.viewer, { summaryService }); - hud._summaryContext = async () => ({ place: 'Yellowstone' }); + hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] }); const element = env.elements.get('hud-summary'); const settle = () => new Promise((resolve) => setImmediate(resolve)); hud._updateCameraData(); @@ -425,7 +425,7 @@ test('a cancelled summary retries the same context but completed answers stay re let hud; try { hud = new IntelHUD(env.viewer, { summaryService }); - hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] }); hud._updateCameraData(); const settle = () => new Promise((resolve) => setImmediate(resolve)); const first = hud._updateSummary(false); @@ -483,7 +483,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move try { hud = new IntelHUD(env.viewer, { summaryService }); // The same place before and after: a slow follow camera, a small pan. - hud._summaryContext = async () => ({ place: 'Lisboa' }); + hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] }); const element = env.elements.get('hud-summary'); const settle = () => new Promise((resolve) => setImmediate(resolve)); hud._updateCameraData(); @@ -510,15 +510,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move }); await update; - // A reply still typing stops when the camera starts moving. + // A reply still typing stops when the camera starts moving, and the + // plain line replaces the fragment. hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park'); t.mock.timers.tick(48); - const typed = element.textContent; env.cameraListeners.moveStart(); + const plain = element.textContent; + assert.equal(plain, hud._composeSummary()); t.mock.timers.tick(2_000); assert.equal( element.textContent, - typed, + plain, 'no more characters after the move', ); } finally { @@ -545,7 +547,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t) let hud; try { hud = new IntelHUD(env.viewer, { summaryService }); - hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] }); hud._updateCameraData(); await hud._updateSummary(true); t.mock.timers.tick(48); diff --git a/src/hudSummaryCjkWords.test.mjs b/src/hudSummaryCjkWords.test.mjs new file mode 100644 index 000000000..f215cd390 --- /dev/null +++ b/src/hudSummaryCjkWords.test.mjs @@ -0,0 +1,62 @@ +// revmain-astra-1 / revmain-sol-1: a ja/zh reply within the 20-character +// limit is still cut to its first five space-separated tokens. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Readable } from 'node:stream'; +import { HUD_SUMMARY_MAX_UNSPACED_CHARS } from './hudSummaryResponse.js'; + +let client = 200; +async function postHudSummary(context, reply) { + client += 1; + const previousKey = process.env.OPENAI_API_KEY; + const previousFetch = globalThis.fetch; + process.env.OPENAI_API_KEY = 'test-key'; + globalThis.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ output_text: reply }), + }); + try { + const { handleHudSummary } = await import( + '../server/providers/openai/hud-summary.js' + ); + const req = Object.assign( + Readable.from([Buffer.from(JSON.stringify(context))]), + { + method: 'POST', + url: '/', + headers: {}, + socket: { remoteAddress: `10.0.1.${client}` }, + }, + ); + return await new Promise((resolve, reject) => { + const res = { + statusCode: 200, + setHeader() {}, + end(body = '') { + resolve(JSON.parse(body)); + }, + }; + Promise.resolve(handleHudSummary(req, res)).catch(reject); + }); + } finally { + globalThis.fetch = previousFetch; + if (previousKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = previousKey; + } +} + +for (const [locale, summary] of [ + ['ja', '東京 上空 の 航空 情報 は 古い'], + ['zh-CN', 'SFO 附近 4 架 航班 数据 过期'], + ['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'], +]) { + test(`a ${locale} phrase within ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters is kept whole`, async () => { + assert.ok([...summary].length <= HUD_SUMMARY_MAX_UNSPACED_CHARS); + const body = await postHudSummary( + { locale, feedProvenance: { overall: 'stale' } }, + JSON.stringify({ summary, feedState: 'stale' }), + ); + assert.equal(body.summary, summary); + }); +} diff --git a/src/hudSummaryLine.test.mjs b/src/hudSummaryLine.test.mjs new file mode 100644 index 000000000..f5cb3fcb2 --- /dev/null +++ b/src/hudSummaryLine.test.mjs @@ -0,0 +1,145 @@ +// The HUD summary line in another language: what the model is sent and what +// the readout shows. hud.js imports `mgrs`, which Node's ESM loader cannot +// read named exports from, so a module hook swaps it for a stub before the +// import (the same setup as hudSummaryGate.test.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); +const { setLocale } = await import('./i18n/index.js'); +const { hudSummaryInput } = await import('./hudSummaryResponse.js'); + +const PARTIAL_FLIGHTS = { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { count: 4, partial: true, source: 'opensky' }, +}; + +/** A ru HUD over one partial Live Flights layer, answering with `reply`. */ +function createHud(t, reply) { + const summary = { textContent: '' }; + const previous = { document: globalThis.document, window: globalThis.window }; + globalThis.document = { + hidden: false, + visibilityState: 'visible', + getElementById: (id) => (id === 'hud-summary' ? summary : null), + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.window ??= { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + t.mock.timers.enable({ apis: ['setInterval'] }); + const viewer = { + scene: { canvas: { width: 0, height: 0 }, pickPositionSupported: false }, + camera: { + pitch: -Math.PI / 2, + heading: 0, + positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + const sent = []; + const hud = new IntelHUD(viewer, { + summaryService: { + configured: async () => true, + async summarize(context) { + sent.push(context); + return { ok: true, status: 200, data: { error: null, ...reply } }; + }, + }, + }); + t.after(() => { + hud.destroy(); + setLocale('en'); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }); + hud._dataManager = { getAll: () => [PARTIAL_FLIGHTS] }; + hud._latestMetrics = { altM: 600 }; + setLocale('ru'); + return { hud, sent, shown: () => summary.textContent }; +} + +test('a ru HUD request carries no English layer name', async (t) => { + const { hud } = createHud(t, {}); + const context = await hud._summaryContext(); + assert.equal(context.locale, 'ru'); + assert.deepEqual(context.enabledLayerLabels, ['Рейсы онлайн']); + const input = hudSummaryInput(context); + assert.doesNotMatch(input, /Live Flights/, input); + // The locale signs the dedupe signature; the English names stay local. + assert.match(JSON.stringify(context), /"locale":"ru"/); + assert.doesNotMatch(JSON.stringify(context), /translatedLayerNames/); +}); + +test('a ru summary shows the state word first and the layer names last', async (t) => { + const { hud, shown } = createHud(t, { + summary: 'Аэропорт задерживает рейсы', + feedState: 'partial', + }); + await hud._updateSummary(false, true); + assert.equal(shown(), 'ЧАСТИЧНО | Аэропорт задерживает рейсы | РЕЙСЫ ОНЛАЙН'); +}); + +for (const words of [ + 'Live Flights частично онлайн', + 'Рейсы онлайн stale данные', +]) { + test(`a ru reply echoing English falls back: ${words}`, async (t) => { + const { hud, shown } = createHud(t, { + summary: words, + feedState: 'partial', + }); + await hud._updateSummary(false, true); + assert.ok(shown().startsWith('ЧАСТИЧНО | '), shown()); + assert.ok(!shown().includes(words), shown()); + }); +} + +test('a camera move during typing shows the plain line, not a fragment', (t) => { + const { hud, shown } = createHud(t, {}); + hud._setSummaryText('ЧАСТИЧНО | Аэропорт задерживает рейсы', true); + hud._onCameraMoveStart(); + assert.equal(shown(), hud._composeSummary()); +}); + +test('a view with no labels and no layers does not ask the model', async (t) => { + const { hud, sent, shown } = createHud(t, { + summary: 'пусто', + feedState: null, + }); + hud._dataManager = { getAll: () => [] }; + await hud._updateSummary(false, true); + assert.equal(sent.length, 0); + assert.equal(shown(), hud._composeSummary()); +}); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index c727e5918..3bb67d655 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -59,7 +59,7 @@ export const HUD_SUMMARY_INSTRUCTIONS = [ "Write one concise intelligence-HUD summary for God's Eye View.", '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.', - 'Name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance or its note.', + 'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.', 'Set feedState to feedProvenance.overall exactly as given, or none when it is null.', '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.', @@ -107,6 +107,32 @@ const SUMMARY_LANGUAGES = Object.freeze({ /** Scripts written without spaces, where five words do not apply. */ const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/; +/** + * Whether a summary for a non-English locale still carries English the HUD + * sent only as data: a feed-state id (`stale`) or an English layer name that + * has a translation (`Live Flights` in Russian). + * @param {string} summary The model's words. + * @param {unknown} locale The request locale. + * @param {string[]} [englishNames] English layer names that the UI translates. + * @returns {boolean} + */ +export function hudSummaryHasEnglish(summary, locale, englishNames = []) { + if (hudSummaryLocale(locale) === DEFAULT_LOCALE) return false; + const text = String(summary || '').toLowerCase(); + return [...HUD_SUMMARY_FEED_STATES, ...englishNames].some((word) => { + const needle = String(word).toLowerCase(); + let at = text.indexOf(needle); + while (at >= 0) { + const before = text[at - 1]; + const after = text[at + needle.length]; + if (!/\p{L}/u.test(before ?? '') && !/\p{L}/u.test(after ?? '')) + return true; + at = text.indexOf(needle, at + 1); + } + return false; + }); +} + /** Whether the summary for this locale is capped in characters, not words. */ export function hudSummaryIsUnspaced(locale) { return UNSPACED_LOCALES.test(hudSummaryLocale(locale)); diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index aece0694d..3fec6cca3 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -255,33 +255,6 @@ test('the instructions name the UI language, with English as the fallback', () = assert.equal(hudSummaryLocale(42), 'en'); }); -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 word leads and the layer names trail the one-line summary', () => { - const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8'); - assert.match( - hud, - /return \[state, text, layers\]\.filter\(Boolean\)\.join\(' \| '\);/, - ); - assert.match(hud, /withProvenance\(\s*data\.summary,/); - assert.match(hud, /return withProvenance\(line, provenance\);/); - // The model sees only the layer names the user sees. - assert.match(hud, /name: localizeLayerName\(layer\.name\)/); - assert.match( - HUD_SUMMARY_INSTRUCTIONS, - /Name layers only as in enabledLayerLabels/, - ); -}); - test('shipped catalogs get their exact language, and CJK a character limit', () => { assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/); assert.match( @@ -397,6 +370,17 @@ for (const [locale, summary] of [ }); } +test('a ja cap steps back to a space instead of cutting a Latin word', async () => { + const { response } = await postHudSummary( + { locale: 'ja', feedProvenance: { overall: 'stale' } }, + JSON.stringify({ + summary: 'San Francisco Bay Area flights', + feedState: 'stale', + }), + ); + assert.equal(response.body.summary, 'San Francisco Bay'); +}); + test('a reply that skips the format carries no summary and no state', async () => { const { response } = await postHudSummary( { locale: 'ru', feedProvenance: { overall: 'stale' } },