From 20104bfd54801fbac6f8c2ed066560fae7576b7e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 16:01:45 -0400 Subject: [PATCH] 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