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/workflow-prompt-codepoint-cut.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions plugins/workflow/src/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
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) {
Expand Down Expand Up @@ -78,7 +80,8 @@
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) {
Expand Down Expand Up @@ -684,8 +687,8 @@
const index = layerIndex(graph, nodeId)
const layer = graph.layers[index]
const outputs = ctx.outputs && typeof ctx.outputs === 'object' ? ctx.outputs : {}
const loops = { ...(ctx.loops ?? {}) }

Check warning on line 690 in plugins/workflow/src/graph.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
const fired = { ...(ctx.fired ?? {}) }

Check warning on line 691 in plugins/workflow/src/graph.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary

// Check if this completion should end the run early (#1537, #1538)
const earlyEnd = checkEarlyEnd(outputs, ctx.text)
Expand Down Expand Up @@ -764,14 +767,14 @@
started: [],
joined: false,
finished: true,
failAttempts: { ...(ctx.failAttempts ?? {}) },

Check warning on line 770 in plugins/workflow/src/graph.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
recovered: false,
reset: [],
earlyEnd: earlyEnd.reason,
}
}

const failAttempts = { ...(ctx.failAttempts ?? {}) }

Check warning on line 777 in plugins/workflow/src/graph.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
const recovery = failRecoveryDecision(graph, nodeId, failAttempts)

if (recovery) {
Expand Down
25 changes: 23 additions & 2 deletions plugins/workflow/test/graph.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
})
Loading