Files
orca/src/shared/orca-dispatch-status-prompt.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

163 lines
6.3 KiB
TypeScript

// Why: full Orca dispatch preambles are multi-KB (CLI instructions before
// `=== TASK ===`). A naive first-N-char fold of the agent-status prompt keeps
// only lifecycle boilerplate and drops the task body the UI needs as a
// fallback label before orchestration metadata arrives. Compact the status
// prompt so preamble detection, the live task id, and the task body all fit
// inside AGENT_STATUS_MAX_FIELD_LENGTH.
export const ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX =
'You are working inside Orca, a multi-agent IDE.'
export const ORCA_DISPATCH_STATUS_TASK_MARKER = '=== TASK ==='
// Why: typed, not pasted, so Claude Code honors the brief: it follows a <pasted_content> block
// only where the user's own words ask it to, and refused bare pasted briefs (STA-8200).
export const ORCA_DISPATCH_PROMPT_LEAD_LINE =
'Please carry out this task from my Orca coordinator by following the brief I pasted below.'
const ORCA_DISPATCH_STATUS_TASK_ID_MARKER = 'Your task ID is:'
// Why: real preambles put === TASK === near the end (~4KB+). Scan past the
// normal single-line budget so the task body is still reachable for compacting.
const ORCA_DISPATCH_STATUS_SOURCE_SCAN_LIMIT = 24_576
const PASTED_CONTENT_OPEN_TAG = '<pasted_content'
// Why: Claude Code's tag carries only a short id; bound the search for `>`.
const PASTED_CONTENT_OPEN_TAG_MAX_LENGTH = 64
/**
* Index of the preamble prefix, or -1. Hook prompts may carry the typed lead
* line and Claude Code's `<pasted_content>` wrapper ahead of it, and hosts
* without the lead line still send the bare preamble.
*/
export function findOrcaDispatchPreambleStart(value: string): number {
// Why: status payloads cross a trust boundary. Keep dispatch detection
// bounded too, or leading whitespace can bypass the normalizer's scan cap.
const scanEnd = Math.min(value.length, ORCA_DISPATCH_STATUS_SOURCE_SCAN_LIMIT)
let start = skipTrimWhitespace(value, 0, scanEnd)
if (value.startsWith(ORCA_DISPATCH_PROMPT_LEAD_LINE, start)) {
start = skipTrimWhitespace(value, start + ORCA_DISPATCH_PROMPT_LEAD_LINE.length, scanEnd)
}
if (value.startsWith(PASTED_CONTENT_OPEN_TAG, start)) {
const tagLength = value.slice(start, start + PASTED_CONTENT_OPEN_TAG_MAX_LENGTH).indexOf('>')
if (tagLength === -1) {
return -1
}
start = skipTrimWhitespace(value, start + tagLength + 1, scanEnd)
}
return start + ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX.length <= scanEnd &&
value.startsWith(ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX, start)
? start
: -1
}
function skipTrimWhitespace(value: string, from: number, scanEnd: number): number {
let index = from
while (index < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(index))) {
index++
}
return index
}
/**
* Collapse a multi-KB dispatch preamble into a single-line status preview that
* still carries enough structure for UI helpers, or null when `value` is not one:
* `<preamble prefix> Your task ID is: <id> === TASK === <task body>`
*/
export function compactDispatchPromptForStatus(
value: string,
maxLength: number,
normalizeSingleLine: (value: string, maxLength: number) => string
): string | null {
const start = findOrcaDispatchPreambleStart(value)
if (start === -1) {
return null
}
const scan = value.slice(start, Math.min(value.length, ORCA_DISPATCH_STATUS_SOURCE_SCAN_LIMIT))
let taskId = ''
const idMarkerIndex = scan.indexOf(ORCA_DISPATCH_STATUS_TASK_ID_MARKER)
if (idMarkerIndex !== -1) {
const afterId = scan.slice(idMarkerIndex + ORCA_DISPATCH_STATUS_TASK_ID_MARKER.length)
let idStart = 0
while (idStart < afterId.length && isEcmaTrimWhitespace(afterId.charCodeAt(idStart))) {
idStart++
}
const idRest = afterId.slice(idStart)
const idEnd = idRest.search(/\s/)
taskId = (idEnd === -1 ? idRest : idRest.slice(0, idEnd)).trim()
}
let taskBody = ''
const taskMarkerIndex = findOrcaDispatchTaskMarkerIndex(scan)
if (taskMarkerIndex !== -1) {
const body = scan.slice(taskMarkerIndex + ORCA_DISPATCH_STATUS_TASK_MARKER.length)
for (const line of body.split(/\r?\n/)) {
const preview = line.trim().replace(/\s+/g, ' ')
if (preview.startsWith('</pasted_content')) {
break
}
if (preview) {
taskBody = preview
break
}
}
}
// Why: keep the dispatch prefix (isOrcaDispatchPrompt) + task id (label match)
// + task body (fallback preview) so UI helpers still work on the 200-char field.
let compact = ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX
if (taskId) {
compact += ` ${ORCA_DISPATCH_STATUS_TASK_ID_MARKER} ${taskId}`
}
if (taskBody) {
compact += ` ${ORCA_DISPATCH_STATUS_TASK_MARKER} ${taskBody}`
}
return normalizeSingleLine(compact, maxLength)
}
/**
* Locate the Orca task separator in a dispatch prompt scan window.
* Why: base-drift commit subjects are repository-controlled and may mention
* `=== TASK ===`. Raw multi-line preambles must use the standalone line Orca
* emits; already-normalized single-line status previews intentionally keep the
* marker inline so re-normalization and UI helpers stay consistent.
*/
export function findOrcaDispatchTaskMarkerIndex(value: string): number {
let searchFrom = 0
while (searchFrom < value.length) {
const markerIndex = value.indexOf(ORCA_DISPATCH_STATUS_TASK_MARKER, searchFrom)
if (markerIndex === -1) {
break
}
const markerEnd = markerIndex + ORCA_DISPATCH_STATUS_TASK_MARKER.length
const startsLine = markerIndex === 0 || isLineBreak(value.charCodeAt(markerIndex - 1))
const endsLine = markerEnd === value.length || isLineBreak(value.charCodeAt(markerEnd))
if (startsLine && endsLine) {
return markerIndex
}
searchFrom = markerEnd
}
// Already-normalized dispatch previews are single-line and intentionally
// carry the marker inline; normalization must stay idempotent across hops.
return value.includes('\n') || value.includes('\r')
? -1
: value.indexOf(ORCA_DISPATCH_STATUS_TASK_MARKER)
}
function isLineBreak(code: number): boolean {
return code === 10 || code === 13
}
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
)
}