From d8a42ffc947d81e8616204063060793d7e723dfd Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:58:05 +0800 Subject: [PATCH] host: cut the host's own line bounds on code point boundaries The busy ack's 600-unit task text and 1,500-unit message text, delivery's 60-unit cleanName, and tools' group notice 100, Bot job line 120, group answer echo 300 and rework note 200 all truncated with slice(0, max), so a cut inside a surrogate pair reached a UTF-8 encoder as U+FFFD. They now use the shared cut from src/shared/text-boundary.js; every cap is unchanged. Each site has a test that plants an emoji across its bound, asserts isWellFormed() and a byte prefix, and goes red when the fix is reverted. --- changelog.d/1930-host-line-bounds.fixed.md | 1 + core/src/host/busy-ack.js | 5 +-- core/src/host/delivery.js | 3 +- core/src/host/tools.js | 8 ++--- core/test/busy-ack.test.js | 17 ++++++++++ core/test/fence.test.js | 11 +++++++ core/test/flybot.test.js | 31 +++++++++++++++++++ core/test/group-work.test.js | 21 +++++++++++++ core/test/task-handoff-route.test.js | 18 +++++++++++ docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 23 +++++++++++++- patches/ds-bot/0002-flybot-api-host.patch | 9 +++--- patches/ds-bot/0005-social-seams.patch | 5 +-- patches/ds-bot/files/src/host/busy-ack.js | 5 +-- patches/ds-bot/files/test/busy-ack.test.js | 17 ++++++++++ patches/ds-bot/files/test/fence.test.js | 11 +++++++ patches/ds-bot/files/test/flybot.test.js | 31 +++++++++++++++++++ .../files/test/task-handoff-route.test.js | 18 +++++++++++ 19 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 changelog.d/1930-host-line-bounds.fixed.md diff --git a/changelog.d/1930-host-line-bounds.fixed.md b/changelog.d/1930-host-line-bounds.fixed.md new file mode 100644 index 000000000..c21990582 --- /dev/null +++ b/changelog.d/1930-host-line-bounds.fixed.md @@ -0,0 +1 @@ +The host's own bounded lines no longer end in half of an emoji ([#1930](https://github.com/ThinkFlowLab/FlyBot/issues/1930)): the busy acknowledgment's 600-unit task text and 1,500-unit message text, `cleanName`'s 60-unit peer name (the one that lands in `fenceUntrusted`'s `from="…"` attribute), the `list_bots` group line's 100-unit notice and 120-unit job line, the group answer echo at 300 and a rework card's note at 200 now cut on a code point boundary, so a value whose cap falls inside a surrogate pair keeps the whole character instead of U+FFFD. Every cap is unchanged. diff --git a/core/src/host/busy-ack.js b/core/src/host/busy-ack.js index 4e66b573b..4de8fafed 100644 --- a/core/src/host/busy-ack.js +++ b/core/src/host/busy-ack.js @@ -14,6 +14,7 @@ // otherwise the chat's own model is used. import { BlockAssembler } from '@deepseek-ai/dsh-llm' import { splitRef } from './text.js' +import { cut } from '../shared/text-boundary.js' export const COALESCE_MS = 3_000 export const ACK_TIMEOUT_MS = 3_000 @@ -56,11 +57,11 @@ export const ACK_SYSTEM = [ /** The user prompt for the ack call: the new message and what the turn is doing. */ export function ackPrompt({ message, tool, working }) { const doing = typeof tool === 'string' && tool.trim() !== '' && tool !== 'ask_user' ? `running the tool \`${tool}\`` : 'working on the previous request' - const task = String(working ?? '').trim().slice(0, 600) + const task = cut(String(working ?? '').trim(), 600).text return [ `Currently: ${doing}.`, ...(task === '' ? [] : [`The request being worked on: ${task}`]), - `New message: ${String(message ?? '').trim().slice(0, 1_500)}`, + `New message: ${cut(String(message ?? '').trim(), 1_500).text}`, ].join('\n') } diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js index 1d5bd6e51..0786b8ba5 100644 --- a/core/src/host/delivery.js +++ b/core/src/host/delivery.js @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { OWNER, OTHER, SCHEDULED } from './turn-origin.js' +import { cut } from '../shared/text-boundary.js' // Text from outside the team, marked so the Bot treats it as data. Marker fragments are // removed until none is left, so a nested "message>" @@ -35,7 +36,7 @@ export const unwrapUntrusted = (text) => { // A name from outside the team, safe on one line of trusted text: no control characters, // newlines, brackets or quotes, at most 60 characters. export const cleanName = (name, fallback = 'a remote Bot') => { - const text = String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) + const text = cut(String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text return text || fallback } export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n` diff --git a/core/src/host/tools.js b/core/src/host/tools.js index 7f384ca30..d0849ca8d 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -86,7 +86,7 @@ export function install(rt) { const parts = [ `members: ${[...members, ...remotes].join(', ')}`, room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn', - room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '', + room.notice ? `notice: ${cut(room.notice.split('\n')[0], 100).text}` : '', room.workDir ? `members work in ${room.workDir} here` : '', ].filter(Boolean) return `- "${room.name}" — ${parts.join('; ')}` @@ -174,7 +174,7 @@ export function install(rt) { const lines = Object.values(state.bots).filter(listed).map(bot => { const model = bot.appliedModel ?? bot.model const tags = [bot.role, model ? `model ${model}` : '', heldSends[bot.id] ? `${heldSends[bot.id]} to approve` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') - const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' + const job = !foreign(bot) && bot.instructions ? ` — ${cut(bot.instructions.split('\n')[0], 120).text}` : '' return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}` }) if (lines.length === 0) return 'No Bots yet.' @@ -740,7 +740,7 @@ export function install(rt) { await rt.save() const who = cleanName(actor?.displayName, 'You') const given = JSON.parse(reply.text).answers[0] - const values = [...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' ').slice(0, 300) + const values = cut([...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' '), 300).text void rt.postToRoom?.(room, { id: 'face-notice', name: 'Room' }, `${who} answered ${bot.name}: ${question.question} → ${values}`, { quiet: true, notice: true }) .catch(error => rt.warn('ds-bot: the answer notice in %s was not shown: %s', room.name, error)) return { ok: true } @@ -1028,7 +1028,7 @@ ${task.writeBack.body}`) } if (args?.rework !== undefined) { const child = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId }) - return `Rework ${child.id} on ${child.reworkOf} for the ${child.handoff?.to ?? 'producer'} pool: ${String(child.note ?? '').slice(0, 200)}\nThe producer takes it from its own pool. Set that card's column to in-progress when you pick it up. Do not post a status in the group.` + return `Rework ${child.id} on ${child.reworkOf} for the ${child.handoff?.to ?? 'producer'} pool: ${cut(String(child.note ?? ''), 200).text}\nThe producer takes it from its own pool. Set that card's column to in-progress when you pick it up. Do not post a status in the group.` } if (args?.release !== undefined) { const returned = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId }) diff --git a/core/test/busy-ack.test.js b/core/test/busy-ack.test.js index ebb773fac..cee4d9d16 100644 --- a/core/test/busy-ack.test.js +++ b/core/test/busy-ack.test.js @@ -284,3 +284,20 @@ test('the own-chat ack is unchanged beside the room path (#1646)', async (t) => await settleAck(host.rt, 'gb') assert.deepEqual(Object.keys(host.rt.busyAcksView()).sort(), ['b', 'gb']) }) + +test('the prompt bounds keep whole characters at 600 and 1,500 (#P282b)', () => { + const sourceTask = 'w'.repeat(599) + '😀tail' + const sourceMessage = 'a'.repeat(1_499) + '😀tail' + const prompt = ackPrompt({ message: sourceMessage, tool: 'bash', working: sourceTask }) + assert.ok(prompt.isWellFormed(), `the prompt is not well formed: ${JSON.stringify(prompt.slice(-8))}`) + const lines = prompt.split('\n') + const keptTask = lines.find(line => line.startsWith('The request being worked on: ')).slice('The request being worked on: '.length) + const keptMessage = lines[lines.length - 1].slice('New message: '.length) + assert.equal(keptTask, 'w'.repeat(599)) + assert.equal(keptMessage, 'a'.repeat(1_499)) + assert.ok(keptTask.length <= 600) + assert.ok(keptMessage.length <= 1_500) + const bytesOf = value => Buffer.from(value, 'utf8') + assert.ok(bytesOf(sourceTask).subarray(0, bytesOf(keptTask).length).equals(bytesOf(keptTask))) + assert.ok(bytesOf(sourceMessage).subarray(0, bytesOf(keptMessage).length).equals(bytesOf(keptMessage))) +}) diff --git a/core/test/fence.test.js b/core/test/fence.test.js index 24b3b0a27..f0b3e17d3 100644 --- a/core/test/fence.test.js +++ b/core/test/fence.test.js @@ -37,3 +37,14 @@ test('#130: previews unwrap the stored untrusted-message wrap', () => { assert.equal(unwrapUntrusted(fenceUntrusted('User', 'hello from the owner')), 'hello from the owner') assert.equal(unwrapUntrusted('plain text'), 'plain text') }) + +test('a peer name at the 60 bound keeps the whole character (#P282b)', () => { + const source = 'a'.repeat(59) + '😀tail' + const name = cleanName(source) + assert.equal(name, 'a'.repeat(59)) + assert.ok(name.isWellFormed(), `the name is not well formed: ${JSON.stringify(name.slice(-4))}`) + assert.ok(name.length <= 60) + const fenced = fenceUntrusted(source, 'hello') + assert.ok(fenced.isWellFormed(), 'the fence header is not well formed') + assert.ok(fenced.startsWith(``)) +}) diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 1618bb015..859568d4e 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -882,6 +882,37 @@ test('list_bots keeps whole characters in a peer display name and owner name (#1 assert.ok(Buffer.from(keptOwner, 'utf8').equals(Buffer.from(ownerName, 'utf8').subarray(0, Buffer.byteLength(keptOwner))), 'the owner name is a prefix of what the peer published, byte for byte') }) +// #P282b: the group line's notice is bounded at 100, and a cut inside an emoji left half a pair. +test('list_bots keeps whole characters in a group notice at the 100 bound (#P282b)', async (t) => { + const notice = 'a'.repeat(99) + '😀tail' + const { tool, stop } = await start({ + rooms: { g: { id: 'g', name: 'Group', members: ['a'], admin: 'a', mode: 'admin', notice, createdAt: 1, createdBy: null } }, + }) + t.after(stop) + const listed = await tool('list_bots', {}, 'a') + assert.equal(listed.isWellFormed(), true, `the listing is not well formed: ${JSON.stringify(listed.slice(0, 80))}`) + const line = listed.split('\n').find(l => l.includes('notice: ')) + assert.ok(line, `the notice is listed: ${JSON.stringify(listed)}`) + const kept = line.split('notice: ')[1].split('; ')[0] + assert.equal(kept, 'a'.repeat(99), `the notice keeps the 100 bound: ${JSON.stringify(kept.slice(-4))}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(notice, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the notice is a prefix, byte for byte') +}) + +// #P282b: a Bot's job line is bounded at 120, and a cut inside an emoji left half a pair. +test('list_bots keeps whole characters in a Bot job line at the 120 bound (#P282b)', async (t) => { + const { tool, stop } = await start() + t.after(stop) + const instructions = 'a'.repeat(119) + '😀tail' + assert.match(await tool('update_bot', { bot: 'b', instructions }, 'a'), /Updated Writer/) + const listed = await tool('list_bots', {}, 'a') + assert.equal(listed.isWellFormed(), true, `the listing is not well formed: ${JSON.stringify(listed.slice(0, 80))}`) + const line = listed.split('\n').find(l => l.includes(' — ')) + assert.ok(line, `the job line is listed: ${JSON.stringify(listed)}`) + const kept = line.split(' — ')[1] + assert.equal(kept, 'a'.repeat(119), `the job line keeps the 120 bound: ${JSON.stringify(kept.slice(-4))}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(instructions, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the job line is a prefix, byte for byte') +}) + test('cleanRemote sanitizes peer name, homeName and owner at the record boundary (#75)', async (t) => { const { ctx, fb, tool, received, stop, grantTo } = await start() t.after(stop) diff --git a/core/test/group-work.test.js b/core/test/group-work.test.js index 7bfaec18d..2e98ef091 100644 --- a/core/test/group-work.test.js +++ b/core/test/group-work.test.js @@ -370,6 +370,27 @@ test('the answer given in the group reaches the asking Bot\'s waiting work and i assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } })).error.code, 'bot/stale') }) +// #P282b: the answer echo is bounded at 300, and a cut inside an emoji left half a pair in the +// room line every member reads. +test('the answer echo in a group keeps whole characters at the 300 bound (#P282b)', async (t) => { + const host = await start(t) + await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') + host.begin('c') + await askBranch(host) + host.finish('c', 'Need to know the branch first.') + await settle(() => host.agentOf('gb').calls.length === 1) + const asked = (await host.call('state', {})).value.questions.r1 + const custom = 'a'.repeat(299) + '😀tail' + assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: [], custom } })).ok, true) + await settle(() => host.agentOf('r1').calls.some(call => call.source?.notice === true)) + const notice = host.agentOf('r1').calls.find(call => call.source?.notice === true) + const said = text(notice) + assert.equal(said.isWellFormed(), true, `the answer echo is not well formed: ${JSON.stringify(said.slice(-8))}`) + const values = said.split(' → ').at(-1) + assert.equal(values, 'a'.repeat(299), `the answer echo keeps the 300 bound: ${JSON.stringify(values.slice(-4))}`) + assert.ok(Buffer.from(values, 'utf8').equals(Buffer.from(custom, 'utf8').subarray(0, Buffer.byteLength(values))), 'the answer echo is a prefix of the answer, byte for byte') +}) + test('a question in the Bot\'s own chat stays there, and a request answered to the user keeps its card there (#550)', async (t) => { const host = await start(t) host.emit('c', 'turn/start', { turn: 1 }) diff --git a/core/test/task-handoff-route.test.js b/core/test/task-handoff-route.test.js index d7e40ce6a..96ff3c11d 100644 --- a/core/test/task-handoff-route.test.js +++ b/core/test/task-handoff-route.test.js @@ -178,6 +178,24 @@ test('a role returns the card to its producer as a rework card the producer can assert.equal(app.rt.state.tasks[childId].claimedBy, 'd1') }) +// #P282b: the rework line carries the note at 200, and a cut inside an emoji left half a pair. +test('the rework line keeps whole characters in the note at the 200 bound (#P282b)', async (t) => { + const app = await start(t) + const id = app.idOf(await app.track('a', { tasks: [{ title: 'Build the route', handoff: { from: 'Lead', to: 'Dev' } }] })) + await app.drive() + await app.track('d1', { claim: true, id }) + await app.track('d1', { tasks: [{ id, handoff: { from: 'Dev', to: 'Reviewer', reason: 'diff ready' } }] }) + await app.drive() + await app.track('r1', { claim: true, id }) + await app.track('r1', { tasks: [{ id, column: 'in-progress' }] }) + const reason = 'a'.repeat(199) + '😀tail' + const returned = await app.track('r1', { rework: { task: id, reason } }) + assert.equal(returned.isWellFormed(), true, `the rework line is not well formed: ${JSON.stringify(returned.slice(-8))}`) + const shown = returned.split(' pool: ')[1].split('\n')[0] + assert.equal(shown, 'a'.repeat(199), `the rework line keeps the 200 bound: ${JSON.stringify(shown.slice(-4))}`) + assert.ok(Buffer.from(shown, 'utf8').equals(Buffer.from(reason, 'utf8').subarray(0, Buffer.byteLength(shown))), 'the note is a prefix of the reason, byte for byte') +}) + test('a card waiting for its rework is not re-offered when its lease expires (#1804)', async (t) => { const app = await start(t) const id = app.idOf(await app.track('a', { tasks: [{ title: 'Build the route', handoff: { from: 'Lead', to: 'Dev' } }] })) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 779e05b1b..179ce68e9 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,7 +38,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | --- | --- | | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | -| `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit — one character costs one whatever its UTF-16 width — and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it — the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` — so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it — the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) — each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` — `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) — so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). | +| `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit — one character costs one whatever its UTF-16 width — and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it — the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` — so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it — the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) — each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` — `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) — so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in `busy-ack.js`, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in `tools.js`, and `cleanName`'s 60-unit peer name in `delivery.js`, which lands in `fenceUntrusted`'s `from="…"` attribute — so none of them can end in half a surrogate pair. | | `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index a8c601a58..248646cca 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -56,7 +56,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. A group's mention receipt (`mentionReceipt`) turns a not-shared refusal (`cannot message their Bots`, #273) into its short invite-or-add hint. 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. An `allow` verdict may say `autoSent: true` (with `recipient`) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A remote turn that died is not that hold (#1068): `replyRemote` sends the host's fixed sentence with `via: 'turn-failed'` and `meta.notice: 'turn-failed'` (`failure: true`), and the social plugin allows only those two sentences. A model reply stays `auto-reply`. A `hold` verdict may carry `recipient`, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as `recipientName` and the cards name it (#418). A Bot's held sends are also visible to Bot coordination: `list_bots` tags the waiting Bot with `N to approve` (a count only, `src/host/tools.js`, #587). A `flybot.approvals` entry with `inline: true` and a `policy` is not an overlay card: it draws the decision on a held reply's own bubble in the chat (`held-reply.js`, props `{ held, decide, call, actions }`), and the overlay skips it. Approval claims (`claimApprovals`, `chatOpened`) live in `approval-claims.js` and are re-exported by `flybot-slots.js`. -7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters through `cut` from `shared/text-boundary.js` (#1923), so a name whose cap falls inside a surrogate pair keeps the whole character instead of reaching the roster as U+FFFD. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. +7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters through `cut` from `shared/text-boundary.js` (#1923), so a name whose cap falls inside a surrogate pair keeps the whole character instead of reaching the roster as U+FFFD. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. The host's own bounded lines take the same `cut`: `cleanName`'s 60-unit peer name (which lands in `fenceUntrusted`'s `from="…"`), the `list_bots` group line's notice (100) and a Bot's job line (120), the group answer echo (300), and a rework card's note (200), so none of them keeps half a surrogate pair. 8. A user's @ of a friend's Bot in a group is answered in that group (#825): the line reaches the Bot as a room line and its reply comes back as one, which is why `mentionReceipt` (`hooks.js`, relay role `mention`) posts only what went wrong — why the send was denied, held, or failed — and no "sent" line at all. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.room.members` adds rows at the foot of a local group's Members card (not a face or shared room), with `host(endpoint, payload)` for core endpoints (#876). Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, a target Bot retired while the hold waited — `the target Bot was retired` — changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index d55c29beb..8c19afcb5 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -9463,7 +9463,7 @@ diff --git a/test/group-work.test.js b/test/group-work.test.js const text = message => message.content.map(block => block.text ?? '').join('\n') const kinds = result => result.replies.map(reply => [reply.kind ?? 'message', reply.name]) -@@ -287,3 +287,254 @@ test('members still at work show in the team view and in the prompts of the othe +@@ -287,3 +287,275 @@ test('members still at work show in the team view and in the prompts of the othe host.finish('gc', '没问题。') await settle(() => result() !== undefined) }) @@ -9537,6 +9537,27 @@ diff --git a/test/group-work.test.js b/test/group-work.test.js + assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } })).error.code, 'bot/stale') +}) + ++// #P282b: the answer echo is bounded at 300, and a cut inside an emoji left half a pair in the ++// room line every member reads. ++test('the answer echo in a group keeps whole characters at the 300 bound (#P282b)', async (t) => { ++ const host = await start(t) ++ await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') ++ host.begin('c') ++ await askBranch(host) ++ host.finish('c', 'Need to know the branch first.') ++ await settle(() => host.agentOf('gb').calls.length === 1) ++ const asked = (await host.call('state', {})).value.questions.r1 ++ const custom = 'a'.repeat(299) + '😀tail' ++ assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: [], custom } })).ok, true) ++ await settle(() => host.agentOf('r1').calls.some(call => call.source?.notice === true)) ++ const notice = host.agentOf('r1').calls.find(call => call.source?.notice === true) ++ const said = text(notice) ++ assert.equal(said.isWellFormed(), true, `the answer echo is not well formed: ${JSON.stringify(said.slice(-8))}`) ++ const values = said.split(' → ').at(-1) ++ assert.equal(values, 'a'.repeat(299), `the answer echo keeps the 300 bound: ${JSON.stringify(values.slice(-4))}`) ++ assert.ok(Buffer.from(values, 'utf8').equals(Buffer.from(custom, 'utf8').subarray(0, Buffer.byteLength(values))), 'the answer echo is a prefix of the answer, byte for byte') ++}) ++ +test('a question in the Bot\'s own chat stays there, and a request answered to the user keeps its card there (#550)', async (t) => { + const host = await start(t) + host.emit('c', 'turn/start', { turn: 1 }) diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index a02204f5d..62ecb6d7f 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -1669,7 +1669,8 @@ diff --git a/src/host/tools.js b/src/host/tools.js - `members: ${members.join(', ')}`, + `members: ${[...members, ...remotes].join(', ')}`, room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn', - room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '', +- room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '', ++ room.notice ? `notice: ${cut(room.notice.split('\n')[0], 100).text}` : '', room.workDir ? `members work in ${room.workDir} here` : '', ].filter(Boolean) return `- "${room.name}" — ${parts.join('; ')}` @@ -1763,7 +1764,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js - const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') - const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' + const tags = [bot.role, model ? `model ${model}` : '', heldSends[bot.id] ? `${heldSends[bot.id]} to approve` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') -+ const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' ++ const job = !foreign(bot) && bot.instructions ? ` — ${cut(bot.instructions.split('\n')[0], 120).text}` : '' return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}` }) if (lines.length === 0) return 'No Bots yet.' @@ -2112,7 +2113,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + await rt.save() + const who = cleanName(actor?.displayName, 'You') + const given = JSON.parse(reply.text).answers[0] -+ const values = [...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' ').slice(0, 300) ++ const values = cut([...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' '), 300).text + void rt.postToRoom?.(room, { id: 'face-notice', name: 'Room' }, `${who} answered ${bot.name}: ${question.question} → ${values}`, { quiet: true, notice: true }) + .catch(error => rt.warn('ds-bot: the answer notice in %s was not shown: %s', room.name, error)) + return { ok: true } @@ -2304,7 +2305,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js + } + if (args?.rework !== undefined) { + const child = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId }) -+ return `Rework ${child.id} on ${child.reworkOf} for the ${child.handoff?.to ?? 'producer'} pool: ${String(child.note ?? '').slice(0, 200)}\nThe producer takes it from its own pool. Set that card's column to in-progress when you pick it up. Do not post a status in the group.` ++ return `Rework ${child.id} on ${child.reworkOf} for the ${child.handoff?.to ?? 'producer'} pool: ${cut(String(child.note ?? ''), 200).text}\nThe producer takes it from its own pool. Set that card's column to in-progress when you pick it up. Do not post a status in the group.` + } + if (args?.release !== undefined) { + const returned = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId }) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 22468387f..2283c687a 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -985,11 +985,12 @@ diff --git a/src/host/channel.js b/src/host/channel.js diff --git a/src/host/delivery.js b/src/host/delivery.js --- a/src/host/delivery.js +++ b/src/host/delivery.js -@@ -2,6 +2,61 @@ +@@ -2,6 +2,62 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' +import { OWNER, OTHER, SCHEDULED } from './turn-origin.js' ++import { cut } from '../shared/text-boundary.js' + +// Text from outside the team, marked so the Bot treats it as data. Marker fragments are +// removed until none is left, so a nested "message>" @@ -1022,7 +1023,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js +// A name from outside the team, safe on one line of trusted text: no control characters, +// newlines, brackets or quotes, at most 60 characters. +export const cleanName = (name, fallback = 'a remote Bot') => { -+ const text = String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) ++ const text = cut(String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text + return text || fallback +} +export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n` diff --git a/patches/ds-bot/files/src/host/busy-ack.js b/patches/ds-bot/files/src/host/busy-ack.js index 4e66b573b..4de8fafed 100644 --- a/patches/ds-bot/files/src/host/busy-ack.js +++ b/patches/ds-bot/files/src/host/busy-ack.js @@ -14,6 +14,7 @@ // otherwise the chat's own model is used. import { BlockAssembler } from '@deepseek-ai/dsh-llm' import { splitRef } from './text.js' +import { cut } from '../shared/text-boundary.js' export const COALESCE_MS = 3_000 export const ACK_TIMEOUT_MS = 3_000 @@ -56,11 +57,11 @@ export const ACK_SYSTEM = [ /** The user prompt for the ack call: the new message and what the turn is doing. */ export function ackPrompt({ message, tool, working }) { const doing = typeof tool === 'string' && tool.trim() !== '' && tool !== 'ask_user' ? `running the tool \`${tool}\`` : 'working on the previous request' - const task = String(working ?? '').trim().slice(0, 600) + const task = cut(String(working ?? '').trim(), 600).text return [ `Currently: ${doing}.`, ...(task === '' ? [] : [`The request being worked on: ${task}`]), - `New message: ${String(message ?? '').trim().slice(0, 1_500)}`, + `New message: ${cut(String(message ?? '').trim(), 1_500).text}`, ].join('\n') } diff --git a/patches/ds-bot/files/test/busy-ack.test.js b/patches/ds-bot/files/test/busy-ack.test.js index ebb773fac..cee4d9d16 100644 --- a/patches/ds-bot/files/test/busy-ack.test.js +++ b/patches/ds-bot/files/test/busy-ack.test.js @@ -284,3 +284,20 @@ test('the own-chat ack is unchanged beside the room path (#1646)', async (t) => await settleAck(host.rt, 'gb') assert.deepEqual(Object.keys(host.rt.busyAcksView()).sort(), ['b', 'gb']) }) + +test('the prompt bounds keep whole characters at 600 and 1,500 (#P282b)', () => { + const sourceTask = 'w'.repeat(599) + '😀tail' + const sourceMessage = 'a'.repeat(1_499) + '😀tail' + const prompt = ackPrompt({ message: sourceMessage, tool: 'bash', working: sourceTask }) + assert.ok(prompt.isWellFormed(), `the prompt is not well formed: ${JSON.stringify(prompt.slice(-8))}`) + const lines = prompt.split('\n') + const keptTask = lines.find(line => line.startsWith('The request being worked on: ')).slice('The request being worked on: '.length) + const keptMessage = lines[lines.length - 1].slice('New message: '.length) + assert.equal(keptTask, 'w'.repeat(599)) + assert.equal(keptMessage, 'a'.repeat(1_499)) + assert.ok(keptTask.length <= 600) + assert.ok(keptMessage.length <= 1_500) + const bytesOf = value => Buffer.from(value, 'utf8') + assert.ok(bytesOf(sourceTask).subarray(0, bytesOf(keptTask).length).equals(bytesOf(keptTask))) + assert.ok(bytesOf(sourceMessage).subarray(0, bytesOf(keptMessage).length).equals(bytesOf(keptMessage))) +}) diff --git a/patches/ds-bot/files/test/fence.test.js b/patches/ds-bot/files/test/fence.test.js index 24b3b0a27..f0b3e17d3 100644 --- a/patches/ds-bot/files/test/fence.test.js +++ b/patches/ds-bot/files/test/fence.test.js @@ -37,3 +37,14 @@ test('#130: previews unwrap the stored untrusted-message wrap', () => { assert.equal(unwrapUntrusted(fenceUntrusted('User', 'hello from the owner')), 'hello from the owner') assert.equal(unwrapUntrusted('plain text'), 'plain text') }) + +test('a peer name at the 60 bound keeps the whole character (#P282b)', () => { + const source = 'a'.repeat(59) + '😀tail' + const name = cleanName(source) + assert.equal(name, 'a'.repeat(59)) + assert.ok(name.isWellFormed(), `the name is not well formed: ${JSON.stringify(name.slice(-4))}`) + assert.ok(name.length <= 60) + const fenced = fenceUntrusted(source, 'hello') + assert.ok(fenced.isWellFormed(), 'the fence header is not well formed') + assert.ok(fenced.startsWith(``)) +}) diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 1618bb015..859568d4e 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -882,6 +882,37 @@ test('list_bots keeps whole characters in a peer display name and owner name (#1 assert.ok(Buffer.from(keptOwner, 'utf8').equals(Buffer.from(ownerName, 'utf8').subarray(0, Buffer.byteLength(keptOwner))), 'the owner name is a prefix of what the peer published, byte for byte') }) +// #P282b: the group line's notice is bounded at 100, and a cut inside an emoji left half a pair. +test('list_bots keeps whole characters in a group notice at the 100 bound (#P282b)', async (t) => { + const notice = 'a'.repeat(99) + '😀tail' + const { tool, stop } = await start({ + rooms: { g: { id: 'g', name: 'Group', members: ['a'], admin: 'a', mode: 'admin', notice, createdAt: 1, createdBy: null } }, + }) + t.after(stop) + const listed = await tool('list_bots', {}, 'a') + assert.equal(listed.isWellFormed(), true, `the listing is not well formed: ${JSON.stringify(listed.slice(0, 80))}`) + const line = listed.split('\n').find(l => l.includes('notice: ')) + assert.ok(line, `the notice is listed: ${JSON.stringify(listed)}`) + const kept = line.split('notice: ')[1].split('; ')[0] + assert.equal(kept, 'a'.repeat(99), `the notice keeps the 100 bound: ${JSON.stringify(kept.slice(-4))}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(notice, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the notice is a prefix, byte for byte') +}) + +// #P282b: a Bot's job line is bounded at 120, and a cut inside an emoji left half a pair. +test('list_bots keeps whole characters in a Bot job line at the 120 bound (#P282b)', async (t) => { + const { tool, stop } = await start() + t.after(stop) + const instructions = 'a'.repeat(119) + '😀tail' + assert.match(await tool('update_bot', { bot: 'b', instructions }, 'a'), /Updated Writer/) + const listed = await tool('list_bots', {}, 'a') + assert.equal(listed.isWellFormed(), true, `the listing is not well formed: ${JSON.stringify(listed.slice(0, 80))}`) + const line = listed.split('\n').find(l => l.includes(' — ')) + assert.ok(line, `the job line is listed: ${JSON.stringify(listed)}`) + const kept = line.split(' — ')[1] + assert.equal(kept, 'a'.repeat(119), `the job line keeps the 120 bound: ${JSON.stringify(kept.slice(-4))}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(instructions, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the job line is a prefix, byte for byte') +}) + test('cleanRemote sanitizes peer name, homeName and owner at the record boundary (#75)', async (t) => { const { ctx, fb, tool, received, stop, grantTo } = await start() t.after(stop) diff --git a/patches/ds-bot/files/test/task-handoff-route.test.js b/patches/ds-bot/files/test/task-handoff-route.test.js index d7e40ce6a..96ff3c11d 100644 --- a/patches/ds-bot/files/test/task-handoff-route.test.js +++ b/patches/ds-bot/files/test/task-handoff-route.test.js @@ -178,6 +178,24 @@ test('a role returns the card to its producer as a rework card the producer can assert.equal(app.rt.state.tasks[childId].claimedBy, 'd1') }) +// #P282b: the rework line carries the note at 200, and a cut inside an emoji left half a pair. +test('the rework line keeps whole characters in the note at the 200 bound (#P282b)', async (t) => { + const app = await start(t) + const id = app.idOf(await app.track('a', { tasks: [{ title: 'Build the route', handoff: { from: 'Lead', to: 'Dev' } }] })) + await app.drive() + await app.track('d1', { claim: true, id }) + await app.track('d1', { tasks: [{ id, handoff: { from: 'Dev', to: 'Reviewer', reason: 'diff ready' } }] }) + await app.drive() + await app.track('r1', { claim: true, id }) + await app.track('r1', { tasks: [{ id, column: 'in-progress' }] }) + const reason = 'a'.repeat(199) + '😀tail' + const returned = await app.track('r1', { rework: { task: id, reason } }) + assert.equal(returned.isWellFormed(), true, `the rework line is not well formed: ${JSON.stringify(returned.slice(-8))}`) + const shown = returned.split(' pool: ')[1].split('\n')[0] + assert.equal(shown, 'a'.repeat(199), `the rework line keeps the 200 bound: ${JSON.stringify(shown.slice(-4))}`) + assert.ok(Buffer.from(shown, 'utf8').equals(Buffer.from(reason, 'utf8').subarray(0, Buffer.byteLength(shown))), 'the note is a prefix of the reason, byte for byte') +}) + test('a card waiting for its rework is not re-offered when its lease expires (#1804)', async (t) => { const app = await start(t) const id = app.idOf(await app.track('a', { tasks: [{ title: 'Build the route', handoff: { from: 'Lead', to: 'Dev' } }] }))