diff --git a/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md b/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md index 2ed141b19d1..dadaed9502d 100644 --- a/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md +++ b/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md @@ -33,12 +33,12 @@ Last reconciled: September 6, 2026. Implementation is **in progress**. - [x] Full unattended existing adversarial harness on iOS and Android. - [ ] Chat-specific interactions, migrated settings and frozen-shell OTA/rollback E2E. -Catalog authorization correction implemented; platform rerun pending. +Catalog authorization correction committed as `2b354df1463`; corrected iOS rerun passed. Investigation found that advertised `mobileWeb.files.*` and `mobileWeb.nativeChat.*` adapters were absent from the static mobile allowlist. Prior platform passes can include legacy fallbacks and do not prove those generic adapters were exercised. -Authenticated dispatch tests now cover this gap; platform evidence must be refreshed -before claiming their end-to-end migration is complete. +Authenticated dispatch tests cover this gap. The corrected iOS adversarial harness +passes; chat-specific and frozen-shell OTA journeys remain unverified. Next: migrate remaining domain operations and mutation fingerprint handling; wire hosted settings with their consumers and page-owned route restoration; @@ -72,8 +72,9 @@ source; it must not depend on those temporary files to explain remaining work. clientOperationId, expectedRuntimeFence and retryUnknown. - [x] Migrate native-chat reads through host-owned opaque resources, preserving future host fields and SSH execution routing. -- [ ] Migrate native-chat host actions, separating image/clipboard/ - pending-storage device actions from domain presentation. +- [x] Migrate native-chat TUI send/respond/stop/prepare-commit actions; + retain native image/clipboard/pending-storage authority. +- [ ] Migrate remaining native-chat readability and file-action adapters. - [ ] Migrate session reads and mutations, terminal one-shots and files. - [ ] Extend remaining source-control, task, review and account consumers. - [ ] Keep errors useful for reconciliation without exposing transport keys, @@ -442,4 +443,31 @@ tests and 321 root files / 2,710 tests. Additional authenticated authorization suite: 4 files / 17 tests. Logs: `/tmp/orca-ota-e2e/catalog-authorization-gates/`. Before-fix failure: `/tmp/orca-ota-e2e/catalog-authorization-before.log`. Export passed with build `2e64a57e693f312c4113831e84404f87f009bbe5803a9ef19ddc7a8b0d5fffe9`. -New iOS adversarial journey is next. +The corrected iOS adversarial journey passed: `/tmp/orca-ota-e2e/ios-catalog-authorized.log`, +`ok: true`, exit 0. It uses the authorization-fixed Desktop and the exported page +above; it does not test the subsequent chat-mutation slice. + +### Native-chat actions — code and export verified + +Hosted send/respond/stop/prepare-commit now choose the generic lane only when +both page-session identity and `workspace.hostRequestDispatch.v1` are supported. +Old shells/hosts retain legacy operations. Desktop resolves the opaque transcript +resource before each write and reuses `terminal.send`, including authenticated +mobile ownership, input locks/floor, launch-draft resolution and SSH execution. +Command pacing remains shared; the final Enter carries draft resolution. + +Catalog lookup/binding share the caller's remaining budget. Once a mutation is +dispatched, errors or malformed receipts never trigger legacy fallback. Ambiguous +outcomes remain unknown; preparation only reports success after acknowledgement. +A page cancellation cannot undo an already transmitted mutation. Host disconnect +stops paced command writes through the existing RPC signal. Native image, +clipboard and pending-storage actions retain native authority. + +Focused host and bridge tests pass, including mixed versions, stale bindings, +authenticated identity, exact stop/command bytes, timeout exhaustion and no retry. +All required gates pass in `/tmp/orca-ota-e2e/chat-mutations-gates/`: +841 mobile files / 5,550 passed; 323 root files / 2,717 passed. Additional catalog +authorization check passes; final deadline-focused rerun is 4 files / 29 tests. +Export: `47338504aaa0cc119190d6ffe03069b54eaa0c6af6663f8fdeaaf6524b134774`. +Android adversarial regression is next. +Native-chat simulator interaction coverage remains open. diff --git a/mobile/src/mobile-web/mobile-web-host-native-chat-mutation-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-host-native-chat-mutation-roundtrip.test.ts new file mode 100644 index 00000000000..7fc0a7e39f2 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-native-chat-mutation-roundtrip.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nativeChatBridgeFixture } from './mobile-web-host-native-chat-test-fixture' + +afterEach(() => vi.restoreAllMocks()) + +async function fixture(host = true, shell = true) { + const f = nativeChatBridgeFixture(host, shell) + const workspaceId = (await f.client.workspaceSnapshot({ limit: 10 })).workspaces[0]!.id + const snapshot = await f.client.sessionSnapshot({ workspaceId }) + const tab = snapshot.tabs.find((tab) => tab.type === 'terminal')! + if (tab.type !== 'terminal' || !tab.nativeChatSessionId) { + throw new Error('Missing chat tab') + } + return { + ...f, + tabId: tab.id, + payload: { workspaceId, sessionId: tab.nativeChatSessionId, deadline: Date.now() + 15_000 } + } +} +describe('generic native-chat actions', () => { + it.each([ + [true, true], + [false, true], + [true, false] + ])('host=%s shell=%s uses a compatible action lane', async (host, shell) => { + const f = await fixture(host, shell) + const result = await f.client.nativeChat.sendMessage( + { ...f.payload, text: 'hello' }, + undefined, + f.tabId + ) + expect(result.outcome).toBe('accepted') + expect( + f.sendRequest.mock.calls.filter(([name]) => name === 'mobileWeb.nativeChat.mutate') + ).toHaveLength(host && shell ? 1 : 0) + expect(f.sendRequest.mock.calls.filter(([name]) => name === 'terminal.send')).toHaveLength( + host && shell ? 0 : 1 + ) + if (host && shell) { + expect(result).toMatchObject({ futureReceipt: { revision: 2 } }) + const [, params] = f.sendRequest.mock.calls.find( + ([name]) => name === 'mobileWeb.nativeChat.mutate' + )! + expect(params).toMatchObject({ + action: 'sendMessage', + text: 'hello', + resourceId: 'opaque-resource' + }) + expect(params).not.toHaveProperty('sessionId') + expect(params).not.toHaveProperty('deadline') + } + f.client.dispose() + }) + it.each(['respond', 'stop', 'prepareCommit'] as const)( + 'forwards %s through the generic lane', + async (action) => { + const f = await fixture() + const result = + action === 'respond' + ? await f.client.nativeChat.respond( + { ...f.payload, text: '1', enter: false }, + undefined, + f.tabId + ) + : await f.client.nativeChat[action](f.payload, undefined, f.tabId) + expect(result).toEqual( + action === 'prepareCommit' + ? { prepared: true } + : { outcome: 'accepted', futureReceipt: { revision: 2 } } + ) + expect(f.sendRequest.mock.calls.some(([name]) => name === 'terminal.send')).toBe(false) + f.client.dispose() + } + ) + it.each(['runtime_error', 'method_not_found'])( + 'never repeats a mutation after %s', + async (code) => { + const f = await fixture() + const original = f.sendRequest.getMockImplementation()! + f.sendRequest.mockImplementation((...args) => + args[0] === 'mobileWeb.nativeChat.mutate' + ? Promise.resolve({ ok: false, error: { code, message: 'failed' } }) + : original(...args) + ) + const result = await f.client.nativeChat.sendMessage( + { ...f.payload, text: 'hello' }, + undefined, + f.tabId + ) + expect(result.outcome).toBe(code === 'runtime_error' ? 'unknown' : 'rejected') + expect( + f.sendRequest.mock.calls.filter(([name]) => name === 'mobileWeb.nativeChat.mutate') + ).toHaveLength(1) + expect(f.sendRequest.mock.calls.some(([name]) => name === 'terminal.send')).toBe(false) + f.client.dispose() + } + ) + it('does not start a mutation after binding exhausts its budget', async () => { + const f = await fixture() + const original = f.sendRequest.getMockImplementation()! + f.sendRequest.mockImplementation(async (...args) => { + const result = await original(...args) + if (args[0] === 'mobileWeb.nativeChat.bind') { + vi.spyOn(Date, 'now').mockReturnValue(f.payload.deadline) + } + return result + }) + expect(await f.client.nativeChat.stop(f.payload, undefined, f.tabId)).toEqual({ + outcome: 'rejected' + }) + expect(f.sendRequest.mock.calls.some(([name]) => name === 'mobileWeb.nativeChat.mutate')).toBe( + false + ) + f.client.dispose() + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts index 5dda68e9eab..87f7b15d248 100644 --- a/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts @@ -1,103 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { - createMobileWebBridgeRoundtripFixture, - MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT -} from './mobile-web-bridge-roundtrip-fixture' -import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' - -function fixture(genericHost = true, genericShell = true) { - const transcript = { - messages: [ - { - id: 'm', - role: 'assistant', - source: 'transcript', - timestamp: null, - blocks: [{ type: 'text', text: 'hello', futureFormatting: 'rich' }], - futureProviderField: { revision: 2 } - } - ], - hasMore: false, - futureLifecycle: 'new' - } - const sendRequest = vi.fn(async (method) => { - if (method === 'worktree.ps') { - return { - ok: true, - result: { - worktrees: [ - { worktreeId: 'host-workspace', repo: '/private/repo', displayName: 'Workspace' } - ] - } - } - } - if (method === 'session.tabs.list') { - return { - ok: true, - result: { - worktree: 'host-workspace', - publicationEpoch: 'epoch', - snapshotVersion: 1, - activeTabId: 'tab', - activeTabType: 'terminal', - tabs: [ - { - id: 'tab', - type: 'terminal', - terminal: 'private-terminal', - title: 'Chat', - isActive: true, - agentStatus: { agentType: 'codex', providerSession: { id: 'private-session' } } - } - ] - } - } - } - if (method === 'mobileWeb.host.catalog') { - return { - ok: true, - result: { - grants: genericHost - ? ['bind', 'read', 'subscribe'].map((operation) => ({ - method: `mobileWeb.nativeChat.${operation}`, - ...(operation === 'subscribe' - ? { mode: 'subscription', unsubscribeMethod: 'nativeChat.unsubscribe' } - : {}), - workspaceParam: 'worktree', - pageSessionParam: 'pageSession', - maxRequestBytes: 16384, - maxResponseBytes: 524288 - })) - : [] - } - } - } - if (method === 'mobileWeb.nativeChat.bind') { - return { ok: true, result: { resourceId: 'opaque-resource' } } - } - return { ok: true, result: transcript } - }) - let emit: (event: unknown) => void = () => {} - const unsubscribe = vi.fn() - const subscribe = vi.fn((_method, _params, listener) => { - emit = listener - return unsubscribe - }) - const bridge = createMobileWebBridgeRoundtripFixture({ - grants: MOBILE_WEB_PRODUCTION_GRANTS, - ...(genericShell ? {} : { shellFeatures: [] }), - rpcClient: { sendRequest, subscribe } as unknown as RpcClient - }) - return { - ...bridge, - sendRequest, - transcript, - subscribe, - unsubscribe, - emit: (event: unknown) => emit(event) - } -} +import { MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT } from './mobile-web-bridge-roundtrip-fixture' +import { nativeChatBridgeFixture as fixture } from './mobile-web-host-native-chat-test-fixture' describe('native-chat generic read migration', () => { it.each([ diff --git a/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts b/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts new file mode 100644 index 00000000000..2659ab0915d --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts @@ -0,0 +1,136 @@ +import { vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' +import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' + +export function nativeChatBridgeFixture(genericHost = true, genericShell = true) { + const transcript = { + messages: [ + { + id: 'm', + role: 'assistant', + source: 'transcript', + timestamp: null, + blocks: [{ type: 'text', text: 'hello', futureFormatting: 'rich' }], + futureProviderField: { revision: 2 } + } + ], + hasMore: false, + futureLifecycle: 'new' + } + const sendRequest = vi.fn(async (method, params) => { + if (method === 'worktree.ps') { + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: { + worktrees: [ + { worktreeId: 'host-workspace', repo: '/private/repo', displayName: 'Workspace' } + ] + } + } + } + if (method === 'session.tabs.list') { + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: { + worktree: 'host-workspace', + publicationEpoch: 'epoch', + snapshotVersion: 1, + activeTabId: 'tab', + activeTabType: 'terminal', + tabs: [ + { + id: 'tab', + type: 'terminal', + terminal: 'private-terminal', + title: 'Chat', + isActive: true, + agentStatus: { agentType: 'codex', providerSession: { id: 'private-session' } } + } + ] + } + } + } + if (method === 'mobileWeb.host.catalog') { + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: { + grants: genericHost + ? ['bind', 'read', 'subscribe', 'mutate'].map((operation) => ({ + method: `mobileWeb.nativeChat.${operation}`, + ...(operation === 'subscribe' + ? { mode: 'subscription', unsubscribeMethod: 'nativeChat.unsubscribe' } + : {}), + workspaceParam: 'worktree', + pageSessionParam: 'pageSession', + maxRequestBytes: 16384, + maxResponseBytes: 524288 + })) + : [] + } + } + } + if (method === 'mobileWeb.nativeChat.bind') { + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: { resourceId: 'opaque-resource' } + } + } + if (method === 'mobileWeb.nativeChat.mutate') { + const input = params as { action: string } + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: + input.action === 'prepareCommit' + ? { prepared: true } + : { + outcome: 'accepted', + futureReceipt: { revision: 2 } + } + } + } + if (method === 'terminal.send') { + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: { send: { accepted: true } } + } + } + return { + id: 'test-request', + _meta: { runtimeId: 'test-runtime' }, + ok: true, + result: transcript + } + }) + let emit: (event: unknown) => void = () => {} + const unsubscribe = vi.fn() + const subscribe = vi.fn((_method, _params, listener) => { + emit = listener + return unsubscribe + }) + const bridge = createMobileWebBridgeRoundtripFixture({ + grants: MOBILE_WEB_PRODUCTION_GRANTS, + ...(genericShell ? {} : { shellFeatures: [] }), + rpcClient: { sendRequest, subscribe } as unknown as RpcClient + }) + return { + ...bridge, + sendRequest, + transcript, + subscribe, + unsubscribe, + emit: (event: unknown) => emit(event) + } +} diff --git a/mobile/src/session/web-host-session-native-chat-deadlines.test.ts b/mobile/src/session/web-host-session-native-chat-deadlines.test.ts index 5b9bffc2475..1f0f9bc7397 100644 --- a/mobile/src/session/web-host-session-native-chat-deadlines.test.ts +++ b/mobile/src/session/web-host-session-native-chat-deadlines.test.ts @@ -47,7 +47,8 @@ describe('hosted native-chat deadlines', () => { deadline: 20_000, clearInputFirst: true }, - { timeoutMs: 10_000 } + { timeoutMs: 10_000 }, + 'terminal' ) expect(respond).toHaveBeenCalledWith( { @@ -57,11 +58,13 @@ describe('hosted native-chat deadlines', () => { enter: false, deadline: 20_000 }, - { timeoutMs: 10_000 } + { timeoutMs: 10_000 }, + 'terminal' ) expect(stop).toHaveBeenCalledWith( { workspaceId: 'workspace', sessionId: 'native_chat_session', deadline: 20_000 }, - { timeoutMs: 10_000 } + { timeoutMs: 10_000 }, + 'terminal' ) }) @@ -139,7 +142,8 @@ describe('hosted native-chat deadlines', () => { await expect(operations.prepareCommit(TARGET, 20_000)).resolves.toBe(true) expect(prepareCommit).toHaveBeenCalledWith( { workspaceId: 'workspace', sessionId: 'native_chat_session', deadline: 20_000 }, - { timeoutMs: 10_000 } + { timeoutMs: 10_000 }, + 'terminal' ) expect(isMobileNativeChatInputStale('terminal')).toBe(false) }) diff --git a/mobile/src/session/web-host-session-native-chat-delivery-outcome.test.ts b/mobile/src/session/web-host-session-native-chat-delivery-outcome.test.ts index a8d59b391b8..57f4261ae49 100644 --- a/mobile/src/session/web-host-session-native-chat-delivery-outcome.test.ts +++ b/mobile/src/session/web-host-session-native-chat-delivery-outcome.test.ts @@ -16,7 +16,7 @@ const TARGET: HostSessionNativeChatTarget = { } describe('hosted native-chat delivery outcomes', () => { - it.each(['timeout', 'cancelled', 'invalid_message', 'internal'] as const)( + it.each(['timeout', 'cancelled', 'invalid_message', 'internal', 'host_error'] as const)( 'keeps %s bridge failures delivery-ambiguous', async (code) => { const operations = webHostSessionNativeChatOperations( diff --git a/mobile/src/session/web-host-session-native-chat-operations.ts b/mobile/src/session/web-host-session-native-chat-operations.ts index 0405059bb94..d56997a0a09 100644 --- a/mobile/src/session/web-host-session-native-chat-operations.ts +++ b/mobile/src/session/web-host-session-native-chat-operations.ts @@ -60,7 +60,8 @@ export function webHostSessionNativeChatOperations( ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), ...(typeCommand ? { typeCommand: true } : {}) }), - { timeoutMs: budget.timeoutMs } + { timeoutMs: budget.timeoutMs }, + target.terminalId ?? undefined ) ).outcome } catch (error) { @@ -78,7 +79,8 @@ export function webHostSessionNativeChatOperations( try { const result = await client.nativeChat.prepareCommit( bridgeTarget(target, { deadline: budget.deadline }), - { timeoutMs: budget.timeoutMs } + { timeoutMs: budget.timeoutMs }, + target.terminalId ?? undefined ) if (result.prepared) { clearMobileNativeChatInputStale(target.terminalId) @@ -97,7 +99,8 @@ export function webHostSessionNativeChatOperations( return ( await client.nativeChat.respond( bridgeTarget(target, { text, enter, deadline: budget.deadline }), - { timeoutMs: budget.timeoutMs } + { timeoutMs: budget.timeoutMs }, + target.terminalId ?? undefined ) ).outcome } catch (error) { @@ -111,9 +114,13 @@ export function webHostSessionNativeChatOperations( } try { return ( - await client.nativeChat.stop(bridgeTarget(target, { deadline: budget.deadline }), { - timeoutMs: budget.timeoutMs - }) + await client.nativeChat.stop( + bridgeTarget(target, { deadline: budget.deadline }), + { + timeoutMs: budget.timeoutMs + }, + target.terminalId ?? undefined + ) ).outcome } catch (error) { return bridgeMutationFailureOutcome(error) @@ -178,7 +185,8 @@ function bridgeMutationFailureOutcome(error: unknown): MobileNativeChatSendOutco return error.code === 'timeout' || error.code === 'cancelled' || error.code === 'invalid_message' || - error.code === 'internal' + error.code === 'internal' || + error.code === 'host_error' ? 'unknown' : 'rejected' } diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts index b1c90d24069..6013fd485de 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts @@ -14,14 +14,15 @@ const PAGE_METHODS = new Map( 'mobileWeb.files.searchPaths', 'mobileWeb.files.read', 'mobileWeb.nativeChat.bind', - 'mobileWeb.nativeChat.read' + 'mobileWeb.nativeChat.read', + 'mobileWeb.nativeChat.mutate' ].map((method) => [ method, { method, workspaceParam: 'worktree', ...(method.startsWith('mobileWeb.nativeChat.') ? { pageSessionParam: 'pageSession' } : {}), - maxRequestBytes: 16 * 1024, + maxRequestBytes: method === 'mobileWeb.nativeChat.mutate' ? 600 * 1024 : 16 * 1024, maxResponseBytes: 512 * 1024 } ]) diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.test.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.test.ts new file mode 100644 index 00000000000..a2728846b4a --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.test.ts @@ -0,0 +1,116 @@ +import { buildTerminalSendPayload } from '../../terminal-send-payload' +import { beforeEach, describe, expect, it, vi } from 'vitest' +const send = vi.hoisted(() => vi.fn()) +vi.mock('./terminal/terminal-send-method', () => ({ + TERMINAL_SEND_METHODS: [{ name: 'terminal.send', handler: send }] +})) +import { MOBILE_WEB_NATIVE_CHAT_MUTATION_METHOD as method } from './mobile-web-native-chat-mutations' +import { bindMobileWebNativeChat } from './mobile-web-native-chat-binding' +import { nativeChatPageFixture } from './mobile-web-native-chat-test-fixture' + +async function fixture() { + const f = nativeChatPageFixture() + const resource = await bindMobileWebNativeChat(f.context, { ...f.scope, tabId: 'tab' }) + return { + ...f, + params: { + ...f.scope, + ...resource, + action: 'sendMessage' as const, + text: 'hello', + timeoutMs: 15_000 + } + } +} +beforeEach(() => + send.mockReset().mockResolvedValue({ send: { accepted: true, handle: 'private-terminal' } }) +) + +describe('host-owned chat actions', () => { + it('uses authenticated identity and the authoritative terminal without returning host handles', async () => { + const f = await fixture() + const params = method.params!.parse({ + ...f.params, + terminal: 'forged', + client: { id: 'forged' }, + clearInputFirst: true + }) + expect(await method.handler(params, f.context)).toEqual({ outcome: 'accepted' }) + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + terminal: 'host-terminal', + text: '\x15hello', + enter: true, + client: { id: 'authenticated-device-token', type: 'mobile' } + }), + f.context + ) + }) + it('sends stop without Return and clears input before commit', async () => { + const f = await fixture() + expect(await method.handler({ ...f.params, action: 'stop' }, f.context)).toEqual({ + outcome: 'accepted' + }) + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ text: '\x1b', enter: false }), + f.context + ) + expect(await method.handler({ ...f.params, action: 'prepareCommit' }, f.context)).toEqual({ + prepared: true + }) + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ text: '\x15', enter: false }), + f.context + ) + }) + it('keeps dispatch errors and malformed acknowledgements delivery-ambiguous', async () => { + const f = await fixture() + send.mockRejectedValueOnce(new Error('Lost acknowledgement')) + expect(await method.handler(f.params, f.context)).toEqual({ outcome: 'unknown' }) + send.mockResolvedValueOnce({ future: true }) + expect(await method.handler(f.params, f.context)).toEqual({ outcome: 'unknown' }) + send.mockResolvedValueOnce({ send: { accepted: false } }) + expect(await method.handler(f.params, f.context)).toEqual({ outcome: 'rejected' }) + }) + it('does not write against a replaced transcript binding or an exhausted budget', async () => { + const f = await fixture() + f.listMobileSessionTabs.mockResolvedValue({ worktree: 'host-workspace', tabs: [] }) + await expect(method.handler(f.params, f.context)).rejects.toThrow('selector_not_found') + expect(await method.handler({ ...f.params, timeoutMs: 1 }, f.context)).toEqual({ + outcome: 'rejected' + }) + expect(send).not.toHaveBeenCalled() + }) + it('paces command keys and attaches the launch draft only to the final submit', async () => { + const f = await fixture() + const draft = { text: 'draft', createdAt: 1 } + const result = await method.handler( + { ...f.params, text: '/😀', typeCommand: true, resolvedLaunchDraft: draft }, + f.context + ) + expect(result).toEqual({ outcome: 'accepted' }) + expect(send.mock.calls.map(([params]) => buildTerminalSendPayload(params))).toEqual([ + '\x15', + '/', + '😀', + '\r' + ]) + expect( + send.mock.calls.slice(0, -1).every(([params]) => params.resolvedLaunchDraft === undefined) + ).toBe(true) + expect(send.mock.calls.at(-1)![0].resolvedLaunchDraft).toEqual(draft) + }) + it('stops a command sequence when its document disconnects', async () => { + const f = await fixture() + const controller = new AbortController() + f.context.signal = controller.signal + send.mockImplementationOnce(async () => { + controller.abort() + return { send: { accepted: true } } + }) + expect(await method.handler({ ...f.params, typeCommand: true }, f.context)).toEqual({ + outcome: 'rejected' + }) + expect(send).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.ts new file mode 100644 index 00000000000..6b463c59e24 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' +import { defineMethod, isStreamingMethod } from '../core' +import { typeAgentTuiCommand } from '../../../../shared/agent-tui-command-typing' +import { AGENT_TUI_CLEAR_INPUT_LINE } from '../../../../shared/agent-tui-input-clear' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { TerminalSend } from './terminal/unary-schemas' +import { MobileWebChatScope, resolveMobileWebNativeChat } from './mobile-web-native-chat-binding' + +const method = TERMINAL_SEND_METHODS.find((entry) => entry.name === 'terminal.send') +if (!method || isStreamingMethod(method)) { + throw new Error('Missing terminal sender') +} +const sender = method +const Params = MobileWebChatScope.extend({ + resourceId: z.string().min(1).max(160), + action: z.enum(['sendMessage', 'respond', 'stop', 'prepareCommit']), + text: z + .string() + .max(64 * 1024) + .optional(), + enter: z.boolean().optional(), + clearInputFirst: z.boolean().optional(), + typeCommand: z.boolean().optional(), + resolvedLaunchDraft: TerminalSend.shape.resolvedLaunchDraft, + timeoutMs: z.number().int().min(1).max(15_000) +}).superRefine((params, context) => { + if ((params.action === 'sendMessage' || params.action === 'respond') && !params.text) { + context.addIssue({ code: 'custom', message: 'Missing chat input', path: ['text'] }) + } +}) +type Outcome = 'accepted' | 'rejected' | 'unknown' + +export const MOBILE_WEB_NATIVE_CHAT_MUTATION_METHOD = defineMethod({ + name: 'mobileWeb.nativeChat.mutate', + params: Params, + handler: async (params, context) => { + if (!context.clientId) { + throw new Error('Missing authenticated mobile client') + } + const deadline = Date.now() + params.timeoutMs + const writable = () => !context.signal?.aborted && deadline - Date.now() >= 2_000 + const write = async ( + text: string, + enter: boolean, + resolvedLaunchDraft?: z.infer['resolvedLaunchDraft'] + ): Promise => { + if (!writable()) { + return 'rejected' + } + const binding = await resolveMobileWebNativeChat(context, params) + if (!writable()) { + return 'rejected' + } + const input = TerminalSend.parse({ + terminal: binding.terminal, + text, + enter, + resolvedLaunchDraft, + client: { id: context.clientId, type: 'mobile' } + }) + try { + const result = (await sender.handler(input, context)) as { + send?: { accepted?: boolean } + } | null + if (result?.send?.accepted === true) { + return 'accepted' + } + return result?.send?.accepted === false ? 'rejected' : 'unknown' + } catch { + // A failed acknowledgement after dispatch cannot prove the PTY was untouched. + return 'unknown' + } + } + if (params.action === 'prepareCommit') { + return { prepared: (await write(AGENT_TUI_CLEAR_INPUT_LINE, false)) === 'accepted' } + } + if (params.action === 'stop') { + return { outcome: await write('\x1b', false) } + } + if (params.action === 'respond') { + return { outcome: await write(params.text!, params.enter === true) } + } + if (params.typeCommand) { + let index = 0 + const submitIndex = [...params.text!].length + 1 + return { + outcome: await typeAgentTuiCommand({ + command: params.text!, + signal: context.signal, + write: (key) => { + const submit = index++ === submitIndex + return write(submit ? '' : key, submit, submit ? params.resolvedLaunchDraft : undefined) + } + }) + } + } + return { + outcome: await write( + `${params.clearInputFirst ? AGENT_TUI_CLEAR_INPUT_LINE : ''}${params.text!}`, + true, + params.resolvedLaunchDraft + ) + } + } +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat-test-fixture.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat-test-fixture.ts new file mode 100644 index 00000000000..4d54b2b480d --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat-test-fixture.ts @@ -0,0 +1,29 @@ +import { vi, type Mock } from 'vitest' +import type { RpcContext } from '../core' +export function nativeChatPageFixture(): { + context: RpcContext + scope: { worktree: string; pageSession: string } + listMobileSessionTabs: Mock + tab: Record +} { + const tab = { + id: 'tab', + type: 'terminal', + terminal: 'host-terminal', + agentStatus: { + agentType: 'codex', + providerSession: { id: 'provider-session', transcriptPath: '/private/transcript' } + } + } + const listMobileSessionTabs = vi + .fn() + .mockResolvedValue({ worktree: 'host-workspace', tabs: [tab] }) + const context = { + connectionId: 'connection', + clientId: 'authenticated-device-token', + pairedDeviceId: 'device', + runtime: { listMobileSessionTabs, registerSubscriptionCleanup: vi.fn() } + } as unknown as RpcContext + const scope = { worktree: 'id:host-workspace', pageSession: 'page' } + return { context, scope, listMobileSessionTabs, tab } +} diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts index 4790b1068cf..6da8cb6229a 100644 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../core' +import { nativeChatPageFixture as fixture } from './mobile-web-native-chat-test-fixture' const read = vi.hoisted(() => vi.fn()) vi.mock('./native-chat', async () => { const { z } = await import('zod') @@ -12,27 +12,6 @@ vi.mock('./native-chat', async () => { import { MOBILE_WEB_NATIVE_CHAT_METHODS } from './mobile-web-native-chat' const bind = MOBILE_WEB_NATIVE_CHAT_METHODS[0] const reader = MOBILE_WEB_NATIVE_CHAT_METHODS[1] -function fixture() { - const tab = { - id: 'tab', - type: 'terminal', - terminal: 'host-terminal', - agentStatus: { - agentType: 'codex', - providerSession: { id: 'provider-session', transcriptPath: '/private/transcript' } - } - } - const listMobileSessionTabs = vi - .fn() - .mockResolvedValue({ worktree: 'host-workspace', tabs: [tab] }) - const context = { - connectionId: 'connection', - pairedDeviceId: 'device', - runtime: { listMobileSessionTabs, registerSubscriptionCleanup: vi.fn() } - } as unknown as RpcContext - const scope = { worktree: 'id:host-workspace', pageSession: 'page' } - return { context, scope, listMobileSessionTabs, tab } -} beforeEach(() => read.mockReset()) describe('Desktop native-chat page adapter', () => { it('resolves opaque identities and preserves future transcript fields without shell projections', async () => { diff --git a/src/main/runtime/rpc/methods/mobile-web-native-chat.ts b/src/main/runtime/rpc/methods/mobile-web-native-chat.ts index ac32b50b38c..1da61a511e4 100644 --- a/src/main/runtime/rpc/methods/mobile-web-native-chat.ts +++ b/src/main/runtime/rpc/methods/mobile-web-native-chat.ts @@ -1,3 +1,4 @@ +import { MOBILE_WEB_NATIVE_CHAT_MUTATION_METHOD } from './mobile-web-native-chat-mutations' import { z } from 'zod' import { defineMethod, isStreamingMethod } from '../core' import { NATIVE_CHAT_METHODS } from './native-chat' @@ -33,5 +34,6 @@ export const MOBILE_WEB_NATIVE_CHAT_METHODS = [ await resolveMobileWebNativeChat(context, params) return result } - }) + }), + MOBILE_WEB_NATIVE_CHAT_MUTATION_METHOD ] diff --git a/src/mobile-web/src/mobile-web-bridge-client.ts b/src/mobile-web/src/mobile-web-bridge-client.ts index 6c0c98cf838..c41ecc0b400 100644 --- a/src/mobile-web/src/mobile-web-bridge-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-client.ts @@ -1,4 +1,7 @@ -import { MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE } from '../../shared/mobile-web/shell-feature-contract' +import { + MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE, + MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE +} from '../../shared/mobile-web/shell-feature-contract' import { subscribeHostSourceControl } from './mobile-web-source-control-host-subscription' import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -221,7 +224,8 @@ export class MobileWebBridgeClient { this.nativeChat = new MobileWebNativeChatRequestClient( this.requests, this.shellFeatures.has(MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE), - this.subscriptions + this.subscriptions, + this.shellFeatures.has(MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE) ) this.hostSubscribe = this.subscriptions.subscribeHost.bind(this.subscriptions) this.account = new MobileWebAccountRequestClient(this.requests, this.subscriptions) diff --git a/src/mobile-web/src/mobile-web-host-native-chat-binding.test.ts b/src/mobile-web/src/mobile-web-host-native-chat-binding.test.ts new file mode 100644 index 00000000000..fb01a033c49 --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-native-chat-binding.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { bindMobileWebHostNativeChat } from './mobile-web-host-native-chat-binding' +import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' + +afterEach(() => vi.useRealTimers()) +it('spends one timeout across the catalog read and resource binding', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const request = vi.fn().mockImplementation(async (_capability, operation) => { + if (operation === 'hostCatalog') { + vi.setSystemTime(4_000) + return { + grants: [{ method: 'mobileWeb.nativeChat.bind' }, { method: 'mobileWeb.nativeChat.mutate' }] + } + } + return { resourceId: 'resource' } + }) + const requests = { supports: () => true, request } as unknown as MobileWebOneShotRequestClient + await expect( + bindMobileWebHostNativeChat(requests, 'workspace', 'tab', 'mobileWeb.nativeChat.mutate', { + timeoutMs: 5_000 + }) + ).resolves.toBe('resource') + expect(request.mock.calls.map((args) => args.at(-1).timeoutMs)).toEqual([5_000, 2_000]) +}) diff --git a/src/mobile-web/src/mobile-web-host-native-chat-binding.ts b/src/mobile-web/src/mobile-web-host-native-chat-binding.ts index 8d35e00de0d..db40b7bdfea 100644 --- a/src/mobile-web/src/mobile-web-host-native-chat-binding.ts +++ b/src/mobile-web/src/mobile-web-host-native-chat-binding.ts @@ -1,3 +1,4 @@ +import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' import { readMobileWebHostMethods, requestMobileWebHost } from './mobile-web-host-request-client' import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' @@ -6,7 +7,8 @@ export async function bindMobileWebHostNativeChat( requests: MobileWebOneShotRequestClient, workspaceId: string, tabId: string, - method: string + method: string, + options?: MobileWebBridgeRequestOptions ): Promise { if ( !requests.supports('workspace', 'hostRequest') || @@ -14,12 +16,29 @@ export async function bindMobileWebHostNativeChat( ) { return null } + const deadline = options?.timeoutMs === undefined ? null : Date.now() + options.timeoutMs + const remainingOptions = () => { + if (deadline === null) { + return options + } + const timeoutMs = deadline - Date.now() + if (timeoutMs <= 0) { + throw new MobileWebBridgeClientError('timeout', true) + } + return { ...options, timeoutMs } + } const methods = ['mobileWeb.nativeChat.bind', method] - const catalog = await readMobileWebHostMethods(requests, methods) + const catalog = await readMobileWebHostMethods(requests, methods, remainingOptions()) if (!methods.every((method) => catalog.grants.some((grant) => grant.method === method))) { return null } - const bound = await requestMobileWebHost(requests, methods[0], workspaceId, { tabId }) + const bound = await requestMobileWebHost( + requests, + methods[0], + workspaceId, + { tabId }, + remainingOptions() + ) if ( typeof bound !== 'object' || bound === null || diff --git a/src/mobile-web/src/mobile-web-host-native-chat-mutation.ts b/src/mobile-web/src/mobile-web-host-native-chat-mutation.ts new file mode 100644 index 00000000000..f11868d0bc4 --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-native-chat-mutation.ts @@ -0,0 +1,84 @@ +import { bindMobileWebHostNativeChat } from './mobile-web-host-native-chat-binding' +import { requestMobileWebHost } from './mobile-web-host-request-client' +import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' +import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' +import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' + +type Result = { outcome: 'accepted' | 'rejected' | 'unknown' } | { prepared: boolean } +type Payload = { workspaceId: string; sessionId: string; deadline: number } + +export async function mutateMobileWebHostNativeChat( + requests: MobileWebOneShotRequestClient, + action: 'sendMessage' | 'respond' | 'stop' | 'prepareCommit', + payload: Payload, + tabId: string, + legacy: () => Promise, + options?: MobileWebBridgeRequestOptions +): Promise { + const method = 'mobileWeb.nativeChat.mutate' + const deadline = Math.min( + payload.deadline, + Date.now() + Math.min(15_000, options?.timeoutMs ?? 15_000) + ) + const budget = () => Math.floor(deadline - Date.now()) + if (budget() < 2_000) { + return failed('rejected') + } + let resourceId: string | null + try { + resourceId = await bindMobileWebHostNativeChat(requests, payload.workspaceId, tabId, method, { + ...options, + timeoutMs: Math.max(1, budget()) + }) + } catch (error) { + if (error instanceof MobileWebBridgeClientError && error.code === 'unsupported_capability') { + return legacy() + } + throw error + } + if (!resourceId) { + return legacy() + } + const timeoutMs = budget() + if (timeoutMs < 2_000) { + return failed('rejected') + } + const { workspaceId, sessionId: _sessionId, deadline: _deadline, ...mutation } = payload + try { + const result = await requestMobileWebHost( + requests, + method, + workspaceId, + { + ...mutation, + resourceId, + action, + timeoutMs + }, + { ...options, timeoutMs } + ) + if (typeof result !== 'object' || result === null || Array.isArray(result)) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + const valid = + action === 'prepareCommit' + ? 'prepared' in result && typeof result.prepared === 'boolean' + : 'outcome' in result && + ['accepted', 'rejected', 'unknown'].includes(String(result.outcome)) + if (!valid) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + return result as T + } catch (error) { + // Never fall back after dispatch: the missing response may hide an accepted write. + const rejected = + error instanceof MobileWebBridgeClientError && + ['invalid_request', 'unsupported_capability', 'rate_limited', 'not_connected'].includes( + error.code + ) + return failed(rejected ? 'rejected' : 'unknown') + } + function failed(outcome: 'rejected' | 'unknown'): T { + return (action === 'prepareCommit' ? { prepared: false } : { outcome }) as T + } +} diff --git a/src/mobile-web/src/mobile-web-native-chat-request-client.ts b/src/mobile-web/src/mobile-web-native-chat-request-client.ts index e1d3c8031a0..44b0712070c 100644 --- a/src/mobile-web/src/mobile-web-native-chat-request-client.ts +++ b/src/mobile-web/src/mobile-web-native-chat-request-client.ts @@ -1,3 +1,4 @@ +import { mutateMobileWebHostNativeChat } from './mobile-web-host-native-chat-mutation' import { subscribeMobileWebHostNativeChat } from './mobile-web-host-native-chat-subscription' import type { MobileWebBridgeSubscriptionClient } from './mobile-web-bridge-subscription-client' import { readMobileWebHostNativeChat } from './mobile-web-host-native-chat-read' @@ -53,7 +54,8 @@ export class MobileWebNativeChatRequestClient { constructor( private readonly requests: MobileWebOneShotRequestClient, private readonly hostPageSession = false, - private readonly subscriptions?: MobileWebBridgeSubscriptionClient + private readonly subscriptions?: MobileWebBridgeSubscriptionClient, + private readonly hostRequestDispatch = false ) {} subscribeForTab( @@ -101,8 +103,19 @@ export class MobileWebNativeChatRequestClient { sendMessage( payload: MobileWebNativeChatSendMessagePayload, - options?: MobileWebBridgeRequestOptions + options?: MobileWebBridgeRequestOptions, + tabId?: string ): Promise { + if (tabId && this.hostPageSession && this.hostRequestDispatch) { + return mutateMobileWebHostNativeChat( + this.requests, + 'sendMessage', + payload, + tabId, + () => this.sendMessage(payload, options), + options + ) + } return this.requests.request( 'nativeChat', 'sendMessage', @@ -115,8 +128,19 @@ export class MobileWebNativeChatRequestClient { prepareCommit( payload: MobileWebNativeChatPrepareCommitPayload, - options?: MobileWebBridgeRequestOptions + options?: MobileWebBridgeRequestOptions, + tabId?: string ): Promise<{ prepared: boolean }> { + if (tabId && this.hostPageSession && this.hostRequestDispatch) { + return mutateMobileWebHostNativeChat( + this.requests, + 'prepareCommit', + payload, + tabId, + () => this.prepareCommit(payload, options), + options + ) + } return this.requests.request( 'nativeChat', 'prepareCommit', @@ -129,8 +153,19 @@ export class MobileWebNativeChatRequestClient { respond( payload: MobileWebNativeChatRespondPayload, - options?: MobileWebBridgeRequestOptions + options?: MobileWebBridgeRequestOptions, + tabId?: string ): Promise { + if (tabId && this.hostPageSession && this.hostRequestDispatch) { + return mutateMobileWebHostNativeChat( + this.requests, + 'respond', + payload, + tabId, + () => this.respond(payload, options), + options + ) + } return this.requests.request( 'nativeChat', 'respond', @@ -143,8 +178,19 @@ export class MobileWebNativeChatRequestClient { stop( payload: MobileWebNativeChatStopPayload, - options?: MobileWebBridgeRequestOptions + options?: MobileWebBridgeRequestOptions, + tabId?: string ): Promise { + if (tabId && this.hostPageSession && this.hostRequestDispatch) { + return mutateMobileWebHostNativeChat( + this.requests, + 'stop', + payload, + tabId, + () => this.stop(payload, options), + options + ) + } return this.requests.request( 'nativeChat', 'stop',