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/2005-project-panel-code-points.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Five Project-panel bounds now cut on a code point boundary, so a dossier or a board card no longer carries half an emoji: the assistant digest's 77-unit card title, the board's 200-unit one-line reason and decision title, and the dossier's capped name, role, persona, model and color, plus its 4,000-unit memory head — that last one is bounded where the memory is read, so the character is dropped whole instead of reaching the panel as U+FFFD ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)).
4 changes: 3 additions & 1 deletion core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,9 @@ export function install(rt) {
const head = Number.isFinite(limits.head) ? limits.head : 4000
const topics = Number.isFinite(limits.topics) ? limits.topics : 64
return {
head: readText(join(dirOf(scope), MAIN)).slice(0, head),
// The head is the dossier's text, so its bound lands on a code point (#2005): a `slice`
// here handed the panel the first half of an emoji, and no later bound can put it back.
head: cut(readText(join(dirOf(scope), MAIN)), head).text,
topics: topicNames(scope).slice(0, topics),
}
}
Expand Down
6 changes: 4 additions & 2 deletions core/src/host/project-assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { defineTool } from './tool-schema.js'
import { MAX_ENTRY } from './constants.js'
import { listProjectState } from './project-state.js'
import { codePoints } from './text.js'
import { codePoints, cut } from './text.js'
import { output } from './tools.js'
import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js'

Expand Down Expand Up @@ -129,7 +129,9 @@ const askOf = (row) => {
const title = text(row.title).replace(/\s+/g, ' ')
const where = text(row.column) === '' ? 'open' : `still ${text(row.column)}`
if (title === '') return `${waitOf(row)} — ${where}`
const short = title.length > 80 ? `${title.slice(0, 77)}…` : title
// One line still, and the 77-unit bound lands on a code point (#2005): a cut inside a pair
// would send the digest half an emoji.
const short = title.length > 80 ? `${cut(title, 77).text}…` : title
return `${waitOf(row)} — ${short} (${where})`
}

Expand Down
8 changes: 6 additions & 2 deletions core/src/host/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { open, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { columnOf } from '../shared/task-board.js'
import { DECISIONS_DIR, listProjectState, sha256 } from './project-state.js'
import { cut } from './text.js'
import { advanceRefusal } from './meetings.js'
import { readMetrics } from './project-metrics.js'

Expand Down Expand Up @@ -102,7 +103,9 @@ const text = value => (typeof value === 'string' ? value.trim() : '')
// The column a finished card reads (#1855): the archive key, never a row.
const DONE_COLUMN = 'done'
const ONE_LINE = 200
const oneLine = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, ONE_LINE) : '')
// #2005: the bound lands on a code point. A `slice` here put the first half of an emoji into the
// queue reason the panel draws, which reads as a replacement character.
const oneLine = value => (typeof value === 'string' ? cut(value.replace(/\s+/g, ' ').trim(), ONE_LINE).text : '')
const stampOf = value => (Number.isFinite(value) ? value : null)

