diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index de1d7469f24..391b55ac5b0 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -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 { - 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. diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 5e1de184c9c..9145bea46f6 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -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) => { diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index a6740b7d90c..dabbaf462ec 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -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 "" --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 diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 459ef548794..2f4f5bf9b5a 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -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 listTerminals( worktreeSelector?: string, diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index f2dc506ca9a..bb64f6dd98b 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -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 diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 75ba01ffa27..dce351dc295 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -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 () => { diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index cc890a101ef..03d9e4fc912 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -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', () => { diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 597e7bba89d..208868ce45b 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -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 "" --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 diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts index e627e112530..9c71da963d4 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts @@ -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))) ) }) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 8d9d92547c8..6f7a1bba077 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index a4e6b427d7d..75c83878335 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -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)) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts index f7418cee573..d4e0737ffdb 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts @@ -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() }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index c9e0f077bc1..8459a9886e4 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -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))) ) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 3da57f9c530..ecb3270874e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -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 } diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 8f0461e522a..36e917ebc7d 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 diff --git a/src/renderer/src/lib/agent-row-primary-text.ts b/src/renderer/src/lib/agent-row-primary-text.ts index 12ae9ab205b..f5934609678 100644 --- a/src/renderer/src/lib/agent-row-primary-text.ts +++ b/src/renderer/src/lib/agent-row-primary-text.ts @@ -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. diff --git a/src/shared/agent-prompt-injection.test.ts b/src/shared/agent-prompt-injection.test.ts index 8a468e19ca1..a20fad0ed8d 100644 --- a/src/shared/agent-prompt-injection.test.ts +++ b/src/shared/agent-prompt-injection.test.ts @@ -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') diff --git a/src/shared/agent-prompt-injection.ts b/src/shared/agent-prompt-injection.ts index 388ae043d57..7c33855ee9c 100644 --- a/src/shared/agent-prompt-injection.ts +++ b/src/shared/agent-prompt-injection.ts @@ -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 { diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts index 98b830a7375..25d6b6413d4 100644 --- a/src/shared/agent-status-field-normalization.ts +++ b/src/shared/agent-status-field-normalization.ts @@ -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 { diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 55da23c2bb5..39b44ee4092 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -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\n${preamble}\n\n` + ) + ).toBe(compact) + expect(normalize(`\n\n\n${preamble}`)).toBe(compact) + expect(normalize(`${preamble}`)).not.toBe(compact) + expect(normalize(`\n${ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\n=== TASK ===\n` + ) + ).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) diff --git a/src/shared/orca-dispatch-status-prompt.ts b/src/shared/orca-dispatch-status-prompt.ts index e657c16df4a..ea82d8e72cb 100644 --- a/src/shared/orca-dispatch-status-prompt.ts +++ b/src/shared/orca-dispatch-status-prompt.ts @@ -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 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 = '`. +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 `` 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: * ` Your task ID is: === TASK === ` */ 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(' = { claude: { detectCmd: 'claude', promptInjectionMode: 'argv', + pasteNeedsTypedRequest: true, // Why: `claude --prefill ` 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 = { win32: `${getOrcaCliCommandNameForPlatform('win32')} claude-teams` }, expectedProcess: 'claude', - promptInjectionMode: 'stdin-after-start' + promptInjectionMode: 'stdin-after-start', + pasteNeedsTypedRequest: true }, openclaude: { detectCmd: 'openclaude',