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
1 change: 1 addition & 0 deletions changelog.d/1930-host-line-bounds.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The host's own bounded lines no longer end in half of an emoji ([#1930](https://github.com/ThinkFlowLab/FlyBot/issues/1930)): the busy acknowledgment's 600-unit task text and 1,500-unit message text, `cleanName`'s 60-unit peer name (the one that lands in `fenceUntrusted`'s `from="…"` attribute), the `list_bots` group line's 100-unit notice and 120-unit job line, the group answer echo at 300 and a rework card's note at 200 now cut on a code point boundary, so a value whose cap falls inside a surrogate pair keeps the whole character instead of U+FFFD. Every cap is unchanged.
5 changes: 3 additions & 2 deletions core/src/host/busy-ack.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// otherwise the chat's own model is used.
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import { splitRef } from './text.js'
import { cut } from '../shared/text-boundary.js'

export const COALESCE_MS = 3_000
export const ACK_TIMEOUT_MS = 3_000
Expand Down Expand Up @@ -56,11 +57,11 @@ export const ACK_SYSTEM = [
/** The user prompt for the ack call: the new message and what the turn is doing. */
export function ackPrompt({ message, tool, working }) {
const doing = typeof tool === 'string' && tool.trim() !== '' && tool !== 'ask_user' ? `running the tool \`${tool}\`` : 'working on the previous request'
const task = String(working ?? '').trim().slice(0, 600)
const task = cut(String(working ?? '').trim(), 600).text
return [
`Currently: ${doing}.`,
...(task === '' ? [] : [`The request being worked on: ${task}`]),
`New message: ${String(message ?? '').trim().slice(0, 1_500)}`,
`New message: ${cut(String(message ?? '').trim(), 1_500).text}`,
].join('\n')
}

Expand Down
3 changes: 2 additions & 1 deletion core/src/host/delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SOURCE_KIND } from './constants.js'
import { OWNER, OTHER, SCHEDULED } from './turn-origin.js'
import { cut } from '../shared/text-boundary.js'

