diff --git a/changelog.d/2691-name-identity.fixed.md b/changelog.d/2691-name-identity.fixed.md new file mode 100644 index 000000000..0c4297b82 --- /dev/null +++ b/changelog.d/2691-name-identity.fixed.md @@ -0,0 +1 @@ +A Bot or group-chat name written in either Unicode canonical form, or carrying an invisible format character such as a zero-width space, is now one identity: the duplicate guard refuses the twin, `@Name` and every lookup reach the same record, and a stored name keeps only the characters that show on screen ([#2691](https://github.com/ThinkFlowLab/FlyBot/issues/2691)). diff --git a/core/client.js b/core/client.js index 847b56f98..bfa4ce3c9 100644 --- a/core/client.js +++ b/core/client.js @@ -2412,8 +2412,9 @@ var ADDRESS_BEFORE = /[a-z0-9_./]/i; var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length)); var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); +var stripInvisible = (value) => String(value ?? "").replace(/\p{Cf}/gu, ""); function nameProblem(name) { - const text2 = String(name ?? "").trim(); + const text2 = stripInvisible(name).trim(); if (text2 === "") return "A Bot needs a name"; if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break"; if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`; diff --git a/core/src/host/roster.js b/core/src/host/roster.js index cc1558c5a..7a105ee88 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -3,7 +3,7 @@ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js' -import { currentColor, cut, isColor, unquote } from './text.js' +import { currentColor, cut, isColor, stripInvisible, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' import { cleanExternal } from './room-participant-external.js' import { currentParticipants, implicitRoomOwner, syncMembers } from './room-membership.js' @@ -62,7 +62,9 @@ export function install(rt) { // `pickModel` leaves the model to the user: the chat opens on a model card. So does a // Bot made when no model is served. async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, pickModel = false, systemPrompt, fields, remote, external, ownerId: explicitOwnerId, hidden, ephemeral }) { - const cleanName = cut(String(botName ?? '').trim(), 40).text + // The stored name is the visible one: the invisible characters a name can carry would + // make two Bots read the same on screen while the key still tells them apart (#2691). + const cleanName = cut(stripInvisible(String(botName ?? '')).trim(), 40).text // A friend's Bot (remote-chat.js) keeps its own name: it is not a team member, so // team names never clash with it and creating it starts no turn. const isRemote = typeof remote?.handle === 'string' @@ -190,7 +192,7 @@ export function install(rt) { const moving = patch.workDir !== undefined const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined if (patch.name !== undefined) { - const next = cut(String(patch.name).trim(), 40).text + const next = cut(stripInvisible(String(patch.name)).trim(), 40).text const clash = rt.findBot(next) if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`) // A name saved before the rule stays usable until it changes. @@ -277,7 +279,7 @@ export function install(rt) { const cleanNotice = text => cut(String(text ?? '').trim(), ROOM_NOTICE_MAX).text function cleanRoomName(text, selfId) { - const next = cut(unquote(text).replace(/\s+/g, ' '), ROOM_NAME_MAX).text + const next = cut(stripInvisible(unquote(text)).replace(/\s+/g, ' '), ROOM_NAME_MAX).text if (next !== '' && rt.findRooms(next).some(room => room.id !== selfId)) throw new Error(`Another group chat is already called "${next}"`) return next } diff --git a/core/src/host/text.js b/core/src/host/text.js index 5e59e49a7..61c41de38 100644 --- a/core/src/host/text.js +++ b/core/src/host/text.js @@ -2,8 +2,9 @@ // cutting and estimating text, and the clock format of chat anchors. import { COLORS, EXTRA_COLORS, LEGACY_COLORS, OWN_CHAT_LINE_MAX } from './constants.js' import { codePointEnd, codePointStart, cut, fits } from '../shared/text-boundary.js' +import { stripInvisible } from '../shared/mentions.js' -export { cut } +export { cut, stripInvisible } // A color id saved under its legacy name reads as the current one. export const currentColor = color => (typeof color === 'string' && Object.hasOwn(LEGACY_COLORS, color) ? LEGACY_COLORS[color] : color) @@ -20,7 +21,10 @@ export const textOf = content => (Array.isArray(content) ? content : []) .join('\n') .trim() -export const normalize = value => String(value ?? '').trim().toLowerCase() +// The identity key for a name (#2691): both canonical spellings (NFC and NFD) of one +// visible name are one key, and so are the invisible characters `trim()` keeps. The NFC +// pass runs again after lowercasing because a lower can compose a new sequence (U+0130). +export const normalize = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC') export const unquote = text => String(text ?? '').trim().replace(/^["'“”「『《]+|["'“”」』》]+$/g, '').trim() diff --git a/core/src/shared/mentions.js b/core/src/shared/mentions.js index 45e32b49a..abe1108a8 100644 --- a/core/src/shared/mentions.js +++ b/core/src/shared/mentions.js @@ -16,9 +16,16 @@ const fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase( && !NAME_GOES_ON.test(text.slice(at + word.length)) const overlaps = (spans, start, end) => spans.some(span => start < span.end && span.start < end) -// What a Bot may be named: something "@" can call without a second reading. +// A name can carry characters that cannot be seen: the Unicode format characters (Cf) — +// zero-width space / non-joiner / joiner, the byte-order mark, soft hyphen, the bidi +// controls — and `trim()` removes no character that is not White_Space. The identity key +// (#2691) drops them, and a stored name drops them too, so what is saved is what is shown. +export const stripInvisible = value => String(value ?? '').replace(/\p{Cf}/gu, '') + +// What a Bot may be named: something "@" can call without a second reading. A name made +// only of invisible characters reads as no name at all. export function nameProblem(name) { - const text = String(name ?? '').trim() + const text = stripInvisible(name).trim() if (text === '') return 'A Bot needs a name' if (/[@`\r\n]/.test(text)) return 'A Bot name cannot contain @, ` or a line break' if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name` diff --git a/core/test/name-identity.test.js b/core/test/name-identity.test.js new file mode 100644 index 000000000..2ad44d891 --- /dev/null +++ b/core/test/name-identity.test.js @@ -0,0 +1,137 @@ +// #2691: one visible name is one identity. The key folds canonically equivalent spellings +// (NFC/NFD) and drops the invisible format characters a name can carry, and the guards that +// admit a Bot or a room use the same key as the lookup. Fake data in a temp home only. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { nameProblem } from '../src/shared/mentions.js' +import { normalize } from '../src/host/text.js' +import { fakeContext } from './fake-ctx.js' + +const TEAM = () => ({ + version: 1, + mainBotIds: ['chief'], + bots: { + chief: { id: 'chief', sessionId: 'chief', name: 'Chief', role: 'Lead', instructions: '', createdAt: 1 }, + cafe: { id: 'cafe', sessionId: 'cafe', name: 'Caf\u00e9', role: '', instructions: '', createdAt: 2 }, + dev: { id: 'dev', sessionId: 'dev', name: 'Dev', role: '', instructions: '', createdAt: 3 }, + ops: { id: 'ops', sessionId: 'ops', name: 'Ops', role: '', instructions: '', createdAt: 4 }, + }, + rooms: { + room: { + id: 'room', name: 'Caf\u00e9', named: true, members: ['chief', 'dev'], admin: 'chief', mode: 'everyone', + seq: 0, log: [], + }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, + revision: 1, +}) + +function setup(t, seed = TEAM()) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-name-identity-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + let sessions = 0 + const agents = new Map() + const ctx = fakeContext() + const agentFor = async (id) => { + if (!agents.has(id)) agents.set(id, { id, session: { id, seq: 1 }, followup: () => {}, ctx: { tools: { restrict: () => () => {} }, effect: execute => execute() } }) + return agents.get(id) + } + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'deepseek-v4-flash' }], + resolveModelInfo: async () => ({}), + }, + workspaceRegistry: { + get: () => undefined, + resolveByPath: async () => undefined, + create: async path => ({ id: 'w1', path }), + archiveSession: async () => {}, + }, + sessionController: { + create: async () => ({ sessionId: `new${++sessions}` }), + resolveAgent: async id => ({ agent: await agentFor(id) }), + selectModel: async () => {}, + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home, github: false, diagnosticsFile: false }) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + return { ctx, rt, state, home } +} + +test('#2691 the name key folds canonical spellings and drops invisible format characters', () => { + const nfc = 'Caf\u00e9' + const nfd = 'Cafe\u0301' + assert.notEqual(nfc, nfd) + assert.equal(normalize(nfd), normalize(nfc)) + // The invisible spellings a name can carry: zero-width space / non-joiner / joiner, + // byte-order mark, soft hyphen, word joiner. + for (const hidden of ['Dev\u200b', 'De\u200cv', 'D\u200dev', '\ufeffDev', 'Dev\u00ad', '\u2060Dev']) { + assert.equal(normalize(hidden), 'dev', JSON.stringify([...hidden].map(c => c.codePointAt(0)))) + } + assert.equal(normalize(' Dev\u200b '), 'dev', 'invisible characters go before trim, so the space they hid is trimmed') + // Lowercasing can compose a new sequence (U+0130), so the NFC pass runs once more after it. + assert.equal(normalize('\u0130lker'), normalize('i\u0307lker')) + assert.equal(normalize(null), '') +}) + +test('#2691 a name of only invisible characters is refused as empty', () => { + assert.equal(nameProblem('\u200b\u200c\u200d\ufeff\u00ad'), 'A Bot needs a name') + assert.equal(nameProblem(' \u200b '), 'A Bot needs a name') + // A visible name with an invisible character is a valid name; the stored name drops the character. + assert.equal(nameProblem('Dev\u200b'), undefined) +}) + +test('#2691 a stored name is found by either canonical spelling or with an invisible character', async (t) => { + const { rt } = setup(t) + await rt.load() + assert.equal(rt.findBot('Caf\u00e9')?.id, 'cafe') + assert.equal(rt.findBot('Cafe\u0301')?.id, 'cafe', 'NFD spelling of the stored NFC name') + assert.equal(rt.findBot('Dev\u200b')?.id, 'dev', 'the stored name with a zero-width space') + assert.deepEqual(rt.findRooms('Cafe\u0301').map(room => room.id), ['room']) +}) + +test('#2691 a name stored before this change still resolves by its NFC spelling', async (t) => { + const seed = TEAM() + seed.bots.cafe.name = 'Cafe\u0301' + seed.rooms.room.name = 'Cafe\u0301' + const { rt, state } = setup(t, seed) + await rt.load() + assert.equal(rt.findBot('Caf\u00e9')?.id, 'cafe') + assert.deepEqual(rt.findRooms('Caf\u00e9').map(room => room.id), ['room']) + assert.equal(state().bots.cafe.name, 'Cafe\u0301', 'the stored name is left exactly as it was') +}) + +test('#2691 the duplicate guard refuses a second Bot and a second room with the same key', async (t) => { + const { rt } = setup(t) + await rt.load() + await assert.rejects(rt.createBot({ name: 'Cafe\u0301', pickModel: true }), /already exists/) + await assert.rejects(rt.createBot({ name: 'Dev\u200b', pickModel: true }), /already exists/) + await assert.rejects(rt.createRoom({ name: 'Cafe\u0301', members: ['chief', 'dev'] }), /already called/) +}) + +test('#2691 a name is stored without the invisible characters it was written with', async (t) => { + const { rt, state } = setup(t) + await rt.load() + const created = await rt.createBot({ name: 'Ne\u200bw', pickModel: true }) + assert.equal(created.name, 'New') + assert.equal(state().bots[created.id].name, 'New') + await rt.updateBot('dev', { name: 'Dev\u200bO\u200bps' }) + assert.equal(state().bots.dev.name, 'DevOps') + const room = await rt.createRoom({ name: 'De\u200bsk', members: ['chief', 'dev'] }) + assert.equal(room.name, 'Desk') + await rt.updateRoom(room.id, { name: 'Desk\u200b 2' }) + assert.equal(state().rooms[room.id].name, 'Desk 2') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0cee01a10..327d92a56 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -7,7 +7,7 @@ owned_paths: # core-client -The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both. +The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both. The bundle also carries the `core/src/shared/` modules the client imports — `mentions.js` (the `@Name` rule, `nameProblem` and `stripInvisible`), `text-boundary.js`, `remote-label.js`, `approval-waiting.js`, `room-share.js` among them — so a change in one of those rebuilds `client.js` too even when no file under `core/src/client/` was edited; the rules themselves are documented on [core-host](core-host.md#key-files). ## Owns diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 0aab9a8b0..7070b22a2 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-character context `clip` cuts through `cut`, and its 3,000-character two-ended `ends` spends the budget in characters at both ends — `charIndex` steps whole code points to find the head's end and the tail's start, so the head is the rendered line's first 2,000 characters and the tail its last 1,000, and the text is flattened with `toWellFormed()` first as `cut` did — so the prompt and the log hold whole characters within the same character budget: a line of 3,500 emoji keeps 3,000 characters where unit arithmetic kept 1,503 ([#2553](https://github.com/ThinkFlowLab/FlyBot/issues/2553)); 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 2,000-unit description and 4,000-unit command (the card stores the command "Allow once" runs rather than a preview of it, and one that still had to be cut ends with `…` inside that bound, so the card says what it abridged and never drops the fact, [#1992](https://github.com/ThinkFlowLab/FlyBot/issues/1992)), 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)). Since #2002 the connector's own error text goes through it too: the `reason` in `core/src/host/connectors.js` — the text an MCP server's failure leaves on the owner's connector card — keeps the longest prefix that stops before a split pair, so it is well formed and a code-point prefix of what the server sent; since #2016 that prefix is 2,000 units rather than 300, and one that had to be cut ends with `…` inside the bound, so the card says what it abridged ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002), [#2016](https://github.com/ThinkFlowLab/FlyBot/issues/2016)). Since #2005 the five bounds the Project panels read go through it as well — the assistant card's 77-unit title in `project-assistant.js`, the board's 200-unit one-line value and a decision title's 200 in `project-board.js`, and `capped`'s name, role, persona, model and color caps plus the dossier's 4,000-unit memory head in `project-team.js`, whose head is bounded where `memoryDigest` reads it (`memory.js`), because a cut at the dossier alone could only turn half a pair into U+FFFD and never drop the character whole — so a dossier row or a board card keeps whole characters ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)). Since [#2321](https://github.com/ThinkFlowLab/FlyBot/issues/2321) a bound also judges in code points, not only counts in them: `fits(text, max)` in `core/src/shared/text-boundary.js` is the one predicate — a character outside the BMP spends one of the budget and not two — and `clip` and `excerpt` judge with it while their cuts keep going through `codePointEnd`/`cut`, so a 300-character text is no longer cut by a 400-character limit on its way to `read_own_chat` and the search rows. Since [#2339](https://github.com/ThinkFlowLab/FlyBot/issues/2339) the two bounds in `core/src/host/` that still judged with `text.length` use it as well — the memory review's 3,000-character two-ended `ends` in `memory.js` and `room-context.js`'s `oneLine` (40/80/24/60) — so a user line or a board line of characters outside the BMP stays whole inside its own limit instead of being cut at half of it; both maxima are unchanged and a board line's cut stays on `cut`, so an ASCII text judges exactly as `length <= max` did. The memory review's own third `clip` is the exported one now, so its cut reads `… (cut)`, and a bound added later that judges with `text.length` fails `core/test/text-bounds.test.js` at its own `file:line`. Both render paths of the memory review flatten before they bound as well: the `fits()` answer of `ends` and the labelled Bot line on its way to `clip` pass through `toWellFormed()` first, so an unpaired surrogate that arrives in a message reaches the review prompt as U+FFFD, whole and in place, instead of as half a pair ([#2556](https://github.com/ThinkFlowLab/FlyBot/issues/2556)). | +| `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-character context `clip` cuts through `cut`, and its 3,000-character two-ended `ends` spends the budget in characters at both ends — `charIndex` steps whole code points to find the head's end and the tail's start, so the head is the rendered line's first 2,000 characters and the tail its last 1,000, and the text is flattened with `toWellFormed()` first as `cut` did — so the prompt and the log hold whole characters within the same character budget: a line of 3,500 emoji keeps 3,000 characters where unit arithmetic kept 1,503 ([#2553](https://github.com/ThinkFlowLab/FlyBot/issues/2553)); 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 2,000-unit description and 4,000-unit command (the card stores the command "Allow once" runs rather than a preview of it, and one that still had to be cut ends with `…` inside that bound, so the card says what it abridged and never drops the fact, [#1992](https://github.com/ThinkFlowLab/FlyBot/issues/1992)), 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)). Since #2002 the connector's own error text goes through it too: the `reason` in `core/src/host/connectors.js` — the text an MCP server's failure leaves on the owner's connector card — keeps the longest prefix that stops before a split pair, so it is well formed and a code-point prefix of what the server sent; since #2016 that prefix is 2,000 units rather than 300, and one that had to be cut ends with `…` inside the bound, so the card says what it abridged ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002), [#2016](https://github.com/ThinkFlowLab/FlyBot/issues/2016)). Since #2005 the five bounds the Project panels read go through it as well — the assistant card's 77-unit title in `project-assistant.js`, the board's 200-unit one-line value and a decision title's 200 in `project-board.js`, and `capped`'s name, role, persona, model and color caps plus the dossier's 4,000-unit memory head in `project-team.js`, whose head is bounded where `memoryDigest` reads it (`memory.js`), because a cut at the dossier alone could only turn half a pair into U+FFFD and never drop the character whole — so a dossier row or a board card keeps whole characters ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)). Since [#2321](https://github.com/ThinkFlowLab/FlyBot/issues/2321) a bound also judges in code points, not only counts in them: `fits(text, max)` in `core/src/shared/text-boundary.js` is the one predicate — a character outside the BMP spends one of the budget and not two — and `clip` and `excerpt` judge with it while their cuts keep going through `codePointEnd`/`cut`, so a 300-character text is no longer cut by a 400-character limit on its way to `read_own_chat` and the search rows. Since [#2339](https://github.com/ThinkFlowLab/FlyBot/issues/2339) the two bounds in `core/src/host/` that still judged with `text.length` use it as well — the memory review's 3,000-character two-ended `ends` in `memory.js` and `room-context.js`'s `oneLine` (40/80/24/60) — so a user line or a board line of characters outside the BMP stays whole inside its own limit instead of being cut at half of it; both maxima are unchanged and a board line's cut stays on `cut`, so an ASCII text judges exactly as `length <= max` did. The memory review's own third `clip` is the exported one now, so its cut reads `… (cut)`, and a bound added later that judges with `text.length` fails `core/test/text-bounds.test.js` at its own `file:line`. Both render paths of the memory review flatten before they bound as well: the `fits()` answer of `ends` and the labelled Bot line on its way to `clip` pass through `toWellFormed()` first, so an unpaired surrogate that arrives in a message reaches the review prompt as U+FFFD, whole and in place, instead of as half a pair ([#2556](https://github.com/ThinkFlowLab/FlyBot/issues/2556)). Since [#2691](https://github.com/ThinkFlowLab/FlyBot/issues/2691) `normalize` is the one identity key for a name: it NFC-normalizes, drops the Unicode format characters (`\p{Cf}` — zero-width space / non-joiner / joiner, the byte-order mark, soft hyphen, the bidi controls) that `trim()` keeps, trims, lowercases and NFC-normalizes again, so either canonical spelling of one visible name is one key; `findBot` and `findRooms` compare with it, the duplicate guards in `roster.js` ask those same finds, and `createBot`, `updateBot` and `cleanRoomName` store the visible form (the format characters stripped before the 40 / `ROOM_NAME_MAX` cut) while `shared/mentions.js` `nameProblem` refuses a name that is only invisible characters. A name already in `state.json` is never rewritten: the key is computed from what is stored, so an old NFD name still resolves. Tests: `core/test/name-identity.test.js`. | | `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/health.js` | `GET /api/health` (#2128, P-392): the one answer this instance gives without a session, and exactly four fields — `ok` (always `true`), `version` (`core/package.json`), `commit` (`git rev-parse HEAD` of the clone `core/` sits in, `null` for a package or path install with no `.git`) and `startedAt` (this process's start, from `process.uptime()`, stable until a restart). It is an **exact** WebServer route, which the web server matches before the `/api` prefix whose admission is the sign-in wall, so no cookie is needed and no other `/api` route changes; outside the Web composition there is no `webServer` and the route does not exist. A method other than `GET` answers 405. Tests: `core/test/health.test.js`. | | `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. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index bb90b9011..d0ca75190 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -7906,17 +7906,28 @@ diff --git a/src/host/steward.js b/src/host/steward.js diff --git a/src/host/text.js b/src/host/text.js --- a/src/host/text.js +++ b/src/host/text.js -@@ -1,6 +1,9 @@ +@@ -1,6 +1,10 @@ // Pure text helpers shared by the host modules: message text, names, model refs, // cutting and estimating text, and the clock format of chat anchors. import { COLORS, EXTRA_COLORS, LEGACY_COLORS, OWN_CHAT_LINE_MAX } from './constants.js' +import { codePointEnd, codePointStart, cut, fits } from '../shared/text-boundary.js' ++import { stripInvisible } from '../shared/mentions.js' + -+export { cut } ++export { cut, stripInvisible } // A color id saved under its legacy name reads as the current one. export const currentColor = color => (typeof color === 'string' && Object.hasOwn(LEGACY_COLORS, color) ? LEGACY_COLORS[color] : color) -@@ -23,11 +23,28 @@ export const unquote = text => String(text ?? '').trim().replace(/^["'“”「 +@@ -17,17 +17,37 @@ export const textOf = content => (Array.isArray(content) ? content : []) + .join('\n') + .trim() + +-export const normalize = value => String(value ?? '').trim().toLowerCase() ++// The identity key for a name (#2691): both canonical spellings (NFC and NFD) of one ++// visible name are one key, and so are the invisible characters `trim()` keeps. The NFC ++// pass runs again after lowercasing because a lower can compose a new sequence (U+0130). ++export const normalize = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC') + + export const unquote = text => String(text ?? '').trim().replace(/^["'“”「『《]+|["'“”」』》]+$/g, '').trim() export const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -8895,6 +8906,25 @@ diff --git a/src/host/usage.js b/src/host/usage.js diff --git a/src/shared/mentions.js b/src/shared/mentions.js --- a/src/shared/mentions.js +++ b/src/shared/mentions.js +@@ -16,9 +16,16 @@ const fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase( + && !NAME_GOES_ON.test(text.slice(at + word.length)) + const overlaps = (spans, start, end) => spans.some(span => start < span.end && span.start < end) + +-// What a Bot may be named: something "@" can call without a second reading. ++// A name can carry characters that cannot be seen: the Unicode format characters (Cf) — ++// zero-width space / non-joiner / joiner, the byte-order mark, soft hyphen, the bidi ++// controls — and `trim()` removes no character that is not White_Space. The identity key ++// (#2691) drops them, and a stored name drops them too, so what is saved is what is shown. ++export const stripInvisible = value => String(value ?? '').replace(/\p{Cf}/gu, '') ++ ++// What a Bot may be named: something "@" can call without a second reading. A name made ++// only of invisible characters reads as no name at all. + export function nameProblem(name) { +- const text = String(name ?? '').trim() ++ const text = stripInvisible(name).trim() + if (text === '') return 'A Bot needs a name' + if (/[@`\r\n]/.test(text)) return 'A Bot name cannot contain @, ` or a line break' + if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name` @@ -63,3 +63,24 @@ export function mentionSpans(text, bots, { references = [] } = {}) { } return spans diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 2262c0a07..a09b0d86e 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -165,7 +165,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js import { join } from 'node:path' import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js' -import { currentColor, isColor, unquote } from './text.js' -+import { currentColor, cut, isColor, unquote } from './text.js' ++import { currentColor, cut, isColor, stripInvisible, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' +import { cleanExternal } from './room-participant-external.js' +import { currentParticipants, implicitRoomOwner, syncMembers } from './room-membership.js' @@ -182,7 +182,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js state.workspaceId = workspace.id await rt.save() return workspace.id -@@ -56,69 +56,103 @@ export function install(rt) { +@@ -56,69 +56,105 @@ export function install(rt) { return `${stem} ${index}` } @@ -196,7 +196,9 @@ diff --git a/src/host/roster.js b/src/host/roster.js + // `pickModel` leaves the model to the user: the chat opens on a model card. So does a + // Bot made when no model is served. + async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, pickModel = false, systemPrompt, fields, remote, external, ownerId: explicitOwnerId, hidden, ephemeral }) { -+ const cleanName = cut(String(botName ?? '').trim(), 40).text ++ // The stored name is the visible one: the invisible characters a name can carry would ++ // make two Bots read the same on screen while the key still tells them apart (#2691). ++ const cleanName = cut(stripInvisible(String(botName ?? '')).trim(), 40).text + // A friend's Bot (remote-chat.js) keeps its own name: it is not a team member, so + // team names never clash with it and creating it starts no turn. + const isRemote = typeof remote?.handle === 'string' @@ -360,7 +362,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined if (patch.name !== undefined) { - const next = String(patch.name).trim().slice(0, 40) -+ const next = cut(String(patch.name).trim(), 40).text ++ const next = cut(stripInvisible(String(patch.name)).trim(), 40).text const clash = rt.findBot(next) if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`) // A name saved before the rule stays usable until it changes. @@ -438,7 +440,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js function cleanRoomName(text, selfId) { - const next = unquote(text).replace(/\s+/g, ' ').slice(0, ROOM_NAME_MAX) -+ const next = cut(unquote(text).replace(/\s+/g, ' '), ROOM_NAME_MAX).text ++ const next = cut(stripInvisible(unquote(text)).replace(/\s+/g, ' '), ROOM_NAME_MAX).text if (next !== '' && rt.findRooms(next).some(room => room.id !== selfId)) throw new Error(`Another group chat is already called "${next}"`) return next } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c03d3e4ac..d8f39366c 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1720,7 +1720,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,227 +1242,143 @@ function maskOf(shape) { +@@ -1242,227 +1242,144 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1767,8 +1767,9 @@ diff --git a/client.js b/client.js +var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; +var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length)); +var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); ++var stripInvisible = (value) => String(value ?? "").replace(/\p{Cf}/gu, ""); +function nameProblem(name) { -+ const text2 = String(name ?? "").trim(); ++ const text2 = stripInvisible(name).trim(); + if (text2 === "") return "A Bot needs a name"; + if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break"; + if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`; diff --git a/patches/ds-bot/files/test/name-identity.test.js b/patches/ds-bot/files/test/name-identity.test.js new file mode 100644 index 000000000..2ad44d891 --- /dev/null +++ b/patches/ds-bot/files/test/name-identity.test.js @@ -0,0 +1,137 @@ +// #2691: one visible name is one identity. The key folds canonically equivalent spellings +// (NFC/NFD) and drops the invisible format characters a name can carry, and the guards that +// admit a Bot or a room use the same key as the lookup. Fake data in a temp home only. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { nameProblem } from '../src/shared/mentions.js' +import { normalize } from '../src/host/text.js' +import { fakeContext } from './fake-ctx.js' + +const TEAM = () => ({ + version: 1, + mainBotIds: ['chief'], + bots: { + chief: { id: 'chief', sessionId: 'chief', name: 'Chief', role: 'Lead', instructions: '', createdAt: 1 }, + cafe: { id: 'cafe', sessionId: 'cafe', name: 'Caf\u00e9', role: '', instructions: '', createdAt: 2 }, + dev: { id: 'dev', sessionId: 'dev', name: 'Dev', role: '', instructions: '', createdAt: 3 }, + ops: { id: 'ops', sessionId: 'ops', name: 'Ops', role: '', instructions: '', createdAt: 4 }, + }, + rooms: { + room: { + id: 'room', name: 'Caf\u00e9', named: true, members: ['chief', 'dev'], admin: 'chief', mode: 'everyone', + seq: 0, log: [], + }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, + revision: 1, +}) + +function setup(t, seed = TEAM()) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-name-identity-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + let sessions = 0 + const agents = new Map() + const ctx = fakeContext() + const agentFor = async (id) => { + if (!agents.has(id)) agents.set(id, { id, session: { id, seq: 1 }, followup: () => {}, ctx: { tools: { restrict: () => () => {} }, effect: execute => execute() } }) + return agents.get(id) + } + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'deepseek-v4-flash' }], + resolveModelInfo: async () => ({}), + }, + workspaceRegistry: { + get: () => undefined, + resolveByPath: async () => undefined, + create: async path => ({ id: 'w1', path }), + archiveSession: async () => {}, + }, + sessionController: { + create: async () => ({ sessionId: `new${++sessions}` }), + resolveAgent: async id => ({ agent: await agentFor(id) }), + selectModel: async () => {}, + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home, github: false, diagnosticsFile: false }) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + return { ctx, rt, state, home } +} + +test('#2691 the name key folds canonical spellings and drops invisible format characters', () => { + const nfc = 'Caf\u00e9' + const nfd = 'Cafe\u0301' + assert.notEqual(nfc, nfd) + assert.equal(normalize(nfd), normalize(nfc)) + // The invisible spellings a name can carry: zero-width space / non-joiner / joiner, + // byte-order mark, soft hyphen, word joiner. + for (const hidden of ['Dev\u200b', 'De\u200cv', 'D\u200dev', '\ufeffDev', 'Dev\u00ad', '\u2060Dev']) { + assert.equal(normalize(hidden), 'dev', JSON.stringify([...hidden].map(c => c.codePointAt(0)))) + } + assert.equal(normalize(' Dev\u200b '), 'dev', 'invisible characters go before trim, so the space they hid is trimmed') + // Lowercasing can compose a new sequence (U+0130), so the NFC pass runs once more after it. + assert.equal(normalize('\u0130lker'), normalize('i\u0307lker')) + assert.equal(normalize(null), '') +}) + +test('#2691 a name of only invisible characters is refused as empty', () => { + assert.equal(nameProblem('\u200b\u200c\u200d\ufeff\u00ad'), 'A Bot needs a name') + assert.equal(nameProblem(' \u200b '), 'A Bot needs a name') + // A visible name with an invisible character is a valid name; the stored name drops the character. + assert.equal(nameProblem('Dev\u200b'), undefined) +}) + +test('#2691 a stored name is found by either canonical spelling or with an invisible character', async (t) => { + const { rt } = setup(t) + await rt.load() + assert.equal(rt.findBot('Caf\u00e9')?.id, 'cafe') + assert.equal(rt.findBot('Cafe\u0301')?.id, 'cafe', 'NFD spelling of the stored NFC name') + assert.equal(rt.findBot('Dev\u200b')?.id, 'dev', 'the stored name with a zero-width space') + assert.deepEqual(rt.findRooms('Cafe\u0301').map(room => room.id), ['room']) +}) + +test('#2691 a name stored before this change still resolves by its NFC spelling', async (t) => { + const seed = TEAM() + seed.bots.cafe.name = 'Cafe\u0301' + seed.rooms.room.name = 'Cafe\u0301' + const { rt, state } = setup(t, seed) + await rt.load() + assert.equal(rt.findBot('Caf\u00e9')?.id, 'cafe') + assert.deepEqual(rt.findRooms('Caf\u00e9').map(room => room.id), ['room']) + assert.equal(state().bots.cafe.name, 'Cafe\u0301', 'the stored name is left exactly as it was') +}) + +test('#2691 the duplicate guard refuses a second Bot and a second room with the same key', async (t) => { + const { rt } = setup(t) + await rt.load() + await assert.rejects(rt.createBot({ name: 'Cafe\u0301', pickModel: true }), /already exists/) + await assert.rejects(rt.createBot({ name: 'Dev\u200b', pickModel: true }), /already exists/) + await assert.rejects(rt.createRoom({ name: 'Cafe\u0301', members: ['chief', 'dev'] }), /already called/) +}) + +test('#2691 a name is stored without the invisible characters it was written with', async (t) => { + const { rt, state } = setup(t) + await rt.load() + const created = await rt.createBot({ name: 'Ne\u200bw', pickModel: true }) + assert.equal(created.name, 'New') + assert.equal(state().bots[created.id].name, 'New') + await rt.updateBot('dev', { name: 'Dev\u200bO\u200bps' }) + assert.equal(state().bots.dev.name, 'DevOps') + const room = await rt.createRoom({ name: 'De\u200bsk', members: ['chief', 'dev'] }) + assert.equal(room.name, 'Desk') + await rt.updateRoom(room.id, { name: 'Desk\u200b 2' }) + assert.equal(state().rooms[room.id].name, 'Desk 2') +})