From afc10134caf5c392d5c157bbb777cc210b59ae25 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Thu, 5 Nov 2026 10:39:50 +0000 Subject: [PATCH] fix(preview): keep the words around a complete untrusted wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `visibleText` unwrapped a complete `…` by keeping only the text between the first opener and the last closer, so a stored line that quotes a remote line and then answers it reached the screen as the quote alone. The module's own comment promises the opposite — words that were never part of a marker are kept — so this is the code disagreeing with its documented contract. Each complete wrap now loses its markers in place: the head before it, its inner text and the tail after it all survive. `stripFence` is unchanged. Docs-not-needed: `core/src/shared/preview.js` is not on an owned_path of any subsystem page; no module, contract or owned path changes, only the reader's own rule. --- changelog.d/2443-preview-fence-words.fixed.md | 1 + core/client.js | 8 +- core/src/shared/preview.js | 12 +- core/test/preview-fence-words.test.js | 124 ++++++++++++++++++ patches/ds-bot/0008-client-bundle.patch | 10 +- patches/ds-bot/files/src/shared/preview.js | 12 +- .../files/test/preview-fence-words.test.js | 124 ++++++++++++++++++ 7 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 changelog.d/2443-preview-fence-words.fixed.md create mode 100644 core/test/preview-fence-words.test.js create mode 100644 patches/ds-bot/files/test/preview-fence-words.test.js diff --git a/changelog.d/2443-preview-fence-words.fixed.md b/changelog.d/2443-preview-fence-words.fixed.md new file mode 100644 index 000000000..28e916cad --- /dev/null +++ b/changelog.d/2443-preview-fence-words.fixed.md @@ -0,0 +1 @@ +A preview now keeps the words on either side of a quoted remote line: a reply that quoted a line and then answered it showed the quote alone in the sidebar and the palette row. diff --git a/core/client.js b/core/client.js index 0302a97ec..ec102d2cc 100644 --- a/core/client.js +++ b/core/client.js @@ -2467,10 +2467,12 @@ function visibleText(text2) { for (let previous; previous !== out; ) { previous = out; const open = out.indexOf(""); - const inner = close > open ? out.slice(out.indexOf(">", open) + 1, close) : out; - out = stripFence(inner); + if (open < 0 || close <= open) break; + const head = out.slice(0, open); + const inner = out.slice(out.indexOf(">", open) + 1, close); + const tail = out.slice(close + "".length); + out = stripFence(head) + inner + stripFence(tail); } return stripFence(out).replace(/\s+/g, " ").trim(); } diff --git a/core/src/shared/preview.js b/core/src/shared/preview.js index 980e1e426..86c606dbb 100644 --- a/core/src/shared/preview.js +++ b/core/src/shared/preview.js @@ -27,16 +27,20 @@ export function stripFence(text) { } // The words of a stored line as a preview shows them: inner text of a wrap, no marker of any shape, -// and one line, because a row is one line. +// and one line, because a row is one line. A wrap in the middle of a line loses its markers in place, +// so the sentence around it survives: a reply that quotes a remote line and then answers it reads as +// the reply, not as the quote. export function visibleText(text) { let out = String(text ?? '') for (let previous; previous !== out;) { previous = out const open = out.indexOf('') - const inner = close > open ? out.slice(out.indexOf('>', open) + 1, close) : out - out = stripFence(inner) + if (open < 0 || close <= open) break + const head = out.slice(0, open) + const inner = out.slice(out.indexOf('>', open) + 1, close) + const tail = out.slice(close + ''.length) + out = stripFence(head) + inner + stripFence(tail) } return stripFence(out).replace(/\s+/g, ' ').trim() } diff --git a/core/test/preview-fence-words.test.js b/core/test/preview-fence-words.test.js new file mode 100644 index 000000000..22d06805a --- /dev/null +++ b/core/test/preview-fence-words.test.js @@ -0,0 +1,124 @@ +// A preview is still the line someone said (#1347): the fence is data, but so are the words on +// either side of it. Four readers show a stored line to a person — the Bot chat's row +// (`readableLine`/`previewOf`), a sidebar row, the exchange sheet under ⌘K, and the host's room +// preview on the /api/bot state answer — and every one of them must keep the words around a +// complete wrap, not only the quoted half. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { install } from '../src/host/channel.js' +import { previewOf, readableLine } from '../src/client/text.js' + +const MIXED = 'He said quoted and I answered done.' +const KEPT = 'He said quoted and I answered done.' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const h = (type, props, ...children) => ({ type, props: { ...(props ?? {}), children: children.flat(Infinity) } }) +class Component { constructor(props) { this.props = props } setState() {} } +const react = { + createElement: h, Fragment: Symbol('Fragment'), Component, PureComponent: Component, + createRef: () => ({ current: null }), useState: value => [typeof value === 'function' ? value() : value, () => {}], + useEffect() {}, useLayoutEffect() {}, useRef: value => ({ current: value }), useCallback: fn => fn, + useMemo: fn => fn(), useReducer: (fn, initial) => [initial, () => {}], +} +const slots = { entries: () => [], subscribe: () => () => {}, version: () => 0 } + +// A client module is bundled and loaded the way the client tests load one (sidebar-readonly.test.js): +// esbuild for the browser half, a stub react in the context, so the module's own code runs here. +async function clientModule(contents) { + const { outputFiles } = await build({ + stdin: { contents, resolveDir: root }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const mod = { exports: {} } + runInNewContext(outputFiles[0].text, { + module: mod, exports: mod.exports, console, Date, JSON, Promise, Symbol, setTimeout, clearTimeout, + __flybot__: { slots, call: () => {} }, + require: name => (name === 'react' ? react : name === 'react-dom' ? { createPortal: node => node } : {}), + }) + return mod.exports +} + +// Elements stay data; a function component is called so a row's own code runs, and the strings it +// draws are collected in order. +function drawnText(node, seen = []) { + if (node === null || node === undefined) return seen + if (typeof node === 'string') { seen.push(node); return seen } + if (typeof node !== 'object') return seen + if (Array.isArray(node)) { for (const child of node) drawnText(child, seen); return seen } + if (typeof node.type === 'function') { drawnText(node.type(node.props), seen); return seen } + for (const child of node.props?.children ?? []) drawnText(child, seen) + return seen +} + +test('the Bot chat row keeps the words before and after a complete wrap', () => { + assert.equal(readableLine(MIXED), KEPT) + const list = { projectionsBySession: { s1: { values: { turnOutline: [{ turn: 1, response: MIXED }] } } }, byId: {} } + assert.equal(previewOf(list, 's1'), KEPT) + assert.equal(readableLine('a one b two c'), 'a one b two c') +}) + +test('the sidebar row of a room keeps them too', async () => { + const { TeamSidebar } = await clientModule("export { TeamSidebar } from './src/client/sidebar.js'") + const room = { id: 'r1', name: 'Team room', kind: 'group', createdAt: 1, preview: MIXED } + const list = { ids: ['r1'], byId: { r1: { updatedAt: 1 } } } + const useSessions = selector => selector(list) + const useUi = selector => selector({ unread: {} }) + const tree = TeamSidebar({ + wide: true, + useRoster: selector => selector({ bots: [], byId: {}, rooms: [room], mainBotId: 'm', agents: [] }), + useUi, + useSessions, + useRegistry: useUi, + useWork: selector => selector({}), + useSessionStatus: selector => selector(new Map()), + useSessionRetainInfo: (id, selector) => selector({}), + useActivity: selector => selector({}), + useUpdate: selector => selector({}), + actions: {}, + }) + const words = drawnText(tree) + assert.ok(words.includes(KEPT), `the row drew ${JSON.stringify(words.filter(word => /quoted|He said|answered/.test(word)))}`) +}) + +test('the host room preview on the state answer keeps them too', async () => { + const state = { + revision: 3, workspaceId: null, mainBotIds: ['main'], + bots: { main: { id: 'main', name: 'Main' } }, + rooms: { r1: { id: 'r1', name: 'Team room', createdAt: 1, log: [{ text: MIXED, who: 'User', authorKind: 'human' }] } }, + humans: {}, agents: {}, questions: {}, prefs: {}, secretRequests: {}, + } + let route + const rt = { + state, + ctx: { effect: (fn) => { fn(); return () => {} }, connection: { fetch: { register: (r) => { route = r; return () => {} } } } }, + warn: () => {}, noteFailure: () => {}, + readOnly: () => undefined, authenticate: async () => null, authorize: () => ({ ok: true }), + bootstrap: async () => {}, catalog: async () => [], loadSecrets: async () => {}, + cachedCatalog: () => [], cachedDefault: () => null, + chatOf: id => `chat-${id}`, ownedLive: full => full, ownedView: full => full, load: async () => {}, refresh: async () => {}, + pluginToolNames: () => [], publicHumans: () => [], presentRoom: room => room, + secretsView: () => [], capabilityLibraryView: () => ({}), flybotRemoteMembers: () => [], + permissionCatalog: () => null, browserEnabled: false, memoryEnabled: true, authzEnforced: () => false, + listRoomApprovals: () => [], roomBusyAcksView: () => ({}), + activityView: () => [], busyAcksView: () => ({}), workView: () => ({}), roomWorkView: () => ({}), + approvalsView: () => ({}), endedView: () => [], memoryNews: () => [], memoryReviews: () => [], + connectorsView: () => [], capabilityStatusesView: () => [], stateLinkReplaced: () => ({ gen: 'g', count: 0 }), + } + install(rt) + const answer = await route.fetch(new Request('http://host/api/bot', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: 'state' }), + })) + const body = await answer.json() + assert.equal(body.ok, true, JSON.stringify(body.error)) + assert.equal(body.value.rooms[0].preview, KEPT) +}) + +test('the exchange sheet under the palette already kept them (control, unchanged by this fix)', async () => { + const { exchangeText } = await clientModule("export { exchangeText } from './src/client/exchange.js'") + assert.equal(exchangeText(MIXED), KEPT) + assert.equal(exchangeText('\nhello there\n'), 'hello there') +}) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index f379ff6bf..2542548ae 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1908,7 +1908,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -1445,24 +1445,86 @@ function mentionSpans(text, bots, { references = [] } = {}) { +@@ -1445,24 +1445,88 @@ function mentionSpans(text, bots, { references = [] } = {}) { } return spans; } @@ -1952,10 +1952,12 @@ diff --git a/client.js b/client.js + for (let previous; previous !== out; ) { + previous = out; + const open = out.indexOf(""); -+ const inner = close > open ? out.slice(out.indexOf(">", open) + 1, close) : out; -+ out = stripFence(inner); ++ if (open < 0 || close <= open) break; ++ const head = out.slice(0, open); ++ const inner = out.slice(out.indexOf(">", open) + 1, close); ++ const tail = out.slice(close + "".length); ++ out = stripFence(head) + inner + stripFence(tail); + } + return stripFence(out).replace(/\s+/g, " ").trim(); +} diff --git a/patches/ds-bot/files/src/shared/preview.js b/patches/ds-bot/files/src/shared/preview.js index 980e1e426..86c606dbb 100644 --- a/patches/ds-bot/files/src/shared/preview.js +++ b/patches/ds-bot/files/src/shared/preview.js @@ -27,16 +27,20 @@ export function stripFence(text) { } // The words of a stored line as a preview shows them: inner text of a wrap, no marker of any shape, -// and one line, because a row is one line. +// and one line, because a row is one line. A wrap in the middle of a line loses its markers in place, +// so the sentence around it survives: a reply that quotes a remote line and then answers it reads as +// the reply, not as the quote. export function visibleText(text) { let out = String(text ?? '') for (let previous; previous !== out;) { previous = out const open = out.indexOf('') - const inner = close > open ? out.slice(out.indexOf('>', open) + 1, close) : out - out = stripFence(inner) + if (open < 0 || close <= open) break + const head = out.slice(0, open) + const inner = out.slice(out.indexOf('>', open) + 1, close) + const tail = out.slice(close + ''.length) + out = stripFence(head) + inner + stripFence(tail) } return stripFence(out).replace(/\s+/g, ' ').trim() } diff --git a/patches/ds-bot/files/test/preview-fence-words.test.js b/patches/ds-bot/files/test/preview-fence-words.test.js new file mode 100644 index 000000000..22d06805a --- /dev/null +++ b/patches/ds-bot/files/test/preview-fence-words.test.js @@ -0,0 +1,124 @@ +// A preview is still the line someone said (#1347): the fence is data, but so are the words on +// either side of it. Four readers show a stored line to a person — the Bot chat's row +// (`readableLine`/`previewOf`), a sidebar row, the exchange sheet under ⌘K, and the host's room +// preview on the /api/bot state answer — and every one of them must keep the words around a +// complete wrap, not only the quoted half. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { install } from '../src/host/channel.js' +import { previewOf, readableLine } from '../src/client/text.js' + +const MIXED = 'He said quoted and I answered done.' +const KEPT = 'He said quoted and I answered done.' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const h = (type, props, ...children) => ({ type, props: { ...(props ?? {}), children: children.flat(Infinity) } }) +class Component { constructor(props) { this.props = props } setState() {} } +const react = { + createElement: h, Fragment: Symbol('Fragment'), Component, PureComponent: Component, + createRef: () => ({ current: null }), useState: value => [typeof value === 'function' ? value() : value, () => {}], + useEffect() {}, useLayoutEffect() {}, useRef: value => ({ current: value }), useCallback: fn => fn, + useMemo: fn => fn(), useReducer: (fn, initial) => [initial, () => {}], +} +const slots = { entries: () => [], subscribe: () => () => {}, version: () => 0 } + +// A client module is bundled and loaded the way the client tests load one (sidebar-readonly.test.js): +// esbuild for the browser half, a stub react in the context, so the module's own code runs here. +async function clientModule(contents) { + const { outputFiles } = await build({ + stdin: { contents, resolveDir: root }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const mod = { exports: {} } + runInNewContext(outputFiles[0].text, { + module: mod, exports: mod.exports, console, Date, JSON, Promise, Symbol, setTimeout, clearTimeout, + __flybot__: { slots, call: () => {} }, + require: name => (name === 'react' ? react : name === 'react-dom' ? { createPortal: node => node } : {}), + }) + return mod.exports +} + +// Elements stay data; a function component is called so a row's own code runs, and the strings it +// draws are collected in order. +function drawnText(node, seen = []) { + if (node === null || node === undefined) return seen + if (typeof node === 'string') { seen.push(node); return seen } + if (typeof node !== 'object') return seen + if (Array.isArray(node)) { for (const child of node) drawnText(child, seen); return seen } + if (typeof node.type === 'function') { drawnText(node.type(node.props), seen); return seen } + for (const child of node.props?.children ?? []) drawnText(child, seen) + return seen +} + +test('the Bot chat row keeps the words before and after a complete wrap', () => { + assert.equal(readableLine(MIXED), KEPT) + const list = { projectionsBySession: { s1: { values: { turnOutline: [{ turn: 1, response: MIXED }] } } }, byId: {} } + assert.equal(previewOf(list, 's1'), KEPT) + assert.equal(readableLine('a one b two c'), 'a one b two c') +}) + +test('the sidebar row of a room keeps them too', async () => { + const { TeamSidebar } = await clientModule("export { TeamSidebar } from './src/client/sidebar.js'") + const room = { id: 'r1', name: 'Team room', kind: 'group', createdAt: 1, preview: MIXED } + const list = { ids: ['r1'], byId: { r1: { updatedAt: 1 } } } + const useSessions = selector => selector(list) + const useUi = selector => selector({ unread: {} }) + const tree = TeamSidebar({ + wide: true, + useRoster: selector => selector({ bots: [], byId: {}, rooms: [room], mainBotId: 'm', agents: [] }), + useUi, + useSessions, + useRegistry: useUi, + useWork: selector => selector({}), + useSessionStatus: selector => selector(new Map()), + useSessionRetainInfo: (id, selector) => selector({}), + useActivity: selector => selector({}), + useUpdate: selector => selector({}), + actions: {}, + }) + const words = drawnText(tree) + assert.ok(words.includes(KEPT), `the row drew ${JSON.stringify(words.filter(word => /quoted|He said|answered/.test(word)))}`) +}) + +test('the host room preview on the state answer keeps them too', async () => { + const state = { + revision: 3, workspaceId: null, mainBotIds: ['main'], + bots: { main: { id: 'main', name: 'Main' } }, + rooms: { r1: { id: 'r1', name: 'Team room', createdAt: 1, log: [{ text: MIXED, who: 'User', authorKind: 'human' }] } }, + humans: {}, agents: {}, questions: {}, prefs: {}, secretRequests: {}, + } + let route + const rt = { + state, + ctx: { effect: (fn) => { fn(); return () => {} }, connection: { fetch: { register: (r) => { route = r; return () => {} } } } }, + warn: () => {}, noteFailure: () => {}, + readOnly: () => undefined, authenticate: async () => null, authorize: () => ({ ok: true }), + bootstrap: async () => {}, catalog: async () => [], loadSecrets: async () => {}, + cachedCatalog: () => [], cachedDefault: () => null, + chatOf: id => `chat-${id}`, ownedLive: full => full, ownedView: full => full, load: async () => {}, refresh: async () => {}, + pluginToolNames: () => [], publicHumans: () => [], presentRoom: room => room, + secretsView: () => [], capabilityLibraryView: () => ({}), flybotRemoteMembers: () => [], + permissionCatalog: () => null, browserEnabled: false, memoryEnabled: true, authzEnforced: () => false, + listRoomApprovals: () => [], roomBusyAcksView: () => ({}), + activityView: () => [], busyAcksView: () => ({}), workView: () => ({}), roomWorkView: () => ({}), + approvalsView: () => ({}), endedView: () => [], memoryNews: () => [], memoryReviews: () => [], + connectorsView: () => [], capabilityStatusesView: () => [], stateLinkReplaced: () => ({ gen: 'g', count: 0 }), + } + install(rt) + const answer = await route.fetch(new Request('http://host/api/bot', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ endpoint: 'state' }), + })) + const body = await answer.json() + assert.equal(body.ok, true, JSON.stringify(body.error)) + assert.equal(body.value.rooms[0].preview, KEPT) +}) + +test('the exchange sheet under the palette already kept them (control, unchanged by this fix)', async () => { + const { exchangeText } = await clientModule("export { exchangeText } from './src/client/exchange.js'") + assert.equal(exchangeText(MIXED), KEPT) + assert.equal(exchangeText('\nhello there\n'), 'hello there') +})