Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions core/src/host/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions core/test/roster.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
44 changes: 36 additions & 8 deletions patches/ds-bot/0002-flybot-api-host.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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
Expand Down
49 changes: 41 additions & 8 deletions patches/ds-bot/0009-identity-schema.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
}
}

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -167,20 +178,42 @@ 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))
- const text = sent.find(entry => entry.sessionId === bot.id).message.content[0].text
- 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
Expand Down
Loading