From 1987f486bb82779da6f85f37503256c6314d7fab Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:05:43 +0800 Subject: [PATCH] roster: cut a Bot's name and role on a code point boundary A Bot's name is bounded at 40 units and its role at 24, both with `slice`, so a name or role whose bound falls between the halves of a surrogate pair is stored as half of one: the sidebar row, the session title and the store's JSON then carry U+FFFD where the user typed a character. Both bounds now go through the host `cut`, the same helper the other bounded fields use since #1916, so the prefix stops before a split pair. Docs-not-needed: no subsystem page states the roster's name or role bound, and both keep their 40/24 limit; only the unit the prefix stops on changes. --- core/src/host/roster.js | 10 ++--- core/test/roster.test.js | 32 +++++++++++++++ patches/ds-bot/0002-flybot-api-host.patch | 44 ++++++++++++++++---- patches/ds-bot/0009-identity-schema.patch | 49 +++++++++++++++++++---- 4 files changed, 114 insertions(+), 21 deletions(-) diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 89d9fdceb..b1aae17f5 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, isColor, unquote } from './text.js' +import { currentColor, cut, isColor, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' import { cleanExternal } from './room-participant-external.js' import { ownEntry } from './store.js' @@ -61,7 +61,7 @@ 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 = String(botName ?? '').trim().slice(0, 40) + const cleanName = cut(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' @@ -106,7 +106,7 @@ export function install(rt) { state.bots[id] = { id, name: cleanName, - role: String(role).trim().slice(0, 24), + role: cut(String(role).trim(), 24).text, // A color is only stored when the user picks one; otherwise the id picks it. ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}), ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}), @@ -189,7 +189,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 = String(patch.name).trim().slice(0, 40) + const next = cut(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. @@ -200,7 +200,7 @@ export function install(rt) { await ctx.sessionTitle.rename(agent.session, next) await followMemberNames(Object.values(state.rooms).filter(room => room.members.includes(id))) } - if (patch.role !== undefined) bot.role = String(patch.role).trim().slice(0, 24) + if (patch.role !== undefined) bot.role = cut(String(patch.role).trim(), 24).text if (patch.instructions !== undefined) bot.instructions = String(patch.instructions).trim() let modelChanged = false if (patch.color !== undefined) { diff --git a/core/test/roster.test.js b/core/test/roster.test.js index 1df44b24f..8eaf39509 100644 --- a/core/test/roster.test.js +++ b/core/test/roster.test.js @@ -144,6 +144,15 @@ test('a Bot made by the Main Bot starts on the Main Bot\'s model and level befor assert.equal(typed.model, 'deepseek/deepseek-v4-flash') }) +// P-275: a Bot's name is bounded at 40 and its role at 24, and both bounds cut UTF-16 units. +// A bound that lands inside an emoji stores half of a surrogate pair, so the sidebar, the +// session title and the store's JSON carry U+FFFD instead of the character. +const bounded = (actual, source, max) => { + assert.ok(actual.length <= max, `${actual.length} code units, over ${max}`) + assert.equal(actual.isWellFormed(), true, `not well formed: ${actual}`) + assert.equal(Buffer.from(actual, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(actual))), true, 'the value is a UTF-8 byte prefix of what was typed') +} + test('a new name must be one "@" can call; a name saved before the rule still saves', async (t) => { const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) @@ -154,6 +163,29 @@ test('a new name must be one "@" can call; a name saved before the rule still sa assert.equal((await call('update-bot', { id: writer.id, name: '新 Writer' })).ok, true) }) +test('a name and a role stop their bound before a split surrogate pair', async (t) => { + const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] }) + t.after(stop) + await call('state', {}) + const name = `${'a'.repeat(39)}\u{1F600}tail` + const role = `${'b'.repeat(23)}\u{1F600}tail` + const writer = (await call('create-bot', { name, role })).value + assert.equal(writer.name, 'a'.repeat(39), 'the bound retracts one unit instead of splitting the pair') + bounded(writer.name, name, 40) + assert.equal(writer.role, 'b'.repeat(23)) + bounded(writer.role, role, 24) + // A rename and a role edit take the same two bounds. + const renamed = `${'c'.repeat(39)}\u{1F600}tail` + const reRole = `${'d'.repeat(23)}\u{1F600}tail` + assert.equal((await call('update-bot', { id: writer.id, name: renamed })).ok, true) + assert.equal((await call('update-bot', { id: writer.id, role: reRole })).ok, true) + const saved = (await call('state', {})).value.bots.find(entry => entry.id === writer.id) + assert.equal(saved.name, 'c'.repeat(39)) + bounded(saved.name, renamed, 40) + assert.equal(saved.role, 'd'.repeat(23)) + bounded(saved.role, reRole, 24) +}) + // Creation is silent (#645): no path that makes a Bot opens a turn, and only a brief from a // Bot that creates one (`create_bot`) becomes instructions. A line typed in the new-Bot card // is the user's first request, sent as a turn by the client, never a standing rule. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index fd32e5d5f..f83d7ce56 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -154,9 +154,12 @@ diff --git a/src/host/home-lock.js b/src/host/home-lock.js diff --git a/src/host/roster.js b/src/host/roster.js --- a/src/host/roster.js +++ b/src/host/roster.js -@@ -5,6 +5,8 @@ import { join } from 'node:path' +@@ -3,8 +3,10 @@ + 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, isColor, unquote } from './text.js' +-import { currentColor, isColor, unquote } from './text.js' ++import { currentColor, cut, isColor, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' +import { cleanExternal } from './room-participant-external.js' +import { ownEntry } from './store.js' @@ -172,20 +175,21 @@ diff --git a/src/host/roster.js b/src/host/roster.js state.workspaceId = workspace.id await rt.save() return workspace.id -@@ -56,18 +56,49 @@ export function install(rt) { +@@ -56,69 +56,103 @@ export function install(rt) { return `${stem} ${index}` } - // `pickModel` leaves the model to the user: the chat opens on a model card, and the - // greeting waits until a model is chosen. 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, copyOf, pickModel = false }) { -+ // `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 = String(botName ?? '').trim().slice(0, 40) +- const cleanName = String(botName ?? '').trim().slice(0, 40) - const problem = nameProblem(cleanName) - if (problem !== undefined) throw new Error(problem) - if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`) ++ // `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 + // 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' @@ -230,7 +234,13 @@ diff --git a/src/host/roster.js b/src/host/roster.js const id = await createSession(cleanName, { dir }) try { state.bots[id] = { -@@ -80,45 +80,48 @@ export function install(rt) { + id, + name: cleanName, +- role: String(role).trim().slice(0, 24), ++ role: cut(String(role).trim(), 24).text, + // A color is only stored when the user picks one; otherwise the id picks it. + ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}), + ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}), ...(modelRef ? { model: modelRef } : {}), ...(effortId ? { effort: effortId } : {}), ...(dir ? { workDir: dir } : {}), @@ -338,6 +348,24 @@ diff --git a/src/host/roster.js b/src/host/roster.js }) } +@@ -166,7 +166,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 = String(patch.name).trim().slice(0, 40) ++ const next = cut(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. +@@ -177,7 +177,7 @@ export function install(rt) { + await ctx.sessionTitle.rename(agent.session, next) + await followMemberNames(Object.values(state.rooms).filter(room => room.members.includes(id))) + } +- if (patch.role !== undefined) bot.role = String(patch.role).trim().slice(0, 24) ++ if (patch.role !== undefined) bot.role = cut(String(patch.role).trim(), 24).text + if (patch.instructions !== undefined) bot.instructions = String(patch.instructions).trim() + let modelChanged = false + if (patch.color !== undefined) { @@ -189,6 +189,8 @@ export function install(rt) { if (avatar) bot.avatar = avatar else delete bot.avatar diff --git a/patches/ds-bot/0009-identity-schema.patch b/patches/ds-bot/0009-identity-schema.patch index e78069825..9610adecc 100644 --- a/patches/ds-bot/0009-identity-schema.patch +++ b/patches/ds-bot/0009-identity-schema.patch @@ -42,7 +42,7 @@ diff --git a/test/roster.test.js b/test/roster.test.js // busy machine. const until = async (check) => { const end = Date.now() + 2000 -@@ -77,46 +77,68 @@ const until = async (check) => { +@@ -77,48 +77,79 @@ const until = async (check) => { } } @@ -123,8 +123,19 @@ diff --git a/test/roster.test.js b/test/roster.test.js + assert.equal(typed.model, 'deepseek/deepseek-v4-flash') }) ++// P-275: a Bot's name is bounded at 40 and its role at 24, and both bounds cut UTF-16 units. ++// A bound that lands inside an emoji stores half of a surrogate pair, so the sidebar, the ++// session title and the store's JSON carry U+FFFD instead of the character. ++const bounded = (actual, source, max) => { ++ assert.ok(actual.length <= max, `${actual.length} code units, over ${max}`) ++ assert.equal(actual.isWellFormed(), true, `not well formed: ${actual}`) ++ assert.equal(Buffer.from(actual, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(actual))), true, 'the value is a UTF-8 byte prefix of what was typed') ++} ++ test('a new name must be one "@" can call; a name saved before the rule still saves', async (t) => { -@@ -129,85 +129,36 @@ test('a new name must be one "@" can call; a name saved before the rule still sa + const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] }) + t.after(stop) +@@ -129,85 +129,59 @@ test('a new name must be one "@" can call; a name saved before the rule still sa assert.equal((await call('update-bot', { id: writer.id, name: '新 Writer' })).ok, true) }) @@ -167,13 +178,10 @@ diff --git a/test/roster.test.js b/test/roster.test.js - -test('a first message from the user is helped with, not replaced by an opening question', async (t) => { - const { call, stop, sent } = start(undefined, { models: ['deepseek-v4-flash'] }) -+// Creation is silent (#645): no path that makes a Bot opens a turn, and only a brief from a -+// Bot that creates one (`create_bot`) becomes instructions. A line typed in the new-Bot card -+// is the user's first request, sent as a turn by the client, never a standing rule. -+test('creating a Bot sends it nothing, and only a parent Bot\'s brief becomes its instructions', async (t) => { -+ const { call, stop, sent, ctx } = start(undefined, { models: ['deepseek-v4-flash'] }) ++test('a name and a role stop their bound before a split surrogate pair', async (t) => { ++ const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] }) t.after(stop) -- await call('state', {}) + await call('state', {}) - const brief = 'Review the team notes and list the next tasks.' - const bot = (await call('create-bot', { name: 'Writer', brief })).value - await until(() => sent.some(entry => entry.sessionId === bot.id)) @@ -181,6 +189,31 @@ diff --git a/test/roster.test.js b/test/roster.test.js - assert.ok(text.includes(brief)) - assert.match(text, /then help with it\./) - assert.doesNotMatch(text, /\b(?:ask_user(?:_question)?|allowCustom)\b/) ++ const name = `${'a'.repeat(39)}\u{1F600}tail` ++ const role = `${'b'.repeat(23)}\u{1F600}tail` ++ const writer = (await call('create-bot', { name, role })).value ++ assert.equal(writer.name, 'a'.repeat(39), 'the bound retracts one unit instead of splitting the pair') ++ bounded(writer.name, name, 40) ++ assert.equal(writer.role, 'b'.repeat(23)) ++ bounded(writer.role, role, 24) ++ // A rename and a role edit take the same two bounds. ++ const renamed = `${'c'.repeat(39)}\u{1F600}tail` ++ const reRole = `${'d'.repeat(23)}\u{1F600}tail` ++ assert.equal((await call('update-bot', { id: writer.id, name: renamed })).ok, true) ++ assert.equal((await call('update-bot', { id: writer.id, role: reRole })).ok, true) ++ const saved = (await call('state', {})).value.bots.find(entry => entry.id === writer.id) ++ assert.equal(saved.name, 'c'.repeat(39)) ++ bounded(saved.name, renamed, 40) ++ assert.equal(saved.role, 'd'.repeat(23)) ++ bounded(saved.role, reRole, 24) ++}) ++ ++// Creation is silent (#645): no path that makes a Bot opens a turn, and only a brief from a ++// Bot that creates one (`create_bot`) becomes instructions. A line typed in the new-Bot card ++// is the user's first request, sent as a turn by the client, never a standing rule. ++test('creating a Bot sends it nothing, and only a parent Bot\'s brief becomes its instructions', async (t) => { ++ const { call, stop, sent, ctx } = start(undefined, { models: ['deepseek-v4-flash'] }) ++ t.after(stop) + const chief = (await call('state', {})).value.mainBotId + const brief = '帮我写周报' + const writer = (await call('create-bot', { name: 'Writer', instructions: 'Write.', brief })).value