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
This commit is contained in:
Jinwoo Hong
2026-09-24 01:33:16 -04:00
committed by GitHub
parent 420fcb3e77
commit ca75bc4c8d
22 changed files with 282 additions and 131 deletions
@@ -8,7 +8,10 @@ import {
assertTerminalInputWithinLimitWithYield,
buildTerminalSendPayload
} from './terminal-send-payload'
import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection'
import {
agentPromptTakesLeadLine,
buildAgentPromptPasteBytes
} from '../../shared/agent-prompt-injection'
export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithResolveTerminalPane {
private lastProvenAbsentLeafPtyVerdictPruneAt: number | undefined
@@ -151,12 +154,21 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
prompt: string,
options: RuntimeAgentPromptWriteOptions = {}
): Promise<RuntimeTerminalSend> {
const payload = buildAgentPromptPasteBytes(prompt)
// Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup.
const payloadFor = (ptyId: string): string => {
const pty = this.ptysById.get(ptyId)
const agent = pty?.foregroundAgent ?? pty?.launchAgent
return buildAgentPromptPasteBytes(
prompt,
agentPromptTakesLeadLine(agent) ? options.leadLine : undefined
)
}
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
throw new Error('terminal_not_writable')
}
const payload = payloadFor(pty.pty.ptyId)
await assertTerminalInputWithinLimitWithYield(payload)
const generation = this.getPtyLifecycleGeneration(pty.pty.ptyId)
const delivery = await this.serializeAgentPromptSubmission(
@@ -165,13 +177,10 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
async () => {
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
this.assertAgentPromptGeneration(pty.pty.ptyId, generation)
return await this.writeTerminalAgentPrompt(
handle,
pty.pty.ptyId,
generation,
payload,
{ ...options, promptForSchedule: prompt }
)
return await this.writeTerminalAgentPrompt(handle, pty.pty.ptyId, generation, payload, {
...options,
promptForSchedule: prompt
})
}
)
const bytesWritten = Buffer.byteLength(payload, 'utf8') + delivery.submits
@@ -187,6 +196,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
if (!leaf.writable || !leaf.ptyId) {
throw new Error('terminal_not_writable')
}
const payload = payloadFor(leaf.ptyId)
await assertTerminalInputWithinLimitWithYield(payload)
// Why: same absence gate as sendTerminal — a stale graph mirror must not
// accept a prompt into a void; unknown liveness still proceeds.
@@ -6,6 +6,7 @@ import {
resolveAgentPromptSubmitDelayForAgent
} from '../../../shared/agent-prompt-injection'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { ORCA_DISPATCH_PROMPT_LEAD_LINE } from '../../../shared/orca-dispatch-status-prompt'
import type { TuiAgent } from '../../../shared/tui-agent'
import { OrcaRuntimeService } from '../orca-runtime'
import { acknowledgeAgentPromptSubmit } from '../orca-runtime-test-mocks.spec'
@@ -509,6 +510,46 @@ describe('OrcaRuntimeService', () => {
}
})
it.each([
['claude', true],
['an unknown agent', true],
['codex', false]
] as const)('types the lead line for %s: %s', async (agent, typesLead) => {
vi.useFakeTimers()
try {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(
`path:${TEST_WORKTREE_PATH}`,
agent === 'an unknown agent' ? undefined : { launchAgent: agent }
)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', {
leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE
})
await vi.runAllTimersAsync()
await sendPromise
const paste = buildAgentPromptPasteBytes('the brief')
expect(writes).toEqual([
typesLead ? `${ORCA_DISPATCH_PROMPT_LEAD_LINE} ${paste}` : paste,
'\r'
])
} finally {
vi.useRealTimers()
}
})
it.each(['claude', 'codex'] as const)(
'waits for %s composer output frames to settle before one submit',
async (agent) => {
@@ -5,29 +5,30 @@ exports[`buildDispatchPreamble > renders a stable snapshot of the full preamble
Your coordinator's terminal handle is: term_COORD
Your task ID is: task_SNAP
You talk to the coordinator only through the CLI commands below. Do not use
Slack, GitHub comments, or any other channel to reach a human during the run.
The coordinator cannot see this terminal, so reach it with the \`orca orchestration\`
commands below; a question or result left only in this terminal never gets to it.
Don't post to Slack, GitHub, or other channels during the run; report through these commands.
=== CLI COMMANDS ===
\`\`\`sh
# Report the terminal task outcome (REQUIRED exactly once).
# Report the task outcome (required, exactly once).
#
# RULE: --body must be a 3-sentence executive summary (what you did,
# --body must be a 3-sentence executive summary (what you did,
# what you found, what's left). Never send an empty body; the coordinator
# reads the body first and only opens artifacts if it needs more detail.
# Append --files-modified only when files changed, and append --report-path
# only when you produced a durable report. Always pass real values; do not
# send the example placeholders literally.
#
# RULE: send worker_done exactly once. Use --outcome succeeded when the
# Send worker_done exactly once. Use --outcome succeeded when the
# requested work is done, or replace it with --outcome failed when it is not.
# Never encode failure only in prose and never silently exit.
# Include BOTH taskId and dispatchId in the payload so a late completion
# from a failed retry cannot complete the current dispatch.
orca orchestration send --from term_WORKER --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id task_SNAP --dispatch-id ctx_SNAP --outcome succeeded
# BEHAVIOR RULE: send a heartbeat every 5 minutes
# Send a heartbeat every 5 minutes
# while actively working on the task. The coordinator uses this to
# distinguish "still thinking" from "hung / crashed." Skip heartbeats only
# while blocked inside \`check --wait\` or \`ask\` — those calls are
@@ -41,11 +42,9 @@ Slack, GitHub comments, or any other channel to reach a human during the run.
# Ask the coordinator a question and block until it answers.
#
# BEHAVIOR RULE #1 (MUST NOT VIOLATE):
# NEVER use AskUserQuestion; use \`orca orchestration ask\`.
# AskUserQuestion opens a local TUI prompt that the
# coordinator cannot see and cannot answer — your session will hang forever
# waiting on a human. Every interactive question goes through \`ask\` below.
# Use this instead of AskUserQuestion: that opens a local prompt the
# coordinator cannot see or answer, so the task would stall until someone
# happened to look at this terminal. Send every question through \`ask\`.
#
# The \`ask\` verb durably records a question in this Dispatch's Run and
# blocks until the coordinator replies, then prints the reply body. If the
@@ -1,3 +1,5 @@
import type { DispatchPreambleSendOptions } from './preamble'
/** The terminal/worktree capabilities the coordinator needs from the runtime it drives. */
export type WorktreeDrift = {
base: string
@@ -9,11 +11,7 @@ export type CoordinatorRuntime = {
sendTerminalAgentPrompt(
handle: string,
prompt: string,
options?: {
acceptQueued?: boolean
observationTimeoutMs?: number
requestId?: string
}
options?: DispatchPreambleSendOptions
): Promise<unknown>
listTerminals(
worktreeSelector?: string,
@@ -1,7 +1,7 @@
/** Picking worker terminals, sending a task's dispatch preamble, and warning about hung dispatches. */
import type { OrchestrationDb } from './db'
import type { TaskRow } from './types'
import { buildDispatchPreamble } from './preamble'
import { buildDispatchPreamble, dispatchPreambleSendOptions } from './preamble'
import type { CoordinatorRuntime, WorktreeDrift } from './coordinator-runtime-contract'
import {
DISPATCH_STALE_THRESHOLD,
@@ -136,11 +136,11 @@ export async function dispatchTaskToWorker(params: {
}
try {
await runtime.sendTerminalAgentPrompt(targetHandle, preamble + gateContext, {
acceptQueued: true,
observationTimeoutMs: 0,
requestId: dispatch.id
})
await runtime.sendTerminalAgentPrompt(
targetHandle,
preamble + gateContext,
dispatchPreambleSendOptions(dispatch.id)
)
} catch (err) {
// Why (#16095): Enter is written before submission is verified, so a stall is only ever an
// unobserved turn start — never proof the preamble is missing. Failing here would reset the
@@ -3,6 +3,7 @@ import { OrchestrationDb } from './db'
import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
import { Coordinator } from './coordinator'
import type { CoordinatorRuntime } from './coordinator-runtime-contract'
import { dispatchPreambleSendOptions, type DispatchPreambleSendOptions } from './preamble'
import { DISPATCH_STALE_THRESHOLD } from './coordinator-stale-base-flag'
import { createRootDispatch } from './db/root-dispatch-test-fixture'
@@ -14,8 +15,10 @@ type DriftResult = {
recentSubjects: string[]
} | null
type SentMessage = { handle: string; text: string; options?: DispatchPreambleSendOptions }
function createMockRuntime(): CoordinatorRuntime & {
sentMessages: { handle: string; text: string }[]
sentMessages: SentMessage[]
terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[]
createdTerminals: string[]
createdTerminalOptions: { title?: string }[]
@@ -25,8 +28,9 @@ function createMockRuntime(): CoordinatorRuntime & {
setProbeDrift(result: DriftResult): void
throwProbeDrift: Error | null
} {
const sentMessages: SentMessage[] = []
const mock = {
sentMessages: [] as { handle: string; text: string }[],
sentMessages,
terminals: [] as {
handle: string
worktreeId: string
@@ -42,8 +46,8 @@ function createMockRuntime(): CoordinatorRuntime & {
setProbeDrift(result: DriftResult): void {
mock.probeDriftResult = result
},
async sendTerminalAgentPrompt(handle: string, prompt: string) {
mock.sentMessages.push({ handle, text: prompt })
async sendTerminalAgentPrompt(handle: string, text: string, options?: SentMessage['options']) {
mock.sentMessages.push({ handle, text, options })
return { handle, accepted: true, bytesWritten: 0 }
},
async listTerminals() {
@@ -159,6 +163,7 @@ describe('Coordinator', () => {
expect(result.completedTasks).toContain(task.id)
expect(runtime.sentMessages.length).toBeGreaterThan(0)
expect(runtime.sentMessages[0].text).toContain('orca-ide orchestration send')
expect(runtime.sentMessages[0].options).toEqual(dispatchPreambleSendOptions(expect.any(String)))
})
it('records the assignee pane key when the runtime can resolve one', async () => {
@@ -142,21 +142,24 @@ describe('buildDispatchPreamble', () => {
expect(result).toMatch(/orchestration send --from term_worker/)
})
it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => {
it('includes ask block that steers questions away from AskUserQuestion', () => {
const result = buildDispatchPreamble(baseParams())
expect(result).toMatch(/orchestration ask --from term_worker/)
expect(result).toContain('--question')
expect(result).toContain('--timeout-ms 600000')
expect(result).not.toContain('--type decision_gate')
// Why: the exact phrase is asserted so the rule can't be trimmed away by
// accident. BEHAVIOR RULE #1 is the only place AskUserQuestion appears.
expect(result).toContain('BEHAVIOR RULE #1')
expect(result).toContain('NEVER use AskUserQuestion')
// AskUserQuestion must appear ONLY inside the rule text, not anywhere
// else (e.g., not in an example payload or header). Count occurrences
// of the exact token as a sanity check.
const occurrences = (result.match(/AskUserQuestion/g) ?? []).length
expect(occurrences).toBe(2)
// accident. The ask block is the only place AskUserQuestion appears.
expect(result).toContain('Use this instead of AskUserQuestion')
expect(result).toContain('Send every question through `ask`')
expect((result.match(/AskUserQuestion/g) ?? []).length).toBe(1)
})
it('avoids shouted rules', () => {
// Why: Claude workers cited shouted rules when refusing briefs as prompt injection (STA-8200).
expect(buildDispatchPreamble(baseParams())).not.toMatch(
/MUST NOT VIOLATE|BEHAVIOR RULE|NEVER use/
)
})
it('binds every injected worker command to the dispatched terminal', () => {
+29 -11
View File
@@ -1,4 +1,6 @@
import type { OrchestrationCliCommand } from './cli-command'
import type { RuntimeAgentPromptWriteOptions } from '../runtime-terminal-contracts'
import { ORCA_DISPATCH_PROMPT_LEAD_LINE } from '../../../shared/orca-dispatch-status-prompt'
export type PreambleParams = {
taskId: string
@@ -61,33 +63,36 @@ export function buildDispatchPreamble(params: PreambleParams): string {
// Why: one-line recipes paste unchanged in POSIX shells, PowerShell, and cmd.exe.
// Why fenced: keeps the shell comments executable without rendering them as Chat UI headings.
// Why plain-reason wording: Claude Code tells the model pasted text may carry instructions
// the user did not write, and shouted rules read as prompt injection (STA-8200).
const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker.
Your coordinator's terminal handle is: ${params.coordinatorHandle}
Your task ID is: ${params.taskId}
You talk to the coordinator only through the CLI commands below. Do not use
Slack, GitHub comments, or any other channel to reach a human during the run.
The coordinator cannot see this terminal, so reach it with the \`${cli} orchestration\`
commands below; a question or result left only in this terminal never gets to it.
Don't post to Slack, GitHub, or other channels during the run; report through these commands.
=== CLI COMMANDS ===
\`\`\`sh
# Report the terminal task outcome (REQUIRED exactly once).
# Report the task outcome (required, exactly once).
#
# RULE: --body must be a 3-sentence executive summary (what you did,
# --body must be a 3-sentence executive summary (what you did,
# what you found, what's left). Never send an empty body; the coordinator
# reads the body first and only opens artifacts if it needs more detail.
# Append --files-modified only when files changed, and append --report-path
# only when you produced a durable report. Always pass real values; do not
# send the example placeholders literally.
#
# RULE: send worker_done exactly once. Use --outcome succeeded when the
# Send worker_done exactly once. Use --outcome succeeded when the
# requested work is done, or replace it with --outcome failed when it is not.
# Never encode failure only in prose and never silently exit.
# Include BOTH taskId and dispatchId in the payload so a late completion
# from a failed retry cannot complete the current dispatch.
${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} --outcome succeeded
# BEHAVIOR RULE: send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes
# Send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes
# while actively working on the task. The coordinator uses this to
# distinguish "still thinking" from "hung / crashed." Skip heartbeats only
# while blocked inside \`check --wait\` or \`ask\` — those calls are
@@ -101,11 +106,9 @@ Slack, GitHub comments, or any other channel to reach a human during the run.
# Ask the coordinator a question and block until it answers.
#
# BEHAVIOR RULE #1 (MUST NOT VIOLATE):
# NEVER use AskUserQuestion; use \`${cli} orchestration ask\`.
# AskUserQuestion opens a local TUI prompt that the
# coordinator cannot see and cannot answer — your session will hang forever
# waiting on a human. Every interactive question goes through \`ask\` below.
# Use this instead of AskUserQuestion: that opens a local prompt the
# coordinator cannot see or answer, so the task would stall until someone
# happened to look at this terminal. Send every question through \`ask\`.
#
# The \`ask\` verb durably records a question in this Dispatch's Run and
# blocks until the coordinator replies, then prints the reply body. If the
@@ -142,6 +145,21 @@ ${postDoneInstructions}`
${params.taskSpec}`
}
export type DispatchPreambleSendOptions = Pick<
RuntimeAgentPromptWriteOptions,
'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId'
>
export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions {
// Why: a delayed provider hook must not revoke an accepted Dispatch.
return {
leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE,
acceptQueued: true,
observationTimeoutMs: 0,
requestId
}
}
function buildPostWorkerDoneInstructions({
cli,
workerKind
@@ -12,6 +12,7 @@ import { ORCHESTRATION_METHODS } from '../../orchestration'
import { createFederationWorkerStartRequest as startRequest } from './federation-request.test-support'
import { configureFederationWorkerRuntime } from './federation-runtime.test-support'
import { syncFederationBarrier } from './federation-sync-barrier.test-support'
import { dispatchPreambleSendOptions } from '../../../../orchestration/preamble'
describe('orchestration federation', () => {
const databases: OrchestrationDb[] = []
@@ -151,11 +152,7 @@ describe('orchestration federation', () => {
expect(workerRuntime.sendTerminalAgentPrompt).toHaveBeenCalledWith(
'term_windows_worker',
expect.stringContaining(`Your task ID is: ${task.id}`),
expect.objectContaining({
acceptQueued: true,
observationTimeoutMs: 0,
requestId: expect.any(String)
})
expect.objectContaining(dispatchPreambleSendOptions(expect.any(String)))
)
})
@@ -1,6 +1,9 @@
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias'
import { buildDispatchPreamble } from '../../../../orchestration/preamble'
import {
buildDispatchPreamble,
dispatchPreambleSendOptions
} from '../../../../orchestration/preamble'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { defineMethod } from '../../../core'
import { assertOrchestrationWorktreeCreationSupported } from '../worker/folder-worktree-placement'
@@ -261,11 +264,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [
canDispatchSubWorkers: (params.depth ?? 1) < runtime.getNestedWorkerMaxDepth(),
cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle)
}),
{
acceptQueued: true,
observationTimeoutMs: 0,
requestId: orchestrationMutation.requestId
}
dispatchPreambleSendOptions(orchestrationMutation.requestId)
)
effects.push({
kind: 'dispatch_input',
@@ -1,6 +1,9 @@
import { defineMethod } from '../../../core'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { buildDispatchPreamble } from '../../../../orchestration/preamble'
import {
buildDispatchPreamble,
dispatchPreambleSendOptions
} from '../../../../orchestration/preamble'
import { resolveDispatchCreator } from './dispatch-creator'
import {
injectRejectedError,
@@ -156,12 +159,11 @@ export const ORCHESTRATION_DISPATCH_METHODS = [
let prompt
if (params.inject) {
try {
prompt = await runtime.sendTerminalAgentPrompt(to, preamble, {
// A delayed provider hook must not revoke an accepted Dispatch.
acceptQueued: true,
observationTimeoutMs: 0,
requestId: orchestrationMutation?.requestId ?? ctx.id
})
prompt = await runtime.sendTerminalAgentPrompt(
to,
preamble,
dispatchPreambleSendOptions(orchestrationMutation?.requestId ?? ctx.id)
)
injected = true
} catch (err) {
db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err))
@@ -5,6 +5,7 @@ import type { OrchestrationDb } from '../../../../orchestration/db'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { buildInjectRejectionMessage } from '../../../../../../shared/orchestration-dispatch-refusal-contract'
import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture'
import { dispatchPreambleSendOptions } from '../../../../orchestration/preamble'
describe('orchestration RPC methods', () => {
const h = createOrchestrationRpcHarness()
@@ -348,11 +349,7 @@ describe('orchestration RPC methods', () => {
expect(send).toHaveBeenCalledWith(
'term_a',
expect.stringContaining('orca-dev orchestration send'),
expect.objectContaining({
acceptQueued: true,
observationTimeoutMs: 0,
requestId: expect.any(String)
})
expect.objectContaining(dispatchPreambleSendOptions(expect.any(String)))
)
})
@@ -394,11 +391,7 @@ describe('orchestration RPC methods', () => {
expect(agentPrompt).toHaveBeenCalledWith(
'term_a',
expect.stringContaining('line one\nline two'),
expect.objectContaining({
acceptQueued: true,
observationTimeoutMs: 0,
requestId: expect.any(String)
})
expect.objectContaining(dispatchPreambleSendOptions(expect.any(String)))
)
expect(rawSend).not.toHaveBeenCalled()
})
@@ -4,6 +4,7 @@ import { createOrchestrationRpcHarness } from '../rpc-test-harness'
import type { OrchestrationDb } from '../../../../orchestration/db'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants'
import { dispatchPreambleSendOptions } from '../../../../orchestration/preamble'
describe('orchestration RPC methods', () => {
const h = createOrchestrationRpcHarness()
@@ -160,11 +161,7 @@ describe('orchestration RPC methods', () => {
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith(
'term_worker',
expect.stringContaining('--dispatch-capability dcap_'),
expect.objectContaining({
acceptQueued: true,
observationTimeoutMs: 0,
requestId: expect.any(String)
})
expect.objectContaining(dispatchPreambleSendOptions(expect.any(String)))
)
})
@@ -1,6 +1,9 @@
import type { RuntimeTerminalSend } from '../../../../../../shared/runtime-terminal-contracts'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { buildDispatchPreamble } from '../../../../orchestration/preamble'
import {
buildDispatchPreamble,
dispatchPreambleSendOptions
} from '../../../../orchestration/preamble'
import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session'
import type { createStructuredWorkerSessionForWorktree } from './worker-topology'
@@ -51,10 +54,10 @@ export async function deliverWorkerDispatchPreamble(args: {
return undefined
}
return (
await runtime.sendTerminalAgentPrompt(terminalHandle, preamble, {
acceptQueued: true,
observationTimeoutMs: 0,
requestId: args.requestId
})
await runtime.sendTerminalAgentPrompt(
terminalHandle,
preamble,
dispatchPreambleSendOptions(args.requestId)
)
).prompt
}
@@ -205,6 +205,8 @@ export type RuntimeProviderSnapshotReadOptions = {
export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & {
/** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */
promptForSchedule?: string
/** See buildAgentPromptPasteBytes. */
leadLine?: string
/** Return an accepted receipt as soon as input lands, instead of waiting for the turn. */
acceptQueued?: boolean
observationTimeoutMs?: number
+11 -9
View File
@@ -1,11 +1,10 @@
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import {
findOrcaDispatchPreambleStart,
findOrcaDispatchTaskMarkerIndex,
ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX,
ORCA_DISPATCH_STATUS_TASK_MARKER
} from '../../../shared/orca-dispatch-status-prompt'
export const ORCA_DISPATCH_PREAMBLE_PREFIX = ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX
const ORCA_DISPATCH_TASK_MARKER = ORCA_DISPATCH_STATUS_TASK_MARKER
const ORCA_DISPATCH_TASK_ID_MARKER = 'Your task ID is:'
// Why: match deriveGeneratedTabTitle's scan budget — previews only need the
@@ -19,7 +18,7 @@ const ORCA_DISPATCH_TASK_MARKER_SCAN_LIMIT = 32_768
/** True when the live prompt is still an Orca dispatch turn (not sticky metadata alone). */
export function isOrcaDispatchPrompt(prompt: string): boolean {
return prompt.trimStart().startsWith(ORCA_DISPATCH_PREAMBLE_PREFIX)
return findOrcaDispatchPreambleStart(prompt) !== -1
}
/**
@@ -77,10 +76,11 @@ export function getAgentRowGeneratedTitleText(
}
export function getOrcaDispatchTaskId(prompt: string): string | null {
if (!isOrcaDispatchPrompt(prompt)) {
const start = findOrcaDispatchPreambleStart(prompt)
if (start === -1) {
return null
}
const scan = prompt.trimStart().slice(0, ORCA_DISPATCH_TASK_ID_SCAN_LIMIT)
const scan = prompt.slice(start, start + ORCA_DISPATCH_TASK_ID_SCAN_LIMIT)
const markerIndex = scan.indexOf(ORCA_DISPATCH_TASK_ID_MARKER)
if (markerIndex === -1) {
return null
@@ -102,12 +102,14 @@ function getOrcaDispatchTaskPreview(prompt: string): string {
// spaces) and capped ~200 chars by normalizePromptField, which preserves
// `=== TASK ===` + body. Prefer the first non-empty line so multi-line raw
// preambles still work; a single-line fold is one "line" after the marker.
if (!isOrcaDispatchPrompt(prompt)) {
const start = findOrcaDispatchPreambleStart(prompt)
if (start === -1) {
return ''
}
const scan = prompt
.trimStart()
.slice(0, ORCA_DISPATCH_TASK_MARKER_SCAN_LIMIT + ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT)
const scan = prompt.slice(
start,
start + ORCA_DISPATCH_TASK_MARKER_SCAN_LIMIT + ORCA_DISPATCH_TASK_PREVIEW_SCAN_LIMIT
)
// Why: share the normalizer's standalone-line marker rule. A naive indexOf
// would treat base-drift commit subjects that mention `=== TASK ===` as the
// real separator when helpers are called with raw multi-line preambles.
+16
View File
@@ -5,6 +5,7 @@ import {
buildAgentPromptPasteBytes,
buildAgentPromptSubmitBytes,
agentPromptSubmitJoinsPasteFrame,
agentPromptTakesLeadLine,
getAgentPromptSubmitDelayMs,
getMaxTerminalPasteBytesForIngestMs,
getTerminalPasteIngestMs,
@@ -30,6 +31,21 @@ describe('agent prompt injection bytes', () => {
)
})
it('types the lead line only for Claude agents and unidentified ones', () => {
expect(agentPromptTakesLeadLine('claude')).toBe(true)
expect(agentPromptTakesLeadLine('claude-agent-teams')).toBe(true)
expect(agentPromptTakesLeadLine(null)).toBe(true)
expect(agentPromptTakesLeadLine('codex')).toBe(false)
expect(agentPromptTakesLeadLine('opencode')).toBe(false)
})
it('types the lead line ahead of the paste frame on one line', () => {
expect(buildAgentPromptPasteBytes('brief', 'Please\r\nfollow\x03\x1b[201~\x7f')).toBe(
`Please follow [201~ ${BEGIN}brief${END}`
)
expect(buildAgentPromptPasteBytes('brief', '')).toBe(`${BEGIN}brief${END}`)
})
it('keeps submit separate from the paste frame', () => {
expect(buildAgentPromptPasteBytes('hello')).not.toContain('\r')
expect(buildAgentPromptSubmitBytes()).toBe('\r')
+11 -2
View File
@@ -7,6 +7,12 @@ export const AGENT_PROMPT_BRACKETED_PASTE_START = '\x1b[200~'
export const AGENT_PROMPT_BRACKETED_PASTE_END = '\x1b[201~'
export const AGENT_PROMPT_SUBMIT = '\r'
/** Why unknown agents keep the lead: an unidentified Claude still needs it, while known non-Claude
* TUIs get pre-lead bytes because Codex drops typed text that shares the paste's write (STA-8200). */
export function agentPromptTakesLeadLine(agent: TuiAgent | null | undefined): boolean {
return !agent || TUI_AGENT_CONFIG[agent].pasteNeedsTypedRequest === true
}
/** OMP recognizes a submitted bracketed paste only when Enter shares its PTY write. */
export function agentPromptSubmitJoinsPasteFrame(agent: TuiAgent | null | undefined): boolean {
return agent === 'omp'
@@ -118,8 +124,11 @@ export function sanitizeAgentPromptText(text: string): string {
return sanitized + text.slice(start)
}
export function buildAgentPromptPasteBytes(prompt: string): string {
return `${AGENT_PROMPT_BRACKETED_PASTE_START}${sanitizeAgentPromptText(prompt)}${AGENT_PROMPT_BRACKETED_PASTE_END}`
/** `leadLine` is typed, not pasted; folded to one line so it cannot submit. */
export function buildAgentPromptPasteBytes(prompt: string, leadLine?: string): string {
// oxlint-disable-next-line no-control-regex -- the lead must type no C0 control or DEL.
const lead = leadLine ? `${leadLine.replace(/[\x00-\x1f\x7f]+/g, ' ')} ` : ''
return `${lead}${AGENT_PROMPT_BRACKETED_PASTE_START}${sanitizeAgentPromptText(prompt)}${AGENT_PROMPT_BRACKETED_PASTE_END}`
}
export function buildAgentPromptSubmitBytes(): string {
@@ -4,10 +4,7 @@
// truncate without splitting surrogate pairs. Extracted from
// agent-status-types.ts, which owns the payload shapes and per-field caps.
import {
compactDispatchPromptForStatus,
isOrcaDispatchStatusPrompt
} from './orca-dispatch-status-prompt'
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
@@ -45,14 +42,13 @@ export function normalizePromptField(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
if (isOrcaDispatchStatusPrompt(value)) {
return compactDispatchPromptForStatus(
return (
compactDispatchPromptForStatus(
value,
AGENT_STATUS_MAX_FIELD_LENGTH,
normalizeSingleLinePreview
)
}
return normalizeSingleLinePreview(value, AGENT_STATUS_MAX_FIELD_LENGTH)
) ?? normalizeSingleLinePreview(value, AGENT_STATUS_MAX_FIELD_LENGTH)
)
}
function normalizeSingleLinePreview(value: string, maxLength: number): string {
+30
View File
@@ -18,6 +18,10 @@ import {
} from './agent-status-types'
import type { AgentType, WellKnownAgentType } from './agent-status-types'
import type { TuiAgent } from './tui-agent'
import {
ORCA_DISPATCH_PROMPT_LEAD_LINE,
ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX
} from './orca-dispatch-status-prompt'
afterEach(() => {
vi.restoreAllMocks()
@@ -188,6 +192,32 @@ Fix dispatch fallback preview for normalized status prompts`
expect(result!.prompt).not.toContain('marker parsing')
})
it('compacts a Claude hook prompt carrying the typed lead line and paste wrapper', () => {
const preamble = `${ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\nYour task ID is: task_lead\n\n=== TASK ===\nAdd greet()`
const normalize = (prompt: string): string =>
normalizeAgentStatusPayload({ state: 'working', prompt })!.prompt
const compact = `${ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX} Your task ID is: task_lead === TASK === Add greet()`
// Why: the shape Claude Code's UserPromptSubmit hook reports for a typed lead plus a paste.
expect(
normalize(
`${ORCA_DISPATCH_PROMPT_LEAD_LINE}\n\n<pasted_content id="aac2">\n${preamble}\n</pasted_content id="aac2">\n`
)
).toBe(compact)
expect(normalize(`\n\n<pasted_content id="965a">\n${preamble}`)).toBe(compact)
expect(normalize(`<pasted_content ${'x'.repeat(80)}>${preamble}`)).not.toBe(compact)
expect(normalize(`<pasted_content ${'x'.repeat(30_000)}`)).not.toContain('TASK')
expect(normalize(`please review: ${preamble}`)).toBe(
`please review: ${preamble.replace(/\n+/g, ' ')}`
)
// Why: the closing paste tag must not become the task body of an empty spec.
expect(
normalize(
`<pasted_content id="1">\n${ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\n=== TASK ===\n</pasted_content id="1">`
)
).toBe(ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX)
})
it('keeps dispatch detection bounded for oversized whitespace prompts', () => {
const trimStartSpy = vi.spyOn(String.prototype, 'trimStart')
const prompt = ' '.repeat(1_000_000)
+43 -16
View File
@@ -8,43 +8,67 @@
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
export function isOrcaDispatchStatusPrompt(value: string): boolean {
/**
* 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 = 0
while (start < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(start))) {
start++
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)
}
return (
start + ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX.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:
* 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 {
const scanEnd = Math.min(value.length, ORCA_DISPATCH_STATUS_SOURCE_SCAN_LIMIT)
// Bound leading trim to the scan window so a multi-MB paste of pure
// whitespace cannot walk the entire string before we give up.
let start = 0
while (start < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(start))) {
start++
): string | null {
const start = findOrcaDispatchPreambleStart(value)
if (start === -1) {
return null
}
const scan = value.slice(start, scanEnd)
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)
@@ -65,6 +89,9 @@ export function compactDispatchPromptForStatus(
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
+5 -1
View File
@@ -36,6 +36,8 @@ export type TuiAgentConfig = {
draftPromptFlag?: string
/** Startup env var that seeds the input without submitting, for agents with no `--prefill`-style flag (e.g. pi); avoids the paste-after-ready race. */
draftPromptEnvVar?: string
/** Claude Code follows pasted text only where the user's typed words ask, so dispatch briefs need a typed lead line. */
pasteNeedsTypedRequest?: boolean
/** Pre-write a trust artifact so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste (see agent-trust-presets.ts). */
preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity'
/** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */
@@ -72,6 +74,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
claude: {
detectCmd: 'claude',
promptInjectionMode: 'argv',
pasteNeedsTypedRequest: true,
// Why: `claude --prefill <text>` seeds the input without submitting, avoiding the paste-after-ready race (PR https://github.com/stablyai/orca/pull/926).
draftPromptFlag: '--prefill'
},
@@ -89,7 +92,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
win32: `${getOrcaCliCommandNameForPlatform('win32')} claude-teams`
},
expectedProcess: 'claude',
promptInjectionMode: 'stdin-after-start'
promptInjectionMode: 'stdin-after-start',
pasteNeedsTypedRequest: true
},
openclaude: {
detectCmd: 'openclaude',