diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 82edd55cd79e..e71b66289468 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -105,7 +105,12 @@ export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ name: 'terminal.agentStatus', params: TerminalHandle, handler: async (params, { runtime }) => ({ - agentStatus: await runtime.getTerminalAgentStatus(params.terminal) + agentStatus: { + ...(await runtime.getTerminalAgentStatus(params.terminal)), + supportsGuardedAgentPrompt: await runtime.isTerminalRunningSettledPromptAgent( + params.terminal + ) + } }) }), defineMethod({ 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 ad471098e49d..43ce0b036d3f 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -114,7 +114,7 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ 'refresh', true ) - // Why: a stream-less request can't safely create ownership, so never write at stale geometry. + // Stream-less input must not write at stale geometry. if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { send: { @@ -127,8 +127,16 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ } const hasText = typeof params.text === 'string' && params.text.length > 0 const hasSuffix = params.enter === true || params.interrupt === true - if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { - // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. + const useSettledAgentPrompt = + params.agentPrompt === true && + hasText && + params.enter === true && + params.interrupt !== true && + params.client?.type === 'desktop' && + (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) + const combinedGuardedSend = params.requireAgentStatus === 'sendable' && hasText && hasSuffix + if (combinedGuardedSend && !useSettledAgentPrompt) { + // Only the verified transaction rechecks the guard between paste and submit. return { send: { handle: params.terminal, @@ -189,13 +197,6 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ markMutationEffectPossible?.() } : assertSendPreconditions - const useSettledAgentPrompt = - params.agentPrompt === true && - hasText && - params.enter === true && - params.interrupt !== true && - params.client?.type === 'desktop' && - (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) const reserveWrite = params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId ? (ptyId: string): void => { @@ -244,8 +245,7 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ } catch (error) { mobileFloorClaim.current?.rollback() if (isAgentSessionPtyWriteRefusedError(error)) { - // Why: name the owner and the stage instead of a bare not-writable, so a client can say - // who holds the session rather than retrying into a lease it will never win. + // Report the session owner so callers do not retry a refused lease. return { send: { handle: params.terminal, diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 29254fbcf3ad..ebd5e65c0ae5 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -51,6 +51,77 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).not.toHaveBeenCalled() }) + it.each([true, false])( + 'rechecks permission before verified submit (blocked=%s)', + async (blocked) => { + const controller = new AbortController() + let permission = false + const writes: string[] = [] + const runtime = makeRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + isTerminalRunningSettledPromptAgent: vi.fn().mockResolvedValue(true), + getTerminalAgentStatus: vi.fn(async () => ({ + handle: 'terminal-1', + isRunningAgent: true, + status: permission ? ('permission' as const) : ('idle' as const) + })), + sendTerminal: vi.fn(), + sendTerminalAgentPrompt: vi.fn(async (_handle, text, options) => { + expect(options?.signal).toBe(controller.signal) + await options?.beforeWrite?.('pty-1') + writes.push(text) + permission = blocked + await options?.beforeWrite?.('pty-1') + writes.push('submit') + return { handle: 'terminal-1', accepted: true, bytesWritten: text.length + 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const response = await dispatcher.dispatch( + makeRequest({ + terminal: 'terminal-1', + text: 'review this', + enter: true, + agentPrompt: true, + requireAgentStatus: 'sendable', + client: { id: 'orca-desktop', type: 'desktop' } + }), + { signal: controller.signal } + ) + expect(response).toMatchObject({ ok: true, result: { send: { accepted: !blocked } } }) + expect(writes).toEqual(blocked ? ['review this'] : ['review this', 'submit']) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + } + ) + + it('refuses combined guarded input when settlement support disappeared', async () => { + const runtime = makeRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + isTerminalRunningSettledPromptAgent: vi.fn().mockResolvedValue(false), + sendTerminal: vi.fn(), + sendTerminalAgentPrompt: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const response = await dispatcher.dispatch( + makeRequest({ + terminal: 'terminal-1', + text: 'review this', + enter: true, + agentPrompt: true, + requireAgentStatus: 'sendable', + client: { id: 'orca-desktop', type: 'desktop' } + }) + ) + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + it('preserves direct input when the CLI target is not a proven settlement agent', async () => { const sendTerminal = vi.fn().mockResolvedValue({ handle: 'terminal-1', diff --git a/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts b/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts index e1bc03f18abf..e22132aec18b 100644 --- a/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts +++ b/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts @@ -31,7 +31,8 @@ function request( terminal: string, promptRequestId: string, text: string, - waitSubmitMs?: number + waitSubmitMs?: number, + guarded = false ): RpcRequest { return { id: `rpc-${promptRequestId}`, @@ -44,6 +45,7 @@ function request( enter: true, agentPrompt: true, waitSubmitMs, + ...(guarded ? { requireAgentStatus: 'sendable' } : {}), client: { id: 'orca-cli', type: 'desktop' } } } @@ -146,25 +148,28 @@ describe('durable terminal prompt delivery receipts', () => { harness.db.close() }) - it('replays after a dispatcher replacement without duplicate text or Enter', async () => { - vi.useFakeTimers() - const harness = await createHarness('codex', true) - const firstPromise = harness.dispatcher.dispatch( - request(harness.handle, 'crash-retry', 'preserve once') - ) - await vi.runAllTimersAsync() - const first = await firstPromise - const writesAfterFirst = [...harness.writes] - const replacement = new RpcDispatcher({ runtime: harness.runtime, methods: TERMINAL_METHODS }) - const replay = await replacement.dispatch( - request(harness.handle, 'crash-retry', 'preserve once') - ) + it.each([false, true])( + 'replays after dispatcher replacement without duplicate input (guarded=%s)', + async (guarded) => { + vi.useFakeTimers() + const harness = await createHarness('codex', true) + const firstPromise = harness.dispatcher.dispatch( + request(harness.handle, 'crash-retry', 'preserve once', undefined, guarded) + ) + await vi.runAllTimersAsync() + const first = await firstPromise + const writesAfterFirst = [...harness.writes] + const replacement = new RpcDispatcher({ runtime: harness.runtime, methods: TERMINAL_METHODS }) + const replay = await replacement.dispatch( + request(harness.handle, 'crash-retry', 'preserve once', undefined, guarded) + ) - expect(first).toMatchObject({ ok: true, result: { mutation: { replayed: false } } }) - expect(replay).toMatchObject({ ok: true, result: { mutation: { replayed: true } } }) - expect(harness.writes).toEqual(writesAfterFirst) - harness.db.close() - }) + expect(first).toMatchObject({ ok: true, result: { mutation: { replayed: false } } }) + expect(replay).toMatchObject({ ok: true, result: { mutation: { replayed: true } } }) + expect(harness.writes).toEqual(writesAfterFirst) + harness.db.close() + } + ) it('keeps an ambiguous partial write pending and refuses to resend it', async () => { vi.useFakeTimers() diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae06..4db6ec26c356 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -57,6 +57,7 @@ describe('terminal send RPC', () => { it('reports runtime-owned terminal agent status', async () => { const runtime = stubRuntime({ + isTerminalRunningSettledPromptAgent: vi.fn().mockResolvedValue(true), getTerminalAgentStatus: vi.fn().mockResolvedValue({ handle: 'terminal-1', isRunningAgent: true, @@ -79,7 +80,8 @@ describe('terminal send RPC', () => { agentStatus: { handle: 'terminal-1', isRunningAgent: true, - status: 'permission' + status: 'permission', + supportsGuardedAgentPrompt: true } }) expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') diff --git a/src/renderer/src/lib/active-agent-note-send-delivery.ts b/src/renderer/src/lib/active-agent-note-send-delivery.ts index 2d9e88ae5078..2b9c6fccbceb 100644 --- a/src/renderer/src/lib/active-agent-note-send-delivery.ts +++ b/src/renderer/src/lib/active-agent-note-send-delivery.ts @@ -67,6 +67,36 @@ export async function sendPromptWithGuardedPasteAndEnter( } } + if (initialAgentStatus.supportsGuardedAgentPrompt) { + try { + const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( + runtimeTarget, + 'terminal.send', + { + terminal: terminalHandle, + text: prompt, + enter: true, + agentPrompt: true, + requireAgentStatus: 'sendable', + client: ORCA_DESKTOP_TERMINAL_CLIENT + }, + { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } + ) + if (send.refusedReason === 'permission') { + return { status: 'permission', code: 'terminal-send-permission' } + } + if (send.refusedReason === 'no-agent') { + return { status: 'no-agent', code: 'no-agent' } + } + return send.accepted + ? { status: 'sent' } + : { status: 'partial-submit-failed', code: 'submit-send-refused' } + } catch { + // A failed observation may follow a paste; never replay it through the legacy path. + return { status: 'partial-submit-failed', code: 'submit-send-error' } + } + } + const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeTerminalPasteText(prompt)}${BRACKETED_PASTE_END}` try { const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( diff --git a/src/renderer/src/lib/active-agent-note-send-verified.test.ts b/src/renderer/src/lib/active-agent-note-send-verified.test.ts new file mode 100644 index 000000000000..25e6ffebeb62 --- /dev/null +++ b/src/renderer/src/lib/active-agent-note-send-verified.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sendPromptWithGuardedPasteAndEnter } from './active-agent-note-send-delivery' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: rpc, + RuntimeRpcCallError: class extends Error {} +})) +vi.mock('./agent-paste-draft', () => ({ + BRACKETED_PASTE_BEGIN: '\x1b[200~', + BRACKETED_PASTE_END: '\x1b[201~', + POST_PASTE_SUBMIT_DELAY_MS: 50 +})) + +const targets = [{ kind: 'local' }, { kind: 'environment', environmentId: 'paired-host' }] as const + +describe('verified note delivery', () => { + beforeEach(() => { + rpc.mockReset() + }) + + it.each(targets)('submits one complete large prompt on its owning runtime %j', async (target) => { + let finish: (() => void) | undefined + rpc.mockImplementation(async (_target, method) => { + if (method === 'terminal.agentStatus') { + return { + agentStatus: { isRunningAgent: true, status: 'idle', supportsGuardedAgentPrompt: true } + } + } + await new Promise((resolve) => { + finish = resolve + }) + return { send: { accepted: true } } + }) + const prompt = 'Review this file\n'.repeat(500) + let settled = false + const send = sendPromptWithGuardedPasteAndEnter(target, 'term-owner', prompt, { + allowLegacyFallback: false + }).then((result) => { + settled = true + return result + }) + await vi.waitFor(() => expect(finish).toBeDefined()) + expect(settled).toBe(false) + finish!() + await expect(send).resolves.toEqual({ status: 'sent' }) + const sends = rpc.mock.calls.filter((call) => call[1] === 'terminal.send') + expect(sends).toEqual([ + [ + target, + 'terminal.send', + { + terminal: 'term-owner', + text: prompt, + enter: true, + agentPrompt: true, + requireAgentStatus: 'sendable', + client: { id: 'orca-desktop', type: 'desktop' } + }, + { timeoutMs: 15000 } + ] + ]) + }) + + it('does not paste again or claim success after an ambiguous verified send', async () => { + rpc + .mockResolvedValueOnce({ + agentStatus: { isRunningAgent: true, status: 'idle', supportsGuardedAgentPrompt: true } + }) + .mockRejectedValueOnce(new Error('agent_prompt_stalled')) + await expect( + sendPromptWithGuardedPasteAndEnter(targets[0], 'term-owner', 'review', { + allowLegacyFallback: false + }) + ).resolves.toEqual({ status: 'partial-submit-failed', code: 'submit-send-error' }) + expect(rpc).toHaveBeenCalledTimes(2) + }) + + it('refuses permission prompts before writing', async () => { + rpc.mockResolvedValue({ + agentStatus: { isRunningAgent: true, status: 'permission', supportsGuardedAgentPrompt: true } + }) + await expect( + sendPromptWithGuardedPasteAndEnter(targets[0], 'term-owner', 'review', { + allowLegacyFallback: false + }) + ).resolves.toMatchObject({ status: 'permission' }) + expect(rpc).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts index b432366527f7..963c8fe52958 100644 --- a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts +++ b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts @@ -20,6 +20,7 @@ export type TerminalAgentSendReadiness = export type TerminalAgentSendReadinessResult = { status: TerminalAgentSendReadiness supportsGuardedSend: boolean + supportsGuardedAgentPrompt?: boolean code?: ActiveAgentNotesSendFailureCode } @@ -41,7 +42,13 @@ export async function getTerminalAgentSendReadiness( if (agentStatus.status === 'permission') { return { status: 'permission', supportsGuardedSend: true } } - return { status: 'sendable', supportsGuardedSend: true } + return { + status: 'sendable', + supportsGuardedSend: true, + ...(agentStatus.supportsGuardedAgentPrompt === true + ? { supportsGuardedAgentPrompt: true } + : {}) + } } catch (error) { if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { if (!options.allowLegacyFallback) { diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index db1c3751ba82..47f84404a381 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -240,6 +240,7 @@ export type RuntimeTerminalAgentStatus = { handle: string isRunningAgent: boolean status: RuntimeTerminalAgentStatusState + supportsGuardedAgentPrompt?: boolean } export type RuntimeTerminalPresentation = 'background' | 'focused'