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