Files
orca/src/shared/agent-status-field-normalization.ts
T
Jinwoo Hong ca75bc4c8d fix(orchestration): type a request ahead of pasted dispatch briefs so Claude workers follow them (#22582)
* fix(orchestration): type a request ahead of pasted dispatch briefs so Claude workers follow them

Claude Code wraps a bracketed paste in <pasted_content> and tells the model to
follow instructions inside it only where the user's own message asks. Orca sent
the whole dispatch brief as a bare paste, so Claude workers (Opus 5.5, Sonnet 5)
refused it as suspected prompt injection. Every dispatch path now types a short
lead line in the same PTY write as the paste frame, the preamble drops shouted
rules, and dispatch detection accepts the lead line and pasted_content wrapper.

Fixes STA-8200

* refactor(orchestration): tidy dispatch lead-line delivery after review

- Share one dispatchPreambleSendOptions() across the four dispatch paths.
- Fold every C0 control and DEL out of the typed lead line.
- Bound the <pasted_content> tag scan and let compaction return null for
  non-dispatch prompts, removing the separate detector.
- Restore the stay-off-other-channels rule in plain wording.
- Test through the real status normalizer and trim duplicated assertions.

Refs STA-8200

* test(orchestration): guard coordinator auto-dispatch lead line

- Capture send options in the coordinator runtime fake and assert the
  auto-dispatch send uses dispatchPreambleSendOptions.
- Drop the helper test that only restated its literal.
- Share DispatchPreambleSendOptions with the coordinator runtime contract.

Refs STA-8200

* docs(orchestration): fit the pasted-spec note inside the kernel line budget

Refs STA-8200

* docs(orchestration): drop the pasted-spec note from the coordinator guide

The typed lead line is the fix; the advisory note cost always-loaded context.

Refs STA-8200

* fix(orchestration): type the dispatch lead line only for Claude agents

Codex discards typed text that shares a PTY write with a bracketed paste,
so the lead line never reached it. Only Claude Code needs the lead to follow
a pasted brief, so known non-Claude agents now get the pre-lead bytes and
unidentified agents keep the lead in case they are Claude.

Refs STA-8200
2026-09-24 01:33:16 -04:00

218 lines
7.8 KiB
TypeScript

// ─── Agent status field normalization ───────────────────────────────────────
// String normalizers shared by every agent-status payload field: trim/fold to
// a single line for previews, preserve structure for multiline bodies, and
// truncate without splitting surrogate pairs. Extracted from
// agent-status-types.ts, which owns the payload shapes and per-field caps.
import { compactDispatchPromptForStatus } from './orca-dispatch-status-prompt'
/** Maximum character length for the prompt field. Truncated on parse. */
export const AGENT_STATUS_MAX_FIELD_LENGTH = 200
const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64
const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8
// Why: when truncation lands mid surrogate-pair (emoji / astral chars), the
// high surrogate would be left dangling and render as the Unicode replacement
// glyph. Drop the lone high surrogate so the result is always a valid UTF-16
// sequence. Shared by the single-line and multiline normalizers so the
// protection can't drift between them.
function truncatePreservingSurrogates(value: string, maxLength: number): string {
if (value.length < maxLength) {
return value
}
let truncated = value.length === maxLength ? value : value.slice(0, maxLength)
const lastCode = truncated.charCodeAt(truncated.length - 1)
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
truncated = truncated.slice(0, -1)
}
return truncated
}
/** Normalize a status field: trim, collapse to single line, truncate. */
function normalizeField(value: unknown, maxLength: number = AGENT_STATUS_MAX_FIELD_LENGTH): string {
if (typeof value !== 'string') {
return ''
}
return normalizeSingleLinePreview(value, maxLength)
}
/** Normalize the agent prompt field, compacting Orca dispatch preambles. */
export function normalizePromptField(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
return (
compactDispatchPromptForStatus(
value,
AGENT_STATUS_MAX_FIELD_LENGTH,
normalizeSingleLinePreview
) ?? normalizeSingleLinePreview(value, AGENT_STATUS_MAX_FIELD_LENGTH)
)
}
function normalizeSingleLinePreview(value: string, maxLength: number): string {
// Why: hook prompt/tool fields are previews. Bound the source scan before
// folding line breaks so paste-sized status text cannot run a full regex
// replacement just to keep a small dashboard label.
const scanEnd = Math.min(
value.length,
maxLength * SINGLE_LINE_FIELD_SCAN_MULTIPLIER + SINGLE_LINE_FIELD_SCAN_OVERHEAD
)
let index = 0
while (index < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(index))) {
index++
}
let normalized = ''
let lineSeparatorRun = false
while (index < scanEnd && normalized.length < maxLength) {
const code = value.charCodeAt(index)
if (isSingleLineSeparator(code)) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index++
}
if (!lineSeparatorRun) {
normalized += ' '
}
lineSeparatorRun = true
index++
continue
}
normalized += value[index]
lineSeparatorRun = false
index++
}
if (normalized.length < maxLength) {
normalized = trimTrailingWhitespace(normalized)
}
return truncatePreservingSurrogates(normalized, maxLength)
}
// Why: assistant messages are a multi-paragraph "what did the agent say"
// body that the dashboard renders with `whitespace-pre-wrap`. Collapsing
// newlines here would erase structure the UI is designed to show. Still
// normalize `\r\n` → `\n` and cap paragraph gaps at one blank line to keep
// the bound meaningful, but otherwise preserve line breaks.
function normalizeMultilineField(value: unknown, maxLength: number): string {
if (typeof value !== 'string') {
return ''
}
// Why: fold Unicode line/paragraph separators (U+2028, U+2029) into ordinary
// `\n` before the blank-line-run cap. These code points render as real line
// breaks under `whitespace-pre-wrap`, so leaving them untouched would let a
// buggy/malicious agent bypass the `\n{3,}` → `\n\n` safeguard by spamming
// arbitrarily many U+2029 paragraph breaks. Matches the single-line
// normalizer's treatment of the same code points, keeping the two paths in
// sync. Step order preserved: `\r\n` → `\n`, bare `\r` → `\n`,
// U+2028/U+2029 → `\n`, then collapse blank-line runs.
const { start, end } = getTrimmedStringBounds(value)
let normalized = ''
let newlineRun = 0
for (let index = start; index < end && normalized.length < maxLength; index++) {
const code = value.charCodeAt(index)
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index++
}
if (newlineRun < 2) {
normalized += '\n'
}
newlineRun++
continue
}
normalized += value[index]
newlineRun = 0
}
return truncatePreservingSurrogates(normalized, maxLength)
}
function getTrimmedStringBounds(value: string): { start: number; end: number } {
let start = 0
let end = value.length
while (start < end && isEcmaTrimWhitespace(value.charCodeAt(start))) {
start++
}
while (end > start && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
end--
}
return { start, end }
}
function trimTrailingWhitespace(value: string): string {
let end = value.length
while (end > 0 && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
end--
}
return end === value.length ? value : value.slice(0, end)
}
function isSingleLineSeparator(code: number): boolean {
return code === 13 || code === 10 || code === 0x2028 || code === 0x2029
}
function isEcmaTrimWhitespace(code: number): boolean {
return (
code === 0x20 ||
(code >= 0x09 && code <= 0x0d) ||
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
)
}
// Why: tool/assistant fields are optional on the entry (absence = "no update
// for this field"). We only surface them when the caller actually provided a
// string value so a missing field doesn't overwrite the prior cached state.
// Why: interactivePrompt carries raw JSON (`{ questions: [...] }`) that clients
// JSON.parse to render a structured card. Unlike the other normalizers we must
// NOT trim, collapse newlines, or fold blank-line runs — any of those would
// corrupt the JSON or alter option text inside it. Only guard the length cap
// (preserving surrogate pairs) and drop empty strings to undefined.
export function normalizeInteractivePromptField(
value: unknown,
maxLength: number
): string | undefined {
if (typeof value !== 'string' || value.length === 0) {
return undefined
}
const truncated = truncatePreservingSurrogates(value, maxLength)
return truncated.length > 0 ? truncated : undefined
}
export function normalizeOptionalField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = normalizeField(value, maxLength)
return normalized.length > 0 ? normalized : undefined
}
export function normalizeOptionalMultilineField(
value: unknown,
maxLength: number
): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = normalizeMultilineField(value, maxLength)
return normalized.length > 0 ? normalized : undefined
}
/** Keep a lead turn's end time only on the gated `working` row and that turn's later all-clear `done`. */
export function normalizeTurnCompletedAtField(value: unknown, state: string): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return state === 'working' || state === 'done' ? value : undefined
}