From a0386f4d773dca6e914530c67d5cf89a3c8d1463 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:59:56 +0800 Subject: [PATCH] client: read an empty host refusal as "Request failed" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `??` keeps an empty string, so a refusal whose `message` was '' was thrown as `new Error('')`: every surface that prints `error.message` drew nothing, and the memory row — whose sentence tests the failure for truth — settled on "Loading…" for a read that had already failed. Answer the empty message with `Request failed` on both the thrown error and the noted copy, the sentence already in i18n for zh. A non-empty host sentence is unchanged. Fixes #2411 --- .../2411-client-empty-error-message.fixed.md | 1 + core/client.js | 4 +- core/src/client/api.js | 4 +- core/test/api-empty-message.test.js | 161 ++++++++++++++++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 11 +- patches/ds-bot/0012-room-membership.patch | 11 +- .../files/test/api-empty-message.test.js | 161 ++++++++++++++++++ 8 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 changelog.d/2411-client-empty-error-message.fixed.md create mode 100644 core/test/api-empty-message.test.js create mode 100644 patches/ds-bot/files/test/api-empty-message.test.js diff --git a/changelog.d/2411-client-empty-error-message.fixed.md b/changelog.d/2411-client-empty-error-message.fixed.md new file mode 100644 index 000000000..a25891b05 --- /dev/null +++ b/changelog.d/2411-client-empty-error-message.fixed.md @@ -0,0 +1 @@ +A refusal the host sends with no message of its own no longer reaches the screen as a blank error: the client's `api/bot` wrapper answers it with `Request failed`, so a surface that prints the failure says what went wrong instead of drawing nothing — and a Bot's memory row settles on that sentence instead of sitting on `Loading…` ([#2411](https://github.com/ThinkFlowLab/FlyBot/issues/2411)). diff --git a/core/client.js b/core/client.js index 6a1221f03..b160f7c6b 100644 --- a/core/client.js +++ b/core/client.js @@ -1730,10 +1730,10 @@ var call = async (endpoint, payload, signal) => { const result = await response.json(); if (!result.ok) { const code = result.error?.code ?? "bot/error"; - const message = hostText(result.error?.message ?? "Request failed"); + const message = hostText(result.error?.message || "Request failed"); const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code, details: result.error?.details }); if (!REFUSALS.has(code)) { - noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); + noteClientError(endpoint, Object.assign(new Error(result.error?.message || "Request failed"), { code })); markNoted(error); } throw error; diff --git a/core/src/client/api.js b/core/src/client/api.js index 8d04e7918..4dd74418e 100644 --- a/core/src/client/api.js +++ b/core/src/client/api.js @@ -35,10 +35,10 @@ export const call = async (endpoint, payload, signal) => { const result = await response.json() if (!result.ok) { const code = result.error?.code ?? 'bot/error' - const message = hostText(result.error?.message ?? 'Request failed') + const message = hostText(result.error?.message || 'Request failed') const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code, details: result.error?.details }) if (!REFUSALS.has(code)) { - noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code })) + noteClientError(endpoint, Object.assign(new Error(result.error?.message || 'Request failed'), { code })) markNoted(error) } throw error diff --git a/core/test/api-empty-message.test.js b/core/test/api-empty-message.test.js new file mode 100644 index 000000000..f92ab649a --- /dev/null +++ b/core/test/api-empty-message.test.js @@ -0,0 +1,161 @@ +// A host refusal whose `message` is an empty string was thrown as `new Error('')`: `??` keeps an +// empty string, and `hostText('')` returns it unchanged. Every consumer that prints +// `error.message` then draws nothing, and the memory row states its count through +// `if (failure)`, so an empty failure is falsy and the row settles on "Loading…" for a read that +// already failed (#2411). A refusal with no sentence of its own must still read as a refusal. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { call } from './src/client/api.js'", + "export { MemoryRow } from './src/client/memory-panel.js'", + "export { hostText } from './src/client/i18n.js'", + "export { clientErrors, clearClientErrors } from './src/client/diagnostics.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// The row counts through an async effect, so it is drawn once as "Loading…" and again with the +// view. The hook runtime keeps its slots across a re-render, like the renderer's own. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +// One fetch stub per call: the api wrapper posts every request through the browser's own fetch. +let answer = null +const fetchStub = async () => answer +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + fetch: fetchStub, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { call, MemoryRow, hostText, clientErrors, clearClientErrors } = module.exports + +// The row's tree holds component elements around the hook, so a draw pass renders every function +// component the way the renderer would, with one hook index continuing through the tree. +const draw = (node) => { + if (Array.isArray(node)) return node.map(draw) + if (!node || typeof node !== 'object') return node + if (typeof node.type === 'function') return draw(node.type(node.props)) + if (node.props?.children === undefined) return node + return { ...node, props: { ...node.props, children: draw(node.props.children) } } +} +const render = (props) => { index = 0; return draw(MemoryRow(props)) } +const reset = () => { slots = []; effects = []; index = 0 } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + for (let turn = 0; turn < 6; turn += 1) await Promise.resolve() +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const textOf = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const byClass = (node, name) => all(node).find(item => typeof item.props?.className === 'string' && item.props.className.split(' ').includes(name)) + +// The host's own reply shape: an HTTP 200 whose body is `{ ok: false, error: { code, message, details } }`. +const refusal = (code, message, details) => ({ ok: true, status: 200, json: async () => ({ ok: false, error: { code, message, details } }) }) + +async function rejection(promise) { + try { await promise } catch (error) { return error } + throw new Error('the call resolved, but the host refused it') +} + +async function rowOf(botId, memoryView) { + reset() + const props = { bot: { id: botId, name: 'Scout' }, actions: { memoryView, openMemory() {} } } + render(props) + await flush() + const tree = render(props) + const copy = byClass(tree, 'bt-mem-row-copy') + assert.ok(copy, 'the row draws its count line') + return { tree, label: textOf(copy) } +} + +test('#2411: a refusal with an empty message is thrown as a readable error', async () => { + answer = refusal('bot/refused', '') + const error = await rejection(call('memory/view', { botId: 'bot-a' })) + assert.equal(typeof error.message, 'string') + assert.notEqual(error.message, '', 'an empty host message must not become an empty error') + assert.equal(error.message, 'Request failed', 'the refusal keeps a sentence of its own') + assert.equal(error.code, 'bot/refused', 'the code still passes through') +}) + +test('#2411: the memory row says the read failed instead of "Loading…"', async () => { + answer = refusal('bot/refused', '') + const { label } = await rowOf('bot-empty-refusal', () => call('memory/view', { botId: 'bot-empty-refusal' })) + assert.doesNotMatch(label, /Loading/, 'a settled failure never draws the pending text') + assert.equal(label, `The memory could not be read: ${hostText('Request failed')}`) +}) + +test('#2411: the fallback also covers a code that is not a refusal', async () => { + clearClientErrors() + answer = refusal('bot/error', '', { ticket: 'x-1' }) + const error = await rejection(call('memory/view', {})) + assert.equal(error.message, 'Request failed', 'the noted copy is not empty either') + assert.deepEqual(error.details, { ticket: 'x-1' }, 'details still pass through') + const noted = clientErrors() + assert.ok(noted.length > 0, 'a non-refusal is still noted for the diagnostics') + const last = noted.at(-1) + assert.match(last.text, /Request failed$/, 'the diagnostics line names the failure, not an empty string') + assert.notEqual(last.text, 'memory/view: ', 'no dangling separator for an empty sentence') +}) + +test('#2411: an internal failure no longer leaves a leading space', async () => { + answer = refusal('bot/internal', '') + const error = await rejection(call('memory/view', {})) + assert.doesNotMatch(error.message, /^ /, 'the sentence starts with a word, not a space') + assert.match(error.message, /^Request failed /, 'the fallback sentence leads the note') +}) + +test('#2411: a non-empty host message is drawn byte-for-byte', async () => { + answer = refusal('bot/refused', 'Unknown Bot') + const error = await rejection(call('memory/view', {})) + assert.equal(error.message, 'Unknown Bot', 'the fix does not rewrite a sentence the host sent') + const { label } = await rowOf('bot-host-sentence', () => call('memory/view', {})) + assert.equal(label, 'The memory could not be read: Unknown Bot') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 95d3d4bd8..8c3cfe3d7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -19,7 +19,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. - A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn for `DIALOG_EXIT_MS` (160 ms) with `pointer-events: none` on the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270). - Settings → Feedback's error list (`feedback.js`) still titles the block with the whole log (`Recent errors · 60`) but lists only the newest eight, so when the report holds more rows one `Show more` control (`aria-expanded`) sits under them and draws every loaded error, folding back to `Show less`; a report of eight or fewer draws no control (#2266). -- An error toast keeps the host's code: `reportError` in `index.js` puts `error.code` into the toast payload and `feedback.js` draws it as a `bt-tag bt-toast-code`, so a reader can tell `bot/read-only` from any other refusal. That code is also the one action a toast can take: a `bot/read-only` toast offers **Reload** (`bt-toast-reload`) in place, which dismisses the toast and runs the client's own full-state read (`actions.reloadTeam`), so the window becomes writable once the other holder let go without a page reload that would drop the composer; a failed read goes to `actions.reportError(error, 'reload')` the way the sidebar's Reload does. Every other code, and a toast with no code, draws the sentence and **Details** exactly as before (P-502). +- An error toast keeps the host's code: `reportError` in `index.js` puts `error.code` into the toast payload and `feedback.js` draws it as a `bt-tag bt-toast-code`, so a reader can tell `bot/read-only` from any other refusal. That code is also the one action a toast can take: a `bot/read-only` toast offers **Reload** (`bt-toast-reload`) in place, which dismisses the toast and runs the client's own full-state read (`actions.reloadTeam`), so the window becomes writable once the other holder let go without a page reload that would drop the composer; a failed read goes to `actions.reportError(error, 'reload')` the way the sidebar's Reload does. Every other code, and a toast with no code, draws the sentence and **Details** exactly as before (P-502). That sentence is never blank: `api.js` answers a host refusal that carries no message of its own with `Request failed` (`result.error?.message || 'Request failed'` on the thrown error and on the noted copy alike), so a surface that prints `error.message` shows a refusal rather than nothing, and a memory row whose count sentence tests the failure for truth settles on it instead of `Loading…` (#2411). Tests: `core/test/api-empty-message.test.js`. - A link picked in a chat message opens in FlyBot's own panel: a capture-phase click handler on each bubble and on the person's own line (`bubbleLinkClick` in `cells.js`; a group card's container carries none, so a reply's link opens the tab of the Bot that spoke) takes an `http(s)` anchor before the host's delegate does, puts a tab in the panel's browser view (the globe button's view) with `actions.browserNewTab` and carries the URL with `actions.browserNavigate`; `browserNewTab` answers `null` for a Bot outside the roster, and with no tab — or no Desktop bridge — the page opens in the window itself. A mention, a file link and a modified or middle click are left alone, and the bubble renders bare `MarkdownText` so the host's `openFile`/`fileImages` reach it: a nested `MarkdownDelegateProvider` replaces the host's capabilities, and one that lacked `fileImages` drew every bubble image as its alt text (#2319). Left to the host a click opened a tab in the host's right sidebar, which this panel's fixed layer covers, so the content area looked blank (#2201). Tests: `core/test/chat-link-open.test.js`. ## Does not own diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 3d84f6034..8abebd15b 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1138,15 +1138,20 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => { +@@ -1086,10 +1086,10 @@ var call = async (endpoint, payload, signal) => { + const result = await response.json(); if (!result.ok) { const code = result.error?.code ?? "bot/error"; - const message = hostText(result.error?.message ?? "Request failed"); +- const message = hostText(result.error?.message ?? "Request failed"); - const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code }); ++ const message = hostText(result.error?.message || "Request failed"); + const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code, details: result.error?.details }); if (!REFUSALS.has(code)) { - noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); +- noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); ++ noteClientError(endpoint, Object.assign(new Error(result.error?.message || "Request failed"), { code })); markNoted(error); + } + throw error; @@ -1097,6 +1097,406 @@ var call = async (endpoint, payload, signal) => { return result.value; }; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index b86e81c8c..a7463227f 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -20,15 +20,20 @@ diff --git a/src/client/api.js b/src/client/api.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal, }) -@@ -31,7 +31,7 @@ export const call = async (endpoint, payload, signal) => { +@@ -30,10 +30,10 @@ export const call = async (endpoint, payload, signal) => { + const result = await response.json() if (!result.ok) { const code = result.error?.code ?? 'bot/error' - const message = hostText(result.error?.message ?? 'Request failed') +- const message = hostText(result.error?.message ?? 'Request failed') - const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code }) ++ const message = hostText(result.error?.message || 'Request failed') + const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code, details: result.error?.details }) if (!REFUSALS.has(code)) { - noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code })) +- noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code })) ++ noteClientError(endpoint, Object.assign(new Error(result.error?.message || 'Request failed'), { code })) markNoted(error) + } + throw error diff --git a/src/client/composer.js b/src/client/composer.js --- a/src/client/composer.js +++ b/src/client/composer.js diff --git a/patches/ds-bot/files/test/api-empty-message.test.js b/patches/ds-bot/files/test/api-empty-message.test.js new file mode 100644 index 000000000..f92ab649a --- /dev/null +++ b/patches/ds-bot/files/test/api-empty-message.test.js @@ -0,0 +1,161 @@ +// A host refusal whose `message` is an empty string was thrown as `new Error('')`: `??` keeps an +// empty string, and `hostText('')` returns it unchanged. Every consumer that prints +// `error.message` then draws nothing, and the memory row states its count through +// `if (failure)`, so an empty failure is falsy and the row settles on "Loading…" for a read that +// already failed (#2411). A refusal with no sentence of its own must still read as a refusal. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { call } from './src/client/api.js'", + "export { MemoryRow } from './src/client/memory-panel.js'", + "export { hostText } from './src/client/i18n.js'", + "export { clientErrors, clearClientErrors } from './src/client/diagnostics.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// The row counts through an async effect, so it is drawn once as "Loading…" and again with the +// view. The hook runtime keeps its slots across a re-render, like the renderer's own. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +// One fetch stub per call: the api wrapper posts every request through the browser's own fetch. +let answer = null +const fetchStub = async () => answer +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + fetch: fetchStub, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { call, MemoryRow, hostText, clientErrors, clearClientErrors } = module.exports + +// The row's tree holds component elements around the hook, so a draw pass renders every function +// component the way the renderer would, with one hook index continuing through the tree. +const draw = (node) => { + if (Array.isArray(node)) return node.map(draw) + if (!node || typeof node !== 'object') return node + if (typeof node.type === 'function') return draw(node.type(node.props)) + if (node.props?.children === undefined) return node + return { ...node, props: { ...node.props, children: draw(node.props.children) } } +} +const render = (props) => { index = 0; return draw(MemoryRow(props)) } +const reset = () => { slots = []; effects = []; index = 0 } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + for (let turn = 0; turn < 6; turn += 1) await Promise.resolve() +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const textOf = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const byClass = (node, name) => all(node).find(item => typeof item.props?.className === 'string' && item.props.className.split(' ').includes(name)) + +// The host's own reply shape: an HTTP 200 whose body is `{ ok: false, error: { code, message, details } }`. +const refusal = (code, message, details) => ({ ok: true, status: 200, json: async () => ({ ok: false, error: { code, message, details } }) }) + +async function rejection(promise) { + try { await promise } catch (error) { return error } + throw new Error('the call resolved, but the host refused it') +} + +async function rowOf(botId, memoryView) { + reset() + const props = { bot: { id: botId, name: 'Scout' }, actions: { memoryView, openMemory() {} } } + render(props) + await flush() + const tree = render(props) + const copy = byClass(tree, 'bt-mem-row-copy') + assert.ok(copy, 'the row draws its count line') + return { tree, label: textOf(copy) } +} + +test('#2411: a refusal with an empty message is thrown as a readable error', async () => { + answer = refusal('bot/refused', '') + const error = await rejection(call('memory/view', { botId: 'bot-a' })) + assert.equal(typeof error.message, 'string') + assert.notEqual(error.message, '', 'an empty host message must not become an empty error') + assert.equal(error.message, 'Request failed', 'the refusal keeps a sentence of its own') + assert.equal(error.code, 'bot/refused', 'the code still passes through') +}) + +test('#2411: the memory row says the read failed instead of "Loading…"', async () => { + answer = refusal('bot/refused', '') + const { label } = await rowOf('bot-empty-refusal', () => call('memory/view', { botId: 'bot-empty-refusal' })) + assert.doesNotMatch(label, /Loading/, 'a settled failure never draws the pending text') + assert.equal(label, `The memory could not be read: ${hostText('Request failed')}`) +}) + +test('#2411: the fallback also covers a code that is not a refusal', async () => { + clearClientErrors() + answer = refusal('bot/error', '', { ticket: 'x-1' }) + const error = await rejection(call('memory/view', {})) + assert.equal(error.message, 'Request failed', 'the noted copy is not empty either') + assert.deepEqual(error.details, { ticket: 'x-1' }, 'details still pass through') + const noted = clientErrors() + assert.ok(noted.length > 0, 'a non-refusal is still noted for the diagnostics') + const last = noted.at(-1) + assert.match(last.text, /Request failed$/, 'the diagnostics line names the failure, not an empty string') + assert.notEqual(last.text, 'memory/view: ', 'no dangling separator for an empty sentence') +}) + +test('#2411: an internal failure no longer leaves a leading space', async () => { + answer = refusal('bot/internal', '') + const error = await rejection(call('memory/view', {})) + assert.doesNotMatch(error.message, /^ /, 'the sentence starts with a word, not a space') + assert.match(error.message, /^Request failed /, 'the fallback sentence leads the note') +}) + +test('#2411: a non-empty host message is drawn byte-for-byte', async () => { + answer = refusal('bot/refused', 'Unknown Bot') + const error = await rejection(call('memory/view', {})) + assert.equal(error.message, 'Unknown Bot', 'the fix does not rewrite a sentence the host sent') + const { label } = await rowOf('bot-host-sentence', () => call('memory/view', {})) + assert.equal(label, 'The memory could not be read: Unknown Bot') +})