diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts index e2f9cfe56b5..46bd20f89e9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' import { TERMINAL_SEND_METHODS } from '../../terminal/terminal-send-method' -import { sendTerminalStreamInput } from '../../terminal/terminal-input-delivery' +import { + isDeliberateHumanInput, + sendTerminalStreamInput +} from '../../terminal/terminal-input-delivery' import { isStreamingMethod, type RpcMethod } from '../../../core' import { RuntimeTerminalWriter } from '../../../../runtime-terminal-writer' import { getDefaultWorkspaceSession } from '../../../../../../shared/constants' @@ -324,3 +327,19 @@ describe('a mobile takeover lifts the settled worker resume fence', () => { expect(fenceChanges.at(-1)).toEqual([harness.workerPaneKey, false]) }) }) + +// The fence hangs off provenance, not off the input floor, so this rule is pinned on its own. +describe('which bytes count as a person typing', () => { + const cases: [string, Parameters, boolean][] = [ + ['a phone keystroke', [{ client: MOBILE_CLIENT }, false], true], + ["an agent's terminal send", [{ client: CLI_CLIENT }, false], false], + ['a phone query reply', [{ client: MOBILE_CLIENT, inputKind: 'query-reply' }, false], false], + ['a legacy phone on a mobile-driven pane', [{}, true], true], + ['a clientless send on an idle pane', [{}, false], false] + ] + for (const [name, args, expected] of cases) { + it(`${name} is ${expected ? '' : 'not '}human input`, () => { + expect(isDeliberateHumanInput(...args)).toBe(expected) + }) + } +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index cf039e681c3..e925b43382e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -51,6 +51,42 @@ export function resolveMobileFloorClientId( return null } +/** + * Whether these bytes are a person typing, rather than an agent's `terminal send` or the emulator + * answering a device query. + * + * Deliberately its own rule instead of a read of the mobile input floor. The floor is arbitration, + * deciding who may write next; this is provenance, deciding who produced the bytes. They agree + * today, and the orchestration takeover fence hangs off THIS one, so reweighing the floor cannot + * move the fence without someone answering this question again on its own terms. + */ +export function isDeliberateHumanInput( + input: { client?: TerminalViewportClient; inputKind?: 'query-reply' }, + mobileWithoutClientMetadata: boolean +): boolean { + if (input.inputKind === 'query-reply') { + return false + } + if (input.client?.type === 'mobile') { + return true + } + // Pre-refactor mobile builds send no client metadata; the pane's mobile driver, or a mobile + // stream's own kind, is the only remaining evidence of who is at the keyboard. + return !input.client && mobileWithoutClientMetadata +} + +/** One mobile write's floor claim and provenance verdict, decided together before the bytes move. */ +export function newMobileInputWrite( + input: { terminal: string; client?: TerminalViewportClient; inputKind?: 'query-reply' }, + mobileWithoutClientMetadata: boolean +): MobileInputFloorClaimHolder { + return { + handle: input.terminal, + humanInput: isDeliberateHumanInput(input, mobileWithoutClientMetadata), + current: null + } +} + export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' export function watchSubscriptionLifetime( @@ -113,7 +149,7 @@ export async function sendTerminalStreamInput( ): Promise { const action = { text: args.text, enter: false, interrupt: false } const clientId = args.isMobile ? args.client?.id : undefined - const floorClaim: MobileInputFloorClaimHolder = { handle: args.terminal, current: null } + const floorClaim = newMobileInputWrite(args, args.isMobile) try { if (!clientId) { const result = await runtime.sendTerminal(args.terminal, action) @@ -142,22 +178,21 @@ export async function sendTerminalStreamInput( export type MobileInputFloorClaimHolder = { handle: string + humanInput: boolean current: ReturnType } /** - * Settle an accepted mobile write: the phone keeps the input floor, and the host records that a - * human is now driving this terminal. - * - * The floor claim is the host's only proof the bytes are deliberate human input — an agent's - * `terminal send` reaches the same methods naming itself a desktop client, and the emulator's own - * query replies never claim the floor — so the takeover fence hangs off exactly this condition. + * Settle an accepted mobile write: the phone keeps the input floor, and if a human produced the + * bytes the host records that they are now driving this terminal. */ export async function settleMobileInputWrite( runtime: OrcaRuntimeService, claim: MobileInputFloorClaimHolder ): Promise { - recordWorkerTerminalUserTakeoverFromInput(runtime, claim.handle) + if (claim.humanInput) { + recordWorkerTerminalUserTakeoverFromInput(runtime, claim.handle) + } await commitMobileInputFloorClaim(claim) } 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 e0702fa30ce..ef666af64bc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -10,9 +10,9 @@ import { getTerminalSendGuardRefusedReason, isTerminalInputLockedForClient, isTerminalSendGuardNotWritable, + newMobileInputWrite, resolveMobileFloorClientId, - settleMobileInputWrite, - type MobileInputFloorClaimHolder + settleMobileInputWrite } from './terminal-input-delivery' import { updateViewportForClient } from './terminal-viewport-update' import { @@ -181,7 +181,7 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ } } const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - const floorClaim: MobileInputFloorClaimHolder = { handle: params.terminal, current: null } + const floorClaim = newMobileInputWrite(params, driver?.kind === 'mobile') const beforeWrite = orchestrationMutation && params.agentPrompt === true ? async (ptyId?: string): Promise => {