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/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..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() @@ -33,6 +41,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() @@ -52,34 +66,39 @@ 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), - input: z.unknown() + 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() }) .strict() 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( @@ -90,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() @@ -231,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 diff --git a/src/shared/mobile-web/shell-payload-tolerance.test.ts b/src/shared/mobile-web/shell-payload-tolerance.test.ts index d97b7c9c296..22ca8c94e60 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' @@ -133,6 +134,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