diff --git a/skill-guides/orca-cli.md b/skill-guides/orca-cli.md index 886d8632cc29..8f4cf8001d8b 100644 --- a/skill-guides/orca-cli.md +++ b/skill-guides/orca-cli.md @@ -229,6 +229,7 @@ Terminal rules: - Use `terminal read` before `terminal send` unless the next input is obvious. - `terminal read` returns accumulated output with escape sequences stripped, not the rendered screen, so anything that repaints (shells, progress bars, TUIs) reads as stacked fragments. Use `--screen` whenever the answer depends on what the terminal actually shows. - `--screen` and `--cursor` are mutually exclusive: a screen read is the current frame and has no history to page. +- `terminal send --text ... --enter` into a Claude Code or Codex composer is refused while the composer holds unsent input (`accepted: false`, `refusedReason: pending-input`, `pendingInput: `), so a user's draft is never submitted with your text. Wait for the user to send or clear it. - Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed. - `accepted: true` proves input acceptance, not a started turn. Use the receipt's `turn_started` stage when submission proof is needed; never resend on silence. - A text-plus-Enter agent prompt is refused with `agent_prompt_blocked` while the agent shows a permission or trust menu. To answer the menu, send `--text` and `--enter` as separate commands. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 6c66a809f418..12223dde2f4b 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -641,6 +641,7 @@ const ORCA_CLI_MARKDOWN = [ "- Use `terminal read` before `terminal send` unless the next input is obvious.", "- `terminal read` returns accumulated output with escape sequences stripped, not the rendered screen, so anything that repaints (shells, progress bars, TUIs) reads as stacked fragments. Use `--screen` whenever the answer depends on what the terminal actually shows.", "- `--screen` and `--cursor` are mutually exclusive: a screen read is the current frame and has no history to page.", + "- `terminal send --text ... --enter` into a Claude Code or Codex composer is refused while the composer holds unsent input (`accepted: false`, `refusedReason: pending-input`, `pendingInput: `), so a user's draft is never submitted with your text. Wait for the user to send or clear it.", "- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.", "- `accepted: true` proves input acceptance, not a started turn. Use the receipt's `turn_started` stage when submission proof is needed; never resend on silence.", "- A text-plus-Enter agent prompt is refused with `agent_prompt_blocked` while the agent shows a permission or trust menu. To answer the menu, send `--text` and `--enter` as separate commands.", diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index e4be6087e00e..e46df797dec8 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -446,6 +446,39 @@ describe('terminal send CLI', () => { expect(process.exitCode).toBe(1) }) + it('exits non-zero and reports the draft when the send is refused for pending input', async () => { + const call = vi.fn().mockResolvedValue({ + result: { + send: { + handle: 'term-1', + accepted: false, + bytesWritten: 0, + refusedReason: 'pending-input', + pendingInput: 'Refactor the login page so that it' + } + } + }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const previousExitCode = process.exitCode + try { + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text', 'status update'], + ['enter', true] + ]), + client: promptClient(call, true), + cwd: '/tmp/worktree', + json: false + }) + + expect(process.exitCode).toBe(1) + expect(String(log.mock.calls[0]?.[0])).toContain('"Refactor the login page so that it"') + } finally { + process.exitCode = previousExitCode + } + }) + it('keeps text-only and bare Enter sends as direct terminal input', async () => { const call = vi.fn().mockResolvedValue({ result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } } diff --git a/src/cli/terminal-format.test.ts b/src/cli/terminal-format.test.ts index 2543be53b170..9eac3c33e1a1 100644 --- a/src/cli/terminal-format.test.ts +++ b/src/cli/terminal-format.test.ts @@ -89,6 +89,32 @@ describe('formatTerminalClose', () => { }) describe('formatTerminalSend', () => { + it('reports delivered bytes only for an accepted send', () => { + expect( + formatTerminalSend({ send: { handle: 'term_1', accepted: true, bytesWritten: 9 } }) + ).toBe('Sent 9 bytes to term_1.') + expect( + formatTerminalSend({ send: { handle: 'term_1', accepted: false, bytesWritten: 0 } }) + ).toBe('Input refused by term_1.') + }) + + it('shows the unsent draft for a pending-input refusal', () => { + expect( + formatTerminalSend({ + send: { + handle: 'term_1', + accepted: false, + bytesWritten: 0, + refusedReason: 'pending-input', + pendingInput: 'fix the\nflaky test' + } + }) + ).toBe( + 'Refused: terminal term_1 has unsent input in its composer: "fix the\\nflaky test"\n' + + 'Wait for the user to send or clear it.' + ) + }) + it('exposes the provider and healthy delivery observation', () => { expect( formatTerminalSend({ diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index acd9f7f73f6a..f2321b75c59a 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -174,6 +174,12 @@ function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string } export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { + if (result.send.refusedReason === 'pending-input') { + return [ + `Refused: terminal ${result.send.handle} has unsent input in its composer: ${JSON.stringify(result.send.pendingInput ?? '')}`, + 'Wait for the user to send or clear it.' + ].join('\n') + } if (!result.send.accepted) { const reason = result.send.refusedReason ? `: ${result.send.refusedReason}` : '' return `Input refused by ${result.send.handle}${reason}.` diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index dd04db45035c..42e573560290 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -127,15 +127,17 @@ describe('mapRuntimeError', () => { } ) - it.each(['agent_prompt_blocked', 'agent_prompt_stalled', 'request_aborted'])( - 'preserves the agent prompt failure %s', - (code) => { - expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ - ok: false, - error: { code, message: code } - }) - } - ) + it.each([ + 'agent_prompt_blocked', + 'agent_prompt_stalled', + 'agent_prompt_pending_input', + 'request_aborted' + ])('preserves the agent prompt failure %s', (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + }) it.each([ 'remote_update_manual_required', diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index c8f0b0c0f3e1..a3c947299c8d 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -93,6 +93,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_tab_pinned', 'agent_prompt_blocked', 'agent_prompt_stalled', + 'agent_prompt_pending_input', 'no_active_terminal', 'repo_not_found', 'timeout', diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 4bd10a5a31fd..c7d9ecac6bbf 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -4,6 +4,7 @@ import { InvalidArgumentError, defineMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { isUntypedTerminalInput } from '../../../../../shared/terminal-untyped-input' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' +import { AgentPromptPendingInputError } from '../../../terminal/input/agent-prompt-permission-hold' import { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params' import { assertTerminalSendExactPtyBinding, @@ -234,6 +235,11 @@ export const TERMINAL_SEND_METHODS = [ ) } catch (error) { mobileFloorClaim.current?.rollback() + const refused = { handle: params.terminal, accepted: false as const, bytesWritten: 0 } + if (error instanceof AgentPromptPendingInputError) { + const { pendingInput } = error + return { send: { ...refused, refusedReason: 'pending-input' as const, pendingInput } } + } if (acceptedPromptCheckpoint) { return acceptedPromptCheckpoint } diff --git a/src/main/runtime/rpc/terminal-send-pending-draft.integration.test.ts b/src/main/runtime/rpc/terminal-send-pending-draft.integration.test.ts new file mode 100644 index 000000000000..c67fbba378de --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-pending-draft.integration.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { TERMINAL_METHODS } from './methods/terminal' +import { AGENT_PROMPT_BRACKETED_PASTE_START } from '../../../shared/agent-prompt-injection' +import { AgentPromptPendingInputError } from '../terminal/input/agent-prompt-permission-hold' + +const CLAUDE_RULE = '─'.repeat(60) + +function cliPrompt(handle: string, text: string, extra: Record = {}): RpcRequest { + return { + id: 'request-1', + authToken: 'test-token', + method: 'terminal.send', + params: { + terminal: handle, + text, + enter: true, + agentPrompt: true, + client: { id: 'orca-cli', type: 'desktop' }, + ...extra + } + } +} + +async function makeClaudeRuntime(composerRow: string): Promise<{ + runtime: OrcaRuntimeService + write: ReturnType + handle: string +}> { + const runtime = new OrcaRuntimeService() + // Why: submission is verified by the agent starting a turn, which a live Claude reports + // through its title; the mock echoes that once Enter arrives so accepted sends resolve. + const write = vi.fn((ptyId: string, data: string) => { + if (data === '\r') { + runtime.onPtyData(ptyId, '\x1b]0;✻ Claude working\x07', Date.now()) + } + return true + }) + runtime.setPtyController({ + write, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree', + title: 'Claude', + activeLeafId: 'pane-1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree', + leafId: 'pane-1', + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'Claude' + } + ] + }) + // Why: the composer draft only exists as rendered output — the user's keystrokes + // never pass through the runtime, so the screen is the sole evidence of a draft. + const frame = `✻ Cogitated for 6s\r\n${CLAUDE_RULE}\r\n${composerRow}` + runtime.onPtyData('pty-1', frame, 1) + const [terminal] = (await runtime.listTerminals()).terminals + return { runtime, write, handle: terminal.handle } +} + +async function expectPasted( + send: Promise, + write: ReturnType, + text: string +): Promise { + await vi.waitFor(() => expect(write).toHaveBeenCalled()) + expect(String(write.mock.calls[0]?.[1])).toContain(`${AGENT_PROMPT_BRACKETED_PASTE_START}${text}`) + await vi.runAllTimersAsync() + expect(await send).toMatchObject({ + ok: true, + result: { send: { accepted: true, bytesWritten: expect.any(Number) } } + }) + expect(write.mock.calls.some((call) => call[1] === '\r')).toBe(true) +} + +describe('terminal.send into a composer with unsent input', () => { + afterEach(() => vi.useRealTimers()) + + it('refuses to submit a CLI prompt on top of the user draft and reports it', async () => { + const { runtime, write, handle } = await makeClaudeRuntime( + '❯ Refactor the login page so that it' + ) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + cliPrompt(handle, 'Status update from the other terminal: the build is green.') + ) + + expect(response).toMatchObject({ + ok: true, + result: { + send: { + handle, + accepted: false, + bytesWritten: 0, + refusedReason: 'pending-input', + pendingInput: 'Refactor the login page so that it' + } + } + }) + expect(write).not.toHaveBeenCalled() + }) + + it('still refuses when the caret was moved to the start of the draft', async () => { + const { runtime, write, handle } = await makeClaudeRuntime( + '❯ Refactor the login page so that it\x1b[3G' + ) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + cliPrompt(handle, 'Status update from the other terminal: the build is green.') + ) + + expect(response).toMatchObject({ + ok: true, + result: { + send: { + accepted: false, + refusedReason: 'pending-input', + pendingInput: 'Refactor the login page so that it' + } + } + }) + expect(write).not.toHaveBeenCalled() + }) + + it('pastes into an empty composer that shows a dim placeholder', async () => { + vi.useFakeTimers() + const { runtime, write, handle } = await makeClaudeRuntime( + '❯ \x1b[2mTry "refactor "\x1b[22m\x1b[3G' + ) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await expectPasted( + dispatcher.dispatch(cliPrompt(handle, 'review this change')), + write, + 'review this change' + ) + }) + + it('does not read a permission dialog option list as a draft', async () => { + vi.useFakeTimers() + // Why: as Claude Code 2.1.246 draws it — glyph indented one column, cursor hidden. + const { runtime, write, handle } = await makeClaudeRuntime( + ' Do you want to create hello.txt?\r\n ❯ 1. Yes\r\n 2. No\x1b[A\x1b[2G\x1b[?25l' + ) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await expectPasted( + dispatcher.dispatch(cliPrompt(handle, 'review this change')), + write, + 'review this change' + ) + }) + + it('pastes into an empty composer', async () => { + vi.useFakeTimers() + const { runtime, write, handle } = await makeClaudeRuntime('❯ ') + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await expectPasted( + dispatcher.dispatch(cliPrompt(handle, 'review this change')), + write, + 'review this change' + ) + }) + + // Why: the write path re-validates request activity, generation, permission and PTY admission + // across `beforeWrite`; composer state is an invariant of the same standing, so a draft the + // user starts during that hook must refuse rather than be pasted onto. Regression for #16290. + it('refuses a draft that appears while beforeWrite is pending', async () => { + const { runtime, write, handle } = await makeClaudeRuntime('❯ ') + // Why: terminal.send resolves the pane's agent before writing. This test drives the write + // path directly so it can supply beforeWrite, so it warms that same lookup first. + await runtime.isTerminalRunningSettledPromptAgent(handle) + + await expect( + runtime.sendTerminalAgentPrompt(handle, 'Status update: the build is green.', { + inputKind: 'driving', + beforeWrite: async () => { + runtime.onPtyData('pty-1', 'Refactor the login page', 2) + // Why: onPtyData queues the emulator write; drain it the way the fixture does, so + // the draft is on screen before the write path continues. + await runtime.listTerminals() + } + }) + ).rejects.toBeInstanceOf(AgentPromptPendingInputError) + expect(write).not.toHaveBeenCalled() + }) + + // Why: the composer re-read awaits the emulator, and a pane can stop mid-await. The + // writability assert therefore has to run AFTER that read — checking before it leaves the + // initial PTY write reachable on a pane that is already going away. + it('refuses when the pty stops while the composer re-read is in flight', async () => { + const { runtime, write, handle } = await makeClaudeRuntime('\u276F ') + await runtime.isTerminalRunningSettledPromptAgent(handle) + + const internals = runtime as unknown as { + readVisibleTerminalState: (ptyId: string) => Promise + } + const readVisibleTerminalState = internals.readVisibleTerminalState.bind(runtime) + // Armed inside beforeWrite so only the SECOND read — the one this guard owns — trips it. + let armed = false + internals.readVisibleTerminalState = async (ptyId: string): Promise => { + const state = await readVisibleTerminalState(ptyId) + if (armed) { + runtime.markPtyStopRequested(ptyId) + } + return state + } + + await expect( + runtime.sendTerminalAgentPrompt(handle, 'Status update: the build is green.', { + inputKind: 'driving', + beforeWrite: async () => { + armed = true + } + }) + ).rejects.toThrow('terminal_not_writable') + expect(write).not.toHaveBeenCalled() + }) + +}) diff --git a/src/main/runtime/terminal/input/agent-prompt-permission-hold.ts b/src/main/runtime/terminal/input/agent-prompt-permission-hold.ts index e779a13f9565..79a0ccbd0a1f 100644 --- a/src/main/runtime/terminal/input/agent-prompt-permission-hold.ts +++ b/src/main/runtime/terminal/input/agent-prompt-permission-hold.ts @@ -11,6 +11,16 @@ export class AgentPromptPermissionReturnedError extends Error { } } +export const AGENT_PROMPT_PENDING_INPUT_ERROR = 'agent_prompt_pending_input' + +/** The writer's other pre-paste refusal: the composer already holds unsent text. */ +export class AgentPromptPendingInputError extends Error { + constructor(readonly pendingInput: string) { + super(AGENT_PROMPT_PENDING_INPUT_ERROR) + this.name = 'AgentPromptPendingInputError' + } +} + export type AgentPromptPermissionHold = { /** The writer's predicate; throws when the write must not happen at all (binding changed). */ isBlocked: () => boolean diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt-pending-input.ts b/src/main/runtime/terminal/input/terminal-agent-prompt-pending-input.ts new file mode 100644 index 000000000000..856d30d67f30 --- /dev/null +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-pending-input.ts @@ -0,0 +1,85 @@ +import { AgentPromptPendingInputError } from './agent-prompt-permission-hold' +import type { AgentPromptActivity } from '../../agent-prompt-submission-verification' +import { isTerminalSendSettlementAgent } from '../../agent-prompt-submission-verification' +import { assertAgentPromptRequestActive, waitForAgentPromptDelay } from '../../orca-runtime-core' +import { OrcaRuntimeWithResubmitHeldAgentPrompt } from './terminal-agent-prompt-resubmit' + +const COMPOSER_DRAFT_READ_ATTEMPTS = 3 +const COMPOSER_DRAFT_RETRY_MS = 50 + +/** + * Own link in the chain because `terminal-agent-prompt.ts` sits exactly on the 300-line + * `max-lines` cap; the guard it calls could not live there. + */ +export abstract class OrcaRuntimeWithPendingComposerInputGuard extends OrcaRuntimeWithResubmitHeldAgentPrompt { + /** + * Permission before composer, in one call: a permission dialog's selected row also starts + * with `❯`, so a blocked prompt must still report agent_prompt_blocked rather than + * pending-input. Combined because `terminal-agent-prompt.ts` is exactly on the 300-line + * `max-lines` cap and could not afford a second statement. + */ + protected async assertAgentPromptEntryGuards( + permissionBaseline: AgentPromptActivity, + ptyId: string, + signal?: AbortSignal + ): Promise { + this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) + await this.assertNoPendingComposerInput(ptyId, signal) + } + + /** + * Second read, after the user hook. A sendable precondition can poll for up to a second, + * which is ample time for the user to start typing, so a draft that appears during + * `beforeWrite` must refuse rather than be pasted onto. Takes `assertWritable` and runs it + * because it replaces that call at the site — `terminal-agent-prompt.ts` is on the + * 300-line `max-lines` cap and cannot afford an extra statement. Order matters: the screen + * read awaits, and a pty that stopped or a handle that went stale during it would otherwise + * reach the write unchecked, so `assertWritable` stays the LAST thing before the caller writes. + */ + protected async assertComposerStillClear( + ptyId: string, + signal: AbortSignal, + assertWritable: (signal: AbortSignal) => void + ): Promise { + await this.assertNoPendingComposerInput(ptyId, signal) + assertWritable(signal) + } + + // Why: the paste lands wherever the composer cursor is, and Enter submits the whole + // row — a user's half-typed draft would ship glued to the prompt (#16289). Only a + // rendered draft refuses; an unreadable screen is unknown and never blocks a send. + protected async assertNoPendingComposerInput( + ptyId: string, + signal?: AbortSignal + ): Promise { + const pty = this.ptysById.get(ptyId) + // Why: the launch agent is stale once the user swaps agents in the pane. + const agent = pty?.foregroundAgent ?? pty?.launchAgent + if (!isTerminalSendSettlementAgent(agent)) { + return + } + const pendingInput = await this.readComposerDraft(ptyId, signal) + if (pendingInput !== null) { + throw new AgentPromptPendingInputError(pendingInput) + } + } + + // Why: a provider frame is discarded when output advanced past it — which is exactly + // what a user's keystroke echo does — so retry briefly before giving up on evidence. + private async readComposerDraft(ptyId: string, signal?: AbortSignal): Promise { + const attempts = this.providerSnapshotPreferredPtys.has(ptyId) + ? COMPOSER_DRAFT_READ_ATTEMPTS + : 1 + for (let attempt = 0; ; attempt += 1) { + assertAgentPromptRequestActive(signal) + const visibleState = await this.readVisibleTerminalState(ptyId) + if (visibleState) { + return visibleState.draft?.trim() ? visibleState.draft : null + } + if (attempt >= attempts - 1) { + return null + } + await waitForAgentPromptDelay(COMPOSER_DRAFT_RETRY_MS, signal) + } + } +} diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt.ts b/src/main/runtime/terminal/input/terminal-agent-prompt.ts index 9e906075e871..bd256be9c5bf 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt.ts @@ -32,9 +32,9 @@ import { verifyAgentPromptSubmission } from '../../agent-prompt-submission-verification' import { AgentPromptPermissionReturnedError } from './agent-prompt-permission-hold' -import { OrcaRuntimeWithResubmitHeldAgentPrompt } from './terminal-agent-prompt-resubmit' +import { OrcaRuntimeWithPendingComposerInputGuard } from './terminal-agent-prompt-pending-input' -export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResubmitHeldAgentPrompt { +export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithPendingComposerInputGuard { protected abstract createAgentPromptRenderGate( ptyId: string, pasteIngestMs: number @@ -80,7 +80,7 @@ export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntim if (options.onPermission === 'wait') { this.throwIfAgentPromptPermissionReturned(permissionBaseline, permissionBaseline) } - this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) + await this.assertAgentPromptEntryGuards(permissionBaseline, ptyId, observationSignal) const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) const pty = this.ptysById.get(ptyId) // OMP needs paste and submit in one write; the foreground consumer wins, with launchAgent only a startup fallback. @@ -131,7 +131,7 @@ export abstract class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntim let baseline: AgentPromptActivity try { await beforeWrite(admissionSignal) - assertWritable(admissionSignal) + await this.assertComposerStillClear(ptyId, admissionSignal, assertWritable) if (options.onPermission === 'wait') { this.throwIfAgentPromptPermissionReturned( permissionBaseline, diff --git a/src/shared/runtime-terminal-send-contract.ts b/src/shared/runtime-terminal-send-contract.ts index c65c0c822912..e894e13210f7 100644 --- a/src/shared/runtime-terminal-send-contract.ts +++ b/src/shared/runtime-terminal-send-contract.ts @@ -4,7 +4,9 @@ export type RuntimeTerminalSend = { handle: string accepted: boolean bytesWritten: number - refusedReason?: 'no-agent' | 'permission' + refusedReason?: 'no-agent' | 'permission' | 'pending-input' + /** Unsent composer text that refused a submitting send (`refusedReason: 'pending-input'`). */ + pendingInput?: string prompt?: RuntimeTerminalPromptDelivery writeSettlement?: WriteSettlement }