diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index 8a4aa6bd197..f4a34494328 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -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 ? ( diff --git a/mobile/src/session/mobile-native-chat-image-scope-state.ts b/mobile/src/session/mobile-native-chat-image-scope-state.ts new file mode 100644 index 00000000000..8d7de510e3a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-image-scope-state.ts @@ -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 + +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 +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts index 94fbcf055a9..079e661e545 100644 --- a/mobile/src/session/mobile-native-chat-question.test.ts +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -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) + ) }) }) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 2129b31db78..5d4e65a46ff 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -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]) +} diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts new file mode 100644 index 00000000000..84cb7033d30 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -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 +} + +export type StructuredQuestionItem = AgentJournalRenderItem & { + body: Extract +} + +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 + 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 + 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 +} diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts new file mode 100644 index 00000000000..2eec7c9050f --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -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 = + | { status: 'accepted'; value: TValue } + | { status: 'refused'; message: string } + | { status: 'failed'; message: string } + | { status: 'unknown' } + +export type StructuredAgentSessionMutationResult = + | { status: 'accepted'; value: TValue; sameFence: boolean } + | { status: 'rejected' } + | { status: 'unknown' } + +export type StructuredAgentSessionMutate = ( + method: string, + fingerprintMethod: string, + fields: Record +) => Promise> + +export async function callAgentSession( + client: RpcClient, + method: string, + params: unknown, + timeoutMs = STRUCTURED_SEND_TIMEOUT_MS +): Promise { + 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(args: { + client: RpcClient + method: string + fingerprintMethod: string + sessionId: string + expectedRuntimeFence: number + fields: Record + timeoutMs?: number +}): Promise> { + const { client, method, fingerprintMethod, sessionId, expectedRuntimeFence, fields, timeoutMs } = + args + try { + const result = await callAgentSession>( + 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' + } + } +} diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts new file mode 100644 index 00000000000..ea7f923de47 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -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 + nativeChatTranscriptIsLocalReadable: boolean +}): { + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + activeChatAgent: string | null + activeChatAgentRef: MutableRefObject + activeChatSessionId: string | null + activeChatStructured: boolean + activeChatResolution: ReturnType + 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(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 + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 75cb34369fe..131f5678325 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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(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 } } diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index ebf1d13887e..77d839adf34 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -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 = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -86,21 +86,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -const NO_ATTACHMENTS: PendingNativeChatImage[] = [] - -function withScopeAttachments( - byScope: Record, - scope: string, - next: PendingNativeChatImage[] -): Record { - if (next.length > 0) { - return { ...byScope, [scope]: next } - } - const remaining = { ...byScope } - delete remaining[scope] - return remaining -} - const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -121,98 +106,34 @@ export function useMobileNativeChatImageAttachments({ onError, sleep = defaultSleep }: Args): MobileNativeChatImageAttachments { - const [attachmentsByScope, setAttachmentsByScope] = useState< - Record - >({}) - const [isAttaching, setIsAttaching] = useState(false) + const [attachmentsByScope, setAttachmentsByScope] = useState({}) 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 => { - // 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[] = [] - 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[]) => { + 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?.() diff --git a/mobile/src/session/use-mobile-native-chat-image-upload.ts b/mobile/src/session/use-mobile-native-chat-image-upload.ts new file mode 100644 index 00000000000..655bd5906d3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-image-upload.ts @@ -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 = { readonly current: T } +type UploadedNativeChatImage = Omit +type ShowToast = (message: string, durationMs?: number) => void + +export function useMobileNativeChatImageUpload(args: { + client: RpcClient | null + activeHandleRef: CurrentRef + getActiveWorktreeConnectionId: () => Promise + connState: ConnectionState + scopeKey: string | null + structuredNativeChat: boolean + showToast: ShowToast + onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void + onAttachSuccess?: () => void + onError?: () => void +}): { + attachImage: (source: MobileImageSource) => Promise + 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 => { + 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 } +} diff --git a/mobile/src/session/use-mobile-native-chat-session-option-controller.ts b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts new file mode 100644 index 00000000000..aa61bdd85ff --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts @@ -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 + hostId: string + isTabChatView: (tabId: string) => boolean + isWorking: boolean + reportedModel: string | null + structured: { + snapshot: SessionOptionDescriptor[] + pendingId: string | null + setOption: (id: string, value: SessionOptionValue) => Promise + invokeAction: (id: string) => Promise + } + 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( + () => + activeChatStructured && structuredSnapshot.length > 0 + ? { + snapshot: structuredSnapshot, + pendingId: structuredPendingId, + setOption: setStructuredOption, + invokeAction: invokeStructuredAction, + recordCommand: () => {} + } + : null, + [ + activeChatStructured, + invokeStructuredAction, + setStructuredOption, + structuredPendingId, + structuredSnapshot + ] + ) + const nativeChatSessionOptions = useMemo( + () => + activeChatStructured + ? structuredController + ? { controller: structuredController, isWorking } + : null + : sessionOptions.snapshot.length > 0 + ? { controller: sessionOptions, isWorking } + : null, + [activeChatStructured, isWorking, sessionOptions, structuredController] + ) + + return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand } +} diff --git a/mobile/src/session/use-mobile-session-image-attachments.test.tsx b/mobile/src/session/use-mobile-session-image-attachments.test.tsx new file mode 100644 index 00000000000..68aeafaea6f --- /dev/null +++ b/mobile/src/session/use-mobile-session-image-attachments.test.tsx @@ -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[0] + +function baseArgs(overrides: Partial = {}): 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 + }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 68135fdb2e2..07edac51f39 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -94,7 +94,7 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, connState, scopeKey: nativeChatScopeKey, - enabled: nativeChatInputLeaseReady, + enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady, structuredNativeChat, showToast, onSendError: onNativeChatSendError, diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts new file mode 100644 index 00000000000..108275223be --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -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 + invokeStructuredOption: (id: string) => Promise +} + +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(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 => { + if ( + !canSetStructuredAgentSessionOption(optionState, id, value) || + typeof value !== 'string' + ) { + return false + } + const targetRecord = optionState.record + setOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + '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( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + return { + optionSnapshot, + optionSurface, + pendingOptionId: optionState.pendingId, + setStructuredOption, + invokeStructuredOption + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 75277fdb5da..4deb1eee1f6 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -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) { + 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 | 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) => { - 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)) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 146863a32f0..6512917b570 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -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 -} - -type StructuredQuestionItem = AgentJournalRenderItem & { - body: Extract -} +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 } -async function callAgentSession( - client: RpcClient, - method: string, - params: unknown, - timeoutMs = STRUCTURED_SEND_TIMEOUT_MS -): Promise { - 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(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(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(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 ( + async ( method: string, fingerprintMethod: string, fields: Record - ): Promise => { + ): Promise> => { 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>(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({ + 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 => { - if ( - !canSetStructuredAgentSessionOption(optionState, id, value) || - typeof value !== 'string' - ) { - return false - } - const targetRecord = optionState.record - setOptionState((current) => ({ ...current, pendingId: id })) - try { - const result = await mutate( - '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( - () => ({ - 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 => { - 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>( - 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({ + 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 => { - 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( '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 => { - 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( '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>( + void requestStructuredAgentSessionMutation({ 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, diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts new file mode 100644 index 00000000000..d29153b44f4 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -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(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(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 } +} diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index a8e3b6fd71b..fa786867fc7 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -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 }