From 20104bfd54801fbac6f8c2ed066560fae7576b7e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 16:01:45 -0400 Subject: [PATCH 1/2] feat(mobile-web): carry working mode, tool-output and model on the hosted agent status The hosted page renders the same Expo chat components as the native app, so a field the shell drops is a behaviour difference, not a missing view. Three were dropped. `workingMode` never crossed the bridge, so `isMobileNativeChatAgentWorking` could not see a monitoring agent and the page showed a busy indicator, Stop and a streaming bubble the app deliberately withholds. `lastAssistantMessageIsToolOutput` never crossed it either, so the page streamed raw tool output into the chat bubble. `model` was already in the contract but the shell never projected it, so the session-option controller always read a null model. Also accepts the tool-call lifecycle `state` the structured lane publishes. The contract refused the key outright, so the first producer to set it on this lane would have failed the whole message rather than been ignored. All four are Rule 1 optional shell->page fields. The two closed sets collapse to absent under the tolerant page parse, and absent is each field's pre-existing reading: a foreground agent, and a call whose liveness the turn's working flag decides. Splits the agent-status projection and the host value bounds out of mobile-web-session-snapshot.ts, which had 16 lines of max-lines headroom left. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...eb-session-agent-status-projection.test.ts | 65 +++++++++++++++++ ...ile-web-session-agent-status-projection.ts | 62 ++++++++++++++++ .../mobile-web/mobile-web-session-snapshot.ts | 72 +++---------------- .../mobile-web-session-value-bounds.ts | 25 +++++++ .../session/mobile-native-chat-eligibility.ts | 9 +-- src/shared/agent-status-types.ts | 3 +- .../native-chat-operation-contract.ts | 10 ++- .../shell-payload-tolerance.test.ts | 41 +++++++++++ 8 files changed, 214 insertions(+), 73 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-session-agent-status-projection.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-session-agent-status-projection.ts create mode 100644 mobile/src/mobile-web/mobile-web-session-value-bounds.ts diff --git a/mobile/src/mobile-web/mobile-web-session-agent-status-projection.test.ts b/mobile/src/mobile-web/mobile-web-session-agent-status-projection.test.ts new file mode 100644 index 00000000000..30dcb27d4c6 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-session-agent-status-projection.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_WORKING_MODES } from '../../../src/shared/agent-status-types' +import { MobileWebNativeChatAgentStatusSchema } from '../../../src/shared/mobile-web/native-chat-operation-contract' +import { mobileWebSessionAgentStatus } from './mobile-web-session-agent-status-projection' + +const HOST_STATUS = { + state: 'working', + agentType: 'claude', + model: 'claude-opus-5', + workingMode: 'monitoring', + lastAssistantMessage: 'Ran the tests', + lastAssistantMessageIsToolOutput: true +} + +describe('mobile web session agent status projection', () => { + it('carries the fields the hosted chat reads to decide working and streaming', () => { + const status = mobileWebSessionAgentStatus(HOST_STATUS) + + expect(status).toMatchObject({ + model: 'claude-opus-5', + workingMode: 'monitoring', + lastAssistantMessageIsToolOutput: true + }) + expect(MobileWebNativeChatAgentStatusSchema.safeParse(status).success).toBe(true) + }) + + it('drops an unrecognized working mode rather than failing the snapshot', () => { + const status = mobileWebSessionAgentStatus({ ...HOST_STATUS, workingMode: 'hibernating' }) + + expect(status).not.toHaveProperty('workingMode') + expect(MobileWebNativeChatAgentStatusSchema.safeParse(status).success).toBe(true) + }) + + it('drops a model past the contract bound', () => { + const status = mobileWebSessionAgentStatus({ ...HOST_STATUS, model: 'm'.repeat(121) }) + + expect(status).not.toHaveProperty('model') + expect(MobileWebNativeChatAgentStatusSchema.safeParse(status).success).toBe(true) + }) + + it('drops a non-boolean tool-output flag', () => { + const status = mobileWebSessionAgentStatus({ + ...HOST_STATUS, + lastAssistantMessageIsToolOutput: 'yes' + }) + + expect(status).not.toHaveProperty('lastAssistantMessageIsToolOutput') + }) + + it('refuses a value that is not a live agent status', () => { + expect(mobileWebSessionAgentStatus({ state: 'sleeping' })).toBeUndefined() + expect(mobileWebSessionAgentStatus(null)).toBeUndefined() + }) + + // The contract inlines the working-mode set because the page bundle cannot reach + // `agent-status-types`; a mode the host publishes and the wire refuses would be silently lost. + it('accepts exactly the working modes the host can publish', () => { + for (const mode of AGENT_WORKING_MODES) { + expect( + MobileWebNativeChatAgentStatusSchema.safeParse({ state: 'working', workingMode: mode }) + .success + ).toBe(true) + } + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-session-agent-status-projection.ts b/mobile/src/mobile-web/mobile-web-session-agent-status-projection.ts new file mode 100644 index 00000000000..f509a8adf17 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-session-agent-status-projection.ts @@ -0,0 +1,62 @@ +import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_STATUS_TOOL_NAME_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH, + AGENT_WORKING_MODES +} from '../../../src/shared/agent-status-types' +import type { MobileWebSessionTab } from '../../../src/shared/mobile-web/bridge-operation-contract' +import { boundedOptionalText, safeNonnegativeInteger } from './mobile-web-session-value-bounds' + +type ProjectedAgentStatus = Extract['agentStatus'] + +/** Projects the host's live agent status onto the page's terminal tab. Every field the page's chat + * reads must appear here: an omission is invisible on the native app, which reads `session.tabs` + * directly, and only degrades the hosted page. */ +export function mobileWebSessionAgentStatus(value: unknown): ProjectedAgentStatus { + if (!isRecord(value) || !isAgentState(value.state)) { + return undefined + } + const stateStartedAt = safeNonnegativeInteger(value.stateStartedAt) + const agentType = boundedOptionalText(value.agentType, AGENT_TYPE_MAX_LENGTH) + const model = boundedOptionalText(value.model, AGENT_MODEL_MAX_LENGTH) + const toolName = boundedOptionalText(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) + const toolInput = boundedOptionalText(value.toolInput, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + const interactivePrompt = boundedOptionalText( + value.interactivePrompt, + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH + ) + const lastAssistantMessage = boundedOptionalText( + value.lastAssistantMessage, + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH + ) + return { + state: value.state, + ...(stateStartedAt === undefined ? {} : { stateStartedAt }), + ...(agentType ? { agentType } : {}), + ...(model ? { model } : {}), + ...(toolName ? { toolName } : {}), + ...(toolInput ? { toolInput } : {}), + ...(interactivePrompt ? { interactivePrompt } : {}), + ...(lastAssistantMessage ? { lastAssistantMessage } : {}), + ...(typeof value.lastAssistantMessageIsToolOutput === 'boolean' + ? { lastAssistantMessageIsToolOutput: value.lastAssistantMessageIsToolOutput } + : {}), + ...(isAgentWorkingMode(value.workingMode) ? { workingMode: value.workingMode } : {}), + ...(typeof value.interrupted === 'boolean' ? { interrupted: value.interrupted } : {}) + } +} + +function isAgentState(value: unknown): value is 'working' | 'blocked' | 'waiting' | 'done' { + return value === 'working' || value === 'blocked' || value === 'waiting' || value === 'done' +} + +function isAgentWorkingMode(value: unknown): value is (typeof AGENT_WORKING_MODES)[number] { + return AGENT_WORKING_MODES.includes(value as (typeof AGENT_WORKING_MODES)[number]) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/mobile/src/mobile-web/mobile-web-session-snapshot.ts b/mobile/src/mobile-web/mobile-web-session-snapshot.ts index c118ca3ec41..87ca221adf4 100644 --- a/mobile/src/mobile-web/mobile-web-session-snapshot.ts +++ b/mobile/src/mobile-web/mobile-web-session-snapshot.ts @@ -1,11 +1,5 @@ import type { MobileWebHostWorkspaceId } from './mobile-web-workspace-authority' -import { - AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, - AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, - AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, - AGENT_STATUS_TOOL_NAME_MAX_LENGTH, - AGENT_TYPE_MAX_LENGTH -} from '../../../src/shared/agent-status-types' +import { AGENT_TYPE_MAX_LENGTH } from '../../../src/shared/agent-status-types' import { MobileWebRelativePathSchema, MOBILE_WEB_SESSION_TAB_LIMIT, @@ -14,7 +8,13 @@ import { type MobileWebSessionTab } from '../../../src/shared/mobile-web/bridge-operation-contract' import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy' +import { mobileWebSessionAgentStatus } from './mobile-web-session-agent-status-projection' import { tabsWithinMobileWebSessionEventBudget } from './mobile-web-session-snapshot-event-budget' +import { + boundedNullableText, + boundedOptionalText, + boundedText +} from './mobile-web-session-value-bounds' import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority' import type { MobileWebHostNativeChatBinding, @@ -127,7 +127,7 @@ function mobileWebSessionTab( } if (value.type === 'terminal') { const launchAgent = boundedOptionalText(value.launchAgent, AGENT_TYPE_MAX_LENGTH) - const agentStatus = mobileWebNativeChatAgentStatus(value.agentStatus) + const agentStatus = mobileWebSessionAgentStatus(value.agentStatus) const nativeChatBinding = mobileWebNativeChatBinding(value, hostWorkspaceId) return { ...base, @@ -189,58 +189,6 @@ function fallbackTitle(type: MobileWebSessionTab['type']): string { return type === 'browser' ? 'Browser' : type[0].toUpperCase() + type.slice(1) } -function boundedText(value: unknown, maximum: number, fallback: string): string { - return typeof value === 'string' && value.length > 0 ? value.slice(0, maximum) : fallback -} - -function boundedNullableText(value: unknown, maximum: number): string | null { - return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null -} - -function boundedOptionalText(value: unknown, maximum: number): string | undefined { - return typeof value === 'string' && value.length > 0 && value.length <= maximum - ? value - : undefined -} - -function mobileWebNativeChatAgentStatus( - value: unknown -): Extract['agentStatus'] { - if (!isRecord(value) || !isAgentState(value.state)) { - return undefined - } - const stateStartedAt = safeNonnegativeInteger(value.stateStartedAt) - return { - state: value.state, - ...(stateStartedAt === undefined ? {} : { stateStartedAt }), - ...(boundedOptionalText(value.agentType, AGENT_TYPE_MAX_LENGTH) - ? { agentType: value.agentType as string } - : {}), - ...(boundedOptionalText(value.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) - ? { toolName: value.toolName as string } - : {}), - ...(boundedOptionalText(value.toolInput, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) - ? { toolInput: value.toolInput as string } - : {}), - ...(boundedOptionalText(value.interactivePrompt, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) - ? { interactivePrompt: value.interactivePrompt as string } - : {}), - ...(boundedOptionalText(value.lastAssistantMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) - ? { lastAssistantMessage: value.lastAssistantMessage as string } - : {}), - ...(typeof value.interrupted === 'boolean' ? { interrupted: value.interrupted } : {}) - } -} - -function safeNonnegativeInteger(value: unknown): number | undefined { - return typeof value === 'number' && - Number.isSafeInteger(value) && - value >= 0 && - value <= Number.MAX_SAFE_INTEGER - ? value - : undefined -} - function mobileWebNativeChatBinding( value: unknown, hostWorkspaceId: MobileWebHostWorkspaceId @@ -278,10 +226,6 @@ function mobileWebNativeChatBinding( } } -function isAgentState(value: unknown): value is 'working' | 'blocked' | 'waiting' | 'done' { - return value === 'working' || value === 'blocked' || value === 'waiting' || value === 'done' -} - function isTabType(value: unknown): value is (typeof TAB_TYPES)[number] { return TAB_TYPES.some((type) => type === value) } diff --git a/mobile/src/mobile-web/mobile-web-session-value-bounds.ts b/mobile/src/mobile-web/mobile-web-session-value-bounds.ts new file mode 100644 index 00000000000..1fc5ab03bd4 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-session-value-bounds.ts @@ -0,0 +1,25 @@ +/** Bounds applied to untrusted host session-snapshot values before they are projected onto the + * page wire. A value outside its contract bound is refused here rather than at the schema, so one + * oversized field cannot fail the snapshot the page needs. */ +export function boundedText(value: unknown, maximum: number, fallback: string): string { + return typeof value === 'string' && value.length > 0 ? value.slice(0, maximum) : fallback +} + +export function boundedNullableText(value: unknown, maximum: number): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null +} + +export function boundedOptionalText(value: unknown, maximum: number): string | undefined { + return typeof value === 'string' && value.length > 0 && value.length <= maximum + ? value + : undefined +} + +export function safeNonnegativeInteger(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 && + value <= Number.MAX_SAFE_INTEGER + ? value + : undefined +} diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 892e7d7cfe4..199335b3dbf 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,5 +1,4 @@ import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' -import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { MobileWebNativeChatAgentStatus } from '../../../src/shared/mobile-web/native-chat-operation-contract' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' import { @@ -36,12 +35,8 @@ export type MobileNativeChatTab = { } export type MobileNativeChatAgentStatusWithProvider = MobileWebNativeChatAgentStatus & { - model?: string - /** Host flag marking `lastAssistantMessage` as tool output rather than a reply. */ - lastAssistantMessageIsToolOutput?: boolean - // Why: only the native `session.tabs` payload carries this; the hosted bridge's status schema - // does not publish it. A monitoring agent is working without holding the foreground. - workingMode?: AgentWorkingMode + // Why native-only: the hosted page addresses a transcript by the shell's opaque + // `nativeChatSessionId`, so the provider session never crosses the bridge. providerSession?: { id: string transcriptPath?: string diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 128e2f80f82..5ff96e7d33c 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -24,7 +24,8 @@ export type { export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] -export type AgentWorkingMode = 'monitoring' +export const AGENT_WORKING_MODES = ['monitoring'] as const +export type AgentWorkingMode = (typeof AGENT_WORKING_MODES)[number] // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is // accepted — these well-known names are just a convenience union for pattern-matching. export type WellKnownAgentType = diff --git a/src/shared/mobile-web/native-chat-operation-contract.ts b/src/shared/mobile-web/native-chat-operation-contract.ts index 4aabcdaa25b..75a4c62cf86 100644 --- a/src/shared/mobile-web/native-chat-operation-contract.ts +++ b/src/shared/mobile-web/native-chat-operation-contract.ts @@ -33,6 +33,12 @@ export const MobileWebNativeChatAgentStatusSchema = z toolInput: OptionalBoundedTextSchema(AGENT_STATUS_TOOL_INPUT_MAX_LENGTH), interactivePrompt: OptionalBoundedTextSchema(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH), lastAssistantMessage: OptionalBoundedTextSchema(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH), + /** Marks `lastAssistantMessage` as tool output, so the streaming preview stays suppressed. */ + lastAssistantMessageIsToolOutput: z.boolean().optional(), + // Inlined like `state` above: the page bundle cannot reach `agent-status-types`. Kept equal to + // `AGENT_WORKING_MODES` by a test. A closed set, so the tolerant page parse collapses an + // unknown future mode to absent — the pre-field reading of a foreground agent. + workingMode: z.enum(['monitoring']).optional(), interrupted: z.boolean().optional() }) .strict() @@ -58,7 +64,9 @@ const MobileWebNativeChatToolCallBlockSchema = z .object({ type: z.literal('tool-call'), name: z.string().min(1).max(256), - input: z.unknown() + input: z.unknown(), + /** Provider lifecycle. Absent on the transcript lane, where the turn's working flag decides. */ + state: z.enum(['running', 'completed', 'failed']).optional() }) .strict() const MobileWebNativeChatToolResultBlockSchema = z diff --git a/src/shared/mobile-web/shell-payload-tolerance.test.ts b/src/shared/mobile-web/shell-payload-tolerance.test.ts index 2249bae62a0..98853640979 100644 --- a/src/shared/mobile-web/shell-payload-tolerance.test.ts +++ b/src/shared/mobile-web/shell-payload-tolerance.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' +import { MobileWebNativeChatReadResultSchema } from './native-chat-operation-contract' import { MobileWebSessionSnapshotResultSchema } from './session-operation-contract' import { tolerantMobileWebShellPayload } from './shell-payload-tolerance' @@ -104,6 +105,46 @@ describe('mobile web shell payload tolerance', () => { }) }) + it('reads a working mode an older page cannot name as a foreground agent', () => { + const parsed = snapshot.safeParse({ + ...SNAPSHOT, + tabs: [ + { + ...SNAPSHOT.tabs[0], + agentStatus: { state: 'working', workingMode: 'hibernating' } + } + ] + }) + + expect(parsed.success).toBe(true) + const tab = parsed.data?.tabs[0] + expect(tab?.type === 'terminal' ? tab.agentStatus : undefined).toEqual({ state: 'working' }) + }) + + it('keeps a tool call whose lifecycle state an older page cannot name', () => { + const transcript = tolerantMobileWebShellPayload(MobileWebNativeChatReadResultSchema) + + const parsed = transcript.safeParse({ + messages: [ + { + id: 'm1', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'tool-call', name: 'Bash', input: {}, state: 'queued' }] + } + ], + hasMore: false + }) + + expect(parsed.success).toBe(true) + expect(parsed.data?.messages[0]?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'Bash', + input: {} + }) + }) + it('leaves the source schema strict so page->shell requests keep their fence', () => { expect( MobileWebSessionSnapshotResultSchema.safeParse({ ...SNAPSHOT, sessionRevision: 9 }).success From 1cdfef70ce84d5fab2e5ed1b8d5b4306bec02d72 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 16:07:54 -0400 Subject: [PATCH 2/2] fix(mobile-web): project host transcript content onto the wire instead of refusing it The shell parsed the host's transcript reply against a .strict() page contract. The host already publishes three things that contract has never carried, all on the transcript lane the hosted page uses: - Claude's resolved edit hunks (editPatch), on every Edit tool result since #18765; - a structured providerFrame on a text block; - text up to MOBILE_TEXT_BLOCK_CHAR_CAP (64 KiB), where the wire allows 4200. Each was an unrecognized key or an over-long string, so the parse threw. On read the page got 'Transcript read failed' and rendered nothing; on subscribe sanitizeEvent returned null and the ledger cancelled with invalid_message, which is not retryable, so the live transcript died mid-turn. The native app reads the same host method directly and showed all three fine, so this was a hosted-only blank screen rather than a missing feature. The shell now projects each message onto the contract: text and tool output are clipped with the truncation marker the host itself uses, the tool-call lifecycle state is carried, a block whose shape the page cannot render is dropped without its siblings, and a message whose id would break dedup is dropped rather than clipped. A projected corpus is asserted to always satisfy the schema, and the .parse stays behind it as a fence. editPatch and providerFrame are dropped, not widened: mobile renders edits through diffFromToolCall and has no provider-frame row, so carrying them would be wire weight no component reads. Both are additive shell->page fields if a mobile renderer for them ever lands. Supersedes sanitizeMobileWebNativeChatMessages, which only reached tool inputs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...web-native-chat-message-projection.test.ts | 111 ++++++++++++++ ...bile-web-native-chat-message-projection.ts | 142 ++++++++++++++++++ .../mobile-web-native-chat-operations.ts | 10 +- ...native-chat-subscription-roundtrip.test.ts | 86 +++++++++++ .../mobile-web-native-chat-subscriptions.ts | 8 +- .../mobile-web-native-chat-tool-input.test.ts | 20 +-- .../mobile-web-native-chat-tool-input.ts | 25 --- .../native-chat-operation-contract.ts | 30 ++-- 8 files changed, 373 insertions(+), 59 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-native-chat-message-projection.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-native-chat-message-projection.ts diff --git a/mobile/src/mobile-web/mobile-web-native-chat-message-projection.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-message-projection.test.ts new file mode 100644 index 00000000000..6bd219ffb2c --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-native-chat-message-projection.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + MobileWebNativeChatReadResultSchema, + MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS +} from '../../../src/shared/mobile-web/native-chat-operation-contract' +import { projectMobileWebNativeChatMessages } from './mobile-web-native-chat-message-projection' + +function hostMessage(blocks: unknown[], overrides: Record = {}) { + return { + id: 'message-1', + role: 'assistant', + timestamp: 1_720_000_000_000, + source: 'transcript', + blocks, + ...overrides + } +} + +function projectedBlocks(blocks: unknown[]): unknown[] { + const messages = projectMobileWebNativeChatMessages([hostMessage(blocks)]) + expect(MobileWebNativeChatReadResultSchema.safeParse({ messages, hasMore: false }).success).toBe( + true + ) + return messages?.[0]?.blocks ?? [] +} + +describe('mobile web native chat message projection', () => { + it('keeps a message the host enriched past this wire instead of failing the transcript', () => { + expect( + projectedBlocks([ + { type: 'tool-result', output: 'done', editPatch: { filePath: 'a.ts', hunks: [] } }, + { + type: 'text', + text: 'Ready', + providerFrame: { + provider: 'claude', + kind: 'unhandled', + payload: { head: 'h', byteLength: 1, digest: 'd', truncated: false } + } + } + ]) + ).toEqual([ + { type: 'tool-result', output: 'done' }, + { type: 'text', text: 'Ready' } + ]) + }) + + it('truncates text the host caps far above this wire rather than refusing it', () => { + const blocks = projectedBlocks([{ type: 'text', text: 'x'.repeat(64_000) }]) + + const text = (blocks[0] as { text: string }).text + expect(text.length).toBe(MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS) + expect(text.endsWith('… (truncated)')).toBe(true) + }) + + it('carries the tool-call lifecycle state and drops one it cannot name', () => { + expect( + projectedBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'running' }, + { type: 'tool-call', name: 'Bash', input: {}, state: 'queued' } + ]) + ).toEqual([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'running' }, + { type: 'tool-call', name: 'Bash', input: {} } + ]) + }) + + it('still bounds tool-call input', () => { + const blocks = projectedBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'y'.repeat(20_000) } } + ]) + + expect(JSON.stringify(blocks).length).toBeLessThan(8_000) + }) + + it('drops a block whose shape the page cannot render, keeping its siblings', () => { + expect( + projectedBlocks([ + { type: 'thinking', text: 'hmm' }, + { type: 'text', text: 'Ready' } + ]) + ).toEqual([{ type: 'text', text: 'Ready' }]) + }) + + it('drops a message whose id would break dedup rather than clipping it', () => { + const messages = projectMobileWebNativeChatMessages([ + hostMessage([{ type: 'text', text: 'a' }], { id: 'i'.repeat(1025) }), + hostMessage([{ type: 'text', text: 'b' }], { id: 'message-2' }) + ]) + + expect(messages?.map((message) => message.id)).toEqual(['message-2']) + }) + + it('refuses a reply whose messages are not a list', () => { + expect(projectMobileWebNativeChatMessages({ messages: [] })).toBeNull() + expect(projectMobileWebNativeChatMessages(undefined)).toBeNull() + }) + + it('projects an adversarial corpus into something the contract always accepts', () => { + const messages = projectMobileWebNativeChatMessages([ + hostMessage(Array.from({ length: 200 }, () => ({ type: 'text', text: 'x'.repeat(9_000) }))), + hostMessage([null, 7, 'text', { type: 'image-ref', path: 'p'.repeat(5_000), alt: 'a' }]), + hostMessage([], { timestamp: Number.NaN, turnId: '', source: 'unknown-source' }), + hostMessage([], { timestamp: Number.POSITIVE_INFINITY, blocks: 'not-a-list' }) + ]) + + expect( + MobileWebNativeChatReadResultSchema.safeParse({ messages, hasMore: false }).success + ).toBe(true) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-native-chat-message-projection.ts b/mobile/src/mobile-web/mobile-web-native-chat-message-projection.ts new file mode 100644 index 00000000000..d665b632ae6 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-native-chat-message-projection.ts @@ -0,0 +1,142 @@ +import { + MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT, + MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_READ_LIMIT, + MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS, + type MobileWebNativeChatMessage +} from '../../../src/shared/mobile-web/native-chat-operation-contract' +import { sanitizeMobileWebNativeChatToolInput } from './mobile-web-native-chat-tool-input' + +type Block = MobileWebNativeChatMessage['blocks'][number] + +const TRUNCATION_MARKER = '\n… (truncated)' +const ROLES = new Set(['user', 'assistant', 'tool', 'reasoning', 'system']) +const SOURCES = new Set(['transcript', 'hook', 'scrape']) +const TOOL_CALL_STATES = new Set(['running', 'completed', 'failed']) + +/** + * Projects host transcript messages onto the page contract rather than validating against it. + * + * The host publishes content this wire has never carried — Claude's resolved edit hunks, a + * structured provider frame, and text up to 64 KiB where the wire allows 4200 — and the shell used + * to `.parse` its reply against a `.strict()` schema. Any one of those blanked the whole transcript + * on `read` and permanently cancelled the stream on `subscribe`, while the native app, which reads + * the same host method directly, showed the message fine. + * + * Fields no mobile component reads are dropped rather than widened: mobile renders edits through + * `diffFromToolCall`, not through `editPatch`. Carrying one later is an additive shell->page field. + */ +export function projectMobileWebNativeChatMessages( + value: unknown +): MobileWebNativeChatMessage[] | null { + if (!Array.isArray(value)) { + return null + } + return value.slice(0, MOBILE_WEB_NATIVE_CHAT_READ_LIMIT).flatMap((message) => { + const projected = projectMessage(message) + return projected ? [projected] : [] + }) +} + +function projectMessage(value: unknown): MobileWebNativeChatMessage | null { + if (!isRecord(value)) { + return null + } + const id = boundedIdentifier(value.id) + const turnId = boundedIdentifier(value.turnId) + if (!id || !isMember(ROLES, value.role) || !isMember(SOURCES, value.source)) { + return null + } + const blocks = Array.isArray(value.blocks) ? value.blocks : [] + return { + id, + role: value.role as MobileWebNativeChatMessage['role'], + blocks: blocks.slice(0, MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT).flatMap((block) => { + const projected = projectBlock(block) + return projected ? [projected] : [] + }), + timestamp: + typeof value.timestamp === 'number' && Number.isFinite(value.timestamp) + ? value.timestamp + : null, + source: value.source as MobileWebNativeChatMessage['source'], + ...(turnId ? { turnId } : {}) + } +} + +function projectBlock(value: unknown): Block | null { + if (!isRecord(value)) { + return null + } + if (value.type === 'text') { + return typeof value.text === 'string' + ? { + type: 'text', + text: clipped(value.text, MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS) + } + : null + } + if (value.type === 'tool-call') { + const name = boundedText(value.name, MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS) + return name + ? { + type: 'tool-call', + name, + input: sanitizeMobileWebNativeChatToolInput(value.input), + ...(isMember(TOOL_CALL_STATES, value.state) + ? { state: value.state as 'running' | 'completed' | 'failed' } + : {}) + } + : null + } + if (value.type === 'tool-result') { + return typeof value.output === 'string' + ? { + type: 'tool-result', + output: clipped(value.output, MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS), + ...(typeof value.isError === 'boolean' ? { isError: value.isError } : {}) + } + : null + } + if (value.type !== 'image-ref') { + return null + } + const path = boundedText(value.path, MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS) + const url = boundedText(value.url, MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS) + const alt = boundedText(value.alt, MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS) + return { + type: 'image-ref', + ...(path ? { path } : {}), + ...(url ? { url } : {}), + ...(alt ? { alt } : {}) + } +} + +/** Clipped, never refused: the page showing a truncated message beats it showing none. */ +function clipped(value: string, maximum: number): string { + return value.length <= maximum + ? value + : `${value.slice(0, maximum - TRUNCATION_MARKER.length)}${TRUNCATION_MARKER}` +} + +function boundedText(value: unknown, maximum: number): string | undefined { + return typeof value === 'string' && value.length > 0 && value.length <= maximum + ? value + : undefined +} + +/** Ids are dedup keys on both sides, so a clipped one is worse than a dropped message. */ +function boundedIdentifier(value: unknown): string | undefined { + return boundedText(value, MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS) +} + +function isMember(members: ReadonlySet, value: unknown): boolean { + return typeof value === 'string' && members.has(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/mobile/src/mobile-web/mobile-web-native-chat-operations.ts b/mobile/src/mobile-web/mobile-web-native-chat-operations.ts index cd3a7a2c54f..9e1ad961ed1 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-operations.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-operations.ts @@ -31,7 +31,7 @@ import { executeMobileWebNativeChatTerminalOperation, isMobileWebNativeChatTerminalOperation } from './mobile-web-native-chat-terminal-operations' -import { sanitizeMobileWebNativeChatMessages } from './mobile-web-native-chat-tool-input' +import { projectMobileWebNativeChatMessages } from './mobile-web-native-chat-message-projection' export async function executeMobileWebNativeChatOperation(args: { operation: string @@ -68,11 +68,15 @@ export async function executeMobileWebNativeChatOperation(args: { ? { worktreeId: binding.hostWorkspaceId, terminal: binding.hostTerminalId } : {}) }) - if (!response.ok || !isRecord(response.result) || !Array.isArray(response.result.messages)) { + const messages = + response.ok && isRecord(response.result) + ? projectMobileWebNativeChatMessages(response.result.messages) + : null + if (!response.ok || !isRecord(response.result) || !messages) { throw new MobileWebBrokerError('host_error') } return MobileWebNativeChatReadResultSchema.parse({ - messages: sanitizeMobileWebNativeChatMessages(response.result.messages), + messages, hasMore: response.result.hasMore === true, ...(safeOffset(response.result.beforeOffset) === undefined ? {} diff --git a/mobile/src/mobile-web/mobile-web-native-chat-subscription-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-subscription-roundtrip.test.ts index 3ccd50a0f14..ecd64c2fe02 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-subscription-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-subscription-roundtrip.test.ts @@ -84,6 +84,92 @@ describe('mobile web native chat subscription round trip', () => { expect(unsubscribeHost).toHaveBeenCalledOnce() dispose() }) + + it('keeps streaming a Claude edit turn the host enriched past this wire', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ worktrees: [{ worktreeId: 'host-workspace', repoId: 'host-repo' }] }) + ) + .mockResolvedValueOnce(success(restoredSessionSnapshot())) + .mockResolvedValueOnce(success(restoredSessionSnapshot())) + let emitHostEvent: (event: unknown) => void = () => {} + const subscribe = vi + .fn() + .mockImplementation((_method, _params, onEvent) => { + emitHostEvent = onEvent + return vi.fn() + }) + let requestIndex = 0 + const { client, dispose } = createMobileWebBridgeRoundtripFixture({ + grants: [...MOBILE_WEB_PRODUCTION_GRANTS], + rpcClient: { sendRequest, subscribe } as unknown as RpcClient, + createRequestId: () => `${String.fromCharCode(65 + requestIndex++)}`.repeat(22), + terminalClientId: 'device' + }) + + const workspace = (await client.workspaceSnapshot({ limit: 1 })).workspaces[0]! + const session = await client.sessionSnapshot({ workspaceId: workspace.id }) + const tab = session.tabs[0]! + if (tab.type !== 'terminal' || !tab.nativeChatSessionId) { + throw new Error('Expected native chat authority') + } + const events: unknown[] = [] + const errors: unknown[] = [] + const subscription = client.nativeChatSubscribe( + { workspaceId: workspace.id, sessionId: tab.nativeChatSessionId, limit: 40 }, + (event) => events.push(event), + (error) => errors.push(error) + ) + await expect(subscription.ready).resolves.toBeUndefined() + + emitHostEvent({ + type: 'appended', + messages: [ + { + id: 'message-2', + role: 'assistant', + blocks: [ + { type: 'text', text: 'x'.repeat(64_000) }, + { type: 'tool-call', name: 'Edit', input: { file_path: 'a.ts' }, state: 'running' } + ], + timestamp: 2, + source: 'transcript' + }, + { + id: 'message-3', + role: 'tool', + blocks: [ + { + type: 'tool-result', + output: 'Edited a.ts', + editPatch: { + filePath: 'a.ts', + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }] + } + } + ], + timestamp: 3, + source: 'transcript' + } + ] + }) + + await vi.waitFor(() => expect(events).toHaveLength(1)) + expect(errors).toEqual([]) + const appended = events[0] as { + messages: { blocks: ({ text?: string } & Record)[] }[] + } + expect(appended.messages[0]?.blocks[0]?.text).toHaveLength(4200) + expect(appended.messages[0]?.blocks[1]).toMatchObject({ state: 'running' }) + expect(appended.messages[1]?.blocks[0]).toEqual({ + type: 'tool-result', + output: 'Edited a.ts' + }) + + subscription.unsubscribe() + dispose() + }) }) function restoredSessionSnapshot() { diff --git a/mobile/src/mobile-web/mobile-web-native-chat-subscriptions.ts b/mobile/src/mobile-web/mobile-web-native-chat-subscriptions.ts index 7c580afbe61..b9066dab13b 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-subscriptions.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-subscriptions.ts @@ -13,7 +13,7 @@ import type { RpcClient } from '../transport/rpc-client' import { MobileWebBrokerError } from './mobile-web-broker-error' import type { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority' import { resolveFreshMobileWebNativeChatBinding } from './mobile-web-native-chat-binding' -import { sanitizeMobileWebNativeChatMessages } from './mobile-web-native-chat-tool-input' +import { projectMobileWebNativeChatMessages } from './mobile-web-native-chat-message-projection' import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' type TranscriptRecord = MobileWebSubscriptionRecord & { @@ -103,15 +103,17 @@ function sanitizeEvent(value: unknown): MobileWebNativeChatEvent | null { if (!isRecord(value) || typeof value.type !== 'string') { return null } + const messages = projectMobileWebNativeChatMessages(value.messages) const candidate = value.type === 'end' ? { type: 'end' } : value.type === 'error' ? { type: 'error', message: value.message } - : value.type === 'snapshot' || value.type === 'replacement' || value.type === 'appended' + : messages && + (value.type === 'snapshot' || value.type === 'replacement' || value.type === 'appended') ? { type: value.type, - messages: sanitizeMobileWebNativeChatMessages(value.messages), + messages, ...(typeof value.hasMore === 'boolean' ? { hasMore: value.hasMore } : {}), ...(safeOffset(value.beforeOffset) === undefined ? {} diff --git a/mobile/src/mobile-web/mobile-web-native-chat-tool-input.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-tool-input.test.ts index 239ee4765fd..783dec00a42 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-tool-input.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-tool-input.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - sanitizeMobileWebNativeChatMessages, - sanitizeMobileWebNativeChatToolInput -} from './mobile-web-native-chat-tool-input' +import { sanitizeMobileWebNativeChatToolInput } from './mobile-web-native-chat-tool-input' describe('mobile web native chat tool input', () => { it('preserves supported tool input used by the existing mobile UI', () => { @@ -41,19 +38,4 @@ describe('mobile web native chat tool input', () => { expect(encoded).not.toContain('hidden') expect(Object.keys(result as Record)).toHaveLength(6) }) - - it('only rewrites tool-call inputs in transcript messages', () => { - const textBlock = { type: 'text', text: 'Ready' } - const messages = [ - { - id: 'message-1', - blocks: [textBlock, { type: 'tool-call', name: 'Read', input: { path: 'src/app.ts' } }] - } - ] - - expect(sanitizeMobileWebNativeChatMessages(messages)).toEqual(messages) - expect((sanitizeMobileWebNativeChatMessages(messages) as typeof messages)[0].blocks[0]).toBe( - textBlock - ) - }) }) diff --git a/mobile/src/mobile-web/mobile-web-native-chat-tool-input.ts b/mobile/src/mobile-web/mobile-web-native-chat-tool-input.ts index cf3e0a5f638..f1ecaf7f9c4 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-tool-input.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-tool-input.ts @@ -11,13 +11,6 @@ type ToolInputBudget = { seen: WeakSet } -export function sanitizeMobileWebNativeChatMessages(value: unknown): unknown { - if (!Array.isArray(value)) { - return value - } - return value.map(sanitizeMessage) -} - export function sanitizeMobileWebNativeChatToolInput(value: unknown): unknown { return sanitizeValue( value, @@ -30,20 +23,6 @@ export function sanitizeMobileWebNativeChatToolInput(value: unknown): unknown { ) } -function sanitizeMessage(value: unknown): unknown { - if (!isRecord(value) || !Array.isArray(value.blocks)) { - return value - } - return { ...value, blocks: value.blocks.map(sanitizeBlock) } -} - -function sanitizeBlock(value: unknown): unknown { - if (!isRecord(value) || value.type !== 'tool-call') { - return value - } - return { ...value, input: sanitizeMobileWebNativeChatToolInput(value.input) } -} - function sanitizeValue(value: unknown, budget: ToolInputBudget, depth: number): unknown { budget.nodes -= 1 if (budget.nodes < 0 || budget.characters <= 0) { @@ -120,7 +99,3 @@ function uniqueKey(result: Record, key: string, index: number): function addTruncationProperty(result: Record): void { result[uniqueKey(result, '…', Object.keys(result).length)] = 'truncated' } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/mobile-web/native-chat-operation-contract.ts b/src/shared/mobile-web/native-chat-operation-contract.ts index 75a4c62cf86..769848e1f3d 100644 --- a/src/shared/mobile-web/native-chat-operation-contract.ts +++ b/src/shared/mobile-web/native-chat-operation-contract.ts @@ -20,6 +20,14 @@ export const MOBILE_WEB_NATIVE_CHAT_EVENT_MAX_BYTES = 512 * 1024 export const MOBILE_WEB_NATIVE_CHAT_PENDING_DELIVERY_LIMIT = 16 export const MOBILE_WEB_NATIVE_CHAT_PENDING_TEXT_MAX_CHARACTERS = 4096 export const MOBILE_WEB_NATIVE_CHAT_MAX_DEADLINE_AHEAD_MS = 30_000 +// Bounds the shell projects host transcript content down to. Named rather than inlined because the +// shell must clamp to exactly what this schema accepts; a mismatch fails the whole transcript. +export const MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS = 4200 +export const MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS = 256 +export const MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS = 1024 +export const MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT = 64 +export const MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS = 4096 +export const MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS = 512 const OptionalBoundedTextSchema = (maximum: number) => z.string().min(1).max(maximum).optional() @@ -58,12 +66,15 @@ const MobileWebNativeChatDeadlineShape = { } as const const MobileWebNativeChatTextBlockSchema = z - .object({ type: z.literal('text'), text: z.string().max(4200) }) + .object({ + type: z.literal('text'), + text: z.string().max(MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS) + }) .strict() const MobileWebNativeChatToolCallBlockSchema = z .object({ type: z.literal('tool-call'), - name: z.string().min(1).max(256), + name: z.string().min(1).max(MOBILE_WEB_NATIVE_CHAT_TOOL_NAME_MAX_CHARACTERS), input: z.unknown(), /** Provider lifecycle. Absent on the transcript lane, where the turn's working flag decides. */ state: z.enum(['running', 'completed', 'failed']).optional() @@ -72,22 +83,22 @@ const MobileWebNativeChatToolCallBlockSchema = z const MobileWebNativeChatToolResultBlockSchema = z .object({ type: z.literal('tool-result'), - output: z.string().max(4200), + output: z.string().max(MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS), isError: z.boolean().optional() }) .strict() const MobileWebNativeChatImageRefBlockSchema = z .object({ type: z.literal('image-ref'), - path: z.string().max(4096).optional(), - url: z.string().max(4096).optional(), - alt: z.string().max(512).optional() + path: z.string().max(MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS).optional(), + url: z.string().max(MOBILE_WEB_NATIVE_CHAT_IMAGE_REF_MAX_CHARACTERS).optional(), + alt: z.string().max(MOBILE_WEB_NATIVE_CHAT_IMAGE_ALT_MAX_CHARACTERS).optional() }) .strict() export const MobileWebNativeChatMessageSchema = z .object({ - id: z.string().min(1).max(1024), + id: z.string().min(1).max(MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS), role: z.enum(['user', 'assistant', 'tool', 'reasoning', 'system']), blocks: z .array( @@ -98,10 +109,10 @@ export const MobileWebNativeChatMessageSchema = z MobileWebNativeChatImageRefBlockSchema ]) ) - .max(64), + .max(MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT), timestamp: z.number().finite().nullable(), source: z.enum(['transcript', 'hook', 'scrape']), - turnId: z.string().min(1).max(1024).optional() + turnId: z.string().min(1).max(MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS).optional() }) .strict() @@ -239,6 +250,7 @@ export const MobileWebNativeChatReadabilityResultSchema = z .strict() export type MobileWebNativeChatAgentStatus = z.infer +export type MobileWebNativeChatMessage = z.infer export type MobileWebNativeChatReadPayload = z.infer export type MobileWebNativeChatSubscribePayload = z.infer< typeof MobileWebNativeChatSubscribePayloadSchema