Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
24 changes: 12 additions & 12 deletions src/main/runtime/rpc/methods/terminal/terminal-send-method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions src/main/runtime/rpc/terminal-agent-prompt-send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
43 changes: 24 additions & 19 deletions src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ function request(
terminal: string,
promptRequestId: string,
text: string,
waitSubmitMs?: number
waitSubmitMs?: number,
guarded = false
): RpcRequest {
return {
id: `rpc-${promptRequestId}`,
Expand All @@ -44,6 +45,7 @@ function request(
enter: true,
agentPrompt: true,
waitSubmitMs,
...(guarded ? { requireAgentStatus: 'sendable' } : {}),
client: { id: 'orca-cli', type: 'desktop' }
}
}
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion src/main/runtime/rpc/terminal-send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
Expand Down
30 changes: 30 additions & 0 deletions src/renderer/src/lib/active-agent-note-send-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>(
Expand Down
90 changes: 90 additions & 0 deletions src/renderer/src/lib/active-agent-note-send-verified.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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)
})
})
9 changes: 8 additions & 1 deletion src/renderer/src/lib/active-agent-terminal-send-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type TerminalAgentSendReadiness =
export type TerminalAgentSendReadinessResult = {
status: TerminalAgentSendReadiness
supportsGuardedSend: boolean
supportsGuardedAgentPrompt?: boolean
code?: ActiveAgentNotesSendFailureCode
}

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/shared/runtime-terminal-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export type RuntimeTerminalAgentStatus = {
handle: string
isRunningAgent: boolean
status: RuntimeTerminalAgentStatusState
supportsGuardedAgentPrompt?: boolean
}

export type RuntimeTerminalPresentation = 'background' | 'focused'
Expand Down
Loading