diff --git a/src/shared/orchestration-task-summary.test.ts b/src/shared/orchestration-task-summary.test.ts new file mode 100644 index 00000000000..75fa6e68374 --- /dev/null +++ b/src/shared/orchestration-task-summary.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { abbreviateOrchestrationTasks } from './orchestration-task-summary' + +describe('abbreviateOrchestrationTasks', () => { + it('collapses whitespace and caps long task specs', () => { + const [task] = abbreviateOrchestrationTasks([ + { id: 'task_1', spec: `First line\n\n${'detail '.repeat(40)}` } + ]) + + expect(task.id).toBe('task_1') + expect(task.spec).not.toContain('\n') + expect(task.spec).toHaveLength(160) + expect(task.spec.endsWith('…')).toBe(true) + expect(task.spec_truncated).toBe(true) + }) + + it('preserves a short one-line spec', () => { + const [task] = abbreviateOrchestrationTasks([{ spec: 'Short task' }]) + + expect(task).toEqual({ spec: 'Short task', spec_truncated: false }) + }) + + it('does not report whitespace normalization as truncation', () => { + const [task] = abbreviateOrchestrationTasks([{ spec: 'Short\n\n task' }]) + + expect(task).toEqual({ spec: 'Short task', spec_truncated: false }) + }) + + it('does not split a surrogate pair at the truncation boundary', () => { + // 158 chars + an astral emoji spanning UTF-16 units 158-159: a naive + // slice(0, 159) would cut the pair and leave a lone high surrogate. + const [task] = abbreviateOrchestrationTasks([{ spec: `${'a'.repeat(158)}😀${'b'.repeat(40)}` }]) + + expect(task.spec_truncated).toBe(true) + expect(task.spec.isWellFormed()).toBe(true) + expect(task.spec.endsWith('…')).toBe(true) + }) +}) diff --git a/src/shared/orchestration-task-summary.ts b/src/shared/orchestration-task-summary.ts new file mode 100644 index 00000000000..5c2cd46e40f --- /dev/null +++ b/src/shared/orchestration-task-summary.ts @@ -0,0 +1,25 @@ +const TASK_SPEC_BRIEF_LENGTH = 160 + +export function abbreviateOrchestrationTasks( + tasks: readonly T[] +): (T & { spec_truncated: boolean })[] { + return tasks.map((task) => { + const spec = task.spec.replace(/\s+/g, ' ').trim() + const truncated = spec.length > TASK_SPEC_BRIEF_LENGTH + return { + ...task, + spec: truncated ? `${truncateAtCodePoint(spec).trimEnd()}…` : spec, + // Why: whitespace normalization alone is not truncation; flagging it + // would make agents re-fetch full specs that --brief already shows. + spec_truncated: truncated + } + }) +} + +function truncateAtCodePoint(spec: string): string { + const sliced = spec.slice(0, TASK_SPEC_BRIEF_LENGTH - 1) + // Why: a cut through a surrogate pair leaves a lone high surrogate that + // strict JSON consumers reject; drop it rather than emit malformed UTF-16. + const lastUnit = sliced.charCodeAt(sliced.length - 1) + return lastUnit >= 0xd800 && lastUnit <= 0xdbff ? sliced.slice(0, -1) : sliced +}