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.
This commit is contained in:
Jinwoo-H
2026-09-06 20:07:23 -04:00
parent 2b354df146
commit ca6c17a9cd
18 changed files with 757 additions and 151 deletions
@@ -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.
@@ -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()
})
})
@@ -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<RpcClient['sendRequest']>(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<RpcClient['subscribe']>((_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([
@@ -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<RpcClient['sendRequest']>(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<RpcClient['subscribe']>((_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)
}
}
@@ -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)
})
@@ -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(
@@ -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'
}
@@ -14,14 +14,15 @@ const PAGE_METHODS = new Map<string, MobileWebHostGrant>(
'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
}
])
@@ -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()
})
})
@@ -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<typeof TerminalSend>['resolvedLaunchDraft']
): Promise<Outcome> => {
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
)
}
}
})
@@ -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<string, unknown>
} {
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 }
}
@@ -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 () => {
@@ -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
]
@@ -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)
@@ -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])
})
@@ -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<string | null> {
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 ||
@@ -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<T extends Result>(
requests: MobileWebOneShotRequestClient,
action: 'sendMessage' | 'respond' | 'stop' | 'prepareCommit',
payload: Payload,
tabId: string,
legacy: () => Promise<T>,
options?: MobileWebBridgeRequestOptions
): Promise<T> {
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
}
}
@@ -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<MobileWebNativeChatSendResult> {
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<MobileWebNativeChatSendResult> {
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<MobileWebNativeChatSendResult> {
if (tabId && this.hostPageSession && this.hostRequestDispatch) {
return mutateMobileWebHostNativeChat(
this.requests,
'stop',
payload,
tabId,
() => this.stop(payload, options),
options
)
}
return this.requests.request(
'nativeChat',
'stop',