// Text from outside the team, marked so the Bot treats it as data. Marker fragments are
// removed until none is left, so a nested "</untrusted-</untrusted-message>message>"
Expand Down Expand Up @@ -35,7 +36,7 @@ export const unwrapUntrusted = (text) => {
// A name from outside the team, safe on one line of trusted text: no control characters,
// newlines, brackets or quotes, at most 60 characters.
export const cleanName = (name, fallback = 'a remote Bot') => {
const text = String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60)
const text = cut(String(name ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim(), 60).text
return text || fallback
}
export const fenceUntrusted = (name, text) => `<untrusted-message from="${cleanName(name)}">\n${stripUntrusted(text ?? '')}\n</untrusted-message>`
Expand Down
8 changes: 4 additions & 4 deletions core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function install(rt) {
const parts = [
`members: ${[...members, ...remotes].join(', ')}`,
room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn',
room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '',
room.notice ? `notice: ${cut(room.notice.split('\n')[0], 100).text}` : '',
room.workDir ? `members work in ${room.workDir} here` : '',
].filter(Boolean)
return `- "${room.name}" — ${parts.join('; ')}`
Expand Down Expand Up @@ -174,7 +174,7 @@ export function install(rt) {
const lines = Object.values(state.bots).filter(listed).map(bot => {
const model = bot.appliedModel ?? bot.model
const tags = [bot.role, model ? `model ${model}` : '', heldSends[bot.id] ? `${heldSends[bot.id]} to approve` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
const job = !foreign(bot) && bot.instructions ? ` — ${cut(bot.instructions.split('\n')[0], 120).text}` : ''
return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
})
if (lines.length === 0) return 'No Bots yet.'
Expand Down Expand Up @@ -740,7 +740,7 @@ export function install(rt) {
await rt.save()
const who = cleanName(actor?.displayName, 'You')
const given = JSON.parse(reply.text).answers[0]
const values = [...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' ').slice(0, 300)
const values = cut([...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' '), 300).text
void rt.postToRoom?.(room, { id: 'face-notice', name: 'Room' }, `${who} answered ${bot.name}: ${question.question} → ${values}`, { quiet: true, notice: true })
.catch(error => rt.warn('ds-bot: the answer notice in %s was not shown: %s', room.name, error))
return { ok: true }
Expand Down Expand Up @@ -1028,7 +1028,7 @@ ${task.writeBack.body}`)
}
if (args?.rework !== undefined) {
const child = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId })
return `Rework ${child.id} on ${child.reworkOf} for the ${child.handoff?.to ?? 'producer'} pool: ${String(child.note ?? '').slice(0, 200)}\nThe producer takes it from its own pool. Set that card's column to in-progress when you pick it up. Do not post a status in the group.`
return `Rework ${child.id} on ${child.reworkOf} for the ${child.handoff?.to ?? 'producer'} pool: ${cut(String(child.note ?? ''), 200).text}\nThe producer takes it from its own pool. Set that card's column to in-progress when you pick it up. Do not post a status in the group.`
}
if (args?.release !== undefined) {
const returned = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId })
Expand Down
17 changes: 17 additions & 0 deletions core/test/busy-ack.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,20 @@ test('the own-chat ack is unchanged beside the room path (#1646)', async (t) =>
await settleAck(host.rt, 'gb')
assert.deepEqual(Object.keys(host.rt.busyAcksView()).sort(), ['b', 'gb'])
})

test('the prompt bounds keep whole characters at 600 and 1,500 (#P282b)', () => {
const sourceTask = 'w'.repeat(599) + '😀tail'
const sourceMessage = 'a'.repeat(1_499) + '😀tail'
const prompt = ackPrompt({ message: sourceMessage, tool: 'bash', working: sourceTask })
assert.ok(prompt.isWellFormed(), `the prompt is not well formed: ${JSON.stringify(prompt.slice(-8))}`)
const lines = prompt.split('\n')
const keptTask = lines.find(line => line.startsWith('The request being worked on: ')).slice('The request being worked on: '.length)
const keptMessage = lines[lines.length - 1].slice('New message: '.length)
assert.equal(keptTask, 'w'.repeat(599))
assert.equal(keptMessage, 'a'.repeat(1_499))
assert.ok(keptTask.length <= 600)
assert.ok(keptMessage.length <= 1_500)
const bytesOf = value => Buffer.from(value, 'utf8')
assert.ok(bytesOf(sourceTask).subarray(0, bytesOf(keptTask).length).equals(bytesOf(keptTask)))
assert.ok(bytesOf(sourceMessage).subarray(0, bytesOf(keptMessage).length).equals(bytesOf(keptMessage)))
})
11 changes: 11 additions & 0 deletions core/test/fence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,14 @@ test('#130: previews unwrap the stored untrusted-message wrap', () => {
assert.equal(unwrapUntrusted(fenceUntrusted('User', 'hello from the owner')), 'hello from the owner')
assert.equal(unwrapUntrusted('plain text'), 'plain text')
})

test('a peer name at the 60 bound keeps the whole character (#P282b)', () => {
const source = 'a'.repeat(59) + '😀tail'
const name = cleanName(source)
assert.equal(name, 'a'.repeat(59))
assert.ok(name.isWellFormed(), `the name is not well formed: ${JSON.stringify(name.slice(-4))}`)
assert.ok(name.length <= 60)
const fenced = fenceUntrusted(source, 'hello')
assert.ok(fenced.isWellFormed(), 'the fence header is not well formed')
assert.ok(fenced.startsWith(`<untrusted-message from="${'a'.repeat(59)}">`))
})
31 changes: 31 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,37 @@ test('list_bots keeps whole characters in a peer display name and owner name (#1
assert.ok(Buffer.from(keptOwner, 'utf8').equals(Buffer.from(ownerName, 'utf8').subarray(0, Buffer.byteLength(keptOwner))), 'the owner name is a prefix of what the peer published, byte for byte')
})

// #P282b: the group line's notice is bounded at 100, and a cut inside an emoji left half a pair.
test('list_bots keeps whole characters in a group notice at the 100 bound (#P282b)', async (t) => {
const notice = 'a'.repeat(99) + '😀tail'
const { tool, stop } = await start({
rooms: { g: { id: 'g', name: 'Group', members: ['a'], admin: 'a', mode: 'admin', notice, createdAt: 1, createdBy: null } },
})
t.after(stop)
const listed = await tool('list_bots', {}, 'a')
assert.equal(listed.isWellFormed(), true, `the listing is not well formed: ${JSON.stringify(listed.slice(0, 80))}`)
const line = listed.split('\n').find(l => l.includes('notice: '))
assert.ok(line, `the notice is listed: ${JSON.stringify(listed)}`)
const kept = line.split('notice: ')[1].split('; ')[0]
assert.equal(kept, 'a'.repeat(99), `the notice keeps the 100 bound: ${JSON.stringify(kept.slice(-4))}`)
assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(notice, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the notice is a prefix, byte for byte')
})

// #P282b: a Bot's job line is bounded at 120, and a cut inside an emoji left half a pair.
test('list_bots keeps whole characters in a Bot job line at the 120 bound (#P282b)', async (t) => {
const { tool, stop } = await start()
t.after(stop)
const instructions = 'a'.repeat(119) + '😀tail'
assert.match(await tool('update_bot', { bot: 'b', instructions }, 'a'), /Updated Writer/)
const listed = await tool('list_bots', {}, 'a')
assert.equal(listed.isWellFormed(), true, `the listing is not well formed: ${JSON.stringify(listed.slice(0, 80))}`)
const line = listed.split('\n').find(l => l.includes(' — '))
assert.ok(line, `the job line is listed: ${JSON.stringify(listed)}`)
const kept = line.split(' — ')[1]
assert.equal(kept, 'a'.repeat(119), `the job line keeps the 120 bound: ${JSON.stringify(kept.slice(-4))}`)
assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(instructions, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the job line is a prefix, byte for byte')
})

test('cleanRemote sanitizes peer name, homeName and owner at the record boundary (#75)', async (t) => {
const { ctx, fb, tool, received, stop, grantTo } = await start()
t.after(stop)
Expand Down
21 changes: 21 additions & 0 deletions core/test/group-work.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,27 @@ test('the answer given in the group reaches the asking Bot\'s waiting work and i
assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } })).error.code, 'bot/stale')
})

