mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
Fix mobile structured chat delivery handling
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user