mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user