Fix mobile structured chat delivery handling

This commit is contained in:
Merge Sim
2026-08-31 23:13:20 -07:00
parent d896af0f69
commit a95e218809
18 changed files with 1623 additions and 668 deletions
@@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
type MobileChatQuestion
} from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
@@ -59,8 +63,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
if (trimmedFreeText.length === 0) {
return
}
// Free text is an unknown entry; formatQuestionAnswer passes it through.
if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) {
if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) {
setFreeText('')
}
}
@@ -95,7 +98,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label, optIndex))}
onPress={() =>
question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
@@ -0,0 +1,18 @@
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = []
export type MobileNativeChatImagesByScope = Record<string, PendingNativeChatImage[]>
export function withScopeAttachments(
byScope: MobileNativeChatImagesByScope,
scope: string,
next: PendingNativeChatImage[]
): MobileNativeChatImagesByScope {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
mobileChatQuestionKey,
parseAgentQuestion,
type MobileChatQuestion
@@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => {
expect(formatQuestionAnswer(numbered, [])).toBe('')
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
})
it('prefixes free-text answers with an opaque prompt token when provided', () => {
expect(
formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ')
).toBe(`target:${encodeURIComponent('hi there')}`)
})
})
describe('mobileChatQuestionKey', () => {
@@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => {
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
mobileChatQuestionKey(first)
)
expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe(
mobileChatQuestionKey(first)
)
})
})
@@ -13,6 +13,8 @@ export type MobileChatQuestion = {
* parallel to `options`. Null where the option was a plain bullet. Used to
* echo the exact choice the agent listed back to the terminal. */
optionTokens: (string | null)[]
/** Opaque prefix used when free-text answers must target a specific prompt. */
freeTextToken?: string
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
@@ -154,3 +156,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str
return parts.join(question.multiSelect ? ', ' : ' ')
}
export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string {
const trimmed = text.trim()
if (trimmed.length === 0) {
return ''
}
return question.freeTextToken
? `${question.freeTextToken}:${encodeURIComponent(trimmed)}`
: formatQuestionAnswer(question, [trimmed])
}
@@ -0,0 +1,251 @@
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
export type StructuredApprovalItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' }>
}
export type StructuredQuestionItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'question' }>
}
export type StructuredPromptResponseTarget = {
itemId: string
expectedRevision: number
optionId: string
}
type PromptTokenPayload =
| {
kind: 'approval'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-option'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-free-text'
itemId: string
revision: number
questionId: string
}
const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:'
export function pendingStructuredApproval(
item: AgentJournalRenderItem
): item is StructuredApprovalItem {
return item.body.kind === 'approval' && item.body.resolution.state === 'pending'
}
export function pendingStructuredQuestion(
item: AgentJournalRenderItem
): item is StructuredQuestionItem {
return item.body.kind === 'question' && item.body.resolution.state === 'pending'
}
function encodeQuestionAnswer(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
function encodePromptToken(payload: PromptTokenPayload): string {
return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
}
function decodePromptToken(value: string): PromptTokenPayload | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
try {
const decoded = JSON.parse(
decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length))
) as Record<string, unknown>
if (
typeof decoded.itemId !== 'string' ||
typeof decoded.revision !== 'number' ||
!Number.isFinite(decoded.revision)
) {
return null
}
if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
questionId: decoded.questionId
}
}
} catch {
return null
}
return null
}
function decodeQuestionFreeTextAnswer(value: string): {
payload: Extract<PromptTokenPayload, { kind: 'question-free-text' }>
answer: string
} | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length)
if (separator === -1) {
return null
}
const payload = decodePromptToken(value.slice(0, separator))
if (payload?.kind !== 'question-free-text') {
return null
}
return { payload, answer: decodeURIComponent(value.slice(separator + 1)) }
}
export function projectStructuredPermission(
prompt: StructuredApprovalItem | null
): MobileChatPermission | null {
if (prompt?.body.kind !== 'approval') {
return null
}
return {
title: prompt.body.title,
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
options: prompt.body.options.map((option) => ({
label: option.label,
send: encodePromptToken({
kind: 'approval',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: option.id
})
}))
}
}
export function projectStructuredQuestion(
prompt: StructuredQuestionItem | null
): MobileChatQuestion | null {
if (prompt?.body.kind !== 'question') {
return null
}
return {
question: prompt.body.question,
options: prompt.body.options.map((option) => option.label),
multiSelect: false,
allowOther: Boolean(prompt.body.freeTextQuestionId),
optionTokens: prompt.body.options.map((option) =>
encodePromptToken({
kind: 'question-option',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: option.id
})
),
...(prompt.body.freeTextQuestionId
? {
freeTextToken: encodePromptToken({
kind: 'question-free-text',
itemId: prompt.itemId,
revision: prompt.revision,
questionId: prompt.body.freeTextQuestionId
})
}
: {})
}
}
export function structuredApprovalResponseTarget(
response: string,
currentPrompt: StructuredApprovalItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
if (token?.kind === 'approval') {
return {
itemId: token.itemId,
expectedRevision: token.revision,
optionId: token.optionId
}
}
if (token) {
return null
}
const option = currentPrompt?.body.options.find(
(candidate) => candidate.id === response || candidate.label === response
)
return currentPrompt && option
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: option.id
}
: null
}
export function structuredQuestionResponseTarget(
response: string,
currentPrompt: StructuredQuestionItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
if (token?.kind === 'question-option') {
return {
itemId: token.itemId,
expectedRevision: token.revision,
optionId: token.optionId
}
}
if (token) {
return null
}
const freeText = decodeQuestionFreeTextAnswer(response)
if (freeText) {
const answer = freeText.answer.trim()
return answer.length > 0
? {
itemId: freeText.payload.itemId,
expectedRevision: freeText.payload.revision,
optionId: encodeQuestionAnswer(freeText.payload.questionId, answer)
}
: null
}
if (!currentPrompt) {
return null
}
const trimmed = response.trim()
const option = currentPrompt.body.options.find(
(candidate) => candidate.id === response || candidate.label === trimmed
)
if (option) {
return {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: option.id
}
}
return currentPrompt.body.freeTextQuestionId && trimmed
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed)
}
: null
}
@@ -0,0 +1,101 @@
import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire'
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS } from './mobile-native-chat-send'
export const STRUCTURED_SEND_TIMEOUT_MS = 15_000
export type StructuredAgentSessionMutationCallResult<TValue> =
| { status: 'accepted'; value: TValue }
| { status: 'refused'; message: string }
| { status: 'failed'; message: string }
| { status: 'unknown' }
export type StructuredAgentSessionMutationResult<TValue> =
| { status: 'accepted'; value: TValue; sameFence: boolean }
| { status: 'rejected' }
| { status: 'unknown' }
export type StructuredAgentSessionMutate = <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
) => Promise<StructuredAgentSessionMutationResult<TValue>>
export async function callAgentSession<TResult>(
client: RpcClient,
method: string,
params: unknown,
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS
): Promise<TResult> {
const response = await client.sendRequest(method, params, {
timeoutMs,
budgetSpansConnect: true
})
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
export function structuredSessionOperationId(): string {
const random =
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: Math.random().toString(36).slice(2)
return `${Date.now().toString(36)}-${random}`
}
export function timeoutForDeadline(deadline: number | undefined): number | null {
if (deadline === undefined) {
return STRUCTURED_SEND_TIMEOUT_MS
}
const timeoutMs = deadline - Date.now()
return timeoutMs >= MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS ? timeoutMs : null
}
export async function requestStructuredAgentSessionMutation<TValue>(args: {
client: RpcClient
method: string
fingerprintMethod: string
sessionId: string
expectedRuntimeFence: number
fields: Record<string, unknown>
timeoutMs?: number
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
const { client, method, fingerprintMethod, sessionId, expectedRuntimeFence, fields, timeoutMs } =
args
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
client,
method,
{
envelope: {
sessionId,
clientOperationId: structuredSessionOperationId(),
expectedRuntimeFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: fingerprintMethod,
sessionId,
fields
})
},
...fields
},
timeoutMs
)
return result.ok
? { status: 'accepted', value: result.value }
: { status: 'refused', message: result.refusal.message }
} catch (error) {
if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) {
return { status: 'unknown' }
}
return {
status: 'failed',
message: error instanceof Error ? error.message : 'Request not sent'
}
}
}
@@ -0,0 +1,83 @@
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
export function useMobileNativeChatActiveResolution(args: {
hostId: string
worktreeId: string
activeSessionTab: MobileNativeChatTab | null
activeSessionTabId: string | null
activeHandleRef: MutableRefObject<string | null>
nativeChatTranscriptIsLocalReadable: boolean
}): {
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
activeChatAgent: string | null
activeChatAgentRef: MutableRefObject<string | null>
activeChatSessionId: string | null
activeChatStructured: boolean
activeChatResolution: ReturnType<typeof resolveMobileNativeChat>
activeTabAgentWorking: boolean
nativeChatStatus: MobileNativeChatTab['agentStatus'] | null
sourceIdentity: string
streamIdentity: string
streamScopeKey: string
} {
const {
activeHandleRef,
activeSessionTab,
activeSessionTabId,
hostId,
nativeChatTranscriptIsLocalReadable,
worktreeId
} = args
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const tabWantsChat =
activeSessionTab?.type === 'agent-session' ||
(activeSessionTabId ? isTabChatView(activeSessionTabId) : false)
const activeChatResolution =
activeSessionTab && activeSessionTabId && tabWantsChat
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
const activeChatAgent = activeChatResolution?.agent ?? null
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const activeChatStructured =
activeChatResolution != null && activeSessionTab?.type === 'agent-session'
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? ''
const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}`
return {
isTabChatView,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
activeChatAgent,
activeChatAgentRef,
activeChatSessionId,
activeChatStructured,
activeChatResolution,
activeTabAgentWorking,
nativeChatStatus,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
streamIdentity,
streamScopeKey
}
}
@@ -1,9 +1,7 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
@@ -11,10 +9,8 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
@@ -22,6 +18,7 @@ import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -60,37 +57,33 @@ export function useMobileNativeChatController(args: {
onSendError,
onSendResolved
} = args
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const tabWantsChat =
activeSessionTab?.type === 'agent-session' ||
(activeSessionTabId ? isTabChatView(activeSessionTabId) : false)
const activeChatResolution =
activeSessionTab && activeSessionTabId && tabWantsChat
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
const activeChatAgent = activeChatResolution?.agent ?? null
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const activeChatStructured =
activeChatResolution != null && activeSessionTab?.type === 'agent-session'
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
// Same chat, but keyed off the tab rather than the view-gated resolution:
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
// and a scope that flips on a view toggle throws the gate's baseline away.
const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
const {
activeChatAgent,
activeChatAgentRef,
activeChatResolution,
activeChatSessionId,
activeChatStructured,
activeTabAgentWorking,
isTabChatView,
nativeChatStatus,
showNativeChat,
showNativeChatRef,
sourceIdentity,
streamIdentity,
streamScopeKey,
toggleTabChatView
} = useMobileNativeChatActiveResolution({
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
nativeChatTranscriptIsLocalReadable
})
const legacyNativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
sourceIdentity,
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
sessionId: activeChatStructured ? null : activeChatSessionId,
transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
@@ -133,10 +126,6 @@ export function useMobileNativeChatController(args: {
transcriptSettled: nativeChatSession.status === 'ready'
})
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null
const nativeChatAgentWorking = activeChatStructured
? structuredNativeChat.isWorking
: activeChatResolution != null && activeTabAgentWorking
@@ -268,33 +257,28 @@ export function useMobileNativeChatController(args: {
onSendError
})
// Bring the terminal view forward when an agent-owned picker command is used.
const handleAgentPicker = useCallback(() => {
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
toggleTabChatView(activeSessionTabId)
}
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
const sessionOptions = useMobileNativeChatSessionOptions({
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
dispatchCommand: handleNativeChatDispatchCommand,
onAgentPicker: handleAgentPicker
})
const structuredSessionOptionsController: MobileNativeChatSessionOptionsController | null =
activeChatStructured && structuredNativeChat.optionSnapshot.length > 0
? {
snapshot: structuredNativeChat.optionSnapshot,
pendingId: structuredNativeChat.pendingOptionId,
setOption: structuredNativeChat.setStructuredOption,
invokeAction: structuredNativeChat.invokeStructuredOption,
recordCommand: () => {}
}
: null
const { nativeChatSessionOptions, recordCommand: recordNativeChatSessionOptionCommand } =
useMobileNativeChatSessionOptionController({
activeChatStructured,
activeSessionTabId,
agent: activeChatResolution?.agent ?? null,
dispatchCommand: handleNativeChatDispatchCommand,
hostId,
isTabChatView,
isWorking: nativeChatAgentWorking,
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
structured: {
snapshot: structuredNativeChat.optionSnapshot,
pendingId: structuredNativeChat.pendingOptionId,
setOption: structuredNativeChat.setStructuredOption,
invokeAction: structuredNativeChat.invokeStructuredOption
},
toggleTabChatView,
worktreeId
})
useLayoutEffect(() => {
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
}, [sessionOptions.recordCommand])
recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand
}, [recordNativeChatSessionOptionCommand])
// Card actions retire the route's held failure banner too, not just sends.
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
@@ -343,12 +327,6 @@ export function useMobileNativeChatController(args: {
? structuredNativeChatSend.sendWithOutcome
: handleNativeChatSendWithOutcome,
readSeededLaunchDraft,
nativeChatSessionOptions: activeChatStructured
? structuredSessionOptionsController
? { controller: structuredSessionOptionsController, isWorking: nativeChatAgentWorking }
: null
: sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking: nativeChatAgentWorking }
: null
nativeChatSessionOptions
}
}
@@ -1,18 +1,17 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import type { MobileImageSource } from './mobile-image-source-picker'
import {
appendPendingNativeChatImages,
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
import {
NO_NATIVE_CHAT_IMAGE_ATTACHMENTS,
withScopeAttachments,
type MobileNativeChatImagesByScope
} from './mobile-native-chat-image-scope-state'
import {
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
pasteMobileNativeChatImagePaths
@@ -31,6 +30,7 @@ import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload'
type CurrentRef<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -86,21 +86,6 @@ export type MobileNativeChatImageAttachments = {
readonly sendNativeChat: (text: string) => Promise<boolean>
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
byScope: Record<string, PendingNativeChatImage[]>,
scope: string,
next: PendingNativeChatImage[]
): Record<string, PendingNativeChatImage[]> {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
@@ -121,98 +106,34 @@ export function useMobileNativeChatImageAttachments({
onError,
sleep = defaultSleep
}: Args): MobileNativeChatImageAttachments {
const [attachmentsByScope, setAttachmentsByScope] = useState<
Record<string, PendingNativeChatImage[]>
>({})
const [isAttaching, setIsAttaching] = useState(false)
const [attachmentsByScope, setAttachmentsByScope] = useState<MobileNativeChatImagesByScope>({})
const idCounter = useRef(0)
// Count in-flight uploads so an overlapping attach can't clear the flag early.
const attachingCount = useRef(0)
// Live connState for attachImage's catch: the closure's value was already
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
const attachments =
(scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
// The chip lands in the scope that initiated the pick, even if the user
// switches tabs while the upload is in flight.
const scope = scopeKey
if (
!client ||
!scope ||
connState !== 'connected' ||
(!activeHandleRef.current && !structuredNativeChat)
) {
return
}
// Only this call's own increment may be undone in `finally`; a cancelled
// pick or pre-upload error never ran `onUploadStart`, so decrementing the
// shared counter would clear a concurrent upload's in-flight flag early.
let started = false
const uploadedImages: Omit<PendingNativeChatImage, 'id'>[] = []
let uploadError: unknown = null
try {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
}
const addUploadedImages = useCallback(
(scope: string, uploadedImages: Omit<PendingNativeChatImage, 'id'>[]) => {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
},
[
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
scopeKey,
showToast
]
[]
)
const { attachImage, isAttaching } = useMobileNativeChatImageUpload({
client,
activeHandleRef,
getActiveWorktreeConnectionId,
connState,
scopeKey,
structuredNativeChat,
showToast,
onImagesUploaded: addUploadedImages,
onAttachSuccess,
onError
})
const removeAttachment = useCallback(
(id: string): void => {
const scope = scopeKey
@@ -247,7 +168,8 @@ export function useMobileNativeChatImageAttachments({
const deadline = openMobileNativeChatSendBudget()
try {
const scope = scopeKey
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
const pendingImages =
(scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
if (structuredNativeChat && pendingImages.length > 0 && scope) {
if (!client || !enabled || connState !== 'connected') {
onError?.()
@@ -0,0 +1,124 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import {
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
type CurrentRef<T> = { readonly current: T }
type UploadedNativeChatImage = Omit<PendingNativeChatImage, 'id'>
type ShowToast = (message: string, durationMs?: number) => void
export function useMobileNativeChatImageUpload(args: {
client: RpcClient | null
activeHandleRef: CurrentRef<string | null>
getActiveWorktreeConnectionId: () => Promise<string | null>
connState: ConnectionState
scopeKey: string | null
structuredNativeChat: boolean
showToast: ShowToast
onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void
onAttachSuccess?: () => void
onError?: () => void
}): {
attachImage: (source: MobileImageSource) => Promise<void>
isAttaching: boolean
} {
const {
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
onImagesUploaded,
scopeKey,
showToast,
structuredNativeChat
} = args
const [isAttaching, setIsAttaching] = useState(false)
const attachingCount = useRef(0)
const connStateRef = useRef(connState)
connStateRef.current = connState
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
const scope = scopeKey
if (
!client ||
!scope ||
connState !== 'connected' ||
(!activeHandleRef.current && !structuredNativeChat)
) {
return
}
let started = false
const uploadedImages: UploadedNativeChatImage[] = []
let uploadError: unknown = null
try {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
onImagesUploaded(scope, uploadedImages)
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
}
},
[
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
onImagesUploaded,
scopeKey,
showToast,
structuredNativeChat
]
)
return { attachImage, isAttaching }
}
@@ -0,0 +1,100 @@
import { useCallback, useMemo } from 'react'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import {
useMobileNativeChatSessionOptions,
type MobileNativeChatSessionOptionsController
} from './use-mobile-native-chat-session-options'
export function useMobileNativeChatSessionOptionController(args: {
activeChatStructured: boolean
activeSessionTabId: string | null
agent: string | null
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
hostId: string
isTabChatView: (tabId: string) => boolean
isWorking: boolean
reportedModel: string | null
structured: {
snapshot: SessionOptionDescriptor[]
pendingId: string | null
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeAction: (id: string) => Promise<boolean>
}
toggleTabChatView: (tabId: string) => void
worktreeId: string
}): {
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
recordCommand: (command: string) => void
} {
const {
activeChatStructured,
activeSessionTabId,
agent,
dispatchCommand,
hostId,
isTabChatView,
isWorking,
reportedModel,
structured,
toggleTabChatView,
worktreeId
} = args
const {
invokeAction: invokeStructuredAction,
pendingId: structuredPendingId,
setOption: setStructuredOption,
snapshot: structuredSnapshot
} = structured
const handleAgentPicker = useCallback(() => {
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
toggleTabChatView(activeSessionTabId)
}
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
const sessionOptions = useMobileNativeChatSessionOptions({
agent: activeChatStructured ? null : agent,
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
reportedModel,
dispatchCommand,
onAgentPicker: handleAgentPicker
})
const structuredController = useMemo<MobileNativeChatSessionOptionsController | null>(
() =>
activeChatStructured && structuredSnapshot.length > 0
? {
snapshot: structuredSnapshot,
pendingId: structuredPendingId,
setOption: setStructuredOption,
invokeAction: invokeStructuredAction,
recordCommand: () => {}
}
: null,
[
activeChatStructured,
invokeStructuredAction,
setStructuredOption,
structuredPendingId,
structuredSnapshot
]
)
const nativeChatSessionOptions = useMemo<MobileNativeChatSessionOptionPickersProps | null>(
() =>
activeChatStructured
? structuredController
? { controller: structuredController, isWorking }
: null
: sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking }
: null,
[activeChatStructured, isWorking, sessionOptions, structuredController]
)
return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand }
}
@@ -0,0 +1,123 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
const mocks = vi.hoisted(() => ({
useMobileImageAttachment: vi.fn(),
useMobileNativeChatImageAttachments: vi.fn()
}))
vi.mock('./use-mobile-image-attachment', () => ({
useMobileImageAttachment: mocks.useMobileImageAttachment
}))
vi.mock('./use-mobile-native-chat-image-attachments', () => ({
useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments
}))
type HookArgs = Parameters<typeof useMobileSessionImageAttachments>[0]
function baseArgs(overrides: Partial<HookArgs> = {}): HookArgs {
return {
client: {} as RpcClient,
activeHandle: 'term-1',
activeHandleRef: { current: null },
canSend: true,
connState: 'connected',
deviceTokenRef: { current: null },
nativeChatScopeKey: 'scope-1',
nativeChatInputLeaseReady: false,
getActiveWorktreeConnectionId: async () => 'conn-1',
beforeTerminalSend: async () => true,
nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'),
structuredNativeChat: true,
readSeededLaunchDraft: () => null,
showToast: vi.fn(),
onNativeChatSendError: vi.fn(),
onSuccess: vi.fn(),
onError: vi.fn(),
...overrides
}
}
describe('useMobileSessionImageAttachments', () => {
let renderer: ReactTestRenderer | null = null
function Harness({ args }: { args: HookArgs }): null {
useMobileSessionImageAttachments(args)
return null
}
beforeEach(() => {
mocks.useMobileImageAttachment.mockReturnValue({
attachImage: vi.fn(),
isAttaching: false
})
mocks.useMobileNativeChatImageAttachments.mockReturnValue({
attachments: [],
isAttaching: false,
attachImage: vi.fn(),
removeAttachment: vi.fn(),
sendNativeChat: vi.fn()
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.clearAllMocks()
})
function render(args: HookArgs): void {
act(() => {
renderer = create(createElement(Harness, { args }))
})
}
it('enables native-chat image sends for connected structured sessions without a terminal lease', () => {
render(baseArgs())
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
structuredNativeChat: true
})
)
})
it('keeps terminal-backed native-chat image sends gated on the input lease', () => {
render(
baseArgs({
activeHandleRef: { current: 'term-1' },
nativeChatInputLeaseReady: false,
structuredNativeChat: false
})
)
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
structuredNativeChat: false
})
)
})
it('disables structured native-chat image sends while disconnected', () => {
render(
baseArgs({
connState: 'connecting',
nativeChatInputLeaseReady: true,
structuredNativeChat: true
})
)
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
structuredNativeChat: true
})
)
})
})
@@ -94,7 +94,7 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
connState,
scopeKey: nativeChatScopeKey,
enabled: nativeChatInputLeaseReady,
enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady,
structuredNativeChat,
showToast,
onSendError: onNativeChatSendError,
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import type {
AgentSessionOptionResult,
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type {
SessionOptionDescriptor,
SessionOptionsSurface,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
applyStructuredAgentSessionOptions,
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionSnapshot
} from '../../../src/shared/structured-agent-session-options'
import type { RpcClient } from '../transport/rpc-client'
import {
callAgentSession,
type StructuredAgentSessionMutate
} from './mobile-structured-agent-session-rpc'
type StructuredOptionsController = {
optionSnapshot: SessionOptionDescriptor[]
optionSurface: SessionOptionsSurface
pendingOptionId: string | null
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
export function useMobileStructuredAgentOptions(args: {
agent: string | null
client: RpcClient | null
sessionId: string | null
enabled: boolean
fence: number | null
mutate: StructuredAgentSessionMutate
}): StructuredOptionsController {
const { agent, client, enabled, fence, mutate, sessionId } = args
const [optionState, setOptionState] = useState(() =>
createStructuredAgentSessionOptionState(agent ?? 'codex')
)
const activeOptionRecordRef = useRef(optionState.record)
const optionCatalog = useMemo(
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
useEffect(() => {
const next = createStructuredAgentSessionOptionState(agent ?? 'codex')
activeOptionRecordRef.current = next.record
setOptionState(next)
}, [agent, enabled, fence, sessionId])
useEffect(() => {
if (!client || !sessionId || !enabled || !optionCatalog) {
return
}
let stale = false
void callAgentSession<AgentSessionOptionsResult>(client, 'agentSession.options', { sessionId })
.then((result) => {
if (!stale) {
setOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result)
: current
)
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [client, enabled, optionCatalog, sessionId, fence])
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
[optionState]
)
const setStructuredOption = useCallback(
async (id: string, value: SessionOptionValue): Promise<boolean> => {
if (
!canSetStructuredAgentSessionOption(optionState, id, value) ||
typeof value !== 'string'
) {
return false
}
const targetRecord = optionState.record
setOptionState((current) => ({ ...current, pendingId: id }))
try {
const result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value }
)
if (activeOptionRecordRef.current !== targetRecord) {
return result.status !== 'rejected'
}
if (result.status === 'accepted') {
setOptionState((current) =>
current.record === targetRecord && result.sameFence
? commitStructuredAgentSessionOptionValues(
current,
result.value.options ?? { [id]: value }
)
: current
)
return true
}
if (result.status === 'unknown') {
setOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOption(current, id, value)
: current
)
return true
}
return false
} finally {
setOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
: current
)
}
},
[mutate, optionState]
)
const invokeStructuredOption = useCallback(async () => false, [])
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return { snapshot: optionSnapshot }
},
[optionSnapshot, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
)
return {
optionSnapshot,
optionSurface,
pendingOptionId: optionState.pendingId,
setStructuredOption,
invokeStructuredOption
}
}
@@ -1,9 +1,14 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem, AgentJournalResolution } from '../../../src/shared/agent-session-journal-types'
import type {
AgentJournalRenderItem,
AgentJournalResolution
} from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
function ok(result: unknown) {
@@ -63,6 +68,10 @@ function approvalItem(): AgentJournalRenderItem {
}
}
function approvalItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem {
return { ...approvalItem(), itemId, revision }
}
function questionItem(): AgentJournalRenderItem {
return {
itemId: 'question-1',
@@ -82,75 +91,105 @@ function questionItem(): AgentJournalRenderItem {
}
}
function questionItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem {
return { ...questionItem(), itemId, revision }
}
function runningStatusItem(): AgentJournalRenderItem {
return {
itemId: 'status-1',
revision: 1,
sequence: 3,
observedAt: 14,
body: {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
}
}
}
function defaultSendRequest(method: string, params?: Record<string, unknown>) {
if (method === 'agentSession.send') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: { turnId: 'turn-1' }
})
}
if (method === 'agentSession.options') {
return ok({
models: [
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{
id: 'gpt-slow',
label: 'GPT Slow',
isDefault: false,
defaultEffort: 'high',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
],
current: {
model: 'gpt-fast',
effort: 'low'
}
})
}
if (method === 'agentSession.setOption') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: {
key: 'model',
value: 'gpt-fast',
options: { model: 'gpt-fast' }
}
})
}
if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 3 },
value: {
itemId: String(params?.itemId ?? ''),
revision: 2,
resolution: {
state: 'resolved',
selectedOptionId: String(params?.optionId ?? ''),
resolvedBy: 'mobile',
resolvedAt: 123
}
}
})
}
return ok({})
}
describe('useMobileStructuredAgentSession', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const onSendError = vi.fn()
const unsubscribe = vi.fn()
const sendRequest = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'agentSession.send') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: { turnId: 'turn-1' }
})
}
if (method === 'agentSession.options') {
return ok({
models: [
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
],
current: {
model: 'gpt-fast',
effort: 'low'
}
})
}
if (method === 'agentSession.setOption') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: {
key: 'model',
value: 'gpt-fast',
options: { model: 'gpt-fast' }
}
})
}
if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 3 },
value: {
itemId: String(params?.itemId ?? ''),
revision: 2,
resolution: {
state: 'resolved',
selectedOptionId: String(params?.optionId ?? ''),
resolvedBy: 'mobile',
resolvedAt: 123
}
}
})
}
return ok({})
})
const sendRequest = vi.fn(defaultSendRequest)
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return unsubscribe
@@ -179,6 +218,7 @@ describe('useMobileStructuredAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
sendRequest.mockImplementation(defaultSendRequest)
listener = null
})
@@ -253,13 +293,15 @@ describe('useMobileStructuredAgentSession', () => {
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
act(() => listener?.(snapshotEvent(3)))
act(() => listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [approvalItem(), questionItem()]
}
}))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [approvalItem(), questionItem()]
}
})
)
if (!hook) {
throw new Error('hook not ready')
@@ -273,14 +315,15 @@ describe('useMobileStructuredAgentSession', () => {
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ label: 'Allow once', send: 'allow-once' },
{ label: 'Deny', send: 'deny' }
{ label: 'Allow once', send: expect.any(String) },
{ label: 'Deny', send: expect.any(String) }
]
})
expect(hook.question).toMatchObject({
question: 'Pick destination',
allowOther: true,
optionTokens: ['choice-a', 'choice-b']
optionTokens: [expect.any(String), expect.any(String)],
freeTextToken: expect.any(String)
})
expect(hook.optionSurface.getSnapshot()).toEqual(hook.optionSnapshot)
@@ -303,7 +346,7 @@ describe('useMobileStructuredAgentSession', () => {
)
await act(async () => {
expect(await hook.respondPermission('allow-once')).toBe(true)
expect(await hook.respondPermission(hook.permission!.options[0]!.send)).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToApproval',
@@ -319,7 +362,9 @@ describe('useMobileStructuredAgentSession', () => {
)
await act(async () => {
expect(await hook.respondQuestion('custom answer')).toBe(true)
expect(
await hook.respondQuestion(formatQuestionFreeTextAnswer(hook.question!, 'custom answer'))
).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToQuestion',
@@ -372,6 +417,200 @@ describe('useMobileStructuredAgentSession', () => {
)
})
it('rejects preview-only structured image URIs instead of sending them as host paths', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
sendRequest.mockClear()
let outcome: 'accepted' | 'unknown' | 'rejected' = 'accepted'
await act(async () => {
outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg'])
})
expect(outcome).toBe('rejected')
expect(onSendError).toHaveBeenCalledWith('Message not sent')
expect(sendRequest).not.toHaveBeenCalledWith(
'agentSession.send',
expect.objectContaining({
body: expect.objectContaining({
blocks: expect.arrayContaining([{ type: 'image-ref', path: 'file:///a.jpg' }])
})
}),
expect.any(Object)
)
})
it('answers the prompt captured by a structured card after a newer prompt lands', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [
approvalItemWithIdentity('approval-old', 4),
questionItemWithIdentity('question-old', 8)
]
}
})
)
const approvalToken = hook!.permission!.options[0]!.send
const questionToken = hook!.question!.optionTokens[0]!
const freeText = formatQuestionFreeTextAnswer(hook!.question!, 'old answer')
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [
approvalItemWithIdentity('approval-new', 9),
questionItemWithIdentity('question-new', 10)
]
}
})
)
sendRequest.mockClear()
await act(async () => {
expect(await hook!.respondPermission(approvalToken)).toBe(true)
expect(await hook!.respondQuestion(questionToken)).toBe(true)
expect(await hook!.respondQuestion(freeText)).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToApproval',
expect.objectContaining({
itemId: 'approval-old',
expectedRevision: 4,
optionId: 'allow-once'
}),
expect.any(Object)
)
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToQuestion',
expect.objectContaining({
itemId: 'question-old',
expectedRevision: 8,
optionId: 'choice-a'
}),
expect.any(Object)
)
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.respondToQuestion',
expect.objectContaining({
itemId: 'question-old',
expectedRevision: 8,
optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('old answer')}`
}),
expect.any(Object)
)
})
it('surfaces unknown structured prompt responses as unconfirmed', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [approvalItem(), questionItem()]
}
})
)
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.respondToApproval') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.respondPermission(hook!.permission!.options[0]!.send)).toBe(false)
})
expect(onSendError).toHaveBeenCalledWith('Response unconfirmed — check chat before retrying')
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.respondToQuestion') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.respondQuestion(hook!.question!.optionTokens[0]!)).toBe(false)
})
expect(onSendError).toHaveBeenCalledWith('Answer unconfirmed — check chat before retrying')
})
it('keeps structured option changes dispatched after unknown delivery', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent(3)))
await vi.waitFor(() => expect(hook!.optionSnapshot.length).toBeGreaterThan(0))
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.setOption') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true)
})
const model = hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model')
expect(model).toMatchObject({
valueSource: 'dispatched',
kind: expect.objectContaining({ currentValue: 'gpt-slow' })
})
expect(onSendError).not.toHaveBeenCalled()
})
it('reports structured Stop as unconfirmed after unknown delivery', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() =>
listener?.({
...snapshotEvent(3),
page: {
...snapshotEvent(3).page,
items: [runningStatusItem()]
}
})
)
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.cancel') {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
return defaultSendRequest(method, params)
})
onSendError.mockClear()
await act(async () => {
hook!.cancel()
await Promise.resolve()
})
expect(onSendError).toHaveBeenCalledWith('Stop unconfirmed — check chat before retrying')
})
it('releases a landed hold when the structured tab unmounts', async () => {
act(() => {
renderer = create(createElement(Harness))
@@ -1,64 +1,40 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import { useCallback, useMemo } from 'react'
import type {
AgentSessionCancelResult,
AgentSessionHistoryResult,
AgentSessionMutationResult,
AgentSessionOptionResult,
AgentSessionOptionsResult,
AgentSessionPromptResult,
AgentSessionSendResult,
AgentSessionSubscribeEvent
AgentSessionSendResult
} from '../../../src/shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire'
import type {
SessionOptionDescriptor,
SessionOptionsSurface,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
import {
structuredAgentSessionSendBody,
type StructuredAgentSessionAttachment
} from '../../../src/shared/structured-agent-session-outbox'
import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder'
import {
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
oldestStructuredAgentSessionCursor,
reduceStructuredAgentSession,
type StructuredAgentSessionAction,
type StructuredAgentSessionState
} from '../../../src/shared/structured-agent-session-reducer'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-projection'
import {
applyStructuredAgentSessionOptions,
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionSnapshot
} from '../../../src/shared/structured-agent-session-options'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
pendingStructuredApproval,
pendingStructuredQuestion,
projectStructuredPermission,
projectStructuredQuestion,
structuredApprovalResponseTarget,
structuredQuestionResponseTarget
} from './mobile-structured-agent-prompts'
import {
requestStructuredAgentSessionMutation,
timeoutForDeadline,
type StructuredAgentSessionMutationResult
} from './mobile-structured-agent-session-rpc'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
const STRUCTURED_SEND_TIMEOUT_MS = 15_000
type StructuredApprovalItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' }>
}
type StructuredQuestionItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'question' }>
}
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string }
@@ -84,95 +60,6 @@ type StructuredMobileSession = {
invokeStructuredOption: (id: string) => Promise<boolean>
}
async function callAgentSession<TResult>(
client: RpcClient,
method: string,
params: unknown,
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS
): Promise<TResult> {
const response = await client.sendRequest(method, params, {
timeoutMs,
budgetSpansConnect: true
})
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
function structuredSessionOperationId(): string {
const random =
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: Math.random().toString(36).slice(2)
return `${Date.now().toString(36)}-${random}`
}
function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent {
if (typeof value !== 'object' || value === null) {
return false
}
const type = (value as { type?: unknown }).type
return type === 'snapshot' || type === 'batch' || type === 'reset' || type === 'end'
}
function pendingStructuredApproval(item: AgentJournalRenderItem): item is StructuredApprovalItem {
return item.body.kind === 'approval' && item.body.resolution.state === 'pending'
}
function pendingStructuredQuestion(item: AgentJournalRenderItem): item is StructuredQuestionItem {
return item.body.kind === 'question' && item.body.resolution.state === 'pending'
}
function encodeQuestionAnswer(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
function projectStructuredPermission(prompt: StructuredApprovalItem | null): MobileChatPermission | null {
if (prompt?.body.kind !== 'approval') {
return null
}
return {
title: prompt.body.title,
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
options: prompt.body.options.map((option) => ({ label: option.label, send: option.id }))
}
}
function projectStructuredQuestion(prompt: StructuredQuestionItem | null): MobileChatQuestion | null {
if (prompt?.body.kind !== 'question') {
return null
}
return {
question: prompt.body.question,
options: prompt.body.options.map((option) => option.label),
multiSelect: false,
allowOther: Boolean(prompt.body.freeTextQuestionId),
optionTokens: prompt.body.options.map((option) => option.id)
}
}
function promptAnswerOptionId(prompt: StructuredQuestionItem, answer: string): string | null {
const trimmed = answer.trim()
const option = prompt.body.options.find(
(candidate) => candidate.id === answer || candidate.label === trimmed
)
if (option) {
return option.id
}
return prompt.body.freeTextQuestionId && trimmed
? encodeQuestionAnswer(prompt.body.freeTextQuestionId, trimmed)
: null
}
function timeoutForDeadline(deadline: number | undefined): number | null {
if (deadline === undefined) {
return STRUCTURED_SEND_TIMEOUT_MS
}
const timeoutMs = deadline - Date.now()
return timeoutMs >= MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS ? timeoutMs : null
}
export function useMobileStructuredAgentSession(args: {
client: RpcClient | null
sessionId: string | null
@@ -181,214 +68,61 @@ export function useMobileStructuredAgentSession(args: {
onSendError: (message: string) => void
}): StructuredMobileSession {
const { agent, client, sessionId, enabled, onSendError } = args
const [state, setState] = useState<StructuredAgentSessionState>(EMPTY_STRUCTURED_AGENT_SESSION)
const [loadingOlder, setLoadingOlder] = useState(false)
const [optionState, setOptionState] = useState(() =>
createStructuredAgentSessionOptionState(agent ?? 'codex')
)
const stateRef = useRef(state)
stateRef.current = state
const activeOptionRecordRef = useRef(optionState.record)
const optionCatalog = useMemo(
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
const apply = useCallback((action: StructuredAgentSessionAction) => {
setState((current) => reduceStructuredAgentSession(current, action))
}, [])
useEffect(() => {
const next = createStructuredAgentSessionOptionState(agent ?? 'codex')
activeOptionRecordRef.current = next.record
setOptionState(next)
}, [agent, enabled, sessionId, state.fence])
useEffect(() => {
if (!client || !sessionId || !enabled) {
return
}
const holderId = structuredAgentSessionHolderId('mobile-chat')
const held = callAgentSession(client, 'agentSession.hold', {
sessionId,
holderId
}).catch(() => undefined)
return () => {
void held.then(() =>
callAgentSession(client, 'agentSession.release', {
sessionId,
holderId
}).catch(() => undefined)
)
}
}, [client, enabled, sessionId])
useEffect(() => {
if (!client || !sessionId || !enabled) {
setState(EMPTY_STRUCTURED_AGENT_SESSION)
setLoadingOlder(false)
return
}
apply({ type: 'loading' })
const unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => {
if (typeof raw === 'object' && raw !== null && (raw as { type?: unknown }).type === 'error') {
apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') })
return
}
if (isSubscribeEvent(raw)) {
apply({ type: 'event', event: raw })
}
})
return unsubscribe
}, [apply, client, enabled, sessionId])
const loadEarlier = useCallback(() => {
const current = stateRef.current
if (!client || !sessionId || loadingOlder || !current.hasOlder) {
return
}
const cursor = oldestStructuredAgentSessionCursor(current)
if (!cursor) {
return
}
setLoadingOlder(true)
void callAgentSession<AgentSessionHistoryResult>(client, 'agentSession.history', {
sessionId,
direction: 'before',
cursor,
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
})
.then((result) => {
if (result.ok) {
apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page })
}
})
.catch((error: unknown) => {
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
})
.finally(() => setLoadingOlder(false))
}, [apply, client, loadingOlder, sessionId])
useEffect(() => {
if (!client || !sessionId || !enabled || !optionCatalog) {
return
}
let stale = false
void callAgentSession<AgentSessionOptionsResult>(client, 'agentSession.options', { sessionId })
.then((result) => {
if (!stale) {
setOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result)
: current
)
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [client, enabled, optionCatalog, sessionId, state.fence])
const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState({
client,
sessionId,
enabled
})
const mutate = useCallback(
async <TValue,>(
async <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
): Promise<TValue | null> => {
): Promise<StructuredAgentSessionMutationResult<TValue>> => {
const current = stateRef.current
if (!client || !sessionId || !enabled || current.fence === null) {
return null
return { status: 'rejected' }
}
const targetFence = current.fence
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(client, method, {
envelope: {
sessionId,
clientOperationId: structuredSessionOperationId(),
expectedRuntimeFence: targetFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: fingerprintMethod,
sessionId,
fields
})
},
...fields
})
if (!result.ok) {
onSendError(result.refusal.message)
return null
const result = await requestStructuredAgentSessionMutation<TValue>({
client,
method,
fingerprintMethod,
sessionId,
expectedRuntimeFence: targetFence,
fields
})
if (result.status === 'accepted') {
return {
status: 'accepted',
value: result.value,
sameFence: stateRef.current.fence === targetFence
}
return stateRef.current.fence === targetFence ? result.value : null
} catch (error) {
if (!isRpcDeliveryUnknown(error) && !isLogicalClientCutoverError(error)) {
onSendError(error instanceof Error ? error.message : 'Request not sent')
}
return null
}
if (result.status === 'unknown') {
return result
}
onSendError(result.message)
return { status: 'rejected' }
},
[client, enabled, onSendError, sessionId]
)
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
[optionState]
)
const setStructuredOption = useCallback(
async (id: string, value: SessionOptionValue): Promise<boolean> => {
if (
!canSetStructuredAgentSessionOption(optionState, id, value) ||
typeof value !== 'string'
) {
return false
}
const targetRecord = optionState.record
setOptionState((current) => ({ ...current, pendingId: id }))
try {
const result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value }
)
if (result && activeOptionRecordRef.current === targetRecord) {
setOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOptionValues(current, result.options ?? { [id]: value })
: current
)
}
return Boolean(result)
} finally {
setOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
: current
)
}
},
[mutate, optionState]
)
const invokeStructuredOption = useCallback(async () => false, [])
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return { snapshot: optionSnapshot }
},
[optionSnapshot, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
)
const {
invokeStructuredOption,
optionSnapshot,
optionSurface,
pendingOptionId,
setStructuredOption
} = useMobileStructuredAgentOptions({
agent,
client,
sessionId,
enabled,
fence: state.fence,
mutate
})
const sendWithOutcome = useCallback(
async (
@@ -397,7 +131,8 @@ export function useMobileStructuredAgentSession(args: {
deadline?: number,
attachments?: readonly StructuredMobileAttachment[]
): Promise<MobileNativeChatSendOutcome> => {
if (!client || !sessionId || !enabled || stateRef.current.fence === null) {
const currentFence = stateRef.current.fence
if (!client || !sessionId || !enabled || currentFence === null) {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
@@ -406,93 +141,81 @@ export function useMobileStructuredAgentSession(args: {
onSendError('Message not sent')
return 'rejected'
}
const sendAttachments =
attachments ??
images?.map((previewUri) => ({
path: previewUri,
previewUri
})) ??
[]
if (attachments === undefined && images !== undefined && images.length > 0) {
onSendError('Message not sent')
return 'rejected'
}
const sendAttachments = attachments ?? []
const body = structuredAgentSessionSendBody(text, sendAttachments)
if (body.blocks.length === 0) {
return 'rejected'
}
const fields = { body }
const clientMessageId = structuredSessionOperationId()
try {
const result = await callAgentSession<AgentSessionMutationResult<AgentSessionSendResult>>(
client,
'agentSession.send',
{
envelope: {
sessionId,
clientOperationId: clientMessageId,
expectedRuntimeFence: stateRef.current.fence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId,
fields
})
},
...fields
},
timeoutMs
)
if (!result.ok) {
onSendError(result.refusal.message)
return 'rejected'
}
const result = await requestStructuredAgentSessionMutation<AgentSessionSendResult>({
client,
method: 'agentSession.send',
fingerprintMethod: 'agentSession.send',
sessionId,
expectedRuntimeFence: currentFence,
fields,
timeoutMs
})
if (result.status === 'accepted') {
return 'accepted'
} catch (error) {
if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) {
return 'unknown'
}
onSendError(error instanceof Error ? error.message : 'Message not sent')
return 'rejected'
}
if (result.status === 'unknown') {
return 'unknown'
}
onSendError(result.message === 'Request not sent' ? 'Message not sent' : result.message)
return 'rejected'
},
[client, enabled, onSendError, sessionId]
)
const respondPermission = useCallback(
async (optionId: string): Promise<boolean> => {
const prompt =
const target = structuredApprovalResponseTarget(
optionId,
stateRef.current.items.find(pendingStructuredApproval) ?? null
const option = prompt?.body.options.find(
(candidate) => candidate.id === optionId || candidate.label === optionId
)
if (!prompt || !option) {
if (!target) {
return false
}
const result = await mutate<AgentSessionPromptResult>(
'agentSession.respondToApproval',
'agentSession.respondTo:approval',
{ itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: option.id }
target
)
return Boolean(result)
if (result.status === 'unknown') {
onSendError('Response unconfirmed — check chat before retrying')
return false
}
return result.status === 'accepted'
},
[mutate]
[mutate, onSendError]
)
const respondQuestion = useCallback(
async (answer: string): Promise<boolean> => {
const prompt =
const target = structuredQuestionResponseTarget(
answer,
stateRef.current.items.find(pendingStructuredQuestion) ?? null
if (!prompt) {
return false
}
const optionId = promptAnswerOptionId(prompt, answer)
if (!optionId) {
)
if (!target) {
return false
}
const result = await mutate<AgentSessionPromptResult>(
'agentSession.respondToQuestion',
'agentSession.respondTo:question',
{ itemId: prompt.itemId, expectedRevision: prompt.revision, optionId }
target
)
return Boolean(result)
if (result.status === 'unknown') {
onSendError('Answer unconfirmed — check chat before retrying')
return false
}
return result.status === 'accepted'
},
[mutate]
[mutate, onSendError]
)
const cancel = useCallback(() => {
@@ -503,33 +226,22 @@ export function useMobileStructuredAgentSession(args: {
return
}
const fields = { turnId }
void callAgentSession<AgentSessionMutationResult<AgentSessionCancelResult>>(
void requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
client,
'agentSession.cancel',
{
envelope: {
sessionId,
clientOperationId: structuredSessionOperationId(),
expectedRuntimeFence: current.fence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.cancel',
sessionId,
fields
})
},
...fields
method: 'agentSession.cancel',
fingerprintMethod: 'agentSession.cancel',
sessionId,
expectedRuntimeFence: current.fence,
fields
}).then((result) => {
if (result.status === 'unknown') {
onSendError('Stop unconfirmed — check chat before retrying')
} else if (result.status === 'refused') {
onSendError(result.message)
} else if (result.status === 'failed') {
onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message)
}
)
.then((result) => {
if (!result.ok) {
onSendError(result.refusal.message)
}
})
.catch((error: unknown) => {
if (!isRpcDeliveryUnknown(error) && !isLogicalClientCutoverError(error)) {
onSendError(error instanceof Error ? error.message : 'Stop not sent')
}
})
})
}, [client, enabled, onSendError, sessionId])
const messages = useMemo(
@@ -564,7 +276,7 @@ export function useMobileStructuredAgentSession(args: {
question: projectStructuredQuestion(questionPrompt),
optionSnapshot,
optionSurface,
pendingOptionId: optionState.pendingId,
pendingOptionId,
respondPermission,
respondQuestion,
setStructuredOption,
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
AgentSessionHistoryResult,
AgentSessionSubscribeEvent
} from '../../../src/shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire'
import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
oldestStructuredAgentSessionCursor,
reduceStructuredAgentSession,
type StructuredAgentSessionAction,
type StructuredAgentSessionState
} from '../../../src/shared/structured-agent-session-reducer'
import type { RpcClient } from '../transport/rpc-client'
import { callAgentSession } from './mobile-structured-agent-session-rpc'
function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent {
if (typeof value !== 'object' || value === null) {
return false
}
const type = (value as { type?: unknown }).type
return type === 'snapshot' || type === 'batch' || type === 'reset' || type === 'end'
}
export function useMobileStructuredAgentState(args: {
client: RpcClient | null
sessionId: string | null
enabled: boolean
}): {
state: StructuredAgentSessionState
stateRef: { readonly current: StructuredAgentSessionState }
loadingOlder: boolean
loadEarlier: () => void
} {
const { client, enabled, sessionId } = args
const [state, setState] = useState<StructuredAgentSessionState>(EMPTY_STRUCTURED_AGENT_SESSION)
const [loadingOlder, setLoadingOlder] = useState(false)
const stateRef = useRef(state)
stateRef.current = state
const apply = useCallback((action: StructuredAgentSessionAction) => {
setState((current) => reduceStructuredAgentSession(current, action))
}, [])
useEffect(() => {
if (!client || !sessionId || !enabled) {
return
}
const holderId = structuredAgentSessionHolderId('mobile-chat')
const held = callAgentSession(client, 'agentSession.hold', {
sessionId,
holderId
}).catch(() => undefined)
return () => {
void held.then(() =>
callAgentSession(client, 'agentSession.release', {
sessionId,
holderId
}).catch(() => undefined)
)
}
}, [client, enabled, sessionId])
useEffect(() => {
if (!client || !sessionId || !enabled) {
setState(EMPTY_STRUCTURED_AGENT_SESSION)
setLoadingOlder(false)
return
}
apply({ type: 'loading' })
const unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => {
if (typeof raw === 'object' && raw !== null && (raw as { type?: unknown }).type === 'error') {
apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') })
return
}
if (isSubscribeEvent(raw)) {
apply({ type: 'event', event: raw })
}
})
return unsubscribe
}, [apply, client, enabled, sessionId])
const loadEarlier = useCallback(() => {
const current = stateRef.current
if (!client || !sessionId || loadingOlder || !current.hasOlder) {
return
}
const cursor = oldestStructuredAgentSessionCursor(current)
if (!cursor) {
return
}
setLoadingOlder(true)
void callAgentSession<AgentSessionHistoryResult>(client, 'agentSession.history', {
sessionId,
direction: 'before',
cursor,
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
})
.then((result) => {
if (result.ok) {
apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page })
}
})
.catch((error: unknown) => {
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
})
.finally(() => setLoadingOlder(false))
}, [apply, client, loadingOlder, sessionId])
return { state, stateRef, loadingOlder, loadEarlier }
}
@@ -88,8 +88,12 @@ export function useMobileStructuredNativeChatSendBridge(args: {
]
)
const send = useCallback(
async (text: string, images?: string[], deadline?: number, attachments?: readonly StructuredNativeChatAttachment[]) =>
(await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected',
async (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected',
[sendWithOutcome]
)
return { send, sendWithOutcome }