mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
feat(mobile): finalize structured native Codex chat
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
|
||||
@@ -18,6 +22,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
const [freeText, setFreeText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const sendingRef = useRef(false)
|
||||
const allowOther = question.allowOther !== false
|
||||
|
||||
const hasOptions = question.options.length > 0
|
||||
const trimmedFreeText = freeText.trim()
|
||||
@@ -42,8 +47,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
}
|
||||
}
|
||||
|
||||
const answerSingle = async (option: string): Promise<void> => {
|
||||
await sendAnswer(formatQuestionAnswer(question, [option]))
|
||||
const answerSingle = async (option: string, optionIndex: number): Promise<void> => {
|
||||
const token = question.optionTokens[optionIndex]
|
||||
await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option]))
|
||||
}
|
||||
|
||||
const submitMulti = async (): Promise<void> => {
|
||||
@@ -57,14 +63,13 @@ 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('')
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitMulti = selected.length > 0 && !sending
|
||||
const canSendFreeText = trimmedFreeText.length > 0 && !sending
|
||||
const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending
|
||||
|
||||
// Stable keys for option rows even if an agent repeats a label.
|
||||
const optionRows = useMemo(
|
||||
@@ -81,7 +86,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
|
||||
{hasOptions ? (
|
||||
<View style={styles.options}>
|
||||
{optionRows.map(({ label, key }) => {
|
||||
{optionRows.map(({ label, key }, optIndex) => {
|
||||
const isSelected = selected.includes(label)
|
||||
return (
|
||||
<Pressable
|
||||
@@ -93,7 +98,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
isSelected && styles.optionSelected,
|
||||
pressed && styles.pressed
|
||||
]}
|
||||
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))}
|
||||
onPress={() =>
|
||||
question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
|
||||
}
|
||||
>
|
||||
{question.multiSelect ? (
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
@@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.freeTextRow}>
|
||||
<TextInput
|
||||
style={styles.freeInput}
|
||||
value={freeText}
|
||||
onChangeText={setFreeText}
|
||||
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.accentBlue}
|
||||
onSubmitEditing={submitFreeText}
|
||||
returnKeyType="send"
|
||||
multiline
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityLabel="Send reply"
|
||||
style={({ pressed }) => [
|
||||
styles.freeSend,
|
||||
!canSendFreeText && styles.freeSendDisabled,
|
||||
pressed && canSendFreeText && styles.pressed
|
||||
]}
|
||||
onPress={submitFreeText}
|
||||
disabled={!canSendFreeText}
|
||||
>
|
||||
<ArrowUp
|
||||
size={18}
|
||||
color={canSendFreeText ? colors.bgBase : colors.textMuted}
|
||||
strokeWidth={2.6}
|
||||
{allowOther ? (
|
||||
<View style={styles.freeTextRow}>
|
||||
<TextInput
|
||||
style={styles.freeInput}
|
||||
value={freeText}
|
||||
onChangeText={setFreeText}
|
||||
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.accentBlue}
|
||||
onSubmitEditing={submitFreeText}
|
||||
returnKeyType="send"
|
||||
multiline
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityLabel="Send reply"
|
||||
style={({ pressed }) => [
|
||||
styles.freeSend,
|
||||
!canSendFreeText && styles.freeSendDisabled,
|
||||
pressed && canSendFreeText && styles.pressed
|
||||
]}
|
||||
onPress={submitFreeText}
|
||||
disabled={!canSendFreeText}
|
||||
>
|
||||
<ArrowUp
|
||||
size={18}
|
||||
color={canSendFreeText ? colors.bgBase : colors.textMuted}
|
||||
strokeWidth={2.6}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function MobileSessionActiveContent({
|
||||
browserScreencastSupported,
|
||||
showToast,
|
||||
nativeChatSendError,
|
||||
nativeChatInputLockReason,
|
||||
nativeChatOverlayInputLockReason,
|
||||
nativeChatController,
|
||||
dictation,
|
||||
handleDictationToggle,
|
||||
@@ -240,7 +240,7 @@ export function MobileSessionActiveContent({
|
||||
dictationMode={dictationMode}
|
||||
onMicPressIn={handleDictationPressIn}
|
||||
onMicPressOut={handleDictationPressOut}
|
||||
inputLockReason={nativeChatInputLockReason}
|
||||
inputLockReason={nativeChatOverlayInputLockReason}
|
||||
sendErrorMessage={nativeChatSendError.message}
|
||||
onClearSendError={nativeChatSendError.clear}
|
||||
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
|
||||
|
||||
@@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
|
||||
{t.type === 'file' && (
|
||||
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
|
||||
{t.type === 'terminal' &&
|
||||
(() => {
|
||||
const agentId = resolveMobileTerminalTabAgentId(t)
|
||||
|
||||
@@ -43,6 +43,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
setFileActionTarget,
|
||||
browserActionTarget,
|
||||
setBrowserActionTarget,
|
||||
agentSessionActionTarget,
|
||||
setAgentSessionActionTarget,
|
||||
discardMarkdownTarget,
|
||||
setDiscardMarkdownTarget,
|
||||
leaveDrafts,
|
||||
@@ -261,6 +263,14 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
onCloseTab={handleCloseSessionTab}
|
||||
bulkCloseActions={bulkCloseActions}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={agentSessionActionTarget != null}
|
||||
title={agentSessionActionTarget?.title || 'Chat'}
|
||||
actions={closeWithBulkActions(agentSessionActionTarget, () =>
|
||||
setAgentSessionActionTarget(null)
|
||||
)}
|
||||
onClose={() => setAgentSessionActionTarget(null)}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={leaveDrafts != null}
|
||||
title="Unsaved markdown changes"
|
||||
|
||||
@@ -36,8 +36,10 @@ export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
|
||||
activated: boolean
|
||||
activationSeq: number
|
||||
latestActivationSeq: number
|
||||
sourceTerminalHandle: string
|
||||
sourceTerminalHandle: string | null
|
||||
activeTerminalHandle: string | null
|
||||
sourceSessionTabId?: string | null
|
||||
activeSessionTabId?: string | null
|
||||
activeTabType: string | null
|
||||
}
|
||||
switchSessionTab: (tab: T) => void
|
||||
|
||||
@@ -58,7 +58,12 @@ export type MobileNativeChatController = {
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
deadline?: number,
|
||||
attachments?: readonly {
|
||||
id?: string
|
||||
path: string
|
||||
previewUri: string
|
||||
}[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
|
||||
@@ -123,6 +123,30 @@ describe('resolveMobileNativeChat', () => {
|
||||
expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves Codex structured agent-session tabs directly', () => {
|
||||
expect(
|
||||
resolveMobileNativeChat({
|
||||
type: 'agent-session',
|
||||
sessionId: 'structured-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
).toEqual({
|
||||
agent: 'codex',
|
||||
sessionId: 'structured-1',
|
||||
transcriptPath: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-Codex structured agent-session tabs', () => {
|
||||
expect(
|
||||
resolveMobileNativeChat({
|
||||
type: 'agent-session',
|
||||
sessionId: 'structured-1',
|
||||
agent: 'claude'
|
||||
} as never)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('canShowMobileNativeChat mirrors resolution', () => {
|
||||
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
|
||||
expect(canShowMobileNativeChat(null)).toBe(false)
|
||||
|
||||
@@ -32,6 +32,8 @@ export type MobileNativeChatTab = {
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
sessionId?: string | null
|
||||
agent?: string | null
|
||||
}
|
||||
|
||||
/** Resolve a session tab to the transcript identity native chat needs, or
|
||||
@@ -42,7 +44,15 @@ export function resolveMobileNativeChat(
|
||||
tab: MobileNativeChatTab | null,
|
||||
nativeChatTranscriptIsLocalReadable = false
|
||||
): MobileNativeChatResolution | null {
|
||||
if (!tab || tab.type !== 'terminal') {
|
||||
if (!tab) {
|
||||
return null
|
||||
}
|
||||
if (tab.type === 'agent-session') {
|
||||
return tab.sessionId && tab.agent === 'codex'
|
||||
? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null }
|
||||
: null
|
||||
}
|
||||
if (tab.type !== 'terminal') {
|
||||
return null
|
||||
}
|
||||
const liveAgent = tab.agentStatus?.agentType ?? null
|
||||
@@ -71,3 +81,15 @@ export function canShowMobileNativeChat(
|
||||
): boolean {
|
||||
return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null
|
||||
}
|
||||
|
||||
export function resolveMobileNativeChatFileSessionId(
|
||||
tab: MobileNativeChatTab | null
|
||||
): string | null {
|
||||
if (tab?.type === 'agent-session') {
|
||||
return tab.sessionId ?? null
|
||||
}
|
||||
if (tab?.type === 'terminal') {
|
||||
return tab.agentStatus?.providerSession?.id ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,14 @@ export type MobileChatQuestion = {
|
||||
question: string
|
||||
options: string[]
|
||||
multiSelect: boolean
|
||||
/** Structured questions hide the free-text row when the provider does not accept it. */
|
||||
allowOther?: boolean
|
||||
/** Per-option leading marker ("1", "b", …) when the source line carried one,
|
||||
* 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 {
|
||||
@@ -152,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])
|
||||
}
|
||||
|
||||
@@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([
|
||||
'View'
|
||||
])
|
||||
|
||||
const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302'
|
||||
const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0'
|
||||
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
|
||||
const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272'
|
||||
const HEAD_CALLBACK_IDENTITY_SHA256 =
|
||||
'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec'
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '69dfda53fd700f4395a18a37ffdaa530e187bc24b4986d8fdc0184127c00b52d'
|
||||
'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc'
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75'
|
||||
const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882'
|
||||
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
|
||||
const HEAD_NESTED_FUNCTION_SHA256 =
|
||||
'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe'
|
||||
'6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
|
||||
const HEAD_NATIVE_REGISTRATION_SHA256 =
|
||||
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
|
||||
const HEAD_NATIVE_REMOVAL_SHA256 =
|
||||
@@ -79,9 +79,9 @@ const HEAD_TIMER_CREATION_SHA256 =
|
||||
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
|
||||
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
|
||||
const HEAD_RUNTIME_STRING_SHA256 =
|
||||
'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447'
|
||||
'1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887'
|
||||
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
|
||||
const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54'
|
||||
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
|
||||
const HEAD_STYLE_REFERENCE_SHA256 =
|
||||
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
|
||||
const HEAD_IDENTITY_FIELD_SHA256 =
|
||||
@@ -472,10 +472,10 @@ describe('mobile session route extraction parity', () => {
|
||||
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
|
||||
(name) => readHookFacts(name, definitions).bindings
|
||||
)
|
||||
expect(main.hooks).toHaveLength(269)
|
||||
expect(main.hooks).toHaveLength(266)
|
||||
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
|
||||
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
|
||||
expect(main.callbacks).toHaveLength(78)
|
||||
expect(main.callbacks).toHaveLength(77)
|
||||
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
|
||||
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
|
||||
expect(main.effects).toHaveLength(24)
|
||||
@@ -517,12 +517,12 @@ describe('mobile session route extraction parity', () => {
|
||||
|
||||
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
|
||||
const strings = readRuntimeStrings()
|
||||
expect(strings).toHaveLength(537)
|
||||
expect(strings).toHaveLength(545)
|
||||
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
|
||||
const jsx = readJsxFacts(readDefinitions())
|
||||
expect(jsx.host).toHaveLength(124)
|
||||
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
|
||||
expect(jsx.leaf).toHaveLength(59)
|
||||
expect(jsx.leaf).toHaveLength(61)
|
||||
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
|
||||
expect(jsx.styleReferences).toHaveLength(172)
|
||||
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TerminalRecord } from './mobile-terminal-records'
|
||||
|
||||
export type Terminal = TerminalRecord
|
||||
|
||||
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
|
||||
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session'
|
||||
|
||||
export type MobileSessionTab =
|
||||
| {
|
||||
@@ -30,6 +30,14 @@ export type MobileSessionTab =
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
| {
|
||||
type: 'agent-session'
|
||||
id: string
|
||||
title: string
|
||||
sessionId: string
|
||||
agent: 'codex'
|
||||
isActive: boolean
|
||||
}
|
||||
| {
|
||||
type: 'markdown'
|
||||
id: string
|
||||
|
||||
@@ -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,123 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
|
||||
|
||||
function clientReturning(
|
||||
...responses: unknown[]
|
||||
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
|
||||
let responseIndex = 0
|
||||
const sendRequest = vi.fn(async () => responses[responseIndex++])
|
||||
return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const acceptedCreateResult = {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
value: {
|
||||
sessionId: 'codex_session_1',
|
||||
fence: 1,
|
||||
page: {
|
||||
sessionId: 'codex_session_1',
|
||||
epoch: 'epoch-1',
|
||||
direction: 'tail',
|
||||
items: [],
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } },
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
},
|
||||
unconfirmedClientMessageIds: []
|
||||
}
|
||||
}
|
||||
const acceptedCreate = { ok: true, result: acceptedCreateResult }
|
||||
|
||||
describe('mobile structured Codex launch', () => {
|
||||
it('creates through the structured agent-session intent after support is confirmed', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
kind: 'created',
|
||||
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/)
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
|
||||
worktree: 'id:workspace-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'agentSession.create',
|
||||
expect.objectContaining({
|
||||
worktree: 'id:workspace-1',
|
||||
agent: 'codex',
|
||||
envelope: expect.objectContaining({ expectedRuntimeFence: null })
|
||||
}),
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
const params = client.sendRequest.mock.calls[1]?.[1] as {
|
||||
envelope: { sessionId: string; payloadFingerprint: string }
|
||||
worktree: string
|
||||
agent: 'codex'
|
||||
}
|
||||
expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/)
|
||||
})
|
||||
|
||||
it('reports unsupported without creating a terminal when the structured path is unavailable', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } })
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
kind: 'unsupported',
|
||||
reason: 'remote'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } })
|
||||
client.sendRequest.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
result: { supported: true }
|
||||
}))
|
||||
client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost')))
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create',
|
||||
'agentSession.create'
|
||||
])
|
||||
expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1])
|
||||
})
|
||||
|
||||
it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } })
|
||||
client.sendRequest.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
result: { supported: true }
|
||||
}))
|
||||
client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
|
||||
client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted'))
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats malformed structured responses as unknown', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{ ok: true, result: { ok: true, value: { sessionId: '' } } }
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
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 { structuredSessionOperationId } from './mobile-structured-agent-session-rpc'
|
||||
|
||||
type StructuredCreateSupport = {
|
||||
supported?: boolean
|
||||
reason?: 'agent' | 'remote' | 'wsl'
|
||||
}
|
||||
|
||||
export type MobileStructuredCodexLaunchResult =
|
||||
| { kind: 'created'; sessionId: string }
|
||||
| { kind: 'unsupported'; reason?: StructuredCreateSupport['reason'] }
|
||||
| { kind: 'failed'; message: string }
|
||||
| { kind: 'unknown'; message: string }
|
||||
|
||||
type StructuredCreateParams = {
|
||||
envelope: {
|
||||
sessionId: string
|
||||
clientOperationId: string
|
||||
expectedRuntimeFence: null
|
||||
payloadFingerprint: string
|
||||
}
|
||||
worktree: string
|
||||
agent: 'codex'
|
||||
}
|
||||
|
||||
function createStructuredCodexSessionId(): string {
|
||||
return `codex_${createRandomUuid().replaceAll('-', '_')}`
|
||||
}
|
||||
|
||||
function createRandomUuid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
|
||||
}
|
||||
|
||||
function createStructuredCodexSessionParams(worktreeId: string): StructuredCreateParams {
|
||||
const sessionId = createStructuredCodexSessionId()
|
||||
const worktree = `id:${worktreeId}`
|
||||
const fields = { worktree, agent: 'codex' as const }
|
||||
return {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: structuredSessionOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
},
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult {
|
||||
const message = error instanceof Error ? error.message.trim() : ''
|
||||
return {
|
||||
kind: 'unknown',
|
||||
message: message || 'The Codex chat result could not be confirmed.'
|
||||
}
|
||||
}
|
||||
|
||||
function isUnknownCreateError(error: unknown): boolean {
|
||||
return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)
|
||||
}
|
||||
|
||||
export async function createMobileStructuredCodexSession(
|
||||
client: RpcClient,
|
||||
worktreeId: string
|
||||
): Promise<MobileStructuredCodexLaunchResult> {
|
||||
const worktree = `id:${worktreeId}`
|
||||
let supportResponse
|
||||
try {
|
||||
supportResponse = await client.sendRequest('agentSession.createSupport', {
|
||||
worktree,
|
||||
agent: 'codex'
|
||||
})
|
||||
} catch {
|
||||
// A support probe has no side effect; an unavailable probe safely degrades to terminal chat.
|
||||
return { kind: 'unsupported' }
|
||||
}
|
||||
if (
|
||||
!supportResponse ||
|
||||
typeof supportResponse !== 'object' ||
|
||||
typeof supportResponse.ok !== 'boolean' ||
|
||||
!supportResponse.ok
|
||||
) {
|
||||
return { kind: 'unsupported' }
|
||||
}
|
||||
const support = supportResponse.result as StructuredCreateSupport | null
|
||||
if (!support || typeof support !== 'object' || support.supported !== true) {
|
||||
return { kind: 'unsupported', reason: support?.reason }
|
||||
}
|
||||
|
||||
const params = createStructuredCodexSessionParams(worktreeId)
|
||||
let response
|
||||
try {
|
||||
response = await client.sendRequest('agentSession.create', params, {
|
||||
timeoutMs: 15_000,
|
||||
budgetSpansConnect: true
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isUnknownCreateError(error)) {
|
||||
return {
|
||||
kind: 'failed',
|
||||
message: error instanceof Error ? error.message : 'Could not open Codex chat.'
|
||||
}
|
||||
}
|
||||
// Replay the durable envelope once so a lost acknowledgement cannot create a sibling.
|
||||
try {
|
||||
response = await client.sendRequest('agentSession.create', params, {
|
||||
timeoutMs: 15_000,
|
||||
budgetSpansConnect: true
|
||||
})
|
||||
} catch (retryError) {
|
||||
// A second transport error cannot disprove the first attempt committed.
|
||||
return unknownCreateResult(retryError)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (
|
||||
!response.error ||
|
||||
typeof response.error !== 'object' ||
|
||||
typeof response.error.code !== 'string'
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
if (response.error.code === 'agent_session_operation_unknown') {
|
||||
return unknownCreateResult(new Error(response.error.message))
|
||||
}
|
||||
return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' }
|
||||
}
|
||||
const result = response.result as AgentSessionMutationResult<AgentSessionAttachResult>
|
||||
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
if (!result.ok) {
|
||||
if (
|
||||
!result.refusal ||
|
||||
typeof result.refusal !== 'object' ||
|
||||
typeof result.refusal.code !== 'string'
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
if (result.refusal.code === 'agent_session_operation_unknown') {
|
||||
return unknownCreateResult(new Error(result.refusal.message))
|
||||
}
|
||||
return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' }
|
||||
}
|
||||
if (
|
||||
!result.value ||
|
||||
typeof result.value.sessionId !== 'string' ||
|
||||
!result.value.sessionId.trim()
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
return { kind: 'created', sessionId: result.value.sessionId }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire'
|
||||
import {
|
||||
createStructuredAgentSessionOperationId,
|
||||
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,
|
||||
options?: { failWhenDisconnected?: boolean }
|
||||
): Promise<TResult> {
|
||||
const response = await client.sendRequest(method, params, {
|
||||
timeoutMs,
|
||||
budgetSpansConnect: true,
|
||||
...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return response.result as TResult
|
||||
}
|
||||
|
||||
export function structuredSessionOperationId(): string {
|
||||
const randomUuid =
|
||||
typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? () => globalThis.crypto.randomUUID()
|
||||
: () => {
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(
|
||||
''
|
||||
)
|
||||
}
|
||||
return createStructuredAgentSessionOperationId(randomUuid)
|
||||
}
|
||||
|
||||
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>
|
||||
clientOperationId?: string
|
||||
retryUnknown?: boolean
|
||||
timeoutMs?: number
|
||||
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
|
||||
const {
|
||||
client,
|
||||
method,
|
||||
fingerprintMethod,
|
||||
sessionId,
|
||||
expectedRuntimeFence,
|
||||
fields,
|
||||
clientOperationId,
|
||||
retryUnknown,
|
||||
timeoutMs
|
||||
} = args
|
||||
try {
|
||||
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
|
||||
client,
|
||||
method,
|
||||
{
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: clientOperationId ?? structuredSessionOperationId(),
|
||||
expectedRuntimeFence,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: fingerprintMethod,
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
},
|
||||
...(retryUnknown ? { retryUnknown: true } : {}),
|
||||
...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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,20 @@ describe('mobile terminal records', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats structured agent-session identity changes as session-tab changes', () => {
|
||||
const base = {
|
||||
type: 'agent-session' as const,
|
||||
id: 'agent-tab-1',
|
||||
title: 'Codex',
|
||||
sessionId: 'session-1',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
}
|
||||
|
||||
expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true)
|
||||
expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false)
|
||||
})
|
||||
|
||||
const record = (over: Partial<TerminalRecord> & { handle: string }): TerminalRecord => ({
|
||||
title: 'Terminal',
|
||||
terminalTheme: undefined,
|
||||
|
||||
@@ -62,6 +62,14 @@ type MobileSessionTabLike =
|
||||
canGoForward?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'agent-session'
|
||||
id: string
|
||||
title?: string
|
||||
sessionId?: string
|
||||
agent?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function mobileTerminalThemesEqual(
|
||||
left: MobileTerminalTheme | null | undefined,
|
||||
@@ -152,6 +160,8 @@ function mobileSessionTabEqual(
|
||||
a.canGoBack === b.canGoBack &&
|
||||
a.canGoForward === b.canGoForward
|
||||
)
|
||||
case 'agent-session':
|
||||
return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,4 +120,17 @@ describe('getMobileSessionTabTitle', () => {
|
||||
|
||||
expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser')
|
||||
})
|
||||
|
||||
it('labels structured agent-session tabs without terminal decoration rules', () => {
|
||||
expect(
|
||||
getMobileSessionTabTitle({
|
||||
type: 'agent-session',
|
||||
id: 'agent-tab-1',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-1',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
})
|
||||
).toBe('Codex Chat')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,9 @@ export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
|
||||
if (tab.type === 'file') {
|
||||
return tab.title || 'File'
|
||||
}
|
||||
if (tab.type === 'agent-session') {
|
||||
return tab.title || 'Chat'
|
||||
}
|
||||
// Why: strip the leading agent status glyph (✳ etc.) once the tab shows the
|
||||
// provider icon. Mobile falls back for glyph-only titles because iOS can
|
||||
// render the bare status glyph as a stray colored box beside the icon.
|
||||
|
||||
@@ -378,4 +378,19 @@ describe('shouldActivateOpenedMobileSessionTab', () => {
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows a structured agent-session tab to anchor chat file activation', () => {
|
||||
expect(
|
||||
shouldActivateOpenedMobileSessionTab({
|
||||
activated: false,
|
||||
activationSeq: 2,
|
||||
latestActivationSeq: 2,
|
||||
sourceTerminalHandle: null,
|
||||
activeTerminalHandle: null,
|
||||
sourceSessionTabId: 'agent-tab-1',
|
||||
activeSessionTabId: 'agent-tab-1',
|
||||
activeTabType: 'agent-session'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,8 +9,10 @@ export type OpenedMobileSessionTabActivationState = {
|
||||
activated: boolean
|
||||
activationSeq: number
|
||||
latestActivationSeq: number
|
||||
sourceTerminalHandle: string
|
||||
sourceTerminalHandle: string | null
|
||||
activeTerminalHandle: string | null
|
||||
sourceSessionTabId?: string | null
|
||||
activeSessionTabId?: string | null
|
||||
activeTabType: string | null
|
||||
}
|
||||
|
||||
@@ -114,12 +116,15 @@ export async function activateOpenedSourceControlDiffTab<T extends OpenedMobileS
|
||||
export function shouldActivateOpenedMobileSessionTab(
|
||||
state: OpenedMobileSessionTabActivationState
|
||||
): boolean {
|
||||
return (
|
||||
!state.activated &&
|
||||
state.activationSeq === state.latestActivationSeq &&
|
||||
state.activeTabType === 'terminal' &&
|
||||
state.activeTerminalHandle === state.sourceTerminalHandle
|
||||
)
|
||||
const sourceStillActive =
|
||||
state.activeTabType === 'agent-session'
|
||||
? state.sourceSessionTabId !== null &&
|
||||
state.sourceSessionTabId !== undefined &&
|
||||
state.activeSessionTabId === state.sourceSessionTabId
|
||||
: state.activeTabType === 'terminal' &&
|
||||
state.sourceTerminalHandle !== null &&
|
||||
state.activeTerminalHandle === state.sourceTerminalHandle
|
||||
return !state.activated && state.activationSeq === state.latestActivationSeq && sourceStillActive
|
||||
}
|
||||
|
||||
export async function activateOpenedMobileSessionTab<T extends OpenedMobileSessionTabCandidate>(
|
||||
|
||||
@@ -142,4 +142,31 @@ describe('useMobileFileTapHandlers', () => {
|
||||
)
|
||||
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
|
||||
})
|
||||
|
||||
it('lets structured chat file taps resolve without a backing terminal handle', async () => {
|
||||
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
|
||||
const options = {
|
||||
...createOptions(sendRequest),
|
||||
activeHandleRef: { current: null as string | null },
|
||||
getActiveSessionTabId: () => 'agent-tab-1',
|
||||
getActiveSessionTabType: () => 'agent-session'
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { options }))
|
||||
})
|
||||
|
||||
handlers!.handleNativeChatFileTap('src/app.ts')
|
||||
await act(async () => {})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'src/app.ts',
|
||||
crossWorkspace: true,
|
||||
nativeChatContext: { tabId: 'agent-tab-1', sessionId: 'session-1' }
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,15 +141,13 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
|
||||
const handleNativeChatFileTap = useCallback((pathText: string) => {
|
||||
const current = optionsRef.current
|
||||
// The chat overlay rides on its backing terminal tab; that handle anchors
|
||||
// the activation gate even though resolution ignores the terminal's cwd.
|
||||
const sourceTerminalHandle = current.activeHandleRef.current
|
||||
if (!current.client || !sourceTerminalHandle) {
|
||||
const nativeChatSessionId = current.nativeChatSessionId
|
||||
const nativeChatTabId = current.getActiveSessionTabId()
|
||||
if (!current.client || (!sourceTerminalHandle && !(nativeChatSessionId && nativeChatTabId))) {
|
||||
return
|
||||
}
|
||||
const activationSeq = ++activationSeqRef.current
|
||||
const nativeChatSessionId = current.nativeChatSessionId
|
||||
const nativeChatTabId = current.getActiveSessionTabId()
|
||||
openMobileNativeChatFileTap<T>({
|
||||
client: current.client,
|
||||
hostId: current.hostId,
|
||||
@@ -172,6 +170,8 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
latestActivationSeq: activationSeqRef.current,
|
||||
sourceTerminalHandle,
|
||||
activeTerminalHandle: current.activeHandleRef.current,
|
||||
sourceSessionTabId: nativeChatTabId,
|
||||
activeSessionTabId: current.getActiveSessionTabId(),
|
||||
activeTabType: current.getActiveSessionTabType()
|
||||
}),
|
||||
switchSessionTab: current.switchSessionTab,
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
@@ -14,6 +15,55 @@ const holdUnconfirmedSend = vi.fn()
|
||||
// and transcript state; defaults keep the send-seam tests unchanged.
|
||||
const viewMode = { isTabChatView: (_tabId: string) => true }
|
||||
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
|
||||
const structuredSendWithOutcome = vi.fn()
|
||||
const structuredCancel = vi.fn()
|
||||
const structuredRespondPermission = vi.fn(async () => true)
|
||||
const structuredRespondQuestion = vi.fn(async () => true)
|
||||
const structuredSetOption = vi.fn(async () => true)
|
||||
const structuredInvokeOption = vi.fn(async () => true)
|
||||
const structuredOptionSnapshot: SessionOptionDescriptor[] = [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
category: 'model',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'gpt-fast',
|
||||
choices: [{ value: 'gpt-fast', label: 'GPT Fast' }]
|
||||
},
|
||||
valueSource: 'reported',
|
||||
settable: true
|
||||
}
|
||||
]
|
||||
const structuredOptionSurface = {
|
||||
getSnapshot: () => structuredOptionSnapshot,
|
||||
setOption: async () => ({ snapshot: structuredOptionSnapshot }),
|
||||
invokeAction: async () => ({ snapshot: structuredOptionSnapshot }),
|
||||
subscribe: () => () => {}
|
||||
}
|
||||
const structuredPermission = {
|
||||
title: 'Allow Bash?',
|
||||
detail: 'rm -rf build',
|
||||
options: [
|
||||
{ label: 'Allow once', send: 'allow-once' },
|
||||
{ label: 'Deny', send: 'deny' }
|
||||
]
|
||||
}
|
||||
const structuredQuestion = {
|
||||
question: 'Pick destination',
|
||||
options: ['Choice A', 'Choice B'],
|
||||
allowOther: true,
|
||||
optionTokens: ['choice-a', 'choice-b']
|
||||
}
|
||||
const structuredSessionState = {
|
||||
messages: [] as unknown[],
|
||||
status: 'ready',
|
||||
transcriptLoading: false,
|
||||
error: undefined,
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier: vi.fn()
|
||||
}
|
||||
const draftsArgs: Record<string, unknown>[] = []
|
||||
const promptsState = {
|
||||
permission: null as unknown,
|
||||
@@ -33,6 +83,24 @@ vi.mock('./use-mobile-session-view-mode', () => ({
|
||||
vi.mock('./use-mobile-native-chat-session', () => ({
|
||||
useMobileNativeChatSession: () => sessionState
|
||||
}))
|
||||
vi.mock('./use-mobile-structured-agent-session', () => ({
|
||||
useMobileStructuredAgentSession: () => ({
|
||||
session: structuredSessionState,
|
||||
isWorking: false,
|
||||
turnId: null,
|
||||
sendWithOutcome: structuredSendWithOutcome,
|
||||
cancel: structuredCancel,
|
||||
permission: structuredPermission,
|
||||
question: structuredQuestion,
|
||||
optionSnapshot: structuredOptionSnapshot,
|
||||
optionSurface: structuredOptionSurface,
|
||||
pendingOptionId: 'model',
|
||||
respondPermission: structuredRespondPermission,
|
||||
respondQuestion: structuredRespondQuestion,
|
||||
setStructuredOption: structuredSetOption,
|
||||
invokeStructuredOption: structuredInvokeOption
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
|
||||
draftsArgs.push(args)
|
||||
@@ -110,18 +178,28 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
// itself is mocked above).
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
|
||||
function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null {
|
||||
function Harness({
|
||||
connState = 'connected',
|
||||
tab = null,
|
||||
activeHandle = 'term-1',
|
||||
inputLeaseReady = true
|
||||
}: {
|
||||
connState?: ConnectionState
|
||||
tab?: unknown
|
||||
activeHandle?: string | null
|
||||
inputLeaseReady?: boolean
|
||||
}): null {
|
||||
controller = useMobileNativeChatController({
|
||||
client: clientStub as unknown as RpcClient,
|
||||
connState,
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: null,
|
||||
activeSessionTabId: 'tab-1',
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
activeSessionTab: tab as never,
|
||||
activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1',
|
||||
activeHandleRef: { current: activeHandle },
|
||||
deviceTokenRef: { current: null },
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
nativeChatInputLeaseReady: true,
|
||||
nativeChatInputLeaseReady: inputLeaseReady,
|
||||
onSendError,
|
||||
onSendResolved
|
||||
})
|
||||
@@ -138,6 +216,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
})
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
captureSendOrigin.mockReturnValue(ORIGIN)
|
||||
structuredSendWithOutcome.mockResolvedValue('accepted')
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
@@ -233,6 +312,80 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
expect(restoreRejectedDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes structured agent-session sends away from terminal/nativeChat transports', async () => {
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tab: {
|
||||
type: 'agent-session',
|
||||
id: 'agent-tab-1',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-structured',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
},
|
||||
activeHandle: null,
|
||||
inputLeaseReady: false
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
let accepted = false
|
||||
await act(async () => {
|
||||
accepted = await controller!.handleNativeChatSend('look')
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(structuredSendWithOutcome).toHaveBeenCalledWith('look')
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
expect(clientStub.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes structured prompt cards and session options on structured tabs', async () => {
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tab: {
|
||||
type: 'agent-session',
|
||||
id: 'agent-tab-1',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-structured',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
},
|
||||
activeHandle: null,
|
||||
inputLeaseReady: false
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(controller!.nativeChatPermission).toEqual(structuredPermission)
|
||||
expect(controller!.nativeChatQuestion).toEqual(structuredQuestion)
|
||||
expect(controller!.nativeChatSessionOptions).not.toBeNull()
|
||||
expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual(
|
||||
structuredOptionSnapshot
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true)
|
||||
})
|
||||
expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once')
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
expect(await controller!.handleNativeChatQuestionAnswer('choice-a')).toBe(true)
|
||||
})
|
||||
expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a')
|
||||
expect(clientStub.sendRequest).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
expect(
|
||||
await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast')
|
||||
).toBe(true)
|
||||
})
|
||||
expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast')
|
||||
})
|
||||
|
||||
it('pre-clears separately for a text-only send but never for an image send', async () => {
|
||||
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
|
||||
// this send; a second clear here wipes the image off the input line and the
|
||||
|
||||
@@ -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,15 +9,17 @@ 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 { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileNativeChatSessionOptions } 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'
|
||||
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'
|
||||
|
||||
@@ -58,36 +58,48 @@ export function useMobileNativeChatController(args: {
|
||||
onSendError,
|
||||
onSendResolved
|
||||
} = args
|
||||
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
|
||||
|
||||
const activeChatResolution =
|
||||
activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId)
|
||||
? 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 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${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
|
||||
const nativeChatSession = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
|
||||
agent: activeChatResolution?.agent ?? null,
|
||||
sessionId: activeChatSessionId,
|
||||
transcriptPath: activeChatResolution?.transcriptPath ?? null
|
||||
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,
|
||||
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
|
||||
sessionId: activeChatStructured ? null : activeChatSessionId,
|
||||
transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
|
||||
})
|
||||
const structuredNativeChat = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId: activeChatStructured ? activeChatSessionId : null,
|
||||
enabled: showNativeChat,
|
||||
connected: connState === 'connected',
|
||||
agent: activeChatStructured ? activeChatAgent : null,
|
||||
onSendError
|
||||
})
|
||||
const nativeChatSession = activeChatStructured
|
||||
? structuredNativeChat.session
|
||||
: legacyNativeChatSession
|
||||
const {
|
||||
composerText: chatComposerText,
|
||||
setComposerText: setChatComposerText,
|
||||
@@ -117,27 +129,29 @@ export function useMobileNativeChatController(args: {
|
||||
transcriptSettled: nativeChatSession.status === 'ready'
|
||||
})
|
||||
|
||||
const activeTabStatus = activeSessionTab?.agentStatus
|
||||
const activeTabAgentWorking =
|
||||
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
|
||||
const nativeChatStatus = activeChatResolution ? activeTabStatus : null
|
||||
const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
|
||||
const nativeChatAgentWorking = activeChatStructured
|
||||
? structuredNativeChat.isWorking
|
||||
: activeChatResolution != null && activeTabAgentWorking
|
||||
// Deliberately not gated on the chat view being visible: the streaming gate
|
||||
// has to tell "hidden mid-turn" from "the turn ended".
|
||||
const nativeChatStreamLive = activeTabAgentWorking
|
||||
const nativeChatStreamLive = activeChatStructured
|
||||
? structuredNativeChat.isWorking
|
||||
: activeTabAgentWorking
|
||||
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
|
||||
// part, and each one re-renders and re-parses the whole accumulated markdown.
|
||||
const nativeChatStreamingText = useThrottledLatestValue(
|
||||
mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
|
||||
activeChatStructured
|
||||
? undefined
|
||||
: mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
|
||||
NATIVE_CHAT_STREAM_THROTTLE_MS
|
||||
)
|
||||
const {
|
||||
permission: nativeChatPermission,
|
||||
question: nativeChatQuestion,
|
||||
permission: legacyNativeChatPermission,
|
||||
question: legacyNativeChatQuestion,
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
ask: nativeChatAskPrompt
|
||||
} = useMobileNativeChatPrompts({
|
||||
enabled: activeChatResolution != null,
|
||||
enabled: activeChatResolution != null && !activeChatStructured,
|
||||
status: nativeChatStatus,
|
||||
messages: nativeChatSession.messages,
|
||||
transcriptLoading: nativeChatSession.transcriptLoading
|
||||
@@ -146,8 +160,6 @@ export function useMobileNativeChatController(args: {
|
||||
const nativeChatTranscriptSettled =
|
||||
nativeChatSession.status === 'ready' ||
|
||||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
|
||||
const nativeChatAskObservable =
|
||||
showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
|
||||
const {
|
||||
askKey: nativeChatAskKey,
|
||||
showAsk: showNativeChatAsk,
|
||||
@@ -157,17 +169,19 @@ export function useMobileNativeChatController(args: {
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
scopeKey: activeSessionTabId,
|
||||
sessionKey: activeChatSessionId,
|
||||
observing: nativeChatAskObservable
|
||||
observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
|
||||
})
|
||||
|
||||
// Every chat write gates on both: the lease proves the input floor is ours, and
|
||||
// `connState` collapses a render before the lease does on disconnect.
|
||||
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
|
||||
const inputSendable = activeChatStructured
|
||||
? client != null && activeChatSessionId != null && connState === 'connected'
|
||||
: nativeChatInputLeaseReady && connState === 'connected'
|
||||
|
||||
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
|
||||
useMobileNativeChatAnswerSend({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: activeChatAgentRef,
|
||||
@@ -178,16 +192,16 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
cancelPending: cancelNativeChatAnswer,
|
||||
onSendError
|
||||
})
|
||||
|
||||
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
|
||||
const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
onSendError
|
||||
@@ -195,7 +209,7 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const handleNativeChatStop = useMobileNativeChatStop({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
streamIdentity,
|
||||
@@ -216,11 +230,11 @@ export function useMobileNativeChatController(args: {
|
||||
const {
|
||||
send: handleNativeChatSend,
|
||||
sendWithOutcome: handleNativeChatSendWithOutcome,
|
||||
answerQuestion: handleNativeChatQuestionAnswer,
|
||||
answerQuestion: legacyHandleNativeChatQuestionAnswer,
|
||||
dispatchCommand: handleNativeChatDispatchCommand
|
||||
} = useMobileNativeChatMessageSend({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
enabled: inputSendable && !activeChatStructured,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: activeChatAgentRef,
|
||||
@@ -234,26 +248,44 @@ 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: activeChatResolution?.agent ?? null,
|
||||
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
|
||||
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
|
||||
dispatchCommand: handleNativeChatDispatchCommand,
|
||||
onAgentPicker: handleAgentPicker
|
||||
const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({
|
||||
sendStructured: structuredNativeChat.sendWithOutcome,
|
||||
captureSendOrigin,
|
||||
clearDraftForSend,
|
||||
acceptSend,
|
||||
holdUnconfirmedSend,
|
||||
restoreRejectedDraft,
|
||||
onSendError
|
||||
})
|
||||
|
||||
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)
|
||||
const handleNativeChatRespondPermission = activeChatStructured
|
||||
? structuredNativeChat.respondPermission
|
||||
: legacyHandleNativeChatRespondPermission
|
||||
const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved)
|
||||
|
||||
return {
|
||||
@@ -272,24 +304,31 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamLive,
|
||||
nativeChatStreamScopeKey: streamScopeKey,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
|
||||
nativeChatPermission: activeChatStructured
|
||||
? structuredNativeChat.permission
|
||||
: legacyNativeChatPermission,
|
||||
nativeChatQuestion: activeChatStructured
|
||||
? structuredNativeChat.question
|
||||
: legacyNativeChatQuestion,
|
||||
nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null,
|
||||
nativeChatAskKey,
|
||||
dismissNativeChatAsk,
|
||||
handleNativeChatAnswerAsk: answerAsk,
|
||||
handleNativeChatCancelAsk: cancelAsk,
|
||||
handleNativeChatRespondPermission: respond,
|
||||
handleNativeChatStop,
|
||||
handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop,
|
||||
nativeChatFilePaths,
|
||||
loadNativeChatFiles,
|
||||
handleNativeChatQuestionAnswer,
|
||||
handleNativeChatSend,
|
||||
handleNativeChatSendWithOutcome,
|
||||
handleNativeChatQuestionAnswer: activeChatStructured
|
||||
? structuredNativeChat.respondQuestion
|
||||
: legacyHandleNativeChatQuestionAnswer,
|
||||
handleNativeChatSend: activeChatStructured
|
||||
? structuredNativeChatSend.send
|
||||
: handleNativeChatSend,
|
||||
handleNativeChatSendWithOutcome: activeChatStructured
|
||||
? structuredNativeChatSend.sendWithOutcome
|
||||
: handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft,
|
||||
nativeChatSessionOptions:
|
||||
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
|
||||
@@ -60,8 +60,11 @@ type Args = {
|
||||
readonly baseSend: (
|
||||
text: string,
|
||||
imagePreviewUris?: string[],
|
||||
deadline?: number
|
||||
deadline?: number,
|
||||
attachments?: readonly PendingNativeChatImage[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Structured sessions send attachments without the terminal paste path. */
|
||||
readonly structuredNativeChat: boolean
|
||||
/** Launch-context text parked on the agent's TUI input line, or null. The
|
||||
* paste's leading clear must cover every line of it, or the draft's earlier
|
||||
* lines survive and ride along with the image. */
|
||||
@@ -83,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))
|
||||
|
||||
@@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({
|
||||
showToast,
|
||||
onSendError,
|
||||
baseSend,
|
||||
structuredNativeChat,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess,
|
||||
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 || !activeHandleRef.current || connState !== 'connected') {
|
||||
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
|
||||
@@ -238,7 +168,32 @@ 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?.()
|
||||
onSendError('Message not sent (disconnected)')
|
||||
return false
|
||||
}
|
||||
const outcome = await baseSend(
|
||||
text,
|
||||
pendingImages.map((attachment) => attachment.previewUri),
|
||||
deadline,
|
||||
pendingImages
|
||||
)
|
||||
if (outcome !== 'rejected') {
|
||||
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
|
||||
setAttachmentsByScope((prev) =>
|
||||
withScopeAttachments(
|
||||
prev,
|
||||
scope,
|
||||
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
|
||||
)
|
||||
)
|
||||
}
|
||||
return outcome !== 'rejected'
|
||||
}
|
||||
if (pendingImages.length === 0 || !scope) {
|
||||
// Heal a previously failed paste: a text-only send to that terminal would
|
||||
// otherwise glue the stale image paste onto this message. Best-effort —
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useLayoutEffect, 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)
|
||||
useLayoutEffect(() => {
|
||||
connStateRef.current = connState
|
||||
}, [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 }
|
||||
}
|
||||
@@ -36,7 +36,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
|
||||
nativeChatInputLeaseReady,
|
||||
nativeChatController,
|
||||
getActiveWorktreeConnectionId,
|
||||
refreshCanPaste
|
||||
refreshCanPaste,
|
||||
activeSessionTab
|
||||
} = scope
|
||||
const handlePaste = useMobileTerminalPaste({
|
||||
client,
|
||||
@@ -80,6 +81,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
|
||||
structuredNativeChat: activeSessionTab?.type === 'agent-session',
|
||||
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError: nativeChatSendError.show,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { Linking } from 'react-native'
|
||||
import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
|
||||
import { resolveMobileNativeChatFileSessionId } from './mobile-native-chat-eligibility'
|
||||
import { activateOpenedSourceControlDiffTab } from './opened-mobile-session-tab'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import type { MobileSessionTerminalSendActionsModel } from './use-mobile-session-terminal-send-actions'
|
||||
@@ -31,10 +32,7 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
nativeChatSessionId:
|
||||
activeSessionTab?.type === 'terminal'
|
||||
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
|
||||
: null,
|
||||
nativeChatSessionId: resolveMobileNativeChatFileSessionId(activeSessionTab),
|
||||
activeHandleRef,
|
||||
terminalCwdRef,
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -29,8 +29,15 @@ type Args = {
|
||||
readonly nativeChatBaseSend: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
deadline?: number,
|
||||
attachments?: readonly {
|
||||
id: string
|
||||
path: string
|
||||
previewUri: string
|
||||
}[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Structured agent sessions do not have a terminal paste path. */
|
||||
readonly structuredNativeChat: boolean
|
||||
/** Launch-context text parked on the agent's TUI input line, or null — sizes
|
||||
* the image paste's leading clear so a multi-line draft cannot ride along. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
@@ -57,6 +64,7 @@ export function useMobileSessionImageAttachments({
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend,
|
||||
nativeChatBaseSend,
|
||||
structuredNativeChat,
|
||||
readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError,
|
||||
@@ -86,7 +94,8 @@ export function useMobileSessionImageAttachments({
|
||||
getActiveWorktreeConnectionId,
|
||||
connState,
|
||||
scopeKey: nativeChatScopeKey,
|
||||
enabled: nativeChatInputLeaseReady,
|
||||
enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady,
|
||||
structuredNativeChat,
|
||||
showToast,
|
||||
onSendError: onNativeChatSendError,
|
||||
baseSend: nativeChatBaseSend,
|
||||
|
||||
@@ -77,6 +77,12 @@ export function useMobileSessionNativeChatDictation(
|
||||
})
|
||||
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
|
||||
nativeChatSendError.bannerMountedRef.current = showNativeChat
|
||||
const nativeChatOverlayInputLockReason =
|
||||
activeSessionTab?.type === 'agent-session'
|
||||
? connState === 'connected'
|
||||
? null
|
||||
: 'disconnected'
|
||||
: nativeChatInputLockReason
|
||||
const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}`
|
||||
const getSendCompletionGeneration = useMobileSendCompletionGeneration({
|
||||
onBlur: resetLiveInputFocus,
|
||||
@@ -211,6 +217,7 @@ export function useMobileSessionNativeChatDictation(
|
||||
nativeChatInputLeaseReady,
|
||||
nativeChatInputLeaseReadyRef,
|
||||
nativeChatInputLockReason,
|
||||
nativeChatOverlayInputLockReason,
|
||||
markNativeChatInputLeaseReady,
|
||||
clearNativeChatInputLease,
|
||||
nativeChatController,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
MobileSessionTab,
|
||||
Terminal
|
||||
} from './mobile-session-route-types'
|
||||
import { useMobileSessionTabActionTargets } from './use-mobile-session-tab-action-targets'
|
||||
import type { MobileSessionFoundationModel } from './use-mobile-session-foundation'
|
||||
|
||||
export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) {
|
||||
@@ -90,19 +91,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
|
||||
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
|
||||
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
|
||||
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
|
||||
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
|
||||
MobileSessionTab,
|
||||
{ type: 'markdown' }
|
||||
> | null>(null)
|
||||
const [fileActionTarget, setFileActionTarget] = useState<Extract<
|
||||
MobileSessionTab,
|
||||
{ type: 'file' }
|
||||
> | null>(null)
|
||||
const [browserActionTarget, setBrowserActionTarget] = useState<Extract<
|
||||
MobileSessionTab,
|
||||
{ type: 'browser' }
|
||||
> | null>(null)
|
||||
const sessionTabActionTargets = useMobileSessionTabActionTargets()
|
||||
const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState<Extract<
|
||||
MobileSessionTab,
|
||||
{ type: 'markdown' }
|
||||
@@ -211,14 +200,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
|
||||
setShowCreateBrowserModal,
|
||||
showHeaderMoreActions,
|
||||
setShowHeaderMoreActions,
|
||||
actionTarget,
|
||||
setActionTarget,
|
||||
markdownActionTarget,
|
||||
setMarkdownActionTarget,
|
||||
fileActionTarget,
|
||||
setFileActionTarget,
|
||||
browserActionTarget,
|
||||
setBrowserActionTarget,
|
||||
...sessionTabActionTargets,
|
||||
discardMarkdownTarget,
|
||||
setDiscardMarkdownTarget,
|
||||
leaveDrafts,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import type { MobileSessionTab, Terminal } from './mobile-session-route-types'
|
||||
|
||||
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
|
||||
type FileTab = Extract<MobileSessionTab, { type: 'file' }>
|
||||
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
|
||||
type AgentSessionTab = Extract<MobileSessionTab, { type: 'agent-session' }>
|
||||
type SetActionTarget<T> = Dispatch<SetStateAction<T | null>>
|
||||
|
||||
export function useMobileSessionTabActionTargets() {
|
||||
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
|
||||
const [markdownActionTarget, setMarkdownActionTarget] = useState<MarkdownTab | null>(null)
|
||||
const [fileActionTarget, setFileActionTarget] = useState<FileTab | null>(null)
|
||||
const [browserActionTarget, setBrowserActionTarget] = useState<BrowserTab | null>(null)
|
||||
const [agentSessionActionTarget, setAgentSessionActionTarget] = useState<AgentSessionTab | null>(
|
||||
null
|
||||
)
|
||||
|
||||
return {
|
||||
actionTarget,
|
||||
agentSessionActionTarget,
|
||||
browserActionTarget,
|
||||
fileActionTarget,
|
||||
markdownActionTarget,
|
||||
setActionTarget,
|
||||
setAgentSessionActionTarget,
|
||||
setBrowserActionTarget,
|
||||
setFileActionTarget,
|
||||
setMarkdownActionTarget
|
||||
}
|
||||
}
|
||||
|
||||
export function useMobileSessionTabActionSheetOpener(args: {
|
||||
activeHandleRef: MutableRefObject<string | null>
|
||||
setActionTarget: SetActionTarget<Terminal>
|
||||
setMarkdownActionTarget: SetActionTarget<MarkdownTab>
|
||||
setFileActionTarget: SetActionTarget<FileTab>
|
||||
setBrowserActionTarget: SetActionTarget<BrowserTab>
|
||||
setAgentSessionActionTarget: SetActionTarget<AgentSessionTab>
|
||||
}): (tab: MobileSessionTab) => void {
|
||||
const {
|
||||
activeHandleRef,
|
||||
setActionTarget,
|
||||
setAgentSessionActionTarget,
|
||||
setBrowserActionTarget,
|
||||
setFileActionTarget,
|
||||
setMarkdownActionTarget
|
||||
} = args
|
||||
return useCallback(
|
||||
(tab: MobileSessionTab) => {
|
||||
if (tab.type === 'terminal') {
|
||||
if (typeof tab.terminal !== 'string') {
|
||||
return
|
||||
}
|
||||
setActionTarget({
|
||||
handle: tab.terminal,
|
||||
title: tab.title,
|
||||
isActive: tab.terminal === activeHandleRef.current
|
||||
})
|
||||
} else if (tab.type === 'markdown') {
|
||||
setMarkdownActionTarget(tab)
|
||||
} else if (tab.type === 'file') {
|
||||
setFileActionTarget(tab)
|
||||
} else if (tab.type === 'agent-session') {
|
||||
setAgentSessionActionTarget(tab)
|
||||
} else {
|
||||
setBrowserActionTarget(tab)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandleRef,
|
||||
setActionTarget,
|
||||
setAgentSessionActionTarget,
|
||||
setBrowserActionTarget,
|
||||
setFileActionTarget,
|
||||
setMarkdownActionTarget
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -136,6 +136,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
|
||||
void readFileTab(tab)
|
||||
return
|
||||
}
|
||||
if (tab.type === 'agent-session') {
|
||||
return
|
||||
}
|
||||
const cached = markdownDocs.get(tab.id)
|
||||
if (cached?.status === 'ready' && cached.isDirty) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'
|
||||
|
||||
vi.mock('../platform/haptics', () => ({
|
||||
triggerSuccess: vi.fn(),
|
||||
triggerError: vi.fn()
|
||||
}))
|
||||
|
||||
function clientReturning(...responses: unknown[]): RpcClient {
|
||||
let responseIndex = 0
|
||||
return {
|
||||
sendRequest: vi.fn(async () => responses[responseIndex++])
|
||||
} as unknown as RpcClient
|
||||
}
|
||||
|
||||
function terminalCreateResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
tab: {
|
||||
type: 'terminal',
|
||||
id: 'terminal-tab-1',
|
||||
title: 'Codex',
|
||||
terminal: 'terminal-1',
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createScope(client: RpcClient) {
|
||||
return {
|
||||
worktreeId: 'workspace-1',
|
||||
client,
|
||||
connState: 'connected',
|
||||
setTerminals: vi.fn(),
|
||||
terminalsRef: { current: [] },
|
||||
setSessionTabs: vi.fn(),
|
||||
defaultTerminalHandlesToLiveInput: vi.fn(),
|
||||
setActiveHandle: vi.fn(),
|
||||
activeSessionTabId: 'existing-tab',
|
||||
activeSessionTabIdRef: { current: 'existing-tab' },
|
||||
setActiveSessionTabId: vi.fn(),
|
||||
setCreating: vi.fn(),
|
||||
creatingTerminalRef: { current: false },
|
||||
creatingBrowser: false,
|
||||
creatingMarkdown: false,
|
||||
setCreateError: vi.fn(),
|
||||
deviceTokenRef: { current: null },
|
||||
initializedHandlesRef: { current: new Set<string>() },
|
||||
activeHandleRef: { current: 'existing-terminal' },
|
||||
activeSessionTabTypeRef: { current: 'terminal' },
|
||||
pendingActiveSessionTabIdRef: { current: null },
|
||||
pendingActiveTerminalHandleRef: { current: null },
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
unsubscribeTerminal: vi.fn(),
|
||||
subscribeToTerminal: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile + Codex tab creation routing', () => {
|
||||
let renderer: ReactTestRenderer | undefined
|
||||
afterEach(() => renderer?.unmount())
|
||||
|
||||
it('uses the structured agent-session path for a bare Codex launch', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { sessionId: 'codex_session_1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
const scope = createScope(client)
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
await actions?.handleCreateTerminal('codex')
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
|
||||
worktree: 'id:workspace-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'agentSession.create',
|
||||
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }),
|
||||
expect.anything()
|
||||
)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith(
|
||||
'session.tabs.createTerminal',
|
||||
expect.anything()
|
||||
)
|
||||
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('agent-session:codex_session_1')
|
||||
expect(scope.setActiveHandle).toHaveBeenCalledWith(null)
|
||||
expect(scope.unsubscribeTerminal).toHaveBeenCalledWith('existing-terminal')
|
||||
})
|
||||
|
||||
it('keeps the legacy terminal path when structured support is disabled', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: false, error: { code: 'structured_agent_session_unsupported', message: 'off' } },
|
||||
terminalCreateResponse()
|
||||
)
|
||||
const scope = createScope(client)
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
await actions?.handleCreateTerminal('codex')
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'session.tabs.createTerminal',
|
||||
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
|
||||
)
|
||||
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
|
||||
})
|
||||
|
||||
it('falls back to a terminal when structured creation is refused', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_ownership_unknown', message: 'provider unavailable' }
|
||||
}
|
||||
},
|
||||
terminalCreateResponse()
|
||||
)
|
||||
const scope = createScope(client)
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
await actions?.handleCreateTerminal('codex')
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'session.tabs.createTerminal',
|
||||
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
|
||||
)
|
||||
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
|
||||
})
|
||||
|
||||
it('keeps prompted Codex launches on the legacy terminal path', async () => {
|
||||
const client = clientReturning(terminalCreateResponse(), {
|
||||
ok: true,
|
||||
result: { send: { accepted: true } }
|
||||
})
|
||||
const scope = createScope(client)
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
await actions?.handleCreateTerminal('codex', { initialPrompt: 'Inspect this diff' })
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'session.tabs.createTerminal',
|
||||
expect.objectContaining({ agent: 'codex' })
|
||||
)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith(
|
||||
'agentSession.createSupport',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create a legacy sibling after an unknown structured outcome', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } })
|
||||
const sendRequest = client.sendRequest as unknown as ReturnType<typeof vi.fn>
|
||||
sendRequest.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
result: { supported: true }
|
||||
}))
|
||||
sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
|
||||
sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('still unknown')))
|
||||
const scope = createScope(client)
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
await actions?.handleCreateTerminal('codex')
|
||||
})
|
||||
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create',
|
||||
'agentSession.create'
|
||||
])
|
||||
expect(scope.setCreateError).toHaveBeenCalledWith('still unknown')
|
||||
expect(scope.showToast).toHaveBeenCalledWith('still unknown', 1800)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
|
||||
import type { Terminal, TerminalCreateResult } from './mobile-session-route-types'
|
||||
import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments'
|
||||
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
|
||||
|
||||
export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) {
|
||||
const {
|
||||
@@ -22,6 +23,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
setActiveHandle,
|
||||
activeSessionTabId,
|
||||
activeSessionTabIdRef,
|
||||
setActiveSessionTabId,
|
||||
setCreating,
|
||||
creatingTerminalRef,
|
||||
@@ -61,6 +63,35 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
|
||||
.slice(2, 10)}`
|
||||
|
||||
try {
|
||||
// Bare Codex launches follow structured support; prompted launches keep their startup semantics.
|
||||
if (agent === 'codex' && options === undefined) {
|
||||
const structured = await createMobileStructuredCodexSession(client, worktreeId)
|
||||
if (structured.kind === 'created') {
|
||||
const previous = activeHandleRef.current
|
||||
if (previous) {
|
||||
unsubscribeTerminal(previous)
|
||||
initializedHandlesRef.current.delete(previous)
|
||||
}
|
||||
const tabId = `agent-session:${structured.sessionId}`
|
||||
pendingActiveSessionTabIdRef.current = tabId
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
activeSessionTabTypeRef.current = 'agent-session'
|
||||
activeSessionTabIdRef.current = tabId
|
||||
setActiveSessionTabId(tabId)
|
||||
activeHandleRef.current = null
|
||||
setActiveHandle(null)
|
||||
// Refresh if the create response beats its published tab frame.
|
||||
scheduleDelayedAction(() => void fetchSessionTabs(), 500)
|
||||
return
|
||||
}
|
||||
if (structured.kind === 'unknown') {
|
||||
// Never create a legacy sibling when the host may already have committed.
|
||||
setCreateError(structured.message)
|
||||
triggerError()
|
||||
showToast(structured.message, 1800)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization'
|
||||
import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import { useMobileSessionTabActionSheetOpener } from './use-mobile-session-tab-action-targets'
|
||||
import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview'
|
||||
|
||||
export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminalWebviewModel) {
|
||||
@@ -27,6 +28,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
|
||||
setMarkdownActionTarget,
|
||||
setFileActionTarget,
|
||||
setBrowserActionTarget,
|
||||
setAgentSessionActionTarget,
|
||||
keyboardHeight,
|
||||
deviceTokenRef,
|
||||
clientRef,
|
||||
@@ -175,24 +177,14 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
|
||||
sessionTabActionSheetKeyboardHideSubRef.current = null
|
||||
}, [])
|
||||
|
||||
const openSessionTabActionSheet = useCallback((tab: MobileSessionTab) => {
|
||||
if (tab.type === 'terminal') {
|
||||
if (typeof tab.terminal !== 'string') {
|
||||
return
|
||||
}
|
||||
setActionTarget({
|
||||
handle: tab.terminal,
|
||||
title: tab.title,
|
||||
isActive: tab.terminal === activeHandleRef.current
|
||||
})
|
||||
} else if (tab.type === 'markdown') {
|
||||
setMarkdownActionTarget(tab)
|
||||
} else if (tab.type === 'file') {
|
||||
setFileActionTarget(tab)
|
||||
} else {
|
||||
setBrowserActionTarget(tab)
|
||||
}
|
||||
}, [])
|
||||
const openSessionTabActionSheet = useMobileSessionTabActionSheetOpener({
|
||||
activeHandleRef,
|
||||
setActionTarget,
|
||||
setMarkdownActionTarget,
|
||||
setFileActionTarget,
|
||||
setBrowserActionTarget,
|
||||
setAgentSessionActionTarget
|
||||
})
|
||||
|
||||
const openSessionTabActionSheetAfterKeyboardDismiss = useCallback(
|
||||
(tab: MobileSessionTab) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
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 { 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) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function snapshotEvent(fence = 3): AgentSessionSubscribeEvent {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-1',
|
||||
fence,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
fence,
|
||||
direction: 'tail',
|
||||
items: [],
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: null,
|
||||
newest: null,
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 0 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pendingResolution(): AgentJournalResolution {
|
||||
return {
|
||||
state: 'pending',
|
||||
selectedOptionId: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function approvalItem(): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: 'approval-1',
|
||||
revision: 2,
|
||||
sequence: 1,
|
||||
observedAt: 10,
|
||||
body: {
|
||||
kind: 'approval',
|
||||
title: 'Allow Bash?',
|
||||
detail: 'rm -rf build',
|
||||
options: [
|
||||
{ id: 'allow-once', label: 'Allow once' },
|
||||
{ id: 'deny', label: 'Deny' }
|
||||
],
|
||||
resolution: pendingResolution()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function approvalItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem {
|
||||
return { ...approvalItem(), itemId, revision }
|
||||
}
|
||||
|
||||
function questionItem(): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: 'question-1',
|
||||
revision: 7,
|
||||
sequence: 2,
|
||||
observedAt: 12,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: 'Pick destination',
|
||||
freeTextQuestionId: 'free-q',
|
||||
options: [
|
||||
{ id: 'choice-a', label: 'Choice A' },
|
||||
{ id: 'choice-b', label: 'Choice B' }
|
||||
],
|
||||
resolution: pendingResolution()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(defaultSendRequest)
|
||||
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
|
||||
listener = onData
|
||||
return unsubscribe
|
||||
})
|
||||
const client = {
|
||||
sendRequest,
|
||||
subscribe
|
||||
} as unknown as RpcClient
|
||||
|
||||
function Harness({
|
||||
sessionId = 'session-1',
|
||||
agent = 'codex',
|
||||
connected = true
|
||||
}: {
|
||||
sessionId?: string | null
|
||||
agent?: string | null
|
||||
connected?: boolean
|
||||
}): null {
|
||||
hook = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId,
|
||||
enabled: true,
|
||||
connected,
|
||||
agent,
|
||||
onSendError
|
||||
} as never)
|
||||
return null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sendRequest.mockImplementation(defaultSendRequest)
|
||||
listener = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
hook = null
|
||||
})
|
||||
|
||||
it('subscribes and holds structured sessions without nativeChat or terminal RPCs', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(subscribe).toHaveBeenCalledWith(
|
||||
'agentSession.subscribe',
|
||||
{ sessionId: 'session-1' },
|
||||
expect.any(Function)
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.hold',
|
||||
expect.objectContaining({ sessionId: 'session-1', holderId: expect.any(String) }),
|
||||
expect.any(Object)
|
||||
)
|
||||
)
|
||||
expect(sendRequest).not.toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^(nativeChat|terminal)\./),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('re-holds after a reconnect that outlives the host release grace', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold')
|
||||
).toHaveLength(1)
|
||||
)
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A transport loss retires the connection-scoped hold; after the host's 15s grace
|
||||
// it may evict the provider child. Reconnect must acquire before replaying the stream.
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: false }))
|
||||
})
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: true }))
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold')
|
||||
).toHaveLength(2)
|
||||
)
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2))
|
||||
const holdOrders = sendRequest.mock.calls
|
||||
.map((call, index) =>
|
||||
call[0] === 'agentSession.hold' ? sendRequest.mock.invocationCallOrder[index] : null
|
||||
)
|
||||
.filter((order): order is number => order !== null)
|
||||
const subscribeOrders = subscribe.mock.invocationCallOrder
|
||||
const secondHoldOrder = holdOrders[1]
|
||||
const secondSubscribeOrder = subscribeOrders[1]
|
||||
if (secondHoldOrder === undefined || secondSubscribeOrder === undefined) {
|
||||
throw new Error('reconnect calls were not recorded')
|
||||
}
|
||||
expect(secondHoldOrder).toBeLessThan(secondSubscribeOrder)
|
||||
})
|
||||
|
||||
it('sends with the shared structured mutation envelope after the stream fence lands', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() => listener?.(snapshotEvent()))
|
||||
|
||||
let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected'
|
||||
await act(async () => {
|
||||
outcome = await hook!.sendWithOutcome('hello')
|
||||
})
|
||||
|
||||
expect(outcome).toBe('accepted')
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.send',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
expectedRuntimeFence: 3,
|
||||
clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/),
|
||||
payloadFingerprint: expect.any(String)
|
||||
}),
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'hello' }]
|
||||
}
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces structured prompt cards and option snapshots', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
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()]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (!hook) {
|
||||
throw new Error('hook not ready')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(hook.permission).not.toBeNull())
|
||||
await vi.waitFor(() => expect(hook.question).not.toBeNull())
|
||||
await vi.waitFor(() => expect(hook.optionSnapshot.length).toBeGreaterThan(0))
|
||||
|
||||
expect(hook.permission).toMatchObject({
|
||||
title: 'Allow Bash?',
|
||||
detail: 'rm -rf build',
|
||||
options: [
|
||||
{ label: 'Allow once', send: expect.any(String) },
|
||||
{ label: 'Deny', send: expect.any(String) }
|
||||
]
|
||||
})
|
||||
expect(hook.question).toMatchObject({
|
||||
question: 'Pick destination',
|
||||
allowOther: true,
|
||||
optionTokens: [expect.any(String), expect.any(String)],
|
||||
freeTextToken: expect.any(String)
|
||||
})
|
||||
expect(hook.optionSurface.getSnapshot()).toEqual(hook.optionSnapshot)
|
||||
|
||||
await act(async () => {
|
||||
expect(await hook.setStructuredOption('model', 'gpt-fast')).toBe(true)
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.setOption',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
expectedRuntimeFence: 3,
|
||||
clientOperationId: expect.any(String),
|
||||
payloadFingerprint: expect.any(String)
|
||||
}),
|
||||
key: 'model',
|
||||
value: 'gpt-fast'
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
expect(await hook.respondPermission(hook.permission!.options[0]!.send)).toBe(true)
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.respondToApproval',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
expectedRuntimeFence: 3
|
||||
}),
|
||||
itemId: 'approval-1',
|
||||
optionId: 'allow-once'
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
expect(
|
||||
await hook.respondQuestion(formatQuestionFreeTextAnswer(hook.question!, 'custom answer'))
|
||||
).toBe(true)
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.respondToQuestion',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
expectedRuntimeFence: 3
|
||||
}),
|
||||
itemId: 'question-1',
|
||||
optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('custom answer')}`
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('sends structured image attachments in the message body', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() => listener?.(snapshotEvent(3)))
|
||||
|
||||
let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected'
|
||||
await act(async () => {
|
||||
outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [
|
||||
{ path: '/tmp/a.png', previewUri: 'file:///a.jpg' }
|
||||
])
|
||||
})
|
||||
|
||||
expect(outcome).toBe('accepted')
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.send',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
expectedRuntimeFence: 3,
|
||||
clientOperationId: expect.any(String),
|
||||
payloadFingerprint: expect.any(String)
|
||||
}),
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{ type: 'image-ref', path: '/tmp/a.png' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
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('uses a fresh operation id when a prompt response delivery is unknown', 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()] }
|
||||
})
|
||||
)
|
||||
let attempts = 0
|
||||
sendRequest.mockImplementation(async (method, params) => {
|
||||
if (method === 'agentSession.respondToApproval' && attempts++ === 0) {
|
||||
throw markRpcDeliveryUnknown(new Error('Connection closed'))
|
||||
}
|
||||
return defaultSendRequest(method, params)
|
||||
})
|
||||
|
||||
const token = hook!.permission!.options[0]!.send
|
||||
await act(async () => {
|
||||
expect(await hook!.respondPermission(token)).toBe(false)
|
||||
expect(await hook!.respondPermission(token)).toBe(true)
|
||||
})
|
||||
|
||||
const calls = sendRequest.mock.calls.filter(
|
||||
([method]) => method === 'agentSession.respondToApproval'
|
||||
)
|
||||
expect(calls).toHaveLength(2)
|
||||
const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
expect(firstId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
|
||||
expect(retryId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
|
||||
expect(retryId).not.toBe(firstId)
|
||||
})
|
||||
|
||||
it('marks a retried send as retryUnknown after ambiguous delivery', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() => listener?.(snapshotEvent(3)))
|
||||
let attempts = 0
|
||||
sendRequest.mockImplementation(async (method, params) => {
|
||||
if (method === 'agentSession.send' && attempts++ === 0) {
|
||||
throw markRpcDeliveryUnknown(new Error('Connection closed'))
|
||||
}
|
||||
return defaultSendRequest(method, params)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
expect(await hook!.sendWithOutcome('retry me')).toBe('unknown')
|
||||
expect(await hook!.sendWithOutcome('retry me')).toBe('accepted')
|
||||
})
|
||||
|
||||
const calls = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.send')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]![1]).not.toHaveProperty('retryUnknown')
|
||||
expect(calls[1]![1]).toMatchObject({ retryUnknown: true })
|
||||
const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
expect(retryId).toBe(firstId)
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.hold',
|
||||
expect.objectContaining({ sessionId: 'session-1' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
)
|
||||
const held = sendRequest.mock.calls.find((call) => call[0] === 'agentSession.hold')?.[1] as {
|
||||
holderId: string
|
||||
}
|
||||
|
||||
act(() => renderer?.unmount())
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'agentSession.release',
|
||||
{ sessionId: 'session-1', holderId: held.holderId },
|
||||
expect.any(Object)
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import type {
|
||||
AgentSessionCancelResult,
|
||||
AgentSessionPromptResult,
|
||||
AgentSessionSendResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface,
|
||||
SessionOptionValue
|
||||
} from '../../../src/shared/native-chat-session-options'
|
||||
import {
|
||||
structuredAgentSessionSendBody,
|
||||
type StructuredAgentSessionAttachment
|
||||
} from '../../../src/shared/structured-agent-session-outbox'
|
||||
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 {
|
||||
pendingStructuredApproval,
|
||||
pendingStructuredQuestion,
|
||||
projectStructuredPermission,
|
||||
projectStructuredQuestion,
|
||||
structuredApprovalResponseTarget,
|
||||
structuredQuestionResponseTarget
|
||||
} from './mobile-structured-agent-prompts'
|
||||
import {
|
||||
requestStructuredAgentSessionMutation,
|
||||
structuredSessionOperationId,
|
||||
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'
|
||||
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
|
||||
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
|
||||
|
||||
type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string }
|
||||
|
||||
type StructuredMobileSession = {
|
||||
session: MobileNativeChatSession
|
||||
isWorking: boolean
|
||||
turnId: string | null
|
||||
sendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredMobileAttachment[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
cancel: () => void
|
||||
permission: MobileChatPermission | null
|
||||
question: MobileChatQuestion | null
|
||||
optionSnapshot: SessionOptionDescriptor[]
|
||||
optionSurface: SessionOptionsSurface
|
||||
pendingOptionId: string | null
|
||||
respondPermission: (optionId: string) => Promise<boolean>
|
||||
respondQuestion: (answer: string) => Promise<boolean>
|
||||
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
|
||||
invokeStructuredOption: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useMobileStructuredAgentSession(args: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
enabled: boolean
|
||||
/** Live transport only; gates the connection-scoped hold, nothing else. */
|
||||
connected: boolean
|
||||
agent: string | null
|
||||
onSendError: (message: string) => void
|
||||
}): StructuredMobileSession {
|
||||
const { agent, client, connected, sessionId, enabled, onSendError } = args
|
||||
const operationIdsRef = useRef(new Map<string, string>())
|
||||
const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState({
|
||||
client,
|
||||
sessionId,
|
||||
enabled,
|
||||
connected
|
||||
})
|
||||
|
||||
const mutate = useCallback(
|
||||
async <TValue>(
|
||||
method: string,
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>
|
||||
): Promise<StructuredAgentSessionMutationResult<TValue>> => {
|
||||
const current = stateRef.current
|
||||
if (!client || !sessionId || !enabled || current.fence === null) {
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
const targetFence = current.fence
|
||||
const key = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}`
|
||||
const clientOperationId = operationIdsRef.current.get(key) ?? structuredSessionOperationId()
|
||||
operationIdsRef.current.set(key, clientOperationId)
|
||||
const result = await requestStructuredAgentSessionMutation<TValue>({
|
||||
client,
|
||||
method,
|
||||
fingerprintMethod,
|
||||
sessionId,
|
||||
expectedRuntimeFence: targetFence,
|
||||
fields,
|
||||
clientOperationId
|
||||
})
|
||||
if (result.status === 'accepted') {
|
||||
operationIdsRef.current.delete(key)
|
||||
return {
|
||||
status: 'accepted',
|
||||
value: result.value,
|
||||
sameFence: stateRef.current.fence === targetFence
|
||||
}
|
||||
}
|
||||
if (result.status === 'unknown') {
|
||||
// Prompt/option/cancel plans cannot redispatch an unknown ledger row;
|
||||
// issue a fresh id so a retry can be admitted after the user checks the
|
||||
// stream. Sends opt into explicit retryUnknown below.
|
||||
operationIdsRef.current.delete(key)
|
||||
return result
|
||||
}
|
||||
operationIdsRef.current.delete(key)
|
||||
onSendError(result.message)
|
||||
return { status: 'rejected' }
|
||||
},
|
||||
[client, enabled, onSendError, sessionId]
|
||||
)
|
||||
|
||||
const {
|
||||
invokeStructuredOption,
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
pendingOptionId,
|
||||
setStructuredOption
|
||||
} = useMobileStructuredAgentOptions({
|
||||
agent,
|
||||
client,
|
||||
sessionId,
|
||||
enabled,
|
||||
fence: state.fence,
|
||||
mutate
|
||||
})
|
||||
|
||||
const sendWithOutcome = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredMobileAttachment[]
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
const currentFence = stateRef.current.fence
|
||||
if (!client || !sessionId || !enabled || currentFence === null) {
|
||||
onSendError('Message not sent (disconnected)')
|
||||
return 'rejected'
|
||||
}
|
||||
const timeoutMs = timeoutForDeadline(deadline)
|
||||
if (timeoutMs === null) {
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
}
|
||||
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 key = `${sessionId}:agentSession.send:${JSON.stringify(fields)}`
|
||||
const priorOperationId = operationIdsRef.current.get(key)
|
||||
const clientOperationId = priorOperationId ?? structuredSessionOperationId()
|
||||
operationIdsRef.current.set(key, clientOperationId)
|
||||
const result = await requestStructuredAgentSessionMutation<AgentSessionSendResult>({
|
||||
client,
|
||||
method: 'agentSession.send',
|
||||
fingerprintMethod: 'agentSession.send',
|
||||
sessionId,
|
||||
expectedRuntimeFence: currentFence,
|
||||
fields,
|
||||
clientOperationId,
|
||||
...(priorOperationId ? { retryUnknown: true } : {}),
|
||||
timeoutMs
|
||||
})
|
||||
if (result.status === 'accepted') {
|
||||
operationIdsRef.current.delete(key)
|
||||
return 'accepted'
|
||||
}
|
||||
if (result.status === 'unknown') {
|
||||
return 'unknown'
|
||||
}
|
||||
operationIdsRef.current.delete(key)
|
||||
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 target = structuredApprovalResponseTarget(
|
||||
optionId,
|
||||
stateRef.current.items.find(pendingStructuredApproval) ?? null
|
||||
)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondTo:approval',
|
||||
target
|
||||
)
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Response unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[mutate, onSendError]
|
||||
)
|
||||
|
||||
const respondQuestion = useCallback(
|
||||
async (answer: string): Promise<boolean> => {
|
||||
const target = structuredQuestionResponseTarget(
|
||||
answer,
|
||||
stateRef.current.items.find(pendingStructuredQuestion) ?? null
|
||||
)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
target
|
||||
)
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Answer unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[mutate, onSendError]
|
||||
)
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
const current = stateRef.current
|
||||
const turnId = activeStructuredAgentSessionTurnId(current.items)
|
||||
if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
|
||||
onSendError('Stop not sent')
|
||||
return
|
||||
}
|
||||
const fields = { turnId }
|
||||
const key = `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}`
|
||||
const clientOperationId = operationIdsRef.current.get(key) ?? structuredSessionOperationId()
|
||||
operationIdsRef.current.set(key, clientOperationId)
|
||||
void requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
|
||||
client,
|
||||
method: 'agentSession.cancel',
|
||||
fingerprintMethod: 'agentSession.cancel',
|
||||
sessionId,
|
||||
expectedRuntimeFence: current.fence,
|
||||
fields,
|
||||
clientOperationId
|
||||
}).then((result) => {
|
||||
if (result.status !== 'unknown') {
|
||||
operationIdsRef.current.delete(key)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}, [client, enabled, onSendError, sessionId])
|
||||
|
||||
const messages = useMemo(
|
||||
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
|
||||
[state.items, state.submissions]
|
||||
)
|
||||
const status = state.status === 'idle' ? 'idle' : state.status
|
||||
const approvalPrompt = useMemo(
|
||||
() => state.items.find(pendingStructuredApproval) ?? null,
|
||||
[state.items]
|
||||
)
|
||||
const questionPrompt = useMemo(
|
||||
() => state.items.find(pendingStructuredQuestion) ?? null,
|
||||
[state.items]
|
||||
)
|
||||
|
||||
return {
|
||||
session: {
|
||||
messages,
|
||||
status,
|
||||
transcriptLoading: status === 'loading',
|
||||
error: state.error,
|
||||
hasMore: state.hasOlder,
|
||||
loadingEarlier: loadingOlder,
|
||||
loadEarlier
|
||||
},
|
||||
isWorking: activeStructuredAgentSessionTurnId(state.items) !== null,
|
||||
turnId: activeStructuredAgentSessionTurnId(state.items),
|
||||
sendWithOutcome,
|
||||
cancel,
|
||||
permission: projectStructuredPermission(approvalPrompt),
|
||||
question: projectStructuredQuestion(questionPrompt),
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
pendingOptionId,
|
||||
respondPermission,
|
||||
respondQuestion,
|
||||
setStructuredOption,
|
||||
invokeStructuredOption
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, 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
|
||||
/** Live transport only; gates the connection-scoped hold, nothing else. */
|
||||
connected: boolean
|
||||
}): {
|
||||
state: StructuredAgentSessionState
|
||||
stateRef: { readonly current: StructuredAgentSessionState }
|
||||
loadingOlder: boolean
|
||||
loadEarlier: () => void
|
||||
} {
|
||||
const { client, connected, enabled, sessionId } = args
|
||||
const [state, setState] = useState<StructuredAgentSessionState>(EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const stateRef = useRef(state)
|
||||
const sessionIdentityRef = useRef<{ client: RpcClient; sessionId: string } | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
const apply = useCallback((action: StructuredAgentSessionAction) => {
|
||||
setState((current) => reduceStructuredAgentSession(current, action))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const sessionChanged =
|
||||
client !== null &&
|
||||
sessionId !== null &&
|
||||
(sessionIdentityRef.current?.client !== client ||
|
||||
sessionIdentityRef.current?.sessionId !== sessionId)
|
||||
if (client && sessionId) {
|
||||
sessionIdentityRef.current = { client, sessionId }
|
||||
}
|
||||
if (!client || !sessionId || !enabled) {
|
||||
sessionIdentityRef.current = null
|
||||
setState(EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
setLoadingOlder(false)
|
||||
return
|
||||
}
|
||||
if (sessionChanged) {
|
||||
// Never show one structured tab's transcript under another tab.
|
||||
setState(EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
}
|
||||
if (!connected) {
|
||||
// The connection-scoped hold and stream are retired in the prior cleanup;
|
||||
// retain the last transcript until the transport comes back.
|
||||
return
|
||||
}
|
||||
apply({ type: 'loading' })
|
||||
const holderId = structuredAgentSessionHolderId('mobile-chat')
|
||||
let cancelled = false
|
||||
let unsubscribe = (): void => {}
|
||||
const held = callAgentSession(client, 'agentSession.hold', {
|
||||
sessionId,
|
||||
holderId
|
||||
})
|
||||
void held
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
void held
|
||||
.then(() =>
|
||||
callAgentSession(
|
||||
client,
|
||||
'agentSession.release',
|
||||
{
|
||||
sessionId,
|
||||
holderId
|
||||
},
|
||||
undefined,
|
||||
{ failWhenDisconnected: true }
|
||||
).catch(() => undefined)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}, [apply, client, connected, 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 }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
|
||||
|
||||
type StructuredNativeChatAttachment = {
|
||||
id?: string
|
||||
path: string
|
||||
previewUri: string
|
||||
}
|
||||
|
||||
export function useMobileStructuredNativeChatSendBridge(args: {
|
||||
sendStructured: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredNativeChatAttachment[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
|
||||
holdUnconfirmedSend: (
|
||||
origin: MobileNativeChatSendOrigin,
|
||||
text: string,
|
||||
onUnconfirmed: () => void
|
||||
) => void
|
||||
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
onSendError: (message: string) => void
|
||||
}): {
|
||||
send: (text: string, images?: string[]) => Promise<boolean>
|
||||
sendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredNativeChatAttachment[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
} {
|
||||
const {
|
||||
acceptSend,
|
||||
captureSendOrigin,
|
||||
clearDraftForSend,
|
||||
holdUnconfirmedSend,
|
||||
onSendError,
|
||||
restoreRejectedDraft,
|
||||
sendStructured
|
||||
} = args
|
||||
const sendWithOutcome = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredNativeChatAttachment[]
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
const origin = captureSendOrigin(text.trimEnd())
|
||||
if (!origin) {
|
||||
onSendError('Message not sent (disconnected)')
|
||||
return 'rejected'
|
||||
}
|
||||
clearDraftForSend(origin, text)
|
||||
const outcome =
|
||||
attachments !== undefined
|
||||
? await sendStructured(text, images, deadline, attachments)
|
||||
: deadline !== undefined
|
||||
? await sendStructured(text, images, deadline)
|
||||
: images !== undefined
|
||||
? await sendStructured(text, images)
|
||||
: await sendStructured(text)
|
||||
if (outcome === 'accepted') {
|
||||
acceptSend(origin, text.trimEnd(), images)
|
||||
return 'accepted'
|
||||
}
|
||||
if (outcome === 'unknown') {
|
||||
holdUnconfirmedSend(origin, text.trimEnd(), () =>
|
||||
onSendError('Delivery unconfirmed — check chat before retrying')
|
||||
)
|
||||
return 'unknown'
|
||||
}
|
||||
restoreRejectedDraft(origin, text)
|
||||
return 'rejected'
|
||||
},
|
||||
[
|
||||
acceptSend,
|
||||
captureSendOrigin,
|
||||
clearDraftForSend,
|
||||
holdUnconfirmedSend,
|
||||
onSendError,
|
||||
restoreRejectedDraft,
|
||||
sendStructured
|
||||
]
|
||||
)
|
||||
const send = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredNativeChatAttachment[]
|
||||
) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected',
|
||||
[sendWithOutcome]
|
||||
)
|
||||
return { send, sendWithOutcome }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
|
||||
import { advertiseMobileRuntimeClientCapabilities } from './mobile-runtime-client-capabilities'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
@@ -232,6 +233,11 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.authenticationGeneration++
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
advertiseMobileRuntimeClientCapabilities(
|
||||
(request) => this.sendEncrypted(request),
|
||||
`mobile-capabilities-${this.nextId()}`,
|
||||
this.deviceToken
|
||||
)
|
||||
this.connectionState.publish('connected')
|
||||
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
|
||||
code: 'direct-connected'
|
||||
|
||||
@@ -93,8 +93,15 @@ async function authenticateSession() {
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
|
||||
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
|
||||
const capabilityRequest = JSON.parse(fakes.sendText.mock.calls[1]![0] as string) as {
|
||||
id: string
|
||||
method: string
|
||||
deviceToken: string
|
||||
params: { clientCapabilities?: string[] }
|
||||
}
|
||||
fakes.sendText.mockClear()
|
||||
return { session, confirmationRequest: request }
|
||||
return { session, confirmationRequest: request, capabilityRequest }
|
||||
}
|
||||
|
||||
describe('mobile relay RPC session', () => {
|
||||
@@ -106,7 +113,7 @@ describe('mobile relay RPC session', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
|
||||
const { session, confirmationRequest } = await authenticateSession()
|
||||
const { session, confirmationRequest, capabilityRequest } = await authenticateSession()
|
||||
|
||||
expect(fakes.linkOptions).toMatchObject({
|
||||
endpoint: relay,
|
||||
@@ -120,6 +127,13 @@ describe('mobile relay RPC session', () => {
|
||||
})
|
||||
expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId')
|
||||
expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion')
|
||||
expect(capabilityRequest).toMatchObject({
|
||||
method: 'runtime.clientCapabilities.update',
|
||||
params: {
|
||||
clientCapabilities: expect.arrayContaining(['agent-session.structured.v1'])
|
||||
},
|
||||
deviceToken: 'device-token'
|
||||
})
|
||||
expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number))
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { mobileRuntimeClientCapabilityUpdateRequest } from './mobile-runtime-client-capabilities'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
@@ -182,6 +183,9 @@ export function connectMobileRelayRpcSession(args: {
|
||||
resumeConfirmation = result.resumeConfirmation
|
||||
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
|
||||
lastConnectedAt = Date.now()
|
||||
sendFrame(
|
||||
mobileRuntimeClientCapabilityUpdateRequest({ id: nextId(), deviceToken: args.deviceToken })
|
||||
)
|
||||
livenessWatchdog.start(livenessIdentity)
|
||||
publishState('connected')
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities'
|
||||
|
||||
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY
|
||||
])
|
||||
|
||||
export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
|
||||
'runtime.clientCapabilities.update' as const
|
||||
|
||||
export function mobileRuntimeClientCapabilityUpdateParams(): {
|
||||
clientCapabilities: string[]
|
||||
} {
|
||||
return { clientCapabilities: [...MOBILE_RUNTIME_CLIENT_CAPABILITIES] }
|
||||
}
|
||||
|
||||
export function mobileRuntimeClientCapabilityUpdateRequest(args: {
|
||||
id: string
|
||||
deviceToken: string
|
||||
}): {
|
||||
id: string
|
||||
deviceToken: string
|
||||
method: typeof MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD
|
||||
params: { clientCapabilities: string[] }
|
||||
} {
|
||||
return {
|
||||
id: args.id,
|
||||
deviceToken: args.deviceToken,
|
||||
method: MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD,
|
||||
params: mobileRuntimeClientCapabilityUpdateParams()
|
||||
}
|
||||
}
|
||||
|
||||
export function advertiseMobileRuntimeClientCapabilities(
|
||||
send: (request: unknown) => boolean | void,
|
||||
id: string,
|
||||
deviceToken: string
|
||||
): void {
|
||||
send(mobileRuntimeClientCapabilityUpdateRequest({ id, deviceToken }))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect } from './rpc-client'
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({
|
||||
publicKey: new Uint8Array(32),
|
||||
secretKey: new Uint8Array(32)
|
||||
}),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||
readonly OPEN = MockWebSocket.OPEN
|
||||
readonly CLOSING = MockWebSocket.CLOSING
|
||||
readonly CLOSED = MockWebSocket.CLOSED
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
sent: string[] = []
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
mockSockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(payload: unknown): void {
|
||||
this.onmessage?.({ data: payload })
|
||||
}
|
||||
}
|
||||
|
||||
type SentRpcRequest = { id: string; method: string; params?: unknown }
|
||||
|
||||
const mockSockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest {
|
||||
const request = socket.sent
|
||||
.map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest)
|
||||
.find((candidate) => candidate.method === method)
|
||||
if (!request) {
|
||||
throw new Error(`Request not sent: ${method}`)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
describe('mobile rpc-client capabilities', () => {
|
||||
beforeEach(() => {
|
||||
mockSockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
it('advertises mobile runtime capabilities after auth and before replayed streams', () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const socket = mockSockets[0]!
|
||||
client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {})
|
||||
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
|
||||
const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update')
|
||||
expect(capabilityRequest.params).toMatchObject({
|
||||
clientCapabilities: expect.arrayContaining(['agent-session.structured.v1'])
|
||||
})
|
||||
expect(socket.sent.findIndex((payload) => payload.includes(capabilityRequest.id))).toBeLessThan(
|
||||
socket.sent.findIndex((payload) =>
|
||||
payload.includes(sentRequest(socket, 'session.tabs.subscribe').id)
|
||||
)
|
||||
)
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -64,36 +64,20 @@ class MockWebSocket {
|
||||
const mockSockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function sentRequest(socket: MockWebSocket, method: string): { id: string; params?: unknown } {
|
||||
for (const payload of socket.sent) {
|
||||
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
if (decoded.method === method) {
|
||||
return { id: decoded.id, params: decoded.params }
|
||||
}
|
||||
type SentRpcRequest = { id: string; method: string; params?: unknown }
|
||||
|
||||
function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest {
|
||||
const request = sentRequests(socket, method)[0]
|
||||
if (request) {
|
||||
return request
|
||||
}
|
||||
throw new Error(`Request not sent: ${method}`)
|
||||
}
|
||||
|
||||
function sentRequests(
|
||||
socket: MockWebSocket,
|
||||
method: string
|
||||
): Array<{ id: string; params?: unknown }> {
|
||||
const requests: Array<{ id: string; params?: unknown }> = []
|
||||
for (const payload of socket.sent) {
|
||||
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
if (decoded.method === method) {
|
||||
requests.push({ id: decoded.id, params: decoded.params })
|
||||
}
|
||||
}
|
||||
return requests
|
||||
function sentRequests(socket: MockWebSocket, method: string): SentRpcRequest[] {
|
||||
return socket.sent
|
||||
.map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest)
|
||||
.filter((request) => request.method === method)
|
||||
}
|
||||
|
||||
function encodeBrowserFrame(): Uint8Array {
|
||||
|
||||
@@ -36,7 +36,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
|
||||
'github.resolveReviewThread',
|
||||
'github.project.updateIssueCommentBySlug',
|
||||
'github.project.deleteIssueCommentBySlug',
|
||||
'hostedReview.forBranch'
|
||||
'hostedReview.forBranch',
|
||||
'runtime.clientCapabilities.update',
|
||||
'agentSession.send',
|
||||
'agentSession.cancel',
|
||||
'agentSession.history',
|
||||
'agentSession.hold',
|
||||
'agentSession.release'
|
||||
]
|
||||
|
||||
const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
|
||||
@@ -145,9 +151,28 @@ describe('mobile RPC allowlist', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not expose structured agent sessions to mobile credentials', () => {
|
||||
it('exposes only the mobile structured agent-session surface', () => {
|
||||
expect(
|
||||
[...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.'))
|
||||
).toEqual([])
|
||||
).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create',
|
||||
'agentSession.ensure',
|
||||
'agentSession.send',
|
||||
'agentSession.cancel',
|
||||
'agentSession.close',
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.setOption',
|
||||
'agentSession.handoffStatus',
|
||||
'agentSession.options',
|
||||
'agentSession.history',
|
||||
'agentSession.subscribe',
|
||||
'agentSession.unsubscribe',
|
||||
'agentSession.hold',
|
||||
'agentSession.release'
|
||||
])
|
||||
expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false)
|
||||
expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,8 +21,15 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
|
||||
tab: RuntimeMobileSessionAgentTab
|
||||
): Promise<void> {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (typeof host?.setSessionTabVisibility === 'function') {
|
||||
await host.setSessionTabVisibility(tab.sessionId, false)
|
||||
if (host) {
|
||||
// Closing the mobile tab is the structured-session view close: stop the
|
||||
// provider child before removing the durable tab reference.
|
||||
if (typeof host.close === 'function') {
|
||||
await host.close(tab.sessionId)
|
||||
}
|
||||
if (typeof host.setSessionTabVisibility === 'function') {
|
||||
await host.setSessionTabVisibility(tab.sessionId, false)
|
||||
}
|
||||
}
|
||||
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id)
|
||||
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
|
||||
|
||||
@@ -87,6 +87,11 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
|
||||
) {
|
||||
super()
|
||||
this.store = store
|
||||
store?.onSettingsChanged?.((updates) => {
|
||||
if ('experimentalStructuredNativeChat' in updates) {
|
||||
this.notifyMobileSessionTabsChanged()
|
||||
}
|
||||
})
|
||||
const runtime = this as RuntimeCommandSurfaceHost<this>
|
||||
installRuntimeFileCommandSurface(runtime, this.fileCommands)
|
||||
installRuntimeGitCommandSurface(runtime, this.gitCommands)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
describe('structured native chat settings', () => {
|
||||
it('republishes mobile session tabs when the host visibility setting changes', () => {
|
||||
const settingsListeners: ((updates: Record<string, unknown>) => void)[] = []
|
||||
const runtime = new OrcaRuntimeService({
|
||||
onSettingsChanged: vi.fn((listener) => {
|
||||
settingsListeners.push(listener as (updates: Record<string, unknown>) => void)
|
||||
return vi.fn()
|
||||
})
|
||||
} as never)
|
||||
const notify = vi.spyOn(runtime, 'notifyMobileSessionTabsChanged').mockImplementation(() => {})
|
||||
|
||||
settingsListeners[0]?.({ compactWorktreeCards: true })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
|
||||
settingsListeners[0]?.({ experimentalStructuredNativeChat: true })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -205,6 +205,7 @@ describe('structured session cold restoration', () => {
|
||||
it('normalizes a restored tab id and removes it when closed', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const closeSessionTab = vi.fn(async () => undefined)
|
||||
const closeStructuredSession = vi.fn(async () => undefined)
|
||||
runtime.setNotifier({ closeSessionTab } as never)
|
||||
const internal = runtime as unknown as {
|
||||
hasPersistedStructuredAgentSessionStore(): boolean
|
||||
@@ -221,6 +222,7 @@ describe('structured session cold restoration', () => {
|
||||
setStructuredAgentSessionHost({
|
||||
reconcileRestartLeases: async () => undefined,
|
||||
restoreReadableSessions: async () => undefined,
|
||||
close: closeStructuredSession,
|
||||
listSessionTabs: () => [
|
||||
{
|
||||
sessionId: 'agent-session:agent-session:restored-session',
|
||||
@@ -304,6 +306,7 @@ describe('structured session cold restoration', () => {
|
||||
'structured-agent-session-restored-session',
|
||||
'workspace-1'
|
||||
)
|
||||
expect(closeStructuredSession).toHaveBeenCalledWith('restored-session')
|
||||
|
||||
const closed = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(closed.tabs.map((tab) => tab.id)).toEqual([
|
||||
|
||||
@@ -77,6 +77,8 @@ export type RpcContext = {
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
// Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth.
|
||||
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
|
||||
// Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields.
|
||||
orchestrationCapability?: string
|
||||
// Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles.
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RpcDispatchStreamingOptions = {
|
||||
pairedDeviceId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
|
||||
@@ -29,8 +29,7 @@ import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher'
|
||||
|
||||
export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] }
|
||||
|
||||
// oxfmt-ignore
|
||||
type DispatchCallOptions = Pick<RpcDispatchStreamingOptions, 'signal' | 'connectionId' | 'clientId' | 'clientKind' | 'clientCapabilities' | 'authenticatedCallerFingerprint'>
|
||||
type DispatchCallOptions = RpcDispatchStreamingOptions
|
||||
|
||||
export class RpcDispatcher {
|
||||
private readonly runtime: OrcaRuntimeService
|
||||
@@ -131,6 +130,7 @@ export class RpcDispatcher {
|
||||
clientId: options?.clientId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
updateClientCapabilities: options?.updateClientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ??
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('client UI RPC methods', () => {
|
||||
visibleTaskProviders: ['github', 'gitlab'],
|
||||
defaultRepoSelection: ['repo-1'],
|
||||
defaultLinearTeamSelection: ['team-1'],
|
||||
experimentalStructuredNativeChat: true,
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5',
|
||||
@@ -60,6 +61,24 @@ describe('client UI RPC methods', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: { settings } })
|
||||
})
|
||||
|
||||
it('rejects paired attempts to mutate the host-owned structured chat setting', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateClientSettings: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('settings.update', { experimentalStructuredNativeChat: true })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument' }
|
||||
})
|
||||
expect(runtime.updateClientSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the runtime host task source settings for mobile Tasks', async () => {
|
||||
const settings = {
|
||||
defaultTuiAgent: null,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { PLUGIN_METHODS } from './plugins'
|
||||
import { SKILL_METHODS } from './skills'
|
||||
import { CLIPBOARD_METHODS } from './clipboard'
|
||||
import { HOST_CAPABILITY_METHODS } from './host-capabilities'
|
||||
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
|
||||
import { EMULATOR_METHODS } from './emulator'
|
||||
import { PAIRING_METHODS } from './pairing'
|
||||
import { UPDATER_METHODS } from './updater'
|
||||
@@ -91,6 +92,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...SKILL_METHODS,
|
||||
...CLIPBOARD_METHODS,
|
||||
...HOST_CAPABILITY_METHODS,
|
||||
...RUNTIME_CLIENT_CAPABILITY_METHODS,
|
||||
...CLIENT_EVENT_METHODS,
|
||||
...CLIENT_UI_METHODS,
|
||||
...EMULATOR_METHODS,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
|
||||
|
||||
function makeRequest(params: unknown): RpcRequest {
|
||||
return {
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'runtime.clientCapabilities.update',
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
function dispatcher(): RpcDispatcher {
|
||||
return new RpcDispatcher({
|
||||
runtime: { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
|
||||
methods: RUNTIME_CLIENT_CAPABILITY_METHODS
|
||||
})
|
||||
}
|
||||
|
||||
describe('runtime.clientCapabilities.update', () => {
|
||||
it('updates the authenticated socket capability set after auth', async () => {
|
||||
const updateClientCapabilities = vi.fn()
|
||||
|
||||
const response = await dispatcher().dispatch(
|
||||
makeRequest({
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}),
|
||||
{ clientKind: 'mobile', updateClientCapabilities }
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
|
||||
})
|
||||
expect(updateClientCapabilities).toHaveBeenCalledWith([
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects malformed upgrades without mutating authenticated state', async () => {
|
||||
const updateClientCapabilities = vi.fn()
|
||||
|
||||
const response = await dispatcher().dispatch(
|
||||
makeRequest({
|
||||
clientCapabilities: [42]
|
||||
}),
|
||||
{ clientKind: 'mobile', updateClientCapabilities }
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument' }
|
||||
})
|
||||
expect(updateClientCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when a transport has no post-auth updater', async () => {
|
||||
const response = await dispatcher().dispatch(
|
||||
makeRequest({
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}),
|
||||
{ clientKind: 'runtime' }
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: 'client_capabilities_update_unsupported' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
import type { RuntimeCapability } from '../../../../shared/protocol-version'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
const ClientCapabilitiesUpdate = z
|
||||
.object({
|
||||
clientCapabilities: z.array(z.string().min(1).max(128)).max(64)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'runtime.clientCapabilities.update',
|
||||
params: ClientCapabilitiesUpdate,
|
||||
handler: (params, { updateClientCapabilities }) => {
|
||||
if (!updateClientCapabilities) {
|
||||
throw new Error('client_capabilities_update_unsupported')
|
||||
}
|
||||
const clientCapabilities = params.clientCapabilities as RuntimeCapability[]
|
||||
updateClientCapabilities(clientCapabilities)
|
||||
return { clientCapabilities }
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -75,6 +75,48 @@ describe('session tab structured capability mutations', () => {
|
||||
expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
|
||||
it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)(
|
||||
'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)',
|
||||
async (method) => {
|
||||
const snapshot = agentSnapshot()
|
||||
const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
|
||||
listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot),
|
||||
closeMobileSessionTab
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
const replies: string[] = []
|
||||
await dispatcher.dispatchStreaming(
|
||||
{
|
||||
id: 'request-1',
|
||||
authToken: 'token',
|
||||
method,
|
||||
params:
|
||||
method === 'session.tabs.close'
|
||||
? { worktree: 'id:wt-1', tabId: 'codex-session', reason: 'user' }
|
||||
: {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'codex-session',
|
||||
reason: 'cleanup',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminal: 'pty-1'
|
||||
}
|
||||
},
|
||||
(response) => replies.push(response),
|
||||
{
|
||||
clientKind: 'mobile',
|
||||
pairedDeviceId: 'paired-mobile',
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
)
|
||||
|
||||
expect(JSON.parse(replies[0]!).ok).toBe(true)
|
||||
expect(closeMobileSessionTab).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function createFixture(capabilities: RuntimeCapability[]) {
|
||||
|
||||
@@ -96,6 +96,22 @@ describe('projectSessionTabAgentStatus', () => {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
])
|
||||
).toEqual(oldClient)
|
||||
expect(
|
||||
projectSessionTabAgentStatus(
|
||||
snapshot,
|
||||
'mobile',
|
||||
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
|
||||
false
|
||||
)
|
||||
).toEqual(oldClient)
|
||||
|
||||
const capableMobile = projectSessionTabAgentStatus(
|
||||
snapshot,
|
||||
'mobile',
|
||||
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
|
||||
true
|
||||
)
|
||||
expect(capableMobile).toBe(snapshot)
|
||||
|
||||
const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
@@ -133,6 +149,14 @@ describe('projectSessionTabAgentStatus', () => {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]).tabs.map((tab) => tab.id)
|
||||
).toEqual(['agent-session:codex'])
|
||||
expect(
|
||||
projectSessionTabAgentStatus(
|
||||
snapshot,
|
||||
'mobile',
|
||||
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
|
||||
true
|
||||
).tabs.map((tab) => tab.id)
|
||||
).toEqual(['agent-session:codex'])
|
||||
})
|
||||
|
||||
it('withholds session boundaries from legacy paired clients', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type {
|
||||
@@ -9,18 +8,21 @@ import type {
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../../../shared/runtime-types'
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
|
||||
import { structuredNativeChatProjectionEnabled } from './structured-agent-session-policy'
|
||||
|
||||
type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot
|
||||
|
||||
export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload>(
|
||||
payload: TPayload,
|
||||
clientKind: 'mobile' | 'runtime' | undefined,
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined,
|
||||
structuredNativeChatEnabled?: boolean
|
||||
): TPayload {
|
||||
const structuredVisible =
|
||||
clientKind !== 'mobile' &&
|
||||
(clientKind === undefined ||
|
||||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false))
|
||||
const structuredVisible = structuredNativeChatProjectionEnabled({
|
||||
clientKind,
|
||||
clientCapabilities,
|
||||
structuredNativeChatEnabled
|
||||
})
|
||||
let projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true)
|
||||
if (structuredVisible && clientKind !== undefined) {
|
||||
projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas'
|
||||
import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection'
|
||||
import { projectSessionTabsForClient } from './session-tabs-inventory'
|
||||
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
|
||||
|
||||
export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
@@ -14,7 +15,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
||||
const visible = projectSessionTabsForClient(
|
||||
await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId),
|
||||
context.clientKind,
|
||||
context.clientCapabilities
|
||||
context.clientCapabilities,
|
||||
context.clientKind === 'mobile'
|
||||
? isStructuredNativeChatEnabled(context.runtime)
|
||||
: undefined
|
||||
)
|
||||
assertProjectedSessionTabVisible(visible, params.tabId)
|
||||
}
|
||||
@@ -80,7 +84,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
||||
const visible = projectSessionTabsForClient(
|
||||
await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId),
|
||||
context.clientKind,
|
||||
context.clientCapabilities
|
||||
context.clientCapabilities,
|
||||
context.clientKind === 'mobile'
|
||||
? isStructuredNativeChatEnabled(context.runtime)
|
||||
: undefined
|
||||
)
|
||||
assertProjectedSessionTabVisible(visible, params.tabId)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
translateProjectedSessionTabMove
|
||||
} from './session-tab-browser-placement-projection'
|
||||
import { projectSessionTabsForClient } from './session-tabs-inventory'
|
||||
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
|
||||
import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas'
|
||||
|
||||
export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
|
||||
@@ -17,7 +18,8 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
|
||||
const visible = projectSessionTabsForClient(
|
||||
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
|
||||
clientKind,
|
||||
clientCapabilities
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
assertProjectedSessionTabVisible(visible, params.tabId)
|
||||
}
|
||||
@@ -36,7 +38,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
|
||||
})
|
||||
}
|
||||
)
|
||||
return projectSessionTabsForMutationClient(result, clientKind, clientCapabilities)
|
||||
return projectSessionTabsForMutationClient(
|
||||
result,
|
||||
clientKind,
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
@@ -46,7 +53,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
|
||||
let translated: Parameters<typeof translateProjectedSessionTabMove>[2] = params
|
||||
if (clientKind) {
|
||||
const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
|
||||
const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities)
|
||||
const projected = projectSessionTabsForClient(
|
||||
raw,
|
||||
clientKind,
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
translated = translateProjectedSessionTabMove(raw, projected, params)
|
||||
}
|
||||
const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId }
|
||||
@@ -129,7 +141,8 @@ async function assertVisibleMutationTab(
|
||||
const visible = projectSessionTabsForClient(
|
||||
await runtime.listMobileSessionTabs(worktree, pairedDeviceId),
|
||||
clientKind,
|
||||
clientCapabilities
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
assertProjectedSessionTabVisible(visible, tabId)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-
|
||||
import type { RpcContext } from '../core'
|
||||
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
|
||||
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
|
||||
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
|
||||
|
||||
type SessionTabsInventory = {
|
||||
snapshots: RuntimeMobileSessionTabsResult[]
|
||||
@@ -26,21 +27,38 @@ function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean {
|
||||
export function projectSessionTabsForClient(
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
clientKind: 'mobile' | 'runtime' | undefined,
|
||||
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2]
|
||||
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2],
|
||||
structuredNativeChatEnabled?: boolean
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
return projectSessionTabBrowserPlacements(
|
||||
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
|
||||
projectSessionTabAgentStatus(
|
||||
snapshot,
|
||||
clientKind,
|
||||
clientCapabilities,
|
||||
structuredNativeChatEnabled
|
||||
),
|
||||
clientCapabilities
|
||||
)
|
||||
}
|
||||
|
||||
function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined {
|
||||
return context.clientKind === 'mobile'
|
||||
? isStructuredNativeChatEnabled(context.runtime)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function projectInventory(
|
||||
inventory: SessionTabsInventory,
|
||||
context: RpcContext
|
||||
): SessionTabsInventory {
|
||||
return {
|
||||
snapshots: inventory.snapshots.map((snapshot) =>
|
||||
projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities)
|
||||
projectSessionTabsForClient(
|
||||
snapshot,
|
||||
context.clientKind,
|
||||
context.clientCapabilities,
|
||||
structuredNativeChatEnabledForContext(context)
|
||||
)
|
||||
),
|
||||
...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context)
|
||||
? { authoritative: true as const }
|
||||
@@ -109,7 +127,8 @@ export async function subscribeSessionTabsInventory(
|
||||
projectSessionTabsForClient(
|
||||
snapshot,
|
||||
context.clientKind,
|
||||
context.clientCapabilities
|
||||
context.clientCapabilities,
|
||||
structuredNativeChatEnabledForContext(context)
|
||||
) as SessionTabsChange
|
||||
const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => {
|
||||
if (snapshot.navigationIntent === undefined) {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
@@ -10,6 +13,48 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
}
|
||||
|
||||
describe('session tab RPC methods', () => {
|
||||
it('does not restore structured tabs for mobile while the host setting is off', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })),
|
||||
restoreStructuredAgentSessionTabs: vi.fn(),
|
||||
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
|
||||
{
|
||||
clientKind: 'mobile',
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores structured tabs for mobile only after capability and setting are present', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
|
||||
restoreStructuredAgentSessionTabs: vi.fn(),
|
||||
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
|
||||
{
|
||||
clientKind: 'mobile',
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('routes mobile-only activation without notifying desktop clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods'
|
||||
import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods'
|
||||
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
|
||||
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
|
||||
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
|
||||
|
||||
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
@@ -22,11 +23,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
name: 'session.tabs.list',
|
||||
params: WorktreeTabSelector,
|
||||
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
|
||||
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
|
||||
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
|
||||
return projectSessionTabsForClient(
|
||||
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
|
||||
clientKind,
|
||||
clientCapabilities
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
}
|
||||
}),
|
||||
@@ -34,7 +36,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
name: 'session.tabs.listAll',
|
||||
params: null,
|
||||
handler: async (_params, context) => {
|
||||
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
|
||||
await restoreStructuredTabsIfSupported(context)
|
||||
return listSessionTabsInventory(context)
|
||||
}
|
||||
}),
|
||||
@@ -89,7 +91,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
let unsubscribe = (): void => {}
|
||||
let closed = false
|
||||
let initialized = false
|
||||
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
|
||||
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
|
||||
const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
|
||||
if (closed) {
|
||||
return
|
||||
@@ -115,7 +117,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
emit({
|
||||
type: 'snapshot',
|
||||
...projectSessionTabsForClient(initial, clientKind, clientCapabilities)
|
||||
...projectSessionTabsForClient(
|
||||
initial,
|
||||
clientKind,
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
})
|
||||
initialized = true
|
||||
if (closed) {
|
||||
@@ -126,7 +133,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
if (snapshot.worktree === subscribedWorktree) {
|
||||
emit({
|
||||
type: 'updated',
|
||||
...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities)
|
||||
...projectSessionTabsForClient(
|
||||
snapshot,
|
||||
clientKind,
|
||||
clientCapabilities,
|
||||
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
}, pairedDeviceId)
|
||||
@@ -157,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
name: 'session.tabs.subscribeAll',
|
||||
params: null,
|
||||
handler: async (_params, context, emit) => {
|
||||
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
|
||||
await restoreStructuredTabsIfSupported(context)
|
||||
return subscribeSessionTabsInventory(context, emit)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -5,21 +5,18 @@
|
||||
// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST
|
||||
// by calling into it, which is an observable side effect.
|
||||
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
|
||||
import type { RpcContext } from '../core'
|
||||
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
|
||||
|
||||
/**
|
||||
* In-process callers are the same build as the host, so they carry no negotiated
|
||||
* capability list; every remote client must say it can read structured sessions.
|
||||
*/
|
||||
export function supportsStructuredSessions(ctx: RpcContext): boolean {
|
||||
return (
|
||||
ctx.clientKind === undefined ||
|
||||
(ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
|
||||
)
|
||||
return supportsStructuredAgentSessions(ctx)
|
||||
}
|
||||
|
||||
export function requireStructuredCapability(ctx: RpcContext): void {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
type StructuredPolicyContext = Pick<RpcContext, 'clientCapabilities' | 'clientKind'> & {
|
||||
runtime?: Pick<OrcaRuntimeService, 'getClientSettings'>
|
||||
structuredNativeChatEnabled?: boolean
|
||||
}
|
||||
|
||||
export function isStructuredNativeChatEnabled(
|
||||
runtime: Pick<OrcaRuntimeService, 'getClientSettings'>
|
||||
): boolean {
|
||||
try {
|
||||
return runtime.getClientSettings().experimentalStructuredNativeChat === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
|
||||
if (context.clientKind === undefined) {
|
||||
return true
|
||||
}
|
||||
const hasCapability =
|
||||
context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true
|
||||
if (!hasCapability) {
|
||||
return false
|
||||
}
|
||||
if (context.clientKind !== 'mobile') {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
context.structuredNativeChatEnabled === true ||
|
||||
(context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false)
|
||||
)
|
||||
}
|
||||
|
||||
export function structuredNativeChatProjectionEnabled(args: {
|
||||
clientKind: 'mobile' | 'runtime' | undefined
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined
|
||||
structuredNativeChatEnabled?: boolean
|
||||
}): boolean {
|
||||
return supportsStructuredAgentSessions(args)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ function hostStub(): StructuredAgentSessionHost {
|
||||
return hostCalls as unknown as StructuredAgentSessionHost
|
||||
}
|
||||
|
||||
function dispatcher(): RpcDispatcher {
|
||||
function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
|
||||
runtimeCalls = {
|
||||
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
|
||||
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
|
||||
@@ -134,7 +134,8 @@ function dispatcher(): RpcDispatcher {
|
||||
registerSubscriptionCleanup: vi.fn(),
|
||||
cleanupSubscription: vi.fn(),
|
||||
cleanupSubscriptionsByPrefix: vi.fn(),
|
||||
...runtimeCalls
|
||||
...runtimeCalls,
|
||||
...runtimeOverrides
|
||||
}
|
||||
return new RpcDispatcher({
|
||||
runtime: runtime as unknown as OrcaRuntimeService,
|
||||
@@ -151,10 +152,11 @@ async function call(
|
||||
clientId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: string[]
|
||||
}
|
||||
},
|
||||
runtimeOverrides: Record<string, unknown> = {}
|
||||
): Promise<RpcResponse> {
|
||||
const replies: RpcResponse[] = []
|
||||
await dispatcher().dispatchStreaming(
|
||||
await dispatcher(runtimeOverrides).dispatchStreaming(
|
||||
request(method, params),
|
||||
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
|
||||
client
|
||||
@@ -170,6 +172,10 @@ const STRUCTURED_CLIENT = {
|
||||
clientKind: 'runtime' as const,
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
const STRUCTURED_MOBILE_CLIENT = {
|
||||
clientKind: 'mobile' as const,
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setStructuredAgentSessionHost(hostStub())
|
||||
@@ -250,6 +256,25 @@ describe('capability gating', () => {
|
||||
expect(hostCalls.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requires the host structured-chat setting for mobile clients', async () => {
|
||||
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
|
||||
getClientSettings: () => ({ experimentalStructuredNativeChat: false })
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
||||
})
|
||||
expect(hostCalls.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves mobile clients only after capability and setting negotiation', async () => {
|
||||
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
|
||||
getClientSettings: () => ({ experimentalStructuredNativeChat: true })
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(hostCalls.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('serves an in-process caller, which negotiates no capabilities at all', async () => {
|
||||
const response = await call('agentSession.send', sendParams())
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { RpcContext } from '../core'
|
||||
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
|
||||
|
||||
export async function restoreStructuredTabsIfSupported(
|
||||
runtime: RpcContext['runtime'],
|
||||
capabilities: readonly string[] | undefined
|
||||
context: Pick<RpcContext, 'runtime' | 'clientKind' | 'clientCapabilities'>
|
||||
): Promise<void> {
|
||||
if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
if (
|
||||
supportsStructuredAgentSessions(context) &&
|
||||
typeof context.runtime.restoreStructuredAgentSessionTabs === 'function'
|
||||
) {
|
||||
await context.runtime.restoreStructuredAgentSessionTabs()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export class RpcStreamingDispatcher {
|
||||
pairedDeviceId: options?.pairedDeviceId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
updateClientCapabilities: options?.updateClientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ??
|
||||
@@ -165,6 +166,7 @@ export class RpcStreamingDispatcher {
|
||||
pairedDeviceId: options?.pairedDeviceId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
updateClientCapabilities: options?.updateClientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
|
||||
@@ -32,6 +32,7 @@ export type RuntimeClientSettings = Pick<
|
||||
| 'defaultLinearTeamSelection'
|
||||
| 'githubProjects'
|
||||
| 'experimentalNewWorktreeCardStyle'
|
||||
| 'experimentalStructuredNativeChat'
|
||||
| 'compactWorktreeCards'
|
||||
| 'minimaxGroupId'
|
||||
| 'minimaxUsageModels'
|
||||
@@ -97,6 +98,7 @@ export class RuntimeClientSettingsController {
|
||||
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
|
||||
githubProjects: settings.githubProjects,
|
||||
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
|
||||
experimentalStructuredNativeChat: settings.experimentalStructuredNativeChat === true,
|
||||
compactWorktreeCards: settings.compactWorktreeCards === true,
|
||||
minimaxGroupId: settings.minimaxGroupId ?? '',
|
||||
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
|
||||
|
||||
@@ -188,6 +188,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'repo.searchRefs',
|
||||
'repo.sparsePresets',
|
||||
'repo.update',
|
||||
'runtime.clientCapabilities.update',
|
||||
'runtime.clientEvents.subscribe',
|
||||
'runtime.clientEvents.unsubscribe',
|
||||
'session.tabs.activate',
|
||||
@@ -201,6 +202,22 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'session.tabs.subscribeAll',
|
||||
'session.tabs.unsubscribe',
|
||||
'session.tabs.unsubscribeAll',
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create',
|
||||
'agentSession.ensure',
|
||||
'agentSession.send',
|
||||
'agentSession.cancel',
|
||||
'agentSession.close',
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.setOption',
|
||||
'agentSession.handoffStatus',
|
||||
'agentSession.options',
|
||||
'agentSession.history',
|
||||
'agentSession.subscribe',
|
||||
'agentSession.unsubscribe',
|
||||
'agentSession.hold',
|
||||
'agentSession.release',
|
||||
'nativeChat.readSession',
|
||||
'nativeChat.subscribe',
|
||||
'nativeChat.unsubscribe',
|
||||
|
||||
@@ -141,6 +141,12 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission {
|
||||
// Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated.
|
||||
clientKind: device.scope,
|
||||
clientCapabilities: authenticatedSocket?.clientCapabilities,
|
||||
updateClientCapabilities:
|
||||
authenticatedSocket && device.scope === 'mobile'
|
||||
? (clientCapabilities) => {
|
||||
authenticatedSocket.clientCapabilities = clientCapabilities
|
||||
}
|
||||
: undefined,
|
||||
pairing: pairingContext,
|
||||
signal: abortRegistration?.signal,
|
||||
sendBinary,
|
||||
|
||||
@@ -87,6 +87,7 @@ export type RuntimeStore = {
|
||||
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
|
||||
floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled']
|
||||
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
|
||||
experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat']
|
||||
defaultTaskSource?: GlobalSettings['defaultTaskSource']
|
||||
defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset']
|
||||
visibleTaskProviders?: GlobalSettings['visibleTaskProviders']
|
||||
@@ -122,4 +123,5 @@ export type RuntimeStore = {
|
||||
updates: Partial<GlobalSettings>,
|
||||
options?: { notifyListeners?: boolean; originWebContentsId?: number }
|
||||
) => unknown
|
||||
onSettingsChanged?: Store['onSettingsChanged']
|
||||
}
|
||||
|
||||
@@ -274,9 +274,11 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
|
||||
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
|
||||
)
|
||||
await timeRendererStartupStep('project-structured-session-tabs', () =>
|
||||
restoreLocalStructuredSessionTabsOnce()
|
||||
)
|
||||
if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) {
|
||||
await timeRendererStartupStep('project-structured-session-tabs', () =>
|
||||
restoreLocalStructuredSessionTabsOnce()
|
||||
)
|
||||
}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,6 +357,16 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(reconnectIndex).toBeGreaterThan(capabilityIndex)
|
||||
})
|
||||
|
||||
it('skips startup structured tab projection while the host setting is off', () => {
|
||||
const source = readSource(STARTUP_HYDRATION_PATH)
|
||||
const projectIndex = source.indexOf("timeRendererStartupStep('project-structured-session-tabs'")
|
||||
|
||||
expect(projectIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(source.slice(projectIndex - 180, projectIndex)).toContain(
|
||||
'settings?.experimentalStructuredNativeChat === true'
|
||||
)
|
||||
})
|
||||
|
||||
it('orders packaged restoration before adoption, projection, and default creation', () => {
|
||||
// Why this file: the startup sequence moved out of App.tsx into the hydration hook;
|
||||
// the ordering it asserts is unchanged, only the module that now spells it out.
|
||||
|
||||
+1
-36
@@ -1,36 +1 @@
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
|
||||
export function projectStructuredAgentSessionMessages(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): NativeChatMessage[] {
|
||||
const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions)
|
||||
// Why: the host renders its own bubble off the submission WAL row, which lands
|
||||
// while the dispatch is still `pending`. Reconciliation only retires the echo on
|
||||
// `accepted`, so keying visibility on that alone double-rendered the bubble for
|
||||
// the whole provider round trip. The entry itself stays for retry/unconfirmed.
|
||||
const journalled = new Set(items.map((item) => item.itemId))
|
||||
return [
|
||||
...projectStructuredItemsToNativeChat(items),
|
||||
...optimistic
|
||||
.filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId)))
|
||||
.map((entry): NativeChatMessage => ({
|
||||
id: agentJournalSubmissionKey(entry.clientMessageId),
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: entry.queuedAt,
|
||||
blocks: entry.body.blocks
|
||||
}))
|
||||
]
|
||||
}
|
||||
export { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection'
|
||||
|
||||
@@ -10,16 +10,10 @@
|
||||
// would otherwise release a hold that has not landed yet, and the late hold would never be undone.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { structuredAgentSessionHolderId } from '../../../../shared/structured-agent-session-holder'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
|
||||
let holderOrdinal = 0
|
||||
|
||||
export function structuredAgentSessionHolderId(surface: string): string {
|
||||
holderOrdinal += 1
|
||||
return `${surface}:${holderOrdinal}`
|
||||
}
|
||||
|
||||
export function useStructuredAgentSessionHold(args: {
|
||||
sessionId: string
|
||||
target: RuntimeClientTarget
|
||||
|
||||
@@ -282,7 +282,10 @@ export async function startLocalStructuredSessionTabsSync(args: {
|
||||
|
||||
export function useLocalStructuredSessionTabsSync(): void {
|
||||
const ready = useAppStore(
|
||||
(state) => state.workspaceSessionReady && state.terminalStartupRestorationReady
|
||||
(state) =>
|
||||
state.workspaceSessionReady &&
|
||||
state.terminalStartupRestorationReady &&
|
||||
state.settings?.experimentalStructuredNativeChat === true
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
let holderOrdinal = 0
|
||||
|
||||
export function structuredAgentSessionHolderId(surface: string): string {
|
||||
holderOrdinal += 1
|
||||
return `${surface}:${holderOrdinal}`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
import {
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from './structured-agent-session-outbox'
|
||||
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
|
||||
|
||||
export function projectStructuredAgentSessionMessages(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): NativeChatMessage[] {
|
||||
const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions)
|
||||
const journalled = new Set(items.map((item) => item.itemId))
|
||||
return [
|
||||
...projectStructuredItemsToNativeChat(items),
|
||||
...optimistic
|
||||
.filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId)))
|
||||
.map((entry): NativeChatMessage => ({
|
||||
id: agentJournalSubmissionKey(entry.clientMessageId),
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: entry.queuedAt,
|
||||
blocks: entry.body.blocks
|
||||
}))
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const SESSION = 'session-alpha'
|
||||
const WORKSPACE = 'workspace-1'
|
||||
const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
|
||||
const NOW = 1_800_000_000_000
|
||||
const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update'
|
||||
|
||||
/** Every method the structured surface publishes: the host method it must reach,
|
||||
* and the result it must hand back. A gate that hides one method and leaks
|
||||
@@ -484,6 +485,49 @@ describe('cross-version structured agent sessions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('post-auth mobile capability negotiation', () => {
|
||||
it('is an additive method that lets the current host record mobile capabilities', async () => {
|
||||
const updates: string[][] = []
|
||||
|
||||
const replies = await callBuild(
|
||||
current,
|
||||
CLIENT_CAPABILITY_UPDATE_METHOD,
|
||||
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
|
||||
{
|
||||
clientKind: 'mobile',
|
||||
clientCapabilities: [],
|
||||
updateClientCapabilities: (capabilities) => updates.push([...capabilities])
|
||||
}
|
||||
)
|
||||
|
||||
expect(current.methodNames).toContain(CLIENT_CAPABILITY_UPDATE_METHOD)
|
||||
expect(current.protocolVersion).toBe(baseline.protocolVersion)
|
||||
expect(replies).toHaveLength(1)
|
||||
expect(replies[0]).toMatchObject({
|
||||
ok: true,
|
||||
result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
|
||||
})
|
||||
expect(updates).toEqual([[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]])
|
||||
})
|
||||
|
||||
it('gets a normal answer from an old host instead of changing the auth shape', async () => {
|
||||
const replies = await callBuild(
|
||||
baseline,
|
||||
CLIENT_CAPABILITY_UPDATE_METHOD,
|
||||
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
|
||||
{ clientKind: 'mobile', clientCapabilities: [] }
|
||||
)
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
if (!baseline.methodNames.includes(CLIENT_CAPABILITY_UPDATE_METHOD)) {
|
||||
expect(replies[0]).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'method_not_found' }
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('an old client against a structured-owned AI Vault row', () => {
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
|
||||
@@ -29,6 +29,7 @@ export type RpcReply = {
|
||||
export type RpcClientIdentity = {
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly string[]
|
||||
updateClientCapabilities?: (capabilities: readonly string[]) => void
|
||||
connectionId?: string
|
||||
clientId?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user