// #P282b: the answer echo is bounded at 300, and a cut inside an emoji left half a pair in the
// room line every member reads.
test('the answer echo in a group keeps whole characters at the 300 bound (#P282b)', async (t) => {
const host = await start(t)
await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb')
host.begin('c')
await askBranch(host)
host.finish('c', 'Need to know the branch first.')
await settle(() => host.agentOf('gb').calls.length === 1)
const asked = (await host.call('state', {})).value.questions.r1
const custom = 'a'.repeat(299) + '😀tail'
assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: [], custom } })).ok, true)
await settle(() => host.agentOf('r1').calls.some(call => call.source?.notice === true))
const notice = host.agentOf('r1').calls.find(call => call.source?.notice === true)
const said = text(notice)
assert.equal(said.isWellFormed(), true, `the answer echo is not well formed: ${JSON.stringify(said.slice(-8))}`)
const values = said.split(' → ').at(-1)
assert.equal(values, 'a'.repeat(299), `the answer echo keeps the 300 bound: ${JSON.stringify(values.slice(-4))}`)
assert.ok(Buffer.from(values, 'utf8').equals(Buffer.from(custom, 'utf8').subarray(0, Buffer.byteLength(values))), 'the answer echo is a prefix of the answer, byte for byte')
})

test('a question in the Bot\'s own chat stays there, and a request answered to the user keeps its card there (#550)', async (t) => {
const host = await start(t)
host.emit('c', 'turn/start', { turn: 1 })
Expand Down
18 changes: 18 additions & 0 deletions core/test/task-handoff-route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,24 @@ test('a role returns the card to its producer as a rework card the producer can
assert.equal(app.rt.state.tasks[childId].claimedBy, 'd1')
})

// #P282b: the rework line carries the note at 200, and a cut inside an emoji left half a pair.
test('the rework line keeps whole characters in the note at the 200 bound (#P282b)', async (t) => {
const app = await start(t)
const id = app.idOf(await app.track('a', { tasks: [{ title: 'Build the route', handoff: { from: 'Lead', to: 'Dev' } }] }))
await app.drive()
await app.track('d1', { claim: true, id })
await app.track('d1', { tasks: [{ id, handoff: { from: 'Dev', to: 'Reviewer', reason: 'diff ready' } }] })
await app.drive()
await app.track('r1', { claim: true, id })
await app.track('r1', { tasks: [{ id, column: 'in-progress' }] })
const reason = 'a'.repeat(199) + '😀tail'
const returned = await app.track('r1', { rework: { task: id, reason } })
assert.equal(returned.isWellFormed(), true, `the rework line is not well formed: ${JSON.stringify(returned.slice(-8))}`)
const shown = returned.split(' pool: ')[1].split('\n')[0]
assert.equal(shown, 'a'.repeat(199), `the rework line keeps the 200 bound: ${JSON.stringify(shown.slice(-4))}`)
assert.ok(Buffer.from(shown, 'utf8').equals(Buffer.from(reason, 'utf8').subarray(0, Buffer.byteLength(shown))), 'the note is a prefix of the reason, byte for byte')
})

test('a card waiting for its rework is not re-offered when its lease expires (#1804)', async (t) => {
const app = await start(t)
const id = app.idOf(await app.track('a', { tasks: [{ title: 'Build the route', handoff: { from: 'Lead', to: 'Dev' } }] }))
Expand Down
Loading
Loading