diff --git a/changelog.d/1993-p2-c5-remaining.fixed.md b/changelog.d/1993-p2-c5-remaining.fixed.md new file mode 100644 index 000000000..f6f29d65d --- /dev/null +++ b/changelog.d/1993-p2-c5-remaining.fixed.md @@ -0,0 +1 @@ +The last five text bounds in `core/src/host/` no longer cut on UTF-16 units ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)): the quoted first line of a checkpoint's kept tail (80), an approval record's reason (500), a mention receipt's reason line (240), the title given to a DSH Agent's Session (80) and a schedule run's failure reason (200) used a bare `slice`, so a bound that landed between the halves of a surrogate pair stored a lone high surrogate β€” read as U+FFFD by any encoder. Each now goes through the shared `cut`, which keeps within the same budget the longest prefix that stops before a split pair; none of the five maxima changes. diff --git a/core/src/host/agents.js b/core/src/host/agents.js index 45d629801..6b569c3ca 100644 --- a/core/src/host/agents.js +++ b/core/src/host/agents.js @@ -3,6 +3,7 @@ // the whole dsh conversation surface and none of the team tools. import { randomUUID } from 'node:crypto' import { AGENT_NAME, AGENT_SESSION_TITLE_MAX, TEAM_TOOLS } from './constants.js' +import { cut } from './text.js' export function install(rt) { const { ctx, state } = rt @@ -166,7 +167,7 @@ export function install(rt) { const clean = String(title ?? '').trim() if (clean === '') throw new Error('A session needs a title') const agent_ = await rt.resolveAgent(sessionId) - await ctx.sessionTitle.rename(agent_.session, clean.slice(0, AGENT_SESSION_TITLE_MAX)) + await ctx.sessionTitle.rename(agent_.session, cut(clean, AGENT_SESSION_TITLE_MAX).text) } async function removeAgent(agentId) { diff --git a/core/src/host/approvals.js b/core/src/host/approvals.js index 3423426fd..2e1f944ad 100644 --- a/core/src/host/approvals.js +++ b/core/src/host/approvals.js @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { EPHEMERAL_DENIED_TOOLS } from './tools.js' import { OWNER, settled } from './turn-origin.js' +import { cut } from './text.js' const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'allowed-once', 'always-room': 'allowed-once' } const RUN_DENIED = new Set(EPHEMERAL_DENIED_TOOLS) @@ -72,7 +73,7 @@ export function install(rt) { botId, ...(owner ? { roomId: owner.roomId } : {}), tool: String(request.toolName ?? ''), - reason: String(request.reason ?? '').slice(0, 500), + reason: cut(String(request.reason ?? ''), 500).text, detail: rt.callDetail?.(sessionId, request.callId) ?? {}, at: Date.now(), } diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 3b2300239..f3987dffb 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1038,7 +1038,7 @@ export function install(rt) { // the room, posted as a relay role the room's round machinery does not answer (groups.js `opens`). async function mentionReceipt(sessionId, room, results, known) { if (room === undefined || results.length === 0) return - const line = value => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240) + const line = value => cut(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim(), 240).text const lines = results.map(({ handle, action, reason }) => { const name = cleanName(known.get(handle)?.name, handle) const why = line(reason) diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index 8092e3700..453255640 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -31,7 +31,7 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { quietHoursOf, inQuietHours, clockIn, quietError } from '../shared/quiet-hours.js' import { defineTool } from './tool-schema.js' -import { codePoints, textOf } from './text.js' +import { codePoints, cut, textOf } from './text.js' import { output } from './tools.js' import { retiredBot } from './store.js' @@ -279,7 +279,7 @@ export function install(rt) { for (const id of entry.ids) { const run = runs()[id] if (run === undefined) continue - noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: reason.slice(0, 200) } : {}) }) + noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: cut(reason, 200).text } : {}) }) } } // A group round whose members' turns failed: the task's run in that group failed. diff --git a/core/src/host/steward.js b/core/src/host/steward.js index 12f82bfb0..fabe6cb26 100644 --- a/core/src/host/steward.js +++ b/core/src/host/steward.js @@ -6,7 +6,7 @@ // // Every line follows the model. Its declared window, less the room its reply // needs, is the usable context; the check line is a share of that. -import { estimateTokens } from './text.js' +import { cut, estimateTokens } from './text.js' // For a model that declares no window. export const DEFAULT_WINDOW = 500_000 @@ -145,7 +145,7 @@ export function checkpointTailNote(tail) { const first = tail[0] const text = (Array.isArray(first?.content) ? first.content : []) .filter(block => block?.type === 'text').map(block => block.text).join(' ').replace(/\s+/g, ' ').trim() - const from = text === '' ? `from your ${first?.role === 'assistant' ? 'own step' : 'latest input'}` : `from the one that begins "${text.slice(0, 80)}"` + const from = text === '' ? `from your ${first?.role === 'assistant' ? 'own step' : 'latest input'}` : `from the one that begins "${cut(text, 80).text}"` return `The last ${tail.length === 1 ? 'message' : `${tail.length} messages`} above, ${from}, stay right after your summary, word for word. Count what they give as given: summarize only what comes before them, and do not copy them.` } diff --git a/core/test/agents.test.js b/core/test/agents.test.js index cb1b2608c..5da35045b 100644 --- a/core/test/agents.test.js +++ b/core/test/agents.test.js @@ -153,6 +153,20 @@ test('agent-rename retitles an owned Session and refuses others', async (t) => { assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: 'm1', title: 'x' })).ok, false) }) +test('P-1993: a Session title is cut on a code point at the 80-unit bound', async (t) => { + const { call, stop, renamed } = start(TEAM) + t.after(stop) + await call('state', {}) + const agent = (await call('add-agent')).value + const head = 'a'.repeat(79) + assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: agent.sessions[0], title: `${head}πŸ˜€tail` })).ok, true) + assert.ok(renamed.at(-1).title.isWellFormed(), `the title is well formed: ${JSON.stringify(renamed.at(-1).title)}`) + assert.equal(renamed.at(-1).title, head, 'the pair at the bound is dropped whole, not split') + // Control: a title under the bound is stored word for word. + assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: agent.sessions[0], title: ' Scratch ' })).ok, true) + assert.equal(renamed.at(-1).title, 'Scratch') +}) + test('remove-agent keeps the Agent with the Sessions it could not archive', async (t) => { const { call, stop, archived, failArchive } = start(TEAM) t.after(stop) diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 859568d4e..0015a5a08 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -622,6 +622,36 @@ test('mentions in a group (#80, #81): the room gets a receipt β€” sent points to assert.equal((agents.get('b')?.calls ?? []).length, 0) }) +test('P-1993: a mention receipt\'s reason is cut on a code point at 240', async (t) => { + const { ctx, fb, stop, agents, grantTo } = await start({ + rooms: { 'room-1': { id: 'room-1', name: 'Group', members: ['a', 'b'], admin: 'a', mode: 'admin', notice: '', createdAt: 1, createdBy: null } }, + }) + t.after(stop) + await grantTo('a', '@helper.friend.home') + fb.transports.add('bot-social', { send: async msg => ({ exchangeId: `r-${msg.to.handle}` }) }) + fb.scope('social').remote.publish([{ handle: '@helper.friend.home', name: 'εŠ©ζ‰‹', home: '@friend.home' }]) + const head = 'a'.repeat(239) + fb.bot.send.use({ name: 'deny-long', handle: m => (m.target?.remote === true ? { action: 'deny', reason: /long/.test(m.text) ? `${head}πŸ˜€tail` : 'no friendship' } : undefined) }) + const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + const until = async (check) => { + const end = Date.now() + 2000 + while (!check()) { + if (Date.now() > end) throw new Error('until: the condition did not hold within 2 s') + await delay(10) + } + } + const say = async (text, n) => { + ctx.emitArgs('session/event', { id: 'room-1' }, { type: 'user/message', seq: n, data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) + await until(() => receipts().length >= n) + } + await say('@helper.friend.home long one', 1) + assert.ok(receipts()[0].isWellFormed(), `the receipt is well formed: ${JSON.stringify(receipts()[0])}`) + assert.equal(receipts()[0], `εŠ©ζ‰‹: not sent β€” ${head}`, 'the pair at the bound is dropped whole, not split') + // Control: a reason under the bound is posted word for word. + await say('@helper.friend.home short one', 2) + assert.equal(receipts()[1], 'εŠ©ζ‰‹: not sent β€” no friendship') +}) + test('mentions (#143): a human line to a friend Bot already in the room stays in the room; Chief is not the sender', async (t) => { const { ctx, fb, stop, agents } = await start({ rooms: { diff --git a/core/test/group-status.test.js b/core/test/group-status.test.js index 99345b57c..3308f5260 100644 --- a/core/test/group-status.test.js +++ b/core/test/group-status.test.js @@ -132,6 +132,24 @@ test('a group Session\'s approval waits in the team view, frees the round, and t assert.equal(text(post), '[Group post from Writer]\nCopied the doc to E:/docs.') }) +test('P-1993: an approval record\'s reason is cut on a code point at 500', async (t) => { + const host = await start() + t.after(host.stop) + const head = 'a'.repeat(499) + const short = host.askApproval('gb', { callId: 'k1', reason: 'copy the doc' }) + const [control] = (await host.call('state', {})).value.work.approvals + assert.equal(control.reason, 'copy the doc', 'a reason under the bound is stored word for word') + assert.equal((await host.call('approval-answer', { id: control.id, choice: 'allow' })).ok, true) + assert.equal(await short, 'allowed-once') + + const long = host.askApproval('gb', { callId: 'k2', reason: `${head}πŸ˜€tail` }) + const [approval] = (await host.call('state', {})).value.work.approvals + assert.ok(approval.reason.isWellFormed(), `the stored reason is well formed: ${JSON.stringify(approval.reason)}`) + assert.equal(approval.reason, head, 'the pair at the bound is dropped whole, not split') + assert.equal((await host.call('approval-answer', { id: approval.id, choice: 'allow' })).ok, true) + assert.equal(await long, 'allowed-once') +}) + // Both fields of a card go through a bound of their own: 300 for the description, 400 for the // command. A cut that splits a surrogate pair leaves a lone high half the card draws as U+FFFD. test('an approval card cuts its description and command at their bounds without splitting a pair', async (t) => { diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index 97baea6ee..98f9010e3 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -628,6 +628,24 @@ test('a run fails only when its turn errors; a turn without an answer has run, a assert.equal(saved(), 'stopped') }) +test('P-1993: a failed run\'s reason is cut on a code point at 200', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) + const [id] = schedule.tasks.keys() + const message = { id: 'msg-1', content: [{ type: 'text', text: reminder(id, 'Sum up') }], source: { kind: 'schedule' } } + const head = `SERVER 503: ${'a'.repeat(187)}` + runTurn(ctx, 'm1', 1, message, { kind: 'error', error: { code: 'SERVER', status: 503, message: `${'a'.repeat(187)}πŸ˜€tail` } }) + const [[result, reason]] = await lastRuns(call) + assert.equal(result, 'failed') + assert.ok(reason.isWellFormed(), `the stored reason is well formed: ${JSON.stringify(reason)}`) + assert.equal(reason, head, 'the pair at the bound is dropped whole, not split') + // Control: a reason under the bound is stored word for word. + runTurn(ctx, 'm1', 2, { ...message, id: 'msg-2' }, { kind: 'error', error: { code: 'SERVER', status: 503, message: 'upstream busy' } }) + assert.deepEqual(await lastRuns(call), [['failed', 'SERVER 503: upstream busy']]) +}) + test('Retry runs the same prompt once now in the Bot’s current part, and its turn is the task’s run', async (t) => { const schedule = scheduleService() const { ctx, home, call } = setup({ schedule }) diff --git a/core/test/steward.test.js b/core/test/steward.test.js index 2267ed9d5..ae06869ad 100644 --- a/core/test/steward.test.js +++ b/core/test/steward.test.js @@ -101,6 +101,17 @@ test('the checkpoint also reads the messages the backend keeps after it', () => assert.ok(checkpointTailNote([say('user', 'ι•Ώ'.repeat(300))]).includes(`"${'ι•Ώ'.repeat(80)}"`)) }) +test('P-1993: the quoted first line of the kept tail is cut on a code point at 80', () => { + const say = (role, text) => ({ role, content: [{ type: 'text', text }] }) + const head = 'a'.repeat(79) + const quoted = note => /begins "(.*)", stay right after your summary/.exec(note)[1] + const long = quoted(checkpointTailNote([say('user', `${head}πŸ˜€tail`)])) + assert.ok(long.isWellFormed(), `the quoted line is well formed: ${JSON.stringify(long)}`) + assert.equal(long, head, 'the pair at the bound is dropped whole, not split') + // Control: a line under the bound is quoted word for word. + assert.equal(quoted(checkpointTailNote([say('user', 'Short line')])), 'Short line') +}) + test('the checkpoint records unanswered requests as remaining, and does not answer them', () => { assert.match(CHECKPOINT_PROMPT, /every request not yet answered in your replies above/) assert.match(CHECKPOINT_PROMPT, /The user never sees this summary, so do not do any of the remaining work in it/) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1b8810d2c..a86509391 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)). 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. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 300-unit description and 400-unit command, and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds β€” `String(text).toWellFormed()` first, `codePointEnd` on the flat string β€” so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too β€” a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) β€” so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). The two bounds on a friend's Bot's own name in that file β€” the name its chat is created with (40) and the name a refresh writes (40) β€” go through `cut` as well, so a refresh cannot store a lone high surrogate and a create no longer hands `roster.createBot` half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. | +| `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. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 300-unit description and 400-unit command, and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds β€” `String(text).toWellFormed()` first, `codePointEnd` on the flat string β€” so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too β€” a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) β€” so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). The two bounds on a friend's Bot's own name in that file β€” the name its chat is created with (40) and the name a refresh writes (40) β€” go through `cut` as well, so a refresh cannot store a lone high surrogate and a create no longer hands `roster.createBot` half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. Since #1993 the last five bounds in `core/src/host/` go through it too β€” the quoted first line of a checkpoint's kept tail (80, `steward.js`), an approval record's `reason` (500, `approvals.js`), a mention receipt's reason line (240, `hooks.js`), the title given to a DSH Agent's Session (80, `AGENT_SESSION_TITLE_MAX` in `agents.js`) and a schedule run's failure `reason` (200, `schedules.js`) β€” so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, and none of the five maxima changes ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)). | | `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 248646cca..24d00739b 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -57,7 +57,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 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. 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. +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`, and since #1993 the 240-unit reason line the receipt quotes goes through the shared `cut` (`shared/text-boundary.js`), so a bound that lands between the halves of a surrogate pair drops the pair whole instead of posting a lone high surrogate the encoder would read as U+FFFD ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)). 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`). 11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail), and so does `archive_bot` with `the responsible Bot was retired`, because retirement means what deletion does to the board; `task-create`, a plugin's `routeOpenTask` and `task-send-back` refuse a retired owner with `No such Bot`, since the last one opens a rework card on that same owner. Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. `flybot.tasks.onWrite(fn)` (#1318) hears the saved view after `task-create` and `task-update`. The first object returned may name `ownerBotId`. The host applies it when it is a different local Bot, records the move, and saves again without calling listeners. A throw is logged and the next listener still runs. Starting a workflow run stays inside the plugin; this seam only moves the card's owner. `flybot.tasks.onProjectProvider(fn)` (#1696) is the read side a plugin exposes for the Project board: the host calls it with `{ hasTask }` and reads back `{ taskId, runId, nodeId? }` refs through `rt.projectRunRefs({ hasTask })`, so core never reads `workflows.json`; a provider that throws, or names a `taskId` the core task record no longer has, is skipped instead of failing the read. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index be68b3975..ccde9784b 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2821,6 +2821,14 @@ diff --git a/src/client/voice.js b/src/client/voice.js diff --git a/src/host/agents.js b/src/host/agents.js --- a/src/host/agents.js +++ b/src/host/agents.js +@@ -3,6 +3,7 @@ + // the whole dsh conversation surface and none of the team tools. + import { randomUUID } from 'node:crypto' + import { AGENT_NAME, AGENT_SESSION_TITLE_MAX, TEAM_TOOLS } from './constants.js' ++import { cut } from './text.js' + + export function install(rt) { + const { ctx, state } = rt @@ -77,6 +77,13 @@ export function install(rt) { const readOnly = rt.readOnly() if (readOnly !== undefined) throw new Error(readOnly) @@ -2933,7 +2941,12 @@ diff --git a/src/host/agents.js b/src/host/agents.js } async function renameAgentSession(agentId, sessionId, title) { -@@ -158,9 +158,9 @@ export function install(rt) { +@@ -154,13 +154,13 @@ export function install(rt) { + const clean = String(title ?? '').trim() + if (clean === '') throw new Error('A session needs a title') + const agent_ = await rt.resolveAgent(sessionId) +- await ctx.sessionTitle.rename(agent_.session, clean.slice(0, AGENT_SESSION_TITLE_MAX)) ++ await ctx.sessionTitle.rename(agent_.session, cut(clean, AGENT_SESSION_TITLE_MAX).text) } async function removeAgent(agentId) { @@ -2964,12 +2977,13 @@ diff --git a/src/host/agents.js b/src/host/agents.js diff --git a/src/host/approvals.js b/src/host/approvals.js --- a/src/host/approvals.js +++ b/src/host/approvals.js -@@ -3,8 +3,45 @@ +@@ -3,8 +3,46 @@ // the group chat. A Bot's own chat keeps DSH's card; its requests are only noted, so // the team view can say who is waiting. import { randomUUID } from 'node:crypto' +import { EPHEMERAL_DENIED_TOOLS } from './tools.js' +import { OWNER, settled } from './turn-origin.js' ++import { cut } from './text.js' -const OUTCOMES = { allow: 'allowed-once', reject: 'rejected' } +const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'allowed-once', 'always-room': 'allowed-once' } @@ -3031,6 +3045,15 @@ diff --git a/src/host/approvals.js b/src/host/approvals.js const id = randomUUID() const entry = { id, +@@ -24,7 +24,7 @@ export function install(rt) { + botId, + ...(owner ? { roomId: owner.roomId } : {}), + tool: String(request.toolName ?? ''), +- reason: String(request.reason ?? '').slice(0, 500), ++ reason: cut(String(request.reason ?? ''), 500).text, + detail: rt.callDetail?.(sessionId, request.callId) ?? {}, + at: Date.now(), + } @@ -47,14 +47,82 @@ export function install(rt) { }) }, { prepend: true }) @@ -5640,7 +5663,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { quietHoursOf, inQuietHours, clockIn, quietError } from '../shared/quiet-hours.js' +import { defineTool } from './tool-schema.js' -+import { codePoints, textOf } from './text.js' ++import { codePoints, cut, textOf } from './text.js' +import { output } from './tools.js' +import { retiredBot } from './store.js' @@ -5847,7 +5870,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + for (const id of entry.ids) { + const run = runs()[id] + if (run === undefined) continue -+ noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: reason.slice(0, 200) } : {}) }) ++ noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: cut(reason, 200).text } : {}) }) + } + } + // A group round whose members' turns failed: the task's run in that group failed. @@ -6331,7 +6354,12 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js diff --git a/src/host/steward.js b/src/host/steward.js --- a/src/host/steward.js +++ b/src/host/steward.js -@@ -10,7 +10,9 @@ import { estimateTokens } from './text.js' +@@ -6,11 +6,13 @@ + // + // Every line follows the model. Its declared window, less the room its reply + // needs, is the usable context; the check line is a share of that. +-import { estimateTokens } from './text.js' ++import { cut, estimateTokens } from './text.js' // For a model that declares no window. export const DEFAULT_WINDOW = 500_000 @@ -6342,6 +6370,15 @@ diff --git a/src/host/steward.js b/src/host/steward.js // Context sizes between requests are estimates, so the hard line stays a little // below what the model can actually take. const HARD_SHARE = 0.95 +@@ -143,7 +143,7 @@ export function checkpointTailNote(tail) { + const first = tail[0] + const text = (Array.isArray(first?.content) ? first.content : []) + .filter(block => block?.type === 'text').map(block => block.text).join(' ').replace(/\s+/g, ' ').trim() +- const from = text === '' ? `from your ${first?.role === 'assistant' ? 'own step' : 'latest input'}` : `from the one that begins "${text.slice(0, 80)}"` ++ const from = text === '' ? `from your ${first?.role === 'assistant' ? 'own step' : 'latest input'}` : `from the one that begins "${cut(text, 80).text}"` + return `The last ${tail.length === 1 ? 'message' : `${tail.length} messages`} above, ${from}, stay right after your summary, word for word. Count what they give as given: summarize only what comes before them, and do not copy them.` + } + diff --git a/src/host/text.js b/src/host/text.js --- a/src/host/text.js +++ b/src/host/text.js @@ -7320,6 +7357,30 @@ diff --git a/src/shared/mentions.js b/src/shared/mentions.js + } + return missing +} +diff --git a/test/agents.test.js b/test/agents.test.js +--- a/test/agents.test.js ++++ b/test/agents.test.js +@@ -153,6 +153,20 @@ test('agent-rename retitles an owned Session and refuses others', async (t) => { + assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: 'm1', title: 'x' })).ok, false) + }) + ++test('P-1993: a Session title is cut on a code point at the 80-unit bound', async (t) => { ++ const { call, stop, renamed } = start(TEAM) ++ t.after(stop) ++ await call('state', {}) ++ const agent = (await call('add-agent')).value ++ const head = 'a'.repeat(79) ++ assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: agent.sessions[0], title: `${head}πŸ˜€tail` })).ok, true) ++ assert.ok(renamed.at(-1).title.isWellFormed(), `the title is well formed: ${JSON.stringify(renamed.at(-1).title)}`) ++ assert.equal(renamed.at(-1).title, head, 'the pair at the bound is dropped whole, not split') ++ // Control: a title under the bound is stored word for word. ++ assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: agent.sessions[0], title: ' Scratch ' })).ok, true) ++ assert.equal(renamed.at(-1).title, 'Scratch') ++}) ++ + test('remove-agent keeps the Agent with the Sessions it could not archive', async (t) => { + const { call, stop, archived, failArchive } = start(TEAM) + t.after(stop) diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js --- a/test/approval-notices.test.js +++ b/test/approval-notices.test.js @@ -9225,10 +9286,28 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js } // Waits on real I/O (state writes) without timers, which some tests mock. -@@ -132,6 +132,29 @@ test('a group Session\'s approval waits in the team view, frees the round, and t +@@ -132,6 +132,47 @@ test('a group Session\'s approval waits in the team view, frees the round, and t assert.equal(text(post), '[Group post from Writer]\nCopied the doc to E:/docs.') }) ++test('P-1993: an approval record\'s reason is cut on a code point at 500', async (t) => { ++ const host = await start() ++ t.after(host.stop) ++ const head = 'a'.repeat(499) ++ const short = host.askApproval('gb', { callId: 'k1', reason: 'copy the doc' }) ++ const [control] = (await host.call('state', {})).value.work.approvals ++ assert.equal(control.reason, 'copy the doc', 'a reason under the bound is stored word for word') ++ assert.equal((await host.call('approval-answer', { id: control.id, choice: 'allow' })).ok, true) ++ assert.equal(await short, 'allowed-once') ++ ++ const long = host.askApproval('gb', { callId: 'k2', reason: `${head}πŸ˜€tail` }) ++ const [approval] = (await host.call('state', {})).value.work.approvals ++ assert.ok(approval.reason.isWellFormed(), `the stored reason is well formed: ${JSON.stringify(approval.reason)}`) ++ assert.equal(approval.reason, head, 'the pair at the bound is dropped whole, not split') ++ assert.equal((await host.call('approval-answer', { id: approval.id, choice: 'allow' })).ok, true) ++ assert.equal(await long, 'allowed-once') ++}) ++ +// Both fields of a card go through a bound of their own: 300 for the description, 400 for the +// command. A cut that splits a surrogate pair leaves a lone high half the card draws as U+FFFD. +test('an approval card cuts its description and command at their bounds without splitting a pair', async (t) => { @@ -11274,7 +11353,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js test('a group chat task lives on the group Session and lists under the group', async (t) => { const schedule = scheduleService() const { ctx, home, call } = setup({ schedule }) -@@ -121,3 +121,728 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,746 @@ test('a due reminder reaches a group as its prompts', () => { assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two') assert.equal(reminderText('plain text'), 'plain text') }) @@ -11694,6 +11773,24 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + assert.equal(saved(), 'stopped') +}) + ++test('P-1993: a failed run\'s reason is cut on a code point at 200', async (t) => { ++ const schedule = scheduleService() ++ const { ctx, home, call } = setup({ schedule }) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY }) ++ const [id] = schedule.tasks.keys() ++ const message = { id: 'msg-1', content: [{ type: 'text', text: reminder(id, 'Sum up') }], source: { kind: 'schedule' } } ++ const head = `SERVER 503: ${'a'.repeat(187)}` ++ runTurn(ctx, 'm1', 1, message, { kind: 'error', error: { code: 'SERVER', status: 503, message: `${'a'.repeat(187)}πŸ˜€tail` } }) ++ const [[result, reason]] = await lastRuns(call) ++ assert.equal(result, 'failed') ++ assert.ok(reason.isWellFormed(), `the stored reason is well formed: ${JSON.stringify(reason)}`) ++ assert.equal(reason, head, 'the pair at the bound is dropped whole, not split') ++ // Control: a reason under the bound is stored word for word. ++ runTurn(ctx, 'm1', 2, { ...message, id: 'msg-2' }, { kind: 'error', error: { code: 'SERVER', status: 503, message: 'upstream busy' } }) ++ assert.deepEqual(await lastRuns(call), [['failed', 'SERVER 503: upstream busy']]) ++}) ++ +test('Retry runs the same prompt once now in the Bot’s current part, and its turn is the task’s run', async (t) => { + const schedule = scheduleService() + const { ctx, home, call } = setup({ schedule }) @@ -12161,6 +12258,24 @@ diff --git a/test/steward.test.js b/test/steward.test.js } // The check line never passes the hard line. assert.equal(linesFor({ window: 100_000, reserve: 0, checkRatio: 0.99 }).check, 95_000) +@@ -101,6 +101,17 @@ test('the checkpoint also reads the messages the backend keeps after it', () => + assert.ok(checkpointTailNote([say('user', 'ι•Ώ'.repeat(300))]).includes(`"${'ι•Ώ'.repeat(80)}"`)) + }) + ++test('P-1993: the quoted first line of the kept tail is cut on a code point at 80', () => { ++ const say = (role, text) => ({ role, content: [{ type: 'text', text }] }) ++ const head = 'a'.repeat(79) ++ const quoted = note => /begins "(.*)", stay right after your summary/.exec(note)[1] ++ const long = quoted(checkpointTailNote([say('user', `${head}πŸ˜€tail`)])) ++ assert.ok(long.isWellFormed(), `the quoted line is well formed: ${JSON.stringify(long)}`) ++ assert.equal(long, head, 'the pair at the bound is dropped whole, not split') ++ // Control: a line under the bound is quoted word for word. ++ assert.equal(quoted(checkpointTailNote([say('user', 'Short line')])), 'Short line') ++}) ++ + test('the checkpoint records unanswered requests as remaining, and does not answer them', () => { + assert.match(CHECKPOINT_PROMPT, /every request not yet answered in your replies above/) + assert.match(CHECKPOINT_PROMPT, /The user never sees this summary, so do not do any of the remaining work in it/) diff --git a/test/text.test.js b/test/text.test.js --- a/test/text.test.js +++ b/test/text.test.js diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 3b2300239..f3987dffb 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1038,7 +1038,7 @@ export function install(rt) { // the room, posted as a relay role the room's round machinery does not answer (groups.js `opens`). async function mentionReceipt(sessionId, room, results, known) { if (room === undefined || results.length === 0) return - const line = value => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240) + const line = value => cut(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim(), 240).text const lines = results.map(({ handle, action, reason }) => { const name = cleanName(known.get(handle)?.name, handle) const why = line(reason) diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 859568d4e..0015a5a08 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -622,6 +622,36 @@ test('mentions in a group (#80, #81): the room gets a receipt β€” sent points to assert.equal((agents.get('b')?.calls ?? []).length, 0) }) +test('P-1993: a mention receipt\'s reason is cut on a code point at 240', async (t) => { + const { ctx, fb, stop, agents, grantTo } = await start({ + rooms: { 'room-1': { id: 'room-1', name: 'Group', members: ['a', 'b'], admin: 'a', mode: 'admin', notice: '', createdAt: 1, createdBy: null } }, + }) + t.after(stop) + await grantTo('a', '@helper.friend.home') + fb.transports.add('bot-social', { send: async msg => ({ exchangeId: `r-${msg.to.handle}` }) }) + fb.scope('social').remote.publish([{ handle: '@helper.friend.home', name: 'εŠ©ζ‰‹', home: '@friend.home' }]) + const head = 'a'.repeat(239) + fb.bot.send.use({ name: 'deny-long', handle: m => (m.target?.remote === true ? { action: 'deny', reason: /long/.test(m.text) ? `${head}πŸ˜€tail` : 'no friendship' } : undefined) }) + const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + const until = async (check) => { + const end = Date.now() + 2000 + while (!check()) { + if (Date.now() > end) throw new Error('until: the condition did not hold within 2 s') + await delay(10) + } + } + const say = async (text, n) => { + ctx.emitArgs('session/event', { id: 'room-1' }, { type: 'user/message', seq: n, data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) + await until(() => receipts().length >= n) + } + await say('@helper.friend.home long one', 1) + assert.ok(receipts()[0].isWellFormed(), `the receipt is well formed: ${JSON.stringify(receipts()[0])}`) + assert.equal(receipts()[0], `εŠ©ζ‰‹: not sent β€” ${head}`, 'the pair at the bound is dropped whole, not split') + // Control: a reason under the bound is posted word for word. + await say('@helper.friend.home short one', 2) + assert.equal(receipts()[1], 'εŠ©ζ‰‹: not sent β€” no friendship') +}) + test('mentions (#143): a human line to a friend Bot already in the room stays in the room; Chief is not the sender', async (t) => { const { ctx, fb, stop, agents } = await start({ rooms: { diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 73290f013..3e6c081e3 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -23,6 +23,7 @@ "src/client/memory-panel.js", "src/client/voice.js", "src/host/agents.js", + "test/agents.test.js", "src/host/capabilities.js", "src/host/chat-index.js", "src/host/connectors.js",