From ca6c17a9cd2ab2b2dd3d45b528e2a4b19ce5f7a1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 20:07:23 -0400 Subject: [PATCH] feat(mobile): move native chat actions to host-advertised RPC Resolve host-owned chat resources and reuse terminal.send for authenticated TUI actions. Feature-gate the page consumer on dispatch fencing, share the action deadline across binding and execution, and preserve unknown delivery without fallback after mutation dispatch. Keep native image and persistence capabilities and old shell/host paths. Record green gates and corrected iOS evidence. --- .../2026-09-06-long-lived-mobile-shell.md | 40 +++++- ...ost-native-chat-mutation-roundtrip.test.ts | 116 +++++++++++++++ ...ile-web-host-native-chat-roundtrip.test.ts | 100 +------------ ...obile-web-host-native-chat-test-fixture.ts | 136 ++++++++++++++++++ ...host-session-native-chat-deadlines.test.ts | 12 +- ...ssion-native-chat-delivery-outcome.test.ts | 2 +- ...web-host-session-native-chat-operations.ts | 22 ++- .../rpc/methods/mobile-web-host-catalog.ts | 5 +- .../mobile-web-native-chat-mutations.test.ts | 116 +++++++++++++++ .../mobile-web-native-chat-mutations.ts | 105 ++++++++++++++ .../mobile-web-native-chat-test-fixture.ts | 29 ++++ .../methods/mobile-web-native-chat.test.ts | 23 +-- .../rpc/methods/mobile-web-native-chat.ts | 4 +- .../src/mobile-web-bridge-client.ts | 8 +- ...obile-web-host-native-chat-binding.test.ts | 25 ++++ .../mobile-web-host-native-chat-binding.ts | 25 +++- .../mobile-web-host-native-chat-mutation.ts | 84 +++++++++++ .../mobile-web-native-chat-request-client.ts | 56 +++++++- 18 files changed, 757 insertions(+), 151 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-host-native-chat-mutation-roundtrip.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-native-chat-mutations.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-native-chat-test-fixture.ts create mode 100644 src/mobile-web/src/mobile-web-host-native-chat-binding.test.ts create mode 100644 src/mobile-web/src/mobile-web-host-native-chat-mutation.ts 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',