From 670497f5cbae95431b1f99c0410973487c2bc968 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 7 Nov 2026 14:33:35 +0800 Subject: [PATCH] Cut task cues, failure text, shares and labels on code points (#2581) Docs-not-needed: the fix routes existing bounds through code-point cuts and the existing cut() helper; no owned interface, tool, flow or stored shape changes. --- changelog.d/2581-utf16-cut.fixed.md | 1 + core/src/host/task-drive.js | 5 ++- core/src/shared/text-boundary.js | 5 +++ core/test/task-drive.test.js | 15 +++++++ patches/ds-bot/files/src/host/task-drive.js | 5 ++- .../ds-bot/files/src/shared/text-boundary.js | 5 +++ patches/ds-bot/files/test/task-drive.test.js | 15 +++++++ plugins/social/src/social.js | 10 ++++- plugins/social/test/social.test.js | 35 ++++++++++++++++ plugins/timeline/src/sanitize.js | 12 +++++- plugins/timeline/test/sanitize.test.js | 13 ++++++ plugins/workflow/client.js | 18 +++++--- plugins/workflow/src/graph.js | 10 +++++ plugins/workflow/src/service.js | 9 +++- plugins/workflow/test/client.test.js | 41 +++++++++++++++++++ plugins/workflow/test/code-point-cut.test.js | 28 +++++++++++++ plugins/workflow/test/graph.test.js | 13 +++++- 17 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 changelog.d/2581-utf16-cut.fixed.md create mode 100644 plugins/workflow/test/code-point-cut.test.js diff --git a/changelog.d/2581-utf16-cut.fixed.md b/changelog.d/2581-utf16-cut.fixed.md new file mode 100644 index 000000000..773508700 --- /dev/null +++ b/changelog.d/2581-utf16-cut.fixed.md @@ -0,0 +1 @@ +Workflow failure text, task cues, co-seat room shares, workflow avatars and role lines, and timeline labels now cut on character boundaries instead of UTF-16 units, so an emoji is never split into a half character and rendered as a replacement character ([#2581](https://github.com/ThinkFlowLab/FlyBot/issues/2581)). diff --git a/core/src/host/task-drive.js b/core/src/host/task-drive.js index b40bc8555..cc79c21ae 100644 --- a/core/src/host/task-drive.js +++ b/core/src/host/task-drive.js @@ -5,6 +5,7 @@ // open a group Session and does not post the reply into the room. Assigning, // progress, and the next assignee are board writes; chat is not the scheduler. import { compareForQueue, compareForWork, occupiesSlot, sameRole } from '../shared/task-board.js' +import { cut } from '../shared/text-boundary.js' import { formatRoomBoard } from './room-context.js' import { retiredBot } from './store.js' @@ -34,7 +35,7 @@ const fingerprint = (tasks) => tasks.map(task => [ // A queue item is offered, not owned: this cue names the item and the claim, never its note โ€” // the note is the body, and reading it is the turn's job, not the wake's (#1247). const queueCueFor = (task) => { - const title = String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200) + const title = cut(String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim(), 200).text const priority = task.priority ?? 'normal' return `Take ${task.id} [${priority}] ${title} โ€” queued for ${task.queueRole}. Claim it with track_tasks {claim: true, id: '${task.id}'}, then set its column to in-progress. Do not post a status in the group. Stop when it is done or waiting on an answer. Do not invent other work.` } @@ -51,7 +52,7 @@ const queueFingerprint = (task) => (task === undefined ? '' : [ ].join('\u0000')) const cueFor = (task) => { - const title = String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200) + const title = cut(String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim(), 200).text const priority = task.priority ?? 'normal' const post = task.writeBack?.state === 'pending' ? ` Then post the decision comment on ${task.writeBack.target} with your GitHub connector and record it with track_tasks postResult {task: '${task.id}', ok: true}, or ok: false with the error.` diff --git a/core/src/shared/text-boundary.js b/core/src/shared/text-boundary.js index 903c5595e..f8bb316f4 100644 --- a/core/src/shared/text-boundary.js +++ b/core/src/shared/text-boundary.js @@ -23,6 +23,11 @@ export const fits = (text, max) => { } // A bounded prefix that never splits a surrogate pair. `cut` says whether anything was dropped. +// `max` stays a UTF-16 unit budget, on purpose: its callers bound the units a stored field may cost +// (a capability description at 2000, the open-page snapshot's address and title, a busy-ack prompt +// at 600), and those shapes are pinned by tests. `fits` above is the *character* judgment, for a +// consumer whose budget is characters (#2321); the two questions differ on purpose, and what must +// never differ is that the kept prefix ends on a code point boundary (#1963, #2581). // The input is flattened first (#1963): a string that already carried an unpaired surrogate went // straight through, so the caller stored a value that was not well formed. 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. diff --git a/core/test/task-drive.test.js b/core/test/task-drive.test.js index 690d6eda9..5bca7ce3d 100644 --- a/core/test/task-drive.test.js +++ b/core/test/task-drive.test.js @@ -636,3 +636,18 @@ test('the reminder cap never silences the task own work (#1172)', async (t) => { assert.equal(app.calls('s').length, 2, 'a working task wakes even with the comment reminder used up') assert.match(app.calls('s')[1].content.map(block => block.text).join('\n'), new RegExp(`Continue ${id} \\[normal\\] Ship the fix`)) }) + +// #2581: the cue re-cuts the title that tasks.js already bounded, and the second bound must land on +// a code point boundary. A title of emoji is two UTF-16 units per character, so the old 200-unit +// slice ended inside one and the other Bot's turn read a lone surrogate as U+FFFD. +test('#2581: a title of emoji is cut on a code point boundary in the cue', async (t) => { + const app = await start(t) + // Five ASCII units plus 300 emoji units: the 200-unit offset lands inside a face. + const title = `Draft${'๐Ÿš€'.repeat(150)}` + const body = await app.track('s', { tasks: [{ title, column: 'not-started' }] }) + const id = app.idOf(body, 'Draft') + const cue = app.calls('s').at(-1).content.map(block => block.text ?? '').join('\n') + assert.equal(cue.isWellFormed(), true, 'the cue carries no lone surrogate') + assert.equal(cue.includes('\uFFFD'), false, 'the cue carries no replacement character') + assert.match(cue, new RegExp(`Continue ${id} \\[normal\\] Draft`)) +}) diff --git a/patches/ds-bot/files/src/host/task-drive.js b/patches/ds-bot/files/src/host/task-drive.js index b40bc8555..cc79c21ae 100644 --- a/patches/ds-bot/files/src/host/task-drive.js +++ b/patches/ds-bot/files/src/host/task-drive.js @@ -5,6 +5,7 @@ // open a group Session and does not post the reply into the room. Assigning, // progress, and the next assignee are board writes; chat is not the scheduler. import { compareForQueue, compareForWork, occupiesSlot, sameRole } from '../shared/task-board.js' +import { cut } from '../shared/text-boundary.js' import { formatRoomBoard } from './room-context.js' import { retiredBot } from './store.js' @@ -34,7 +35,7 @@ const fingerprint = (tasks) => tasks.map(task => [ // A queue item is offered, not owned: this cue names the item and the claim, never its note โ€” // the note is the body, and reading it is the turn's job, not the wake's (#1247). const queueCueFor = (task) => { - const title = String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200) + const title = cut(String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim(), 200).text const priority = task.priority ?? 'normal' return `Take ${task.id} [${priority}] ${title} โ€” queued for ${task.queueRole}. Claim it with track_tasks {claim: true, id: '${task.id}'}, then set its column to in-progress. Do not post a status in the group. Stop when it is done or waiting on an answer. Do not invent other work.` } @@ -51,7 +52,7 @@ const queueFingerprint = (task) => (task === undefined ? '' : [ ].join('\u0000')) const cueFor = (task) => { - const title = String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200) + const title = cut(String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim(), 200).text const priority = task.priority ?? 'normal' const post = task.writeBack?.state === 'pending' ? ` Then post the decision comment on ${task.writeBack.target} with your GitHub connector and record it with track_tasks postResult {task: '${task.id}', ok: true}, or ok: false with the error.` diff --git a/patches/ds-bot/files/src/shared/text-boundary.js b/patches/ds-bot/files/src/shared/text-boundary.js index 903c5595e..f8bb316f4 100644 --- a/patches/ds-bot/files/src/shared/text-boundary.js +++ b/patches/ds-bot/files/src/shared/text-boundary.js @@ -23,6 +23,11 @@ export const fits = (text, max) => { } // A bounded prefix that never splits a surrogate pair. `cut` says whether anything was dropped. +// `max` stays a UTF-16 unit budget, on purpose: its callers bound the units a stored field may cost +// (a capability description at 2000, the open-page snapshot's address and title, a busy-ack prompt +// at 600), and those shapes are pinned by tests. `fits` above is the *character* judgment, for a +// consumer whose budget is characters (#2321); the two questions differ on purpose, and what must +// never differ is that the kept prefix ends on a code point boundary (#1963, #2581). // The input is flattened first (#1963): a string that already carried an unpaired surrogate went // straight through, so the caller stored a value that was not well formed. 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. diff --git a/patches/ds-bot/files/test/task-drive.test.js b/patches/ds-bot/files/test/task-drive.test.js index 690d6eda9..5bca7ce3d 100644 --- a/patches/ds-bot/files/test/task-drive.test.js +++ b/patches/ds-bot/files/test/task-drive.test.js @@ -636,3 +636,18 @@ test('the reminder cap never silences the task own work (#1172)', async (t) => { assert.equal(app.calls('s').length, 2, 'a working task wakes even with the comment reminder used up') assert.match(app.calls('s')[1].content.map(block => block.text).join('\n'), new RegExp(`Continue ${id} \\[normal\\] Ship the fix`)) }) + +// #2581: the cue re-cuts the title that tasks.js already bounded, and the second bound must land on +// a code point boundary. A title of emoji is two UTF-16 units per character, so the old 200-unit +// slice ended inside one and the other Bot's turn read a lone surrogate as U+FFFD. +test('#2581: a title of emoji is cut on a code point boundary in the cue', async (t) => { + const app = await start(t) + // Five ASCII units plus 300 emoji units: the 200-unit offset lands inside a face. + const title = `Draft${'๐Ÿš€'.repeat(150)}` + const body = await app.track('s', { tasks: [{ title, column: 'not-started' }] }) + const id = app.idOf(body, 'Draft') + const cue = app.calls('s').at(-1).content.map(block => block.text ?? '').join('\n') + assert.equal(cue.isWellFormed(), true, 'the cue carries no lone surrogate') + assert.equal(cue.includes('\uFFFD'), false, 'the cue carries no replacement character') + assert.match(cue, new RegExp(`Continue ${id} \\[normal\\] Draft`)) +}) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 95b9859f8..acd5643fb 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -101,6 +101,14 @@ export const sizeLabel = (n) => { const kb = (n / 1024).toFixed(1) return Number(kb) >= 1024 ? `${(n / 1048576).toFixed(1)} MB` : `${kb} KB` } +// Cut on code points, never on UTF-16 units: the share envelope leaves this Bot as JSON and a +// slice that ends inside a surrogate pair reaches the other home as U+FFFD (#2581). +export const cutCodePoints = (value, max) => { + const text = String(value ?? '').toWellFormed() + if (!Number.isFinite(max) || text.length <= max) return text + return Array.from(text).slice(0, max).join('') +} + // Remote strings that reach model-visible text: one line, no brackets or markup, capped. export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback // Owner-only draft body: keep punctuation the friend will see; strip controls and cap. @@ -1838,7 +1846,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp who: cleanText(flybot.bot.get(msg.from)?.name, 'member'), kind: 'message', authorKind: 'human', - text: String(msg.text ?? '').slice(0, 18_000), + text: cutCodePoints(msg.text, 18_000), calls: [to], } return `FLYBOT-ROOM-SHARE\n${JSON.stringify(body)}` diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index a53b86015..dee488b36 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -1336,6 +1336,41 @@ test('#1141: a co-member reaches a shared-room Bot without being the friendship assert.equal((room.participants ?? []).some(p => p.handle === '@coder.bob.home' && p.leftSeq === undefined), false) }) +// #2581: the co-seat envelope leaves this Bot as JSON, and its 18 000 is a character budget. +// A UTF-16 slice there can end inside an emoji, and the other home reads half a pair as U+FFFD. +test('#2581: a co-seat room line keeps its emoji whole at the envelope bound', async (t) => { + const rooms = { + delivery: { + id: 'delivery', name: 'ไบคไป˜็ป„', members: ['a', 'b'], admin: 'a', named: true, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'b', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', remote: true, id: '@coder.bob.home', handle: '@coder.bob.home', ownerHome: '@bob.home', ownerName: 'Bob', displayName: 'Coder', role: 'member', joinedSeq: 1, joinedAt: 1 }, + ], + }, + } + const { ctx, stop } = await team({ rooms }) + t.after(stop) + const server = mockServer() + server.tools.list_remote_bots = () => ({ homes: [{ relationship_id: 'r1', home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, bots: [{ bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder' }], scope: 'member', via: ['@writer.danyang.home'] }] }) + const social = createSocial(ctx.flybot, { autoAccept: true, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot, '@bob.home') + await social.registerAll() + await social.remoteBots({ fresh: true }) + await ctx.flybot.grants.set('a', [{ grantee: '@bob.home' }]) + // 18 001 characters, 36 001 UTF-16 units: the old 18 000-unit slice kept 8 999 faces and half of the next. + const text = `A${'๐Ÿš€'.repeat(18_000)}` + const sent = await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text, role: 'reply', via: 'auto-reply' }) + assert.equal(sent.action, 'allow', JSON.stringify(sent)) + const call = server.calls.filter(item => item.name === 'send_as_member').at(-1) + const body = JSON.parse(String(call.args.text).slice('FLYBOT-ROOM-SHARE\n'.length)) + assert.equal(body.text.isWellFormed(), true, 'the envelope text carries no lone surrogate') + assert.equal(body.text.includes('\uFFFD'), false) + assert.equal(Array.from(body.text).length, 18_000, 'the budget is characters, the shared text included') + assert.equal(body.text, `A${'๐Ÿš€'.repeat(17_999)}`) +}) + // A server that holds every send_as_member and lists the holds. `reasons` names what Bot Social // held it for: the plain approval requirement, or a finding of its own (a DLP category). function holdingServer({ reasons = ['mvp.all_outbound_requires_approval'], riskFlags = [], holdReason } = {}) { diff --git a/plugins/timeline/src/sanitize.js b/plugins/timeline/src/sanitize.js index a55ff967f..3cb6fa73c 100644 --- a/plugins/timeline/src/sanitize.js +++ b/plugins/timeline/src/sanitize.js @@ -5,11 +5,21 @@ const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g // Bidirectional overrides and isolates reorder text on screen; zero-width characters hide it. const INVISIBLE = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g +// Cut on code points, never on UTF-16 units: `max` is a character budget and a unit offset can +// end inside a surrogate pair, so the panel or the exported JSON would carry half a character +// (#2581). The input is flattened first, so a value that already carried an unpaired surrogate is +// never stored as one. +const cutCodePoints = (text, max) => { + const value = text.toWellFormed() + if (!Number.isFinite(max) || value.length <= max) return value + return Array.from(value).slice(0, max).join('') +} + // One line of plain text, no controls, no fence markers, no angle brackets, capped. export const cleanLabel = (value, max = 60) => { let out = String(value ?? '').replace(CONTROLS, ' ').replace(INVISIBLE, '') for (let previous; previous !== out;) { previous = out; out = out.replace(/<\s*\/?\s*untrusted-?/gi, '') } - return out.replace(/[<>`"]/g, '').replace(/\s+/g, ' ').trim().slice(0, max) + return cutCodePoints(out.replace(/[<>`"]/g, '').replace(/\s+/g, ' ').trim(), max) } const ID = /^[A-Za-z0-9][A-Za-z0-9:_#.@-]{0,159}$/ diff --git a/plugins/timeline/test/sanitize.test.js b/plugins/timeline/test/sanitize.test.js index 7a8ec60b1..3fdfa057a 100644 --- a/plugins/timeline/test/sanitize.test.js +++ b/plugins/timeline/test/sanitize.test.js @@ -54,3 +54,16 @@ test('cleanCode keeps an enumerated error code and nothing else', () => { assert.equal(cleanCode('AUTH'), 'AUTH') for (const bad of ['sk-live-0123456789abcdef', 'quota exceeded for key abc', 'rate_limit', '', '', 'A'.repeat(41), 5, null, undefined]) assert.equal(cleanCode(bad), 'UNKNOWN', String(bad)) }) + +// #2581: 60 is a character budget. A label of emoji is 120 UTF-16 units, and a unit slice at 60 +// ends inside the 31st face, so the panel and the exported JSON carried half a character. +test('#2581: cleanLabel cuts emoji on a code point boundary', () => { + const out = cleanLabel('๐Ÿš€'.repeat(61)) + assert.equal(out.isWellFormed(), true) + assert.equal(out.includes('\uFFFD'), false) + assert.equal(Array.from(out).length, 60) + assert.equal(out, '๐Ÿš€'.repeat(60)) + // A label already inside the budget is untouched, however many units it costs. + const short = '๐Ÿš€'.repeat(30) + assert.equal(cleanLabel(short), short) +}) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index dde5b571e..8f825b446 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -295,6 +295,14 @@ window.__ModuleLoader__.load({ const COLUMN_KEY = { 'not-started': 'Not started', 'in-progress': 'In progress', done: 'Done' } const COLUMN_OF = { open: 'not-started', claimed: 'not-started', working: 'in-progress', blocked: 'in-progress', 'result-ready': 'in-progress', accepted: 'done', cancelled: 'done', failed: 'done' } const COLORS = { Planner: '#8b5cf6', Writer: '#f0509a', Coder: '#1fbf6a', Reviewer: '#f5a524', Tester: '#14b8a6', Deployer: '#2f7cf6', '็ ”็ฉถๅ‘˜': '#a78bfa', 'ๆ•ฐๆฎๅˆ†ๆžๅธˆ': '#ec4899' } + // One code point, not one UTF-16 unit: half a surrogate pair renders as a replacement + // character in the avatar circle (#2017, #2581). + const firstGlyph = name => Array.from(String(name ?? '').trim())[0] ?? '?' + // A cut in code points, so a label never ends inside a surrogate pair (#2581). + const cutCodePoints = (value, max) => { + const text = String(value ?? '') + return text.length <= max ? text : Array.from(text).slice(0, max).join('') + } // Zero-width plane center line: shift wire coordinates so strokes stay inside the SVG box. const WIRE_SPAN = 480 const NODE_WIRE_OUT = 56 @@ -397,7 +405,7 @@ window.__ModuleLoader__.load({ const Avatar = globalThis.__flybot__?.BotAvatar if (bot && typeof Avatar === 'function') return h(Avatar, { bot, size, badge: false, live: false }) const color = COLORS[node?.name] ?? '#4d6bfe' - return h('span', { className: 'wf-av', style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) } }, (node?.name ?? '?').slice(0, 1)) + return h('span', { className: 'wf-av', style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) } }, firstGlyph(node?.name)) } const BOT_TINTS = ['#4d6bfe', '#8b5cf6', '#14b8a6', '#f5a524', '#ec4899', '#1fbf6a', '#2f7cf6'] @@ -410,7 +418,7 @@ window.__ModuleLoader__.load({ function botPaletteSubtitle(bot, peers) { const role = String(bot?.role ?? '').replace(/\s+/g, ' ').trim() const dupes = (peers ?? []).filter(item => item.name === bot.name).length > 1 - if (role) return role.length > 28 ? `${role.slice(0, 27)}โ€ฆ` : role + if (role) return Array.from(role).length > 28 ? `${cutCodePoints(role, 27)}โ€ฆ` : role if (dupes && bot?.id) return `โ€ฆ${String(bot.id).slice(-6)}` return '' } @@ -422,7 +430,7 @@ window.__ModuleLoader__.load({ return h('span', { className: 'wf-palette-av', style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) }, - }, (bot?.name ?? '?').slice(0, 1)) + }, firstGlyph(bot?.name)) } function PaletteBotChip({ bot, peers, onPointerDown, dragging }) { @@ -515,7 +523,7 @@ window.__ModuleLoader__.load({ fill: color, fontSize: 12, fontWeight: 600, - }, (node.name ?? '?').slice(0, 1))) + }, firstGlyph(node.name))) }))) } @@ -2690,7 +2698,7 @@ window.__ModuleLoader__.load({ runHistoryRows, liveStageRuns, resolveFocusedRun, newestAttempt, parallelFrameStyle, fitPlane, editPlane, flowLayout, flowRows, DRAWER_FLOW_W, NODE_W, NODE_H, - botPaletteSubtitle, botTint, settledCall, + botPaletteSubtitle, botTint, settledCall, firstGlyph, MiniFlowchart, } }, }) diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js index 582ef67d4..fc48875c4 100644 --- a/plugins/workflow/src/graph.js +++ b/plugins/workflow/src/graph.js @@ -18,6 +18,16 @@ function stripControls(value) { return out } +// Cut on code points, never on UTF-16 units: a unit offset can split a surrogate +// pair and leave a lone surrogate, which renders as a replacement character. `max` is a +// code-point budget (the rule of core's shared/text-boundary.js), and the input is flattened +// first so a value that already carried an unpaired surrogate is never stored as one. +export function cutCodePoints(value, max) { + const text = String(value ?? '').toWellFormed() + if (!Number.isFinite(max) || text.length <= max) return text + return Array.from(text).slice(0, max).join('') +} + // Cut on code points, never on UTF-16 units: a unit offset can split a surrogate // pair and leave a lone surrogate, which renders as a replacement character. export function cleanLine(value, max = 300) { diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 6ff241c75..a3353df16 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -7,7 +7,7 @@ import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'n import { join } from 'node:path' import { releaseAll, spawnAll } from './ephemeral.js' import { - chainLabel, cleanLine, cleanPorts, cleanPrompt, cleanTools, completeNode, doneCount, failNode, freshStatus, layerMarks, + chainLabel, cleanLine, cleanPorts, cleanPrompt, cleanTools, completeNode, cutCodePoints, doneCount, failNode, freshStatus, layerMarks, missingProviders, missingVerdicts, nodeById, normalizeGraph, orderedIds, readStepResult, resolveInputs, retryNode, rowPreview, startFrontier, summarizeTask, unmetRequired, validateGraph, } from './graph.js' @@ -21,6 +21,11 @@ const MAX_MESSAGES = 200 const FILE = 'workflows.json' const DEFAULT_CONCURRENT = 2 const MAX_CONCURRENT = 8 +// #2581: a failed step's own text is the node's (or peer Bot's) output, so the bound goes through +// cutCodePoints: a UTF-16 slice at 197 units can end inside an emoji and every reader downstream +// gets U+FFFD. 200 is the budget in characters, the ellipsis included. +const clipFailureText = text => (Array.from(text).length > 200 ? `${cutCodePoints(text, 197)}โ€ฆ` : text) + const MAX_ORPHANS = 50 // How many held task ids a workflow lists in `workflow/state`; the count beside // them is always the whole truth (#1647). @@ -635,7 +640,7 @@ export function createWorkflowService({ code: 'step-failed', at: next.updatedAt, ...(node ? { nodeName: node.name } : {}), - ...(text ? { text: text.length > 200 ? `${text.slice(0, 197)}โ€ฆ` : text } : {}), + ...(text ? { text: clipFailureText(text) } : {}), } } const lines = linesFor(item, working.task, { diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 5664b3573..664df980e 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4854,3 +4854,44 @@ test('a paused run is named Paused in the strip and in the Runs history', async view.unmount() } }) + +// #2581: an avatar takes one code point, not one UTF-16 unit; the role line and the canvas label +// are bounded in characters for the same reason. A name or role of emoji used to leave half a +// surrogate pair in the circle / subtitle / flowchart. +test('#2581: an emoji-leading name keeps one whole character in the palette avatar', async () => { + const graph = delivery() + const call = async (name) => (name === 'workflow/state' ? { workflows: [graph], templates: [] } : { ok: true, workflows: [graph] }) + const roster = { bots: [{ id: 'chief-e', name: '๐Ÿš€Dev', role: 'Ops', remote: false }] } + const view = mount(client.Section, { roster, call, actions: {} }) + try { + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('ๅ‘ๅธƒไบคไป˜ๆตๆฐด็บฟ'))[0].props.onClick() + button(view.tree, 'Flow').props.onClick() + button(view.tree, 'Edit').props.onClick() + await settle() + const avatar = findAll(view.tree, node => node.props?.className === 'wf-palette-av')[0] + assert.ok(avatar, 'the palette renders the Bot avatar') + assert.equal(textOf(avatar), '๐Ÿš€') + assert.equal(client.firstGlyph('๐Ÿš€Dev'), '๐Ÿš€') + assert.equal(client.firstGlyph(''), '?') + } finally { + view.unmount() + } + const role = client.botPaletteSubtitle({ id: 'chief-e', name: '๐Ÿš€Dev', role: '๐Ÿš€'.repeat(28) }, roster.bots) + assert.equal(role.isWellFormed(), true, 'the subtitle carries no lone surrogate') + assert.equal(role.includes('\uFFFD'), false) + assert.equal(Array.from(role).length, 28, '27 characters plus the ellipsis') +}) + +test('#2581: the canvas node label is one code point', () => { + const graph = delivery() + graph.nodes = graph.nodes.map(node => (node.id === 'coder' ? { ...node, name: '๐Ÿš€Dev' } : node)) + const view = mount(client.MiniFlowchart, { workflow: graph }) + try { + const labels = findAll(view.tree, node => node.type === 'text').map(textOf) + assert.ok(labels.includes('๐Ÿš€'), `the flowchart text labels are ${JSON.stringify(labels)}`) + assert.equal(labels.every(label => label.isWellFormed()), true, 'no label is half a pair') + } finally { + view.unmount() + } +}) diff --git a/plugins/workflow/test/code-point-cut.test.js b/plugins/workflow/test/code-point-cut.test.js new file mode 100644 index 000000000..bf6ffa5ec --- /dev/null +++ b/plugins/workflow/test/code-point-cut.test.js @@ -0,0 +1,28 @@ +// #2581: every bound in this plugin judges characters, not UTF-16 units. A failed step's own text +// is the node's or the peer Bot's output, so a unit slice at 197 ends inside an emoji and the +// stored failure, the history and the API all carry a lone surrogate (U+FFFD once encoded). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createWorkflowService } from '../src/service.js' + +const NOW = 1_700_000_000_000 + +test('#2581: a failed step keeps a well-formed text when its characters are emoji', async () => { + const svc = createWorkflowService({ persist: false, now: () => NOW, bots: () => [] }) + const made = svc.define({ + name: 'Lane', + nodes: [{ id: 'plan', name: 'Planner', prompt: 'Write the plan.' }], + edges: [], + }) + const id = made.workflows.find(item => item.name === 'Lane').id + const started = await svc.runNamed({ workflow: 'Lane', brief: 'go' }) + const runId = started.workflows.find(item => item.id === id).run.id + // 300 characters, 600 units: the old 197-unit slice kept 98 faces and half of the 99th. + const text = '๐Ÿš€'.repeat(300) + const failed = svc.advance({ workflowId: id, runId, nodeId: 'plan', outcome: 'failed', text }) + const failure = failed.workflows.find(item => item.id === id).run.failure + assert.equal(failure.text.isWellFormed(), true, 'the stored failure text is well formed') + assert.equal(failure.text.includes('\uFFFD'), false) + assert.equal(failure.text.endsWith('๐Ÿš€โ€ฆ'), true) + assert.equal(Array.from(failure.text).length, 198, '197 characters plus the ellipsis') +}) diff --git a/plugins/workflow/test/graph.test.js b/plugins/workflow/test/graph.test.js index 69cb7ff6e..9bb626315 100644 --- a/plugins/workflow/test/graph.test.js +++ b/plugins/workflow/test/graph.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { - addNode, chainLabel, cleanLine, cleanPrompt, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, + addNode, chainLabel, cleanLine, cleanPrompt, completeNode, connectNodes, cutCodePoints, doneCount, failNode, freshStatus, layerMarks, moveNode, nextDemoOp, normalizeGraph, readStepResult, reconnectEdge, removeEdge, removeNode, retryNode, rowPreview, startFrontier, summarizeTask, validateGraph, } from '../src/graph.js' @@ -209,3 +209,14 @@ test('a node prompt and a step reply stop on the bound without splitting the cha assert.equal(step.text, reply, 'the step body keeps the whole last character') assert.equal(hasLoneSurrogate(step.text), false, 'readStepResult left a lone surrogate in the body') }) + +// #2581: the plugin's own bound judges characters, not UTF-16 units. +test('#2581: cutCodePoints counts characters, not UTF-16 units', () => { + assert.equal(cutCodePoints('๐Ÿš€Dev', 1), '๐Ÿš€') + assert.equal(cutCodePoints('๐Ÿš€Dev', 10), '๐Ÿš€Dev') + assert.equal(cutCodePoints('๐Ÿš€'.repeat(3), 2), '๐Ÿš€๐Ÿš€') + assert.equal(cutCodePoints('๐Ÿš€'.repeat(3), 2).isWellFormed(), true) + // A value that already carried an unpaired surrogate is flattened, never stored as one. + assert.equal(cutCodePoints('a\uD800b', 10).isWellFormed(), true) + assert.equal(cutCodePoints(undefined, 5), '') +})