diff --git a/changelog.d/workflow-prompt-codepoint-cut.fixed.md b/changelog.d/workflow-prompt-codepoint-cut.fixed.md new file mode 100644 index 000000000..93e4ab085 --- /dev/null +++ b/changelog.d/workflow-prompt-codepoint-cut.fixed.md @@ -0,0 +1 @@ +A workflow node prompt, the task brief handed to a run, and the reply a step hands on now stop on their character bound without splitting the character there: the cut counts code points like a task title already did, so a prompt or reply that ends in an emoji or another character outside the BMP no longer carries half of it as a replacement character. diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js index dafb801ac..582ef67d4 100644 --- a/plugins/workflow/src/graph.js +++ b/plugins/workflow/src/graph.js @@ -34,7 +34,9 @@ export function cleanPrompt(value, max = 4000) { if (code === 0x0a || code === 0x09) { out += ch; continue } out += code <= 0x1f || code === 0x7f ? ' ' : ch } - return out.replace(/[ \t]+\n/g, '\n').trim().slice(0, max) + const text = out.replace(/[ \t]+\n/g, '\n').trim() + // Same rule as cleanLine above: a UTF-16 cut can end inside one character. + return text.length <= max ? text : Array.from(text).slice(0, max).join('') } export function cleanTools(value) { @@ -78,7 +80,8 @@ export function readStepResult(text, node) { if (!match) { kept.push(line); continue } outputs[match[1].toLowerCase()] = cleanLine(match[2], 2000) } - const body = kept.join('\n').trim().slice(0, 2000) + const joined = kept.join('\n').trim() + const body = joined.length <= 2000 ? joined : Array.from(joined).slice(0, 2000).join('') const declared = node?.outputs ?? [] if (declared.length === 1 && !outputs[declared[0].name] && body) outputs[declared[0].name] = body if (declared.length > 0) { diff --git a/plugins/workflow/test/graph.test.js b/plugins/workflow/test/graph.test.js index f7484b33a..69cb7ff6e 100644 --- a/plugins/workflow/test/graph.test.js +++ b/plugins/workflow/test/graph.test.js @@ -1,8 +1,8 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { - addNode, chainLabel, cleanLine, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, - moveNode, nextDemoOp, normalizeGraph, reconnectEdge, removeEdge, removeNode, retryNode, + addNode, chainLabel, cleanLine, cleanPrompt, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, + moveNode, nextDemoOp, normalizeGraph, readStepResult, reconnectEdge, removeEdge, removeNode, retryNode, rowPreview, startFrontier, summarizeTask, validateGraph, } from '../src/graph.js' import { deliveryWorkflow, weeklyWorkflow } from '../src/template.js' @@ -188,3 +188,24 @@ test('a long task label keeps its whole title and never ends in a lone surrogate assert.equal(cleanLine('🚀'.repeat(10), 7), '🚀'.repeat(7)) assert.equal(hasLoneSurrogate(cleanLine('🚀'.repeat(10), 7)), false) }) + +test('a node prompt and a step reply stop on the bound without splitting the character there', () => { + // #1588 moved cleanLine's cut onto code points; the prompt and the step body + // still counted UTF-16 units, so a cut inside one astral character left a lone + // surrogate — the prompt a Bot was told to follow, and its own reply, carried + // half a character. The bound counts code points here too, so a character that + // fits stays whole instead of being half-kept or dropped. + const fitting = `${'a'.repeat(3999)}🚀` + const prompt = cleanPrompt(fitting, 4000) + assert.equal(prompt, fitting, 'a character that fits the bound is kept whole') + assert.equal(hasLoneSurrogate(prompt), false, 'cleanPrompt left a lone surrogate at the bound') + + const over = cleanPrompt(`${'a'.repeat(4000)}🚀`, 4000) + assert.equal(over, 'a'.repeat(4000), 'the character past the bound goes whole, never half') + assert.equal(hasLoneSurrogate(over), false, 'cleanPrompt left a lone surrogate when it must drop one') + + const reply = `${'a'.repeat(1999)}🚀` + const step = readStepResult(reply, { outputs: [] }) + 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') +})