Merge parity-0906: hosted agent status fields and transcript projection onto the wire

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-06 16:13:48 -04:00
15 changed files with 587 additions and 132 deletions
@@ -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<string, unknown> = {}) {
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)
})
})
@@ -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<string>, value: unknown): boolean {
return typeof value === 'string' && members.has(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -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
? {}
@@ -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<RpcClient['sendRequest']>()
.mockResolvedValueOnce(
success({ worktrees: [{ worktreeId: 'host-workspace', repoId: 'host-repo' }] })
)
.mockResolvedValueOnce(success(restoredSessionSnapshot()))
.mockResolvedValueOnce(success(restoredSessionSnapshot()))
let emitHostEvent: (event: unknown) => void = () => {}
const subscribe = vi
.fn<RpcClient['subscribe']>()
.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<string, unknown>)[] }[]
}
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() {
@@ -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
? {}
@@ -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<string, unknown>)).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
)
})
})
@@ -11,13 +11,6 @@ type ToolInputBudget = {
seen: WeakSet<object>
}
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<string, unknown>, key: string, index: number):
function addTruncationProperty(result: Record<string, unknown>): void {
result[uniqueKey(result, '…', Object.keys(result).length)] = 'truncated'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -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)
}
})
})
@@ -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<MobileWebSessionTab, { type: 'terminal' }>['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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -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<MobileWebSessionTab, { type: 'terminal' }>['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)
}
@@ -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
}
@@ -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
+2 -1
View File
@@ -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 =
@@ -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<typeof MobileWebNativeChatAgentStatusSchema>
export type MobileWebNativeChatMessage = z.infer<typeof MobileWebNativeChatMessageSchema>
export type MobileWebNativeChatReadPayload = z.infer<typeof MobileWebNativeChatReadPayloadSchema>
export type MobileWebNativeChatSubscribePayload = z.infer<
typeof MobileWebNativeChatSubscribePayloadSchema
@@ -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