From 67f957bffbf556ded7a485519c0ee5db0b232550 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:40:51 +0800 Subject: [PATCH] Count the task board's text bounds in code points The typed card's question/answer and `boundedLine`'s handoff, evidence and lease bounds compared `string.length`, so one emoji cost two: a 1,001-emoji question was refused as 2,002 and a 151-emoji handoff reason as 302, while the sentence named a bound the text never exceeded. Both comparisons now go through `codePoints` from `text.js` (#1895), the host's one counter, so a bound moves with the text rather than with its encoding. Every accepted value and every refusal keeps its field name and sentence; only the unit they count changes. --- .../1901-task-text-code-points.fixed.md | 1 + core/src/host/tasks.js | 5 ++- core/test/task-contract.test.js | 37 +++++++++++++++++++ core/test/tasks.test.js | 33 +++++++++++++++++ docs/subsystems/core-host.md | 4 +- patches/ds-bot/files/src/host/tasks.js | 5 ++- .../ds-bot/files/test/task-contract.test.js | 37 +++++++++++++++++++ patches/ds-bot/files/test/tasks.test.js | 33 +++++++++++++++++ 8 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1901-task-text-code-points.fixed.md diff --git a/changelog.d/1901-task-text-code-points.fixed.md b/changelog.d/1901-task-text-code-points.fixed.md new file mode 100644 index 000000000..06e49ca8a --- /dev/null +++ b/changelog.d/1901-task-text-code-points.fixed.md @@ -0,0 +1 @@ +The task board's typed question and answer (2,000) and its handoff, evidence and lease bounds (300, 200, 200) now count characters rather than UTF-16 units ([#1901](https://github.com/ThinkFlowLab/FlyBot/issues/1901)), so a 2,000-character emoji question is accepted instead of refused as 4,000. diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index b1ff6b0c9..d416fde3a 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -21,6 +21,7 @@ import { retiredBot } from './store.js' import { currentParticipants, deriveParticipants } from './room-membership.js' import { cardNamesIssue } from '../shared/issue-ref.js' import { ownEntry } from './store.js' +import { codePoints } from './text.js' const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) // result-ready still waits for the requester: prune must not evict it with terminals (#111). @@ -167,7 +168,7 @@ const readColumn = (value) => { return column } const selectedInput = (value, name) => { - if (typeof value !== 'string' || !value.trim() || value.length > 2000) throw invalid(`${name} must contain 1–2000 characters`) + if (typeof value !== 'string' || !value.trim() || codePoints(value) > 2000) throw invalid(`${name} must contain 1–2000 characters`) return value } @@ -410,7 +411,7 @@ export function install(rt) { const knownRole = role => RESERVED_ROLES.has(role) || Object.values(state.bots ?? {}).some(bot => Boolean(bot?.role) && sameRole(bot.role, role)) const boundedLine = (value, max, name) => { const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim() - if (text.length > max) throw invalid(`${name} is at most ${max} characters`) + if (codePoints(text) > max) throw invalid(`${name} is at most ${max} characters`) return text } const readTaskClass = (value) => { diff --git a/core/test/task-contract.test.js b/core/test/task-contract.test.js index d0545e488..366278335 100644 --- a/core/test/task-contract.test.js +++ b/core/test/task-contract.test.js @@ -249,3 +249,40 @@ test('the Project board reads the typed fields from the same row (#1720)', async assert.equal(fresh.lease.holder, 'b1') assert.equal(new Set(rows.map(row => row.id)).size, rows.length, 'one row per task id') }) + +test('the handoff, evidence and lease bounds count characters, not UTF-16 units (#1901)', async (t) => { + const app = await start(t) + const byTitle = title => Object.values(app.rt.state.tasks).find(task => task.title === title) + // A 300-character handoff reason written in emoji is 600 UTF-16 units: exactly at the bound. + const reason = '😀'.repeat(300) + const handoff = await app.create({ title: 'A long reason', handoff: { from: 'Lead', to: 'Dev', reason } }) + assert.equal(app.rt.state.tasks[handoff.id].handoff.reason, reason) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'One over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(301) } }), + /handoff\.reason is at most 300 characters/, + ) + // Mixed text counts what the reader sees: 299 emoji + one ASCII is 300, one more is 301. + const mixed = '😀'.repeat(299) + 'x' + const mixedCard = await app.create({ title: 'A mixed reason', handoff: { from: 'Lead', to: 'Dev', reason: mixed } }) + assert.equal(app.rt.state.tasks[mixedCard.id].handoff.reason, mixed) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Mixed over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(300) + 'x' } }), + /handoff\.reason is at most 300 characters/, + ) + // evidence.check 200/201. + const check = '😀'.repeat(200) + await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long check', evidence: [{ check }] }) + assert.equal(byTitle('A long check').evidence[0].check, check) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Check over', evidence: [{ check: '😀'.repeat(201) }] }), + /evidence\.check is at most 200 characters/, + ) + // lease.holder 200/201. + const holder = '😀'.repeat(200) + await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long holder', lease: { holder, takenAt: 1, expiresAt: 2 } }) + assert.equal(byTitle('A long holder').lease.holder, holder) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Holder over', lease: { holder: '😀'.repeat(201), takenAt: 1, expiresAt: 2 } }), + /lease\.holder is at most 200 characters/, + ) +}) diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js index 9c1616bc1..e42f1f102 100644 --- a/core/test/tasks.test.js +++ b/core/test/tasks.test.js @@ -918,3 +918,36 @@ test('sending a result back names the note in the comment, and a task without a const saved = JSON.parse(readFileSync(app.statePath, 'utf8')).tasks[plainId] assert.equal(saved.writeBack, undefined) }) + +test('a typed question and answer count characters, not UTF-16 units (#1901)', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const emoji = '😀'.repeat(2000) + // 2,000 characters written in emoji is 4,000 UTF-16 units: exactly at the bound. + const asked = await app.call('task-wait-input', { id, question: emoji }, alice.sessionToken) + assert.equal(asked.ok, true, JSON.stringify(asked)) + assert.equal(asked.value.inputRequest.question, emoji) + const answer = await app.call('task-answer-input', { id, questionId: asked.value.inputRequest.id, answer: emoji }, alice.sessionToken) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.inputRequest.answer, emoji) + // One character past the bound is refused, and the sentence still names the bound it kept. + const overQuestion = await app.call('task-wait-input', { id, question: '😀'.repeat(2001) }, alice.sessionToken) + assert.equal(overQuestion.ok, false) + assert.equal(overQuestion.error.code, 'bot/invalid') + assert.match(overQuestion.error.message, /Question must contain 1–2000 characters/) + // Mixed text counts what the reader sees, so a "halve the limit" fix cannot pass. + const mixed = '😀'.repeat(1999) + 'x' + const mixedAsked = await app.call('task-wait-input', { id, question: mixed }, alice.sessionToken) + assert.equal(mixedAsked.ok, true, JSON.stringify(mixedAsked)) + const overAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: '😀'.repeat(2001) }, alice.sessionToken) + assert.equal(overAnswer.ok, false) + assert.equal(overAnswer.error.code, 'bot/invalid') + assert.match(overAnswer.error.message, /Answer must contain 1–2000 characters/) + const mixedAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: mixed }, alice.sessionToken) + assert.equal(mixedAnswer.ok, true, JSON.stringify(mixedAnswer)) + const overMixed = await app.call('task-wait-input', { id, question: '😀'.repeat(2000) + 'x' }, alice.sessionToken) + assert.equal(overMixed.ok, false) + assert.match(overMixed.error.message, /Question must contain 1–2000 characters/) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 339daea52..f37f73a69 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -251,7 +251,7 @@ A tracked request with a stable id (`task-`) and a small state machine, own ### The typed task contract (#1720) -Five additive fields sit beside the record above; none changes what an existing field means, and a record stored before this slice reads back with them absent and nothing rewritten. `taskClass` is the typed kind (`work | rework | release | triage | meeting`) — it is not `kind`, which keeps its group-card meaning, so a group card is `kind: 'group'` with `taskClass: 'work'`. `reworkOf` names the card a rework card fixes and is not `parentId`, which stays the one-level part-of-overall link (`settleOverall` is untouched); a rework card therefore fires no overall-card settle. `handoff` is `{from, to, at, reason}` with both roles named (a role is known when a Bot carries it, or it is `requester`; an unknown role is refused with the role quoted back), `reason` at most 300 characters. `evidence` is at most eight `{sha, pr, check, at}` pointers, and `lease` is `{holder, takenAt, expiresAt}` on a claimed card. Every value is validated on write by one function, `writeContract` in `src/host/tasks.js`, reached from `track_tasks`, `task-create` and `task-update`; it reads every field before it assigns one, so a refusal (`bot/invalid`, or `bot/unknown-role` for a role no Bot carries) leaves the card exactly as it was. A review rejection (`task-send-back`) records the handover on the original, moves it to `working` and opens exactly one child card with `taskClass: 'rework'` and `reworkOf` naming the original — the single write that returns the original to `result-ready` is the acceptance of that child, guarded by the original still being `working`, so a replay creates no second child and moves the original once. A claim takes a lease (30 minutes); a claim on a live lease is refused as `bot/lease-held` with the holder named, and an expired lease returns a queued card to the queue. The read model does not copy: `project-board.js` puts the five fields on the row it already emits when the card carries them, so a pre-slice card keeps its old row shape. Tests: `core/test/task-contract.test.js`. +Five additive fields sit beside the record above; none changes what an existing field means, and a record stored before this slice reads back with them absent and nothing rewritten. `taskClass` is the typed kind (`work | rework | release | triage | meeting`) — it is not `kind`, which keeps its group-card meaning, so a group card is `kind: 'group'` with `taskClass: 'work'`. `reworkOf` names the card a rework card fixes and is not `parentId`, which stays the one-level part-of-overall link (`settleOverall` is untouched); a rework card therefore fires no overall-card settle. `handoff` is `{from, to, at, reason}` with both roles named (a role is known when a Bot carries it, or it is `requester`; an unknown role is refused with the role quoted back), `reason` at most 300 characters. These bounds count code points, not UTF-16 units (#1901), so a 300-character emoji reason is stored rather than refused as 600. `evidence` is at most eight `{sha, pr, check, at}` pointers, and `lease` is `{holder, takenAt, expiresAt}` on a claimed card. Every value is validated on write by one function, `writeContract` in `src/host/tasks.js`, reached from `track_tasks`, `task-create` and `task-update`; it reads every field before it assigns one, so a refusal (`bot/invalid`, or `bot/unknown-role` for a role no Bot carries) leaves the card exactly as it was. A review rejection (`task-send-back`) records the handover on the original, moves it to `working` and opens exactly one child card with `taskClass: 'rework'` and `reworkOf` naming the original — the single write that returns the original to `result-ready` is the acceptance of that child, guarded by the original still being `working`, so a replay creates no second child and moves the original once. A claim takes a lease (30 minutes); a claim on a live lease is refused as `bot/lease-held` with the holder named, and an expired lease returns a queued card to the queue. The read model does not copy: `project-board.js` puts the five fields on the row it already emits when the card carries them, so a pre-slice card keeps its old row shape. Tests: `core/test/task-contract.test.js`. ### Task input waits @@ -260,7 +260,7 @@ working task (#207). It records `blocked`, `waitingFor: 'input'` and a fresh `inputRequest { id, question, requestedAt }`. `task-answer-input { id, questionId, answer }` requires that exact current question, the same requester and no pending task approvals before returning to `working`. Question/answer must be nonblank -strings of at most 2,000 characters. Only the latest selected question/answer +strings of at most 2,000 characters, counted in code points (#1901). Only the latest selected question/answer snapshot is kept, with actor entries linking its question ID; chat history is not copied. The wait and snapshot survive restart. diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index b1ff6b0c9..d416fde3a 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -21,6 +21,7 @@ import { retiredBot } from './store.js' import { currentParticipants, deriveParticipants } from './room-membership.js' import { cardNamesIssue } from '../shared/issue-ref.js' import { ownEntry } from './store.js' +import { codePoints } from './text.js' const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) // result-ready still waits for the requester: prune must not evict it with terminals (#111). @@ -167,7 +168,7 @@ const readColumn = (value) => { return column } const selectedInput = (value, name) => { - if (typeof value !== 'string' || !value.trim() || value.length > 2000) throw invalid(`${name} must contain 1–2000 characters`) + if (typeof value !== 'string' || !value.trim() || codePoints(value) > 2000) throw invalid(`${name} must contain 1–2000 characters`) return value } @@ -410,7 +411,7 @@ export function install(rt) { const knownRole = role => RESERVED_ROLES.has(role) || Object.values(state.bots ?? {}).some(bot => Boolean(bot?.role) && sameRole(bot.role, role)) const boundedLine = (value, max, name) => { const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim() - if (text.length > max) throw invalid(`${name} is at most ${max} characters`) + if (codePoints(text) > max) throw invalid(`${name} is at most ${max} characters`) return text } const readTaskClass = (value) => { diff --git a/patches/ds-bot/files/test/task-contract.test.js b/patches/ds-bot/files/test/task-contract.test.js index d0545e488..366278335 100644 --- a/patches/ds-bot/files/test/task-contract.test.js +++ b/patches/ds-bot/files/test/task-contract.test.js @@ -249,3 +249,40 @@ test('the Project board reads the typed fields from the same row (#1720)', async assert.equal(fresh.lease.holder, 'b1') assert.equal(new Set(rows.map(row => row.id)).size, rows.length, 'one row per task id') }) + +test('the handoff, evidence and lease bounds count characters, not UTF-16 units (#1901)', async (t) => { + const app = await start(t) + const byTitle = title => Object.values(app.rt.state.tasks).find(task => task.title === title) + // A 300-character handoff reason written in emoji is 600 UTF-16 units: exactly at the bound. + const reason = '😀'.repeat(300) + const handoff = await app.create({ title: 'A long reason', handoff: { from: 'Lead', to: 'Dev', reason } }) + assert.equal(app.rt.state.tasks[handoff.id].handoff.reason, reason) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'One over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(301) } }), + /handoff\.reason is at most 300 characters/, + ) + // Mixed text counts what the reader sees: 299 emoji + one ASCII is 300, one more is 301. + const mixed = '😀'.repeat(299) + 'x' + const mixedCard = await app.create({ title: 'A mixed reason', handoff: { from: 'Lead', to: 'Dev', reason: mixed } }) + assert.equal(app.rt.state.tasks[mixedCard.id].handoff.reason, mixed) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Mixed over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(300) + 'x' } }), + /handoff\.reason is at most 300 characters/, + ) + // evidence.check 200/201. + const check = '😀'.repeat(200) + await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long check', evidence: [{ check }] }) + assert.equal(byTitle('A long check').evidence[0].check, check) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Check over', evidence: [{ check: '😀'.repeat(201) }] }), + /evidence\.check is at most 200 characters/, + ) + // lease.holder 200/201. + const holder = '😀'.repeat(200) + await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long holder', lease: { holder, takenAt: 1, expiresAt: 2 } }) + assert.equal(byTitle('A long holder').lease.holder, holder) + await assert.rejects( + () => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Holder over', lease: { holder: '😀'.repeat(201), takenAt: 1, expiresAt: 2 } }), + /lease\.holder is at most 200 characters/, + ) +}) diff --git a/patches/ds-bot/files/test/tasks.test.js b/patches/ds-bot/files/test/tasks.test.js index 9c1616bc1..e42f1f102 100644 --- a/patches/ds-bot/files/test/tasks.test.js +++ b/patches/ds-bot/files/test/tasks.test.js @@ -918,3 +918,36 @@ test('sending a result back names the note in the comment, and a task without a const saved = JSON.parse(readFileSync(app.statePath, 'utf8')).tasks[plainId] assert.equal(saved.writeBack, undefined) }) + +test('a typed question and answer count characters, not UTF-16 units (#1901)', async (t) => { + const app = await start(t) + t.after(() => app.stop()) + const alice = await app.signIn('Alice') + const id = await workingInputTask(app, alice.sessionToken) + const emoji = '😀'.repeat(2000) + // 2,000 characters written in emoji is 4,000 UTF-16 units: exactly at the bound. + const asked = await app.call('task-wait-input', { id, question: emoji }, alice.sessionToken) + assert.equal(asked.ok, true, JSON.stringify(asked)) + assert.equal(asked.value.inputRequest.question, emoji) + const answer = await app.call('task-answer-input', { id, questionId: asked.value.inputRequest.id, answer: emoji }, alice.sessionToken) + assert.equal(answer.ok, true, JSON.stringify(answer)) + assert.equal(answer.value.inputRequest.answer, emoji) + // One character past the bound is refused, and the sentence still names the bound it kept. + const overQuestion = await app.call('task-wait-input', { id, question: '😀'.repeat(2001) }, alice.sessionToken) + assert.equal(overQuestion.ok, false) + assert.equal(overQuestion.error.code, 'bot/invalid') + assert.match(overQuestion.error.message, /Question must contain 1–2000 characters/) + // Mixed text counts what the reader sees, so a "halve the limit" fix cannot pass. + const mixed = '😀'.repeat(1999) + 'x' + const mixedAsked = await app.call('task-wait-input', { id, question: mixed }, alice.sessionToken) + assert.equal(mixedAsked.ok, true, JSON.stringify(mixedAsked)) + const overAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: '😀'.repeat(2001) }, alice.sessionToken) + assert.equal(overAnswer.ok, false) + assert.equal(overAnswer.error.code, 'bot/invalid') + assert.match(overAnswer.error.message, /Answer must contain 1–2000 characters/) + const mixedAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: mixed }, alice.sessionToken) + assert.equal(mixedAnswer.ok, true, JSON.stringify(mixedAnswer)) + const overMixed = await app.call('task-wait-input', { id, question: '😀'.repeat(2000) + 'x' }, alice.sessionToken) + assert.equal(overMixed.ok, false) + assert.match(overMixed.error.message, /Question must contain 1–2000 characters/) +})