// Waiting: an open card a role may claim and no member has. `waitedMs` is measured from the card's
Expand Down Expand Up @@ -160,7 +163,8 @@ async function titleOf(dir, path) {
for (const raw of buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/)) {
const line = raw.trim()
if (line === '') continue
return line.replace(/^#+\s*/, '').slice(0, TITLE_MAX)
// The same code-point rule as `oneLine` (#2005): a decision heading may end in an emoji.
return cut(line.replace(/^#+\s*/, ''), TITLE_MAX).text
}
return ''
} catch {
Expand Down
6 changes: 4 additions & 2 deletions core/src/host/project-team.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import { cut } from './text.js'
export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 }

const text = value => (typeof value === 'string' ? value.trim() : '')
const capped = (value, limit) => text(value).slice(0, limit)
// #2005: every capped field lands on a code point — a `slice` bound could end on the first half
// of an emoji, which the panel draws as a replacement character.
const capped = (value, limit) => cut(text(value), limit).text

export function install(rt) {
// The attached names that are skills. A pack is expanded to its skill children; a capability the
Expand Down Expand Up @@ -64,7 +66,7 @@ export function install(rt) {
color: capped(bot.color, DOSSIER_LIMITS.color),
createdAt: Number.isFinite(bot.createdAt) ? bot.createdAt : null,
memory: {
head: (typeof digest.head === 'string' ? digest.head : '').slice(0, DOSSIER_LIMITS.memoryHead),
head: cut(typeof digest.head === 'string' ? digest.head : '', DOSSIER_LIMITS.memoryHead).text,
topics: (Array.isArray(digest.topics) ? digest.topics : []).map(name => text(name)).filter(name => name !== '').slice(0, DOSSIER_LIMITS.topics),
},
skills: skillNames(bot),
Expand Down
13 changes: 13 additions & 0 deletions core/test/project-assistant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,19 @@ test('#P-195: only the cards that wait on the reader reach the personal block',
assert.match(shared, /t-working — Worker/, 'the shared section still lists it')
})

// #2005: the personal block shortens a long card title to 77 units to keep the line readable, and
// that bound must land on a code point. A title whose 78th UTF-16 unit is the first half of an
// emoji used to carry that half into the digest text, which a reader then sees as a replacement
// character.
test('#2005: a long card title in the digest is shortened at a code point, never inside an emoji', () => {
const row = { id: 't-cut', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 1, title: `${'a'.repeat(76)}😀${'b'.repeat(10)}`, status: 'blocked' }
const built = buildDigest({ project: { id: 'p', name: 'Cut' }, rows: [row], owner: 'owner-1', actionable: actionableFor([row], 'owner-1'), at: AT })
const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:"))
const line = personal.split('\n').find(entry => entry.startsWith('- t-cut'))
assert.equal(line.isWellFormed(), true, 'the digest line is well formed, never a lone high surrogate')
assert.equal(line, `- t-cut — needs your call — ${'a'.repeat(76)}… (still in-progress)`, 'the 78th unit was the first half of the pair, so the pair is dropped whole')
})

test('opting out stops generation and pauses the 09:00 task, and re-enabling restores both', async () => {
const { rt, service, delivered, stop } = await start()
try {
Expand Down
37 changes: 37 additions & 0 deletions core/test/project-board.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,29 @@ test('#1767: a Project without a decisions folder answers an empty list with the
}
})

// #2005: a decision file's title is its first heading, bounded at 200 units. A heading whose 200th
// unit is the first half of an emoji used to reach the board as an unpaired surrogate, so the
// Decisions section carried half a character.
test('#2005: a decision title is bounded at a code point, never inside an emoji', async () => {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-'))
const dir = mkdtempSync(join(tmpdir(), 'ds-bot-title-cut-'))
mkdirSync(join(dir, '.git'))
mkdirSync(join(dir, 'decisions'))
writeFileSync(join(dir, 'decisions', '2026-10-30-cut.md'), `# ${'a'.repeat(199)}😀\n`)
const team = recordSeed()
team.projects['project-p1'].workDir = dir
const { rt, stop } = await start(home, team)
try {
const board = await rt.projectBoard('project-p1')
const [file] = board.decisions.files
assert.equal(file.title.isWellFormed(), true, 'the title is well formed, never a lone high surrogate')
assert.equal(file.title, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole')
} finally {
await stop()
rmSync(dir, { recursive: true, force: true })
}
})

// #1768: the queue, the leases and the blocked cards beside the columns. The seed adds one card of
// each shape on Project 1 and one on Project 2, so every group can be asked what it must not show.
const queueSeed = () => {
Expand Down Expand Up @@ -473,6 +496,20 @@ test('#1768: a blocked card carries its own reason and another Project is in non
} finally { await stop() }
})

// #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line
// whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate
// — the queue reason is the case that reads a field another home supplied.
test('#2005: a blocked card reason is bounded at a code point, never inside an emoji', async () => {
const { rt, stop } = await start(undefined, queueSeed())
try {
rt.state.tasks['task-blocked'].inputRequest.question = `${'a'.repeat(199)}😀tail`
const queues = await rt.projectBoardQueues('project-p1')
const reason = queues.blocked.find(item => item.id === 'task-blocked').reason
assert.equal(reason.isWellFormed(), true, 'the reason is well formed, never a lone high surrogate')
assert.equal(reason, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole')
} finally { await stop() }
})

test('#1768: an unreadable queue answers unknown, never zero', async () => {
const { rt, stop } = await start(undefined, queueSeed())
try {
Expand Down
44 changes: 44 additions & 0 deletions core/test/project-team.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createRuntime, provideFlyBot } from '../index.js'
import { install } from '../src/host/project-team.js'
import { fakeContext } from './fake-ctx.js'

const LONG = 'x'.repeat(5000)
Expand Down Expand Up @@ -188,3 +189,46 @@ test('project-team cuts a room name from another home at 200 without splitting a
assert.ok(name.startsWith(kept), 'a code-point prefix of the group name')
assert.ok(kept.length <= 200, 'the budget of 200 UTF-16 units is unchanged')
})

// #2005: `capped()` bounds the name, role, persona, model and color of a dossier row, and every
// one of those bounds must land on a code point. A bot name whose 200th unit is the first half of
// an emoji used to reach the panel as an unpaired surrogate.
test('#2005: project-team caps a bot name at a code point, never inside an emoji', async (t) => {
const { callAs, rt, stop } = await start()
t.after(stop)
rt.state.bots['bot-chief'].name = `${'a'.repeat(199)}😀`
const rows = (await callAs('project-team', { id: 'project-1' })).value.rows
assert.equal(rows[0].name.isWellFormed(), true, 'the name is well formed, never a lone high surrogate')
assert.equal(rows[0].name, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole')
})

// #2005: the dossier's memory head is bounded at 4,000 units, and that bound must land on a code
// point too; a memory file whose 4,000th unit is the first half of an emoji used to reach the
// panel as half a character.
test('#2005: project-team cuts the memory head at a code point, never inside an emoji', async (t) => {
const { callAs, home, stop } = await start()
t.after(stop)
memory(home, 'bot-chief', `${'a'.repeat(3999)}😀`)
const rows = (await callAs('project-team', { id: 'project-1' })).value.rows
assert.equal(rows[0].memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate')
assert.equal(rows[0].memory.head, 'a'.repeat(3999), 'the 4,000th unit was the first half of the pair, so the pair is dropped whole')
})

// #2005: the head is bounded twice on purpose — once where `memoryDigest` reads it, and once in the
// dossier row itself, which is where the module promises that every string it answers is capped. The
// second bound cannot be reached through the reader (a 4,000-unit head is already well formed), so it
// is pinned by a direct call that hands the module a 4,001-unit head itself.
test('#2005: project-team cuts a head handed to it directly at a code point', () => {
const head = `${'a'.repeat(3999)}😀`
const rt = {
state: { rooms: {} },
readProject: () => ({ id: 'project-1', chief: { botId: 'bot-chief' }, assistant: { botId: '' }, maintainers: [], members: [] }),
botOf: id => (id === 'bot-chief' ? { id, name: 'Chief', role: 'Chief', instructions: '', model: 'deepseek-chat', color: '#112233', capabilities: [], createdAt: 1 } : null),
memoryDigest: () => ({ head, topics: [] }),
}
install(rt)
const row = rt.projectTeam('project-1').rows[0]
assert.equal(head.length, 4001, 'the caller handed the dossier 4,001 units')
assert.equal(row.memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate')
assert.equal(row.memory.head, 'a'.repeat(3999), 'the pair at the bound is dropped whole instead of keeping its first half')
})
Loading
Loading