From 6cb09d8f9729e2e5adfbfc72ffe39eca63cb49d5 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Wed, 2 Sep 2026 23:45:07 -0700 Subject: [PATCH] feat(mobile): finalize structured native Codex chat --- .../src/session/MobileNativeChatQuestion.tsx | 81 +- .../session/MobileSessionActiveContent.tsx | 4 +- mobile/src/session/MobileSessionHeader.tsx | 1 + mobile/src/session/MobileSessionSheets.tsx | 10 + mobile/src/session/mobile-file-tap-open.ts | 4 +- .../mobile-native-chat-controller-contract.ts | 7 +- .../mobile-native-chat-eligibility.test.ts | 24 + .../session/mobile-native-chat-eligibility.ts | 24 +- .../mobile-native-chat-image-scope-state.ts | 18 + .../mobile-native-chat-question.test.ts | 10 + .../session/mobile-native-chat-question.ts | 14 + .../mobile-session-route-parity.test.ts | 22 +- .../src/session/mobile-session-route-types.ts | 10 +- .../mobile-structured-agent-prompts.ts | 251 ++++++ ...le-structured-agent-session-launch.test.ts | 123 +++ .../mobile-structured-agent-session-launch.ts | 170 ++++ .../mobile-structured-agent-session-rpc.ts | 122 +++ .../session/mobile-terminal-records.test.ts | 14 + mobile/src/session/mobile-terminal-records.ts | 10 + .../session/mobile-terminal-tab-agent.test.ts | 13 + .../src/session/mobile-terminal-tab-agent.ts | 3 + .../session/opened-mobile-session-tab.test.ts | 15 + .../src/session/opened-mobile-session-tab.ts | 19 +- .../use-mobile-file-tap-handlers.test.ts | 27 + .../session/use-mobile-file-tap-handlers.ts | 10 +- ...se-mobile-native-chat-active-resolution.ts | 83 ++ .../use-mobile-native-chat-controller.test.ts | 163 +++- .../use-mobile-native-chat-controller.ts | 203 +++-- ...se-mobile-native-chat-image-attachments.ts | 167 ++-- .../use-mobile-native-chat-image-upload.ts | 126 +++ ...e-native-chat-session-option-controller.ts | 100 +++ .../session/use-mobile-session-attachments.ts | 4 +- .../use-mobile-session-file-actions.ts | 6 +- ...-mobile-session-image-attachments.test.tsx | 123 +++ .../use-mobile-session-image-attachments.ts | 13 +- ...se-mobile-session-native-chat-dictation.ts | 7 + .../use-mobile-session-screen-state.ts | 24 +- .../use-mobile-session-tab-action-targets.ts | 85 ++ .../use-mobile-session-tab-switching.ts | 3 + ...le-session-terminal-create-actions.test.ts | 231 ++++++ ...-mobile-session-terminal-create-actions.ts | 31 + ...se-mobile-session-terminal-send-actions.ts | 28 +- .../use-mobile-structured-agent-options.ts | 161 ++++ ...e-mobile-structured-agent-session.test.tsx | 751 ++++++++++++++++++ .../use-mobile-structured-agent-session.ts | 315 ++++++++ .../use-mobile-structured-agent-state.ts | 155 ++++ ...bile-structured-native-chat-send-bridge.ts | 100 +++ mobile/src/transport/direct-rpc-client.ts | 6 + .../mobile-relay-rpc-session.test.ts | 18 +- .../src/transport/mobile-relay-rpc-session.ts | 4 + .../mobile-runtime-client-capabilities.ts | 44 + .../transport/rpc-client-capabilities.test.ts | 103 +++ mobile/src/transport/rpc-client.test.ts | 36 +- src/main/runtime/mobile-rpc-allowlist.test.ts | 31 +- ...time-close-structured-agent-session-tab.ts | 11 +- src/main/runtime/orca-runtime-state-fields.ts | 5 + ...me-structured-native-chat-settings.test.ts | 21 + ...runtime-structured-session-restore.test.ts | 3 + src/main/runtime/rpc/core.ts | 2 + .../runtime/rpc/dispatcher-stream-options.ts | 1 + src/main/runtime/rpc/dispatcher.ts | 4 +- .../runtime/rpc/methods/client-ui.test.ts | 19 + src/main/runtime/rpc/methods/index.ts | 2 + .../runtime-client-capabilities.test.ts | 74 ++ .../methods/runtime-client-capabilities.ts | 24 + ...ion-tab-agent-capability-mutations.test.ts | 42 + ...ession-tab-agent-status-projection.test.ts | 24 + .../session-tab-agent-status-projection.ts | 14 +- .../rpc/methods/session-tab-close-methods.ts | 11 +- .../methods/session-tab-mutation-methods.ts | 21 +- .../rpc/methods/session-tabs-inventory.ts | 27 +- .../runtime/rpc/methods/session-tabs.test.ts | 47 +- src/main/runtime/rpc/methods/session-tabs.ts | 26 +- .../methods/structured-agent-session-gate.ts | 7 +- .../structured-agent-session-policy.ts | 47 ++ .../methods/structured-agent-session.test.ts | 33 +- .../methods/structured-session-tab-restore.ts | 12 +- .../runtime/rpc/rpc-streaming-dispatcher.ts | 2 + src/main/runtime/runtime-client-settings.ts | 2 + .../runtime-rpc-mobile-method-allowlist.ts | 17 + .../runtime-rpc-websocket-dispatch.ts | 6 + src/main/runtime/runtime-store-contract.ts | 2 + .../app-shell/use-app-startup-hydration.ts | 8 +- src/renderer/src/app-startup-routing.test.ts | 10 + ...ctured-agent-session-message-projection.ts | 37 +- .../use-structured-agent-session-hold.ts | 8 +- .../local-structured-session-tabs-sync.ts | 5 +- src/shared/structured-agent-session-holder.ts | 6 + ...ctured-agent-session-message-projection.ts | 29 + ...ss-version-agent-session-wire.unit.test.ts | 44 + .../versioned-agent-session-wire.ts | 1 + 91 files changed, 4357 insertions(+), 424 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-image-scope-state.ts create mode 100644 mobile/src/session/mobile-structured-agent-prompts.ts create mode 100644 mobile/src/session/mobile-structured-agent-session-launch.test.ts create mode 100644 mobile/src/session/mobile-structured-agent-session-launch.ts create mode 100644 mobile/src/session/mobile-structured-agent-session-rpc.ts create mode 100644 mobile/src/session/use-mobile-native-chat-active-resolution.ts create mode 100644 mobile/src/session/use-mobile-native-chat-image-upload.ts create mode 100644 mobile/src/session/use-mobile-native-chat-session-option-controller.ts create mode 100644 mobile/src/session/use-mobile-session-image-attachments.test.tsx create mode 100644 mobile/src/session/use-mobile-session-tab-action-targets.ts create mode 100644 mobile/src/session/use-mobile-session-terminal-create-actions.test.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-options.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-session.test.tsx create mode 100644 mobile/src/session/use-mobile-structured-agent-session.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-state.ts create mode 100644 mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts create mode 100644 mobile/src/transport/mobile-runtime-client-capabilities.ts create mode 100644 mobile/src/transport/rpc-client-capabilities.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts create mode 100644 src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts create mode 100644 src/main/runtime/rpc/methods/runtime-client-capabilities.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-policy.ts create mode 100644 src/shared/structured-agent-session-holder.ts create mode 100644 src/shared/structured-agent-session-message-projection.ts diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f470214dbed..f4a34494328 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' +import { + formatQuestionAnswer, + formatQuestionFreeTextAnswer, + type MobileChatQuestion +} from './mobile-native-chat-question' type Props = { question: MobileChatQuestion @@ -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 => { - await sendAnswer(formatQuestionAnswer(question, [option])) + const answerSingle = async (option: string, optionIndex: number): Promise => { + const token = question.optionTokens[optionIndex] + await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option])) } const submitMulti = async (): Promise => { @@ -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 ? ( - {optionRows.map(({ label, key }) => { + {optionRows.map(({ label, key }, optIndex) => { const isSelected = selected.includes(label) return ( (question.multiSelect ? toggle(label) : answerSingle(label))} + onPress={() => + question.multiSelect ? toggle(label) : answerSingle(label, optIndex) + } > {question.multiSelect ? ( @@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J ) : null} - - - [ - styles.freeSend, - !canSendFreeText && styles.freeSendDisabled, - pressed && canSendFreeText && styles.pressed - ]} - onPress={submitFreeText} - disabled={!canSendFreeText} - > - + - - + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + ) : null} ) } diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 7dd09977c45..019e83c6a99 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -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 ?? ''} diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index 1ddc7cb1d83..552f507a787 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC {t.type === 'file' && ( )} + {t.type === 'agent-session' && } {t.type === 'terminal' && (() => { const agentId = resolveMobileTerminalTabAgentId(t) diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx index 48dcabed23c..0aac2bb9d42 100644 --- a/mobile/src/session/MobileSessionSheets.tsx +++ b/mobile/src/session/MobileSessionSheets.tsx @@ -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} /> + + setAgentSessionActionTarget(null) + )} + onClose={() => setAgentSessionActionTarget(null)} + /> = { 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 diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 2283256da05..890a3a1562e 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -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 /** 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). */ diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 7daa4babea6..e1bd97cad8f 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -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) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index abda64b04ab..a3f66eb14aa 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -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 +} diff --git a/mobile/src/session/mobile-native-chat-image-scope-state.ts b/mobile/src/session/mobile-native-chat-image-scope-state.ts new file mode 100644 index 00000000000..8d7de510e3a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-image-scope-state.ts @@ -0,0 +1,18 @@ +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' + +export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = [] + +export type MobileNativeChatImagesByScope = Record + +export function withScopeAttachments( + byScope: MobileNativeChatImagesByScope, + scope: string, + next: PendingNativeChatImage[] +): MobileNativeChatImagesByScope { + if (next.length > 0) { + return { ...byScope, [scope]: next } + } + const remaining = { ...byScope } + delete remaining[scope] + return remaining +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts index 94fbcf055a9..079e661e545 100644 --- a/mobile/src/session/mobile-native-chat-question.test.ts +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatQuestionAnswer, + formatQuestionFreeTextAnswer, mobileChatQuestionKey, parseAgentQuestion, type MobileChatQuestion @@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => { expect(formatQuestionAnswer(numbered, [])).toBe('') expect(formatQuestionAnswer(numbered, [' '])).toBe('') }) + + it('prefixes free-text answers with an opaque prompt token when provided', () => { + expect( + formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ') + ).toBe(`target:${encodeURIComponent('hi there')}`) + }) }) describe('mobileChatQuestionKey', () => { @@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => { expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( mobileChatQuestionKey(first) ) + expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe( + mobileChatQuestionKey(first) + ) }) }) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 8a1f06dfbf7..5d4e65a46ff 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -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]) +} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 5134cd373d4..b6abab8001e 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -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) diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index a61b653a0e3..36c90b0a29d 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -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 diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts new file mode 100644 index 00000000000..84cb7033d30 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -0,0 +1,251 @@ +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' + +export type StructuredApprovalItem = AgentJournalRenderItem & { + body: Extract +} + +export type StructuredQuestionItem = AgentJournalRenderItem & { + body: Extract +} + +export type StructuredPromptResponseTarget = { + itemId: string + expectedRevision: number + optionId: string +} + +type PromptTokenPayload = + | { + kind: 'approval' + itemId: string + revision: number + optionId: string + } + | { + kind: 'question-option' + itemId: string + revision: number + optionId: string + } + | { + kind: 'question-free-text' + itemId: string + revision: number + questionId: string + } + +const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:' + +export function pendingStructuredApproval( + item: AgentJournalRenderItem +): item is StructuredApprovalItem { + return item.body.kind === 'approval' && item.body.resolution.state === 'pending' +} + +export function pendingStructuredQuestion( + item: AgentJournalRenderItem +): item is StructuredQuestionItem { + return item.body.kind === 'question' && item.body.resolution.state === 'pending' +} + +function encodeQuestionAnswer(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +function encodePromptToken(payload: PromptTokenPayload): string { + return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}` +} + +function decodePromptToken(value: string): PromptTokenPayload | null { + if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { + return null + } + try { + const decoded = JSON.parse( + decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length)) + ) as Record + if ( + typeof decoded.itemId !== 'string' || + typeof decoded.revision !== 'number' || + !Number.isFinite(decoded.revision) + ) { + return null + } + if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + optionId: decoded.optionId + } + } + if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + optionId: decoded.optionId + } + } + if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + questionId: decoded.questionId + } + } + } catch { + return null + } + return null +} + +function decodeQuestionFreeTextAnswer(value: string): { + payload: Extract + answer: string +} | null { + if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { + return null + } + const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length) + if (separator === -1) { + return null + } + const payload = decodePromptToken(value.slice(0, separator)) + if (payload?.kind !== 'question-free-text') { + return null + } + return { payload, answer: decodeURIComponent(value.slice(separator + 1)) } +} + +export function projectStructuredPermission( + prompt: StructuredApprovalItem | null +): MobileChatPermission | null { + if (prompt?.body.kind !== 'approval') { + return null + } + return { + title: prompt.body.title, + ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), + options: prompt.body.options.map((option) => ({ + label: option.label, + send: encodePromptToken({ + kind: 'approval', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: option.id + }) + })) + } +} + +export function projectStructuredQuestion( + prompt: StructuredQuestionItem | null +): MobileChatQuestion | null { + if (prompt?.body.kind !== 'question') { + return null + } + return { + question: prompt.body.question, + options: prompt.body.options.map((option) => option.label), + multiSelect: false, + allowOther: Boolean(prompt.body.freeTextQuestionId), + optionTokens: prompt.body.options.map((option) => + encodePromptToken({ + kind: 'question-option', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: option.id + }) + ), + ...(prompt.body.freeTextQuestionId + ? { + freeTextToken: encodePromptToken({ + kind: 'question-free-text', + itemId: prompt.itemId, + revision: prompt.revision, + questionId: prompt.body.freeTextQuestionId + }) + } + : {}) + } +} + +export function structuredApprovalResponseTarget( + response: string, + currentPrompt: StructuredApprovalItem | null +): StructuredPromptResponseTarget | null { + const token = decodePromptToken(response) + if (token?.kind === 'approval') { + return { + itemId: token.itemId, + expectedRevision: token.revision, + optionId: token.optionId + } + } + if (token) { + return null + } + const option = currentPrompt?.body.options.find( + (candidate) => candidate.id === response || candidate.label === response + ) + return currentPrompt && option + ? { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: option.id + } + : null +} + +export function structuredQuestionResponseTarget( + response: string, + currentPrompt: StructuredQuestionItem | null +): StructuredPromptResponseTarget | null { + const token = decodePromptToken(response) + if (token?.kind === 'question-option') { + return { + itemId: token.itemId, + expectedRevision: token.revision, + optionId: token.optionId + } + } + if (token) { + return null + } + const freeText = decodeQuestionFreeTextAnswer(response) + if (freeText) { + const answer = freeText.answer.trim() + return answer.length > 0 + ? { + itemId: freeText.payload.itemId, + expectedRevision: freeText.payload.revision, + optionId: encodeQuestionAnswer(freeText.payload.questionId, answer) + } + : null + } + if (!currentPrompt) { + return null + } + const trimmed = response.trim() + const option = currentPrompt.body.options.find( + (candidate) => candidate.id === response || candidate.label === trimmed + ) + if (option) { + return { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: option.id + } + } + return currentPrompt.body.freeTextQuestionId && trimmed + ? { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed) + } + : null +} diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts new file mode 100644 index 00000000000..e749822470e --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -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 } { + let responseIndex = 0 + const sendRequest = vi.fn(async () => responses[responseIndex++]) + return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType } +} + +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' + }) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts new file mode 100644 index 00000000000..64e4f07307f --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -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 { + 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 + 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 } +} diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts new file mode 100644 index 00000000000..9f6e150e698 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -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 = + | { status: 'accepted'; value: TValue } + | { status: 'refused'; message: string } + | { status: 'failed'; message: string } + | { status: 'unknown' } + +export type StructuredAgentSessionMutationResult = + | { status: 'accepted'; value: TValue; sameFence: boolean } + | { status: 'rejected' } + | { status: 'unknown' } + +export type StructuredAgentSessionMutate = ( + method: string, + fingerprintMethod: string, + fields: Record +) => Promise> + +export async function callAgentSession( + client: RpcClient, + method: string, + params: unknown, + timeoutMs = STRUCTURED_SEND_TIMEOUT_MS, + options?: { failWhenDisconnected?: boolean } +): Promise { + 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(args: { + client: RpcClient + method: string + fingerprintMethod: string + sessionId: string + expectedRuntimeFence: number + fields: Record + clientOperationId?: string + retryUnknown?: boolean + timeoutMs?: number +}): Promise> { + const { + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence, + fields, + clientOperationId, + retryUnknown, + timeoutMs + } = args + try { + const result = await callAgentSession>( + 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' + } + } +} diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index e4ce55a84aa..bcc9510d0e8 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -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 & { handle: string }): TerminalRecord => ({ title: 'Terminal', terminalTheme: undefined, diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 09426863b99..f03a31acf41 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -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 } } diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index 5177981f0e6..6ad034335ad 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -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') + }) }) diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index 20326d41c98..dfc7be812e8 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -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. diff --git a/mobile/src/session/opened-mobile-session-tab.test.ts b/mobile/src/session/opened-mobile-session-tab.test.ts index 988a3ea313f..dbeed5ed830 100644 --- a/mobile/src/session/opened-mobile-session-tab.test.ts +++ b/mobile/src/session/opened-mobile-session-tab.test.ts @@ -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) + }) }) diff --git a/mobile/src/session/opened-mobile-session-tab.ts b/mobile/src/session/opened-mobile-session-tab.ts index 17c8cff9848..f76571eceef 100644 --- a/mobile/src/session/opened-mobile-session-tab.ts +++ b/mobile/src/session/opened-mobile-session-tab.ts @@ -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( diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 21f07562459..6d0adbbf8df 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -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 } + ) + }) }) diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts index 67995115f45..5bfe71f839b 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -141,15 +141,13 @@ export function useMobileFileTapHandlers( 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({ client: current.client, hostId: current.hostId, @@ -172,6 +170,8 @@ export function useMobileFileTapHandlers( latestActivationSeq: activationSeqRef.current, sourceTerminalHandle, activeTerminalHandle: current.activeHandleRef.current, + sourceSessionTabId: nativeChatTabId, + activeSessionTabId: current.getActiveSessionTabId(), activeTabType: current.getActiveSessionTabType() }), switchSessionTab: current.switchSessionTab, diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts new file mode 100644 index 00000000000..ea7f923de47 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -0,0 +1,83 @@ +import { useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { useMobileSessionViewMode } from './use-mobile-session-view-mode' + +export function useMobileNativeChatActiveResolution(args: { + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean +}): { + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + activeChatAgent: string | null + activeChatAgentRef: MutableRefObject + activeChatSessionId: string | null + activeChatStructured: boolean + activeChatResolution: ReturnType + activeTabAgentWorking: boolean + nativeChatStatus: MobileNativeChatTab['agentStatus'] | null + sourceIdentity: string + streamIdentity: string + streamScopeKey: string +} { + const { + activeHandleRef, + activeSessionTab, + activeSessionTabId, + hostId, + nativeChatTranscriptIsLocalReadable, + worktreeId + } = args + const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) + const tabWantsChat = + activeSessionTab?.type === 'agent-session' || + (activeSessionTabId ? isTabChatView(activeSessionTabId) : false) + const activeChatResolution = + activeSessionTab && activeSessionTabId && tabWantsChat + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + const activeChatAgent = activeChatResolution?.agent ?? null + const activeChatAgentRef = useRef(activeChatAgent) + + useLayoutEffect(() => { + showNativeChatRef.current = showNativeChat + activeChatAgentRef.current = activeChatAgent + }, [activeChatAgent, showNativeChat]) + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const activeChatStructured = + activeChatResolution != null && activeSessionTab?.type === 'agent-session' + const activeTabStatus = activeSessionTab?.agentStatus + const activeTabAgentWorking = + activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null + const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` + const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? '' + const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}` + + return { + isTabChatView, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + activeChatAgent, + activeChatAgentRef, + activeChatSessionId, + activeChatStructured, + activeChatResolution, + activeTabAgentWorking, + nativeChatStatus, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), + streamIdentity, + streamScopeKey + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 40937adc0e0..83f8075d914 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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[] = [] 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) => { 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 diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 109dea93ec3..e67d4d0802b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,9 +1,7 @@ -import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' -import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import { useMobileSessionViewMode } from './use-mobile-session-view-mode' +import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' @@ -11,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(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 } } diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index dbcb97df527..77d839adf34 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,18 +1,17 @@ import { useCallback, useRef, useState } from 'react' -import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - ImageLibraryPermissionError, - pickMobileImages, - type MobileImageSource -} from './mobile-image-source-picker' +import type { MobileImageSource } from './mobile-image-source-picker' import { appendPendingNativeChatImages, - uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { + NO_NATIVE_CHAT_IMAGE_ATTACHMENTS, + withScopeAttachments, + type MobileNativeChatImagesByScope +} from './mobile-native-chat-image-scope-state' import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths @@ -31,6 +30,7 @@ import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' +import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -60,8 +60,11 @@ type Args = { readonly baseSend: ( text: string, imagePreviewUris?: string[], - deadline?: number + deadline?: number, + attachments?: readonly PendingNativeChatImage[] ) => Promise + /** 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 } -const NO_ATTACHMENTS: PendingNativeChatImage[] = [] - -function withScopeAttachments( - byScope: Record, - scope: string, - next: PendingNativeChatImage[] -): Record { - if (next.length > 0) { - return { ...byScope, [scope]: next } - } - const remaining = { ...byScope } - delete remaining[scope] - return remaining -} - const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({ showToast, onSendError, baseSend, + structuredNativeChat, readSeededLaunchDraft, onAttachSuccess, onError, sleep = defaultSleep }: Args): MobileNativeChatImageAttachments { - const [attachmentsByScope, setAttachmentsByScope] = useState< - Record - >({}) - const [isAttaching, setIsAttaching] = useState(false) + const [attachmentsByScope, setAttachmentsByScope] = useState({}) const idCounter = useRef(0) - // Count in-flight uploads so an overlapping attach can't clear the flag early. - const attachingCount = useRef(0) - // Live connState for attachImage's catch: the closure's value was already - // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. - const connStateRef = useRef(connState) - connStateRef.current = connState + const attachments = + (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS - const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS - - const attachImage = useCallback( - async (source: MobileImageSource): Promise => { - // The chip lands in the scope that initiated the pick, even if the user - // switches tabs while the upload is in flight. - const scope = scopeKey - if (!client || !scope || !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[] = [] - let uploadError: unknown = null - try { - await uploadMobileNativeChatImages(source, { - client, - getConnectionId: getActiveWorktreeConnectionId, - pickImages: pickMobileImages, - onImageUploaded: (image) => uploadedImages.push(image), - onUploadStart: () => { - started = true - attachingCount.current += 1 - setIsAttaching(true) - } - }) - } catch (error) { - uploadError = error - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current === 0) { - setIsAttaching(false) - } - } - } - if (uploadedImages.length > 0) { - setAttachmentsByScope((prev) => ({ - ...prev, - [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) - })) - onAttachSuccess?.() - } - if (uploadError !== null) { - const message = uploadError instanceof Error ? uploadError.message : String(uploadError) - onError?.() - if (connStateRef.current !== 'connected') { - showToast('Attach failed (disconnected)', 1500) - return - } - if (uploadError instanceof ImageLibraryPermissionError) { - showToast('Photo permission denied', 1500) - return - } - if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { - showToast('Image too large to attach', 1500) - return - } - showToast('Attach failed', 1500) - } + const addUploadedImages = useCallback( + (scope: string, uploadedImages: Omit[]) => { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) }, - [ - activeHandleRef, - client, - connState, - getActiveWorktreeConnectionId, - onAttachSuccess, - onError, - scopeKey, - showToast - ] + [] ) + const { attachImage, isAttaching } = useMobileNativeChatImageUpload({ + client, + activeHandleRef, + getActiveWorktreeConnectionId, + connState, + scopeKey, + structuredNativeChat, + showToast, + onImagesUploaded: addUploadedImages, + onAttachSuccess, + onError + }) + const removeAttachment = useCallback( (id: string): void => { const scope = scopeKey @@ -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 — diff --git a/mobile/src/session/use-mobile-native-chat-image-upload.ts b/mobile/src/session/use-mobile-native-chat-image-upload.ts new file mode 100644 index 00000000000..01567b5c727 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-image-upload.ts @@ -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 = { readonly current: T } +type UploadedNativeChatImage = Omit +type ShowToast = (message: string, durationMs?: number) => void + +export function useMobileNativeChatImageUpload(args: { + client: RpcClient | null + activeHandleRef: CurrentRef + getActiveWorktreeConnectionId: () => Promise + connState: ConnectionState + scopeKey: string | null + structuredNativeChat: boolean + showToast: ShowToast + onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void + onAttachSuccess?: () => void + onError?: () => void +}): { + attachImage: (source: MobileImageSource) => Promise + isAttaching: boolean +} { + const { + activeHandleRef, + client, + connState, + getActiveWorktreeConnectionId, + onAttachSuccess, + onError, + onImagesUploaded, + scopeKey, + showToast, + structuredNativeChat + } = args + const [isAttaching, setIsAttaching] = useState(false) + const attachingCount = useRef(0) + const connStateRef = useRef(connState) + useLayoutEffect(() => { + connStateRef.current = connState + }, [connState]) + + const attachImage = useCallback( + async (source: MobileImageSource): Promise => { + const scope = scopeKey + if ( + !client || + !scope || + connState !== 'connected' || + (!activeHandleRef.current && !structuredNativeChat) + ) { + return + } + let started = false + const uploadedImages: UploadedNativeChatImage[] = [] + let uploadError: unknown = null + try { + await uploadMobileNativeChatImages(source, { + client, + getConnectionId: getActiveWorktreeConnectionId, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), + onUploadStart: () => { + started = true + attachingCount.current += 1 + setIsAttaching(true) + } + }) + } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + onImagesUploaded(scope, uploadedImages) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) + onError?.() + if (connStateRef.current !== 'connected') { + showToast('Attach failed (disconnected)', 1500) + return + } + if (uploadError instanceof ImageLibraryPermissionError) { + showToast('Photo permission denied', 1500) + return + } + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + showToast('Image too large to attach', 1500) + return + } + showToast('Attach failed', 1500) + } + }, + [ + activeHandleRef, + client, + connState, + getActiveWorktreeConnectionId, + onAttachSuccess, + onError, + onImagesUploaded, + scopeKey, + showToast, + structuredNativeChat + ] + ) + + return { attachImage, isAttaching } +} diff --git a/mobile/src/session/use-mobile-native-chat-session-option-controller.ts b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts new file mode 100644 index 00000000000..aa61bdd85ff --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts @@ -0,0 +1,100 @@ +import { useCallback, useMemo } from 'react' +import type { + SessionOptionDescriptor, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' +import { + useMobileNativeChatSessionOptions, + type MobileNativeChatSessionOptionsController +} from './use-mobile-native-chat-session-options' + +export function useMobileNativeChatSessionOptionController(args: { + activeChatStructured: boolean + activeSessionTabId: string | null + agent: string | null + dispatchCommand: (text: string) => Promise + hostId: string + isTabChatView: (tabId: string) => boolean + isWorking: boolean + reportedModel: string | null + structured: { + snapshot: SessionOptionDescriptor[] + pendingId: string | null + setOption: (id: string, value: SessionOptionValue) => Promise + invokeAction: (id: string) => Promise + } + toggleTabChatView: (tabId: string) => void + worktreeId: string +}): { + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null + recordCommand: (command: string) => void +} { + const { + activeChatStructured, + activeSessionTabId, + agent, + dispatchCommand, + hostId, + isTabChatView, + isWorking, + reportedModel, + structured, + toggleTabChatView, + worktreeId + } = args + const { + invokeAction: invokeStructuredAction, + pendingId: structuredPendingId, + setOption: setStructuredOption, + snapshot: structuredSnapshot + } = structured + + const handleAgentPicker = useCallback(() => { + if (activeSessionTabId && isTabChatView(activeSessionTabId)) { + toggleTabChatView(activeSessionTabId) + } + }, [activeSessionTabId, isTabChatView, toggleTabChatView]) + + const sessionOptions = useMobileNativeChatSessionOptions({ + agent: activeChatStructured ? null : agent, + scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), + reportedModel, + dispatchCommand, + onAgentPicker: handleAgentPicker + }) + const structuredController = useMemo( + () => + activeChatStructured && structuredSnapshot.length > 0 + ? { + snapshot: structuredSnapshot, + pendingId: structuredPendingId, + setOption: setStructuredOption, + invokeAction: invokeStructuredAction, + recordCommand: () => {} + } + : null, + [ + activeChatStructured, + invokeStructuredAction, + setStructuredOption, + structuredPendingId, + structuredSnapshot + ] + ) + const nativeChatSessionOptions = useMemo( + () => + activeChatStructured + ? structuredController + ? { controller: structuredController, isWorking } + : null + : sessionOptions.snapshot.length > 0 + ? { controller: sessionOptions, isWorking } + : null, + [activeChatStructured, isWorking, sessionOptions, structuredController] + ) + + return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand } +} diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 66691545118..841d3984b8f 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -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, diff --git a/mobile/src/session/use-mobile-session-file-actions.ts b/mobile/src/session/use-mobile-session-file-actions.ts index 7ba21770a21..56aa2b049d8 100644 --- a/mobile/src/session/use-mobile-session-file-actions.ts +++ b/mobile/src/session/use-mobile-session-file-actions.ts @@ -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), diff --git a/mobile/src/session/use-mobile-session-image-attachments.test.tsx b/mobile/src/session/use-mobile-session-image-attachments.test.tsx new file mode 100644 index 00000000000..68aeafaea6f --- /dev/null +++ b/mobile/src/session/use-mobile-session-image-attachments.test.tsx @@ -0,0 +1,123 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' + +const mocks = vi.hoisted(() => ({ + useMobileImageAttachment: vi.fn(), + useMobileNativeChatImageAttachments: vi.fn() +})) + +vi.mock('./use-mobile-image-attachment', () => ({ + useMobileImageAttachment: mocks.useMobileImageAttachment +})) + +vi.mock('./use-mobile-native-chat-image-attachments', () => ({ + useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments +})) + +type HookArgs = Parameters[0] + +function baseArgs(overrides: Partial = {}): HookArgs { + return { + client: {} as RpcClient, + activeHandle: 'term-1', + activeHandleRef: { current: null }, + canSend: true, + connState: 'connected', + deviceTokenRef: { current: null }, + nativeChatScopeKey: 'scope-1', + nativeChatInputLeaseReady: false, + getActiveWorktreeConnectionId: async () => 'conn-1', + beforeTerminalSend: async () => true, + nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'), + structuredNativeChat: true, + readSeededLaunchDraft: () => null, + showToast: vi.fn(), + onNativeChatSendError: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + ...overrides + } +} + +describe('useMobileSessionImageAttachments', () => { + let renderer: ReactTestRenderer | null = null + + function Harness({ args }: { args: HookArgs }): null { + useMobileSessionImageAttachments(args) + return null + } + + beforeEach(() => { + mocks.useMobileImageAttachment.mockReturnValue({ + attachImage: vi.fn(), + isAttaching: false + }) + mocks.useMobileNativeChatImageAttachments.mockReturnValue({ + attachments: [], + isAttaching: false, + attachImage: vi.fn(), + removeAttachment: vi.fn(), + sendNativeChat: vi.fn() + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + function render(args: HookArgs): void { + act(() => { + renderer = create(createElement(Harness, { args })) + }) + } + + it('enables native-chat image sends for connected structured sessions without a terminal lease', () => { + render(baseArgs()) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + structuredNativeChat: true + }) + ) + }) + + it('keeps terminal-backed native-chat image sends gated on the input lease', () => { + render( + baseArgs({ + activeHandleRef: { current: 'term-1' }, + nativeChatInputLeaseReady: false, + structuredNativeChat: false + }) + ) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + structuredNativeChat: false + }) + ) + }) + + it('disables structured native-chat image sends while disconnected', () => { + render( + baseArgs({ + connState: 'connecting', + nativeChatInputLeaseReady: true, + structuredNativeChat: true + }) + ) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + structuredNativeChat: true + }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 9b5a010df9f..07edac51f39 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -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 + /** 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, diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 7942233dbfd..6046cba1059 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -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, diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index 107ac3181de..6e2f82124b3 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -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([]) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false) - const [actionTarget, setActionTarget] = useState(null) - const [markdownActionTarget, setMarkdownActionTarget] = useState | null>(null) - const [fileActionTarget, setFileActionTarget] = useState | null>(null) - const [browserActionTarget, setBrowserActionTarget] = useState | null>(null) + const sessionTabActionTargets = useMobileSessionTabActionTargets() const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState +type FileTab = Extract +type BrowserTab = Extract +type AgentSessionTab = Extract +type SetActionTarget = Dispatch> + +export function useMobileSessionTabActionTargets() { + const [actionTarget, setActionTarget] = useState(null) + const [markdownActionTarget, setMarkdownActionTarget] = useState(null) + const [fileActionTarget, setFileActionTarget] = useState(null) + const [browserActionTarget, setBrowserActionTarget] = useState(null) + const [agentSessionActionTarget, setAgentSessionActionTarget] = useState( + null + ) + + return { + actionTarget, + agentSessionActionTarget, + browserActionTarget, + fileActionTarget, + markdownActionTarget, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + } +} + +export function useMobileSessionTabActionSheetOpener(args: { + activeHandleRef: MutableRefObject + setActionTarget: SetActionTarget + setMarkdownActionTarget: SetActionTarget + setFileActionTarget: SetActionTarget + setBrowserActionTarget: SetActionTarget + setAgentSessionActionTarget: SetActionTarget +}): (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 + ] + ) +} diff --git a/mobile/src/session/use-mobile-session-tab-switching.ts b/mobile/src/session/use-mobile-session-tab-switching.ts index 21095b613dd..48c48c2f994 100644 --- a/mobile/src/session/use-mobile-session-tab-switching.ts +++ b/mobile/src/session/use-mobile-session-tab-switching.ts @@ -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 diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts new file mode 100644 index 00000000000..c0a4e8368c5 --- /dev/null +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts @@ -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() }, + 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 | 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 | 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 | 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 | 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 + 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 | 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) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.ts index 895b9a5a256..0ccd3591011 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.ts @@ -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, diff --git a/mobile/src/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/session/use-mobile-session-terminal-send-actions.ts index c80edee9271..6909f71ca63 100644 --- a/mobile/src/session/use-mobile-session-terminal-send-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-send-actions.ts @@ -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) => { diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts new file mode 100644 index 00000000000..108275223be --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../src/shared/structured-agent-session-options' +import type { RpcClient } from '../transport/rpc-client' +import { + callAgentSession, + type StructuredAgentSessionMutate +} from './mobile-structured-agent-session-rpc' + +type StructuredOptionsController = { + optionSnapshot: SessionOptionDescriptor[] + optionSurface: SessionOptionsSurface + pendingOptionId: string | null + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + invokeStructuredOption: (id: string) => Promise +} + +export function useMobileStructuredAgentOptions(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + fence: number | null + mutate: StructuredAgentSessionMutate +}): StructuredOptionsController { + const { agent, client, enabled, fence, mutate, sessionId } = args + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent ?? 'codex') + ) + const activeOptionRecordRef = useRef(optionState.record) + const optionCatalog = useMemo( + () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), + [agent] + ) + + useEffect(() => { + const next = createStructuredAgentSessionOptionState(agent ?? 'codex') + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, enabled, fence, sessionId]) + + useEffect(() => { + if (!client || !sessionId || !enabled || !optionCatalog) { + return + } + let stale = false + void callAgentSession(client, 'agentSession.options', { sessionId }) + .then((result) => { + if (!stale) { + setOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [client, enabled, optionCatalog, sessionId, fence]) + + const optionSnapshot = useMemo( + () => structuredAgentSessionOptionSnapshot(optionState), + [optionState] + ) + + const setStructuredOption = useCallback( + async (id: string, value: SessionOptionValue): Promise => { + if ( + !canSetStructuredAgentSessionOption(optionState, id, value) || + typeof value !== 'string' + ) { + return false + } + const targetRecord = optionState.record + setOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value } + ) + if (activeOptionRecordRef.current !== targetRecord) { + return result.status !== 'rejected' + } + if (result.status === 'accepted') { + setOptionState((current) => + current.record === targetRecord && result.sameFence + ? commitStructuredAgentSessionOptionValues( + current, + result.value.options ?? { [id]: value } + ) + : current + ) + return true + } + if (result.status === 'unknown') { + setOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOption(current, id, value) + : current + ) + return true + } + return false + } finally { + setOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + }, + [mutate, optionState] + ) + + const invokeStructuredOption = useCallback(async () => false, []) + + const setOption = useCallback( + async (id: string, value: SessionOptionValue) => { + await setStructuredOption(id, value) + return { snapshot: optionSnapshot } + }, + [optionSnapshot, setStructuredOption] + ) + + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + return { + optionSnapshot, + optionSurface, + pendingOptionId: optionState.pendingId, + setStructuredOption, + invokeStructuredOption + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx new file mode 100644 index 00000000000..94ad0535f3e --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -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) { + if (method === 'agentSession.send') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { turnId: 'turn-1' } + }) + } + if (method === 'agentSession.options') { + return ok({ + models: [ + { + id: 'gpt-fast', + label: 'GPT Fast', + isDefault: true, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-slow', + label: 'GPT Slow', + isDefault: false, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { + model: 'gpt-fast', + effort: 'low' + } + }) + } + if (method === 'agentSession.setOption') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast' } + } + }) + } + if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 3 }, + value: { + itemId: String(params?.itemId ?? ''), + revision: 2, + resolution: { + state: 'resolved', + selectedOptionId: String(params?.optionId ?? ''), + resolvedBy: 'mobile', + resolvedAt: 123 + } + } + }) + } + return ok({}) +} + +describe('useMobileStructuredAgentSession', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + const onSendError = vi.fn() + const unsubscribe = vi.fn() + const sendRequest = vi.fn(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) + ) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts new file mode 100644 index 00000000000..64c761b9dff --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -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 + cancel: () => void + permission: MobileChatPermission | null + question: MobileChatQuestion | null + optionSnapshot: SessionOptionDescriptor[] + optionSurface: SessionOptionsSurface + pendingOptionId: string | null + respondPermission: (optionId: string) => Promise + respondQuestion: (answer: string) => Promise + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + invokeStructuredOption: (id: string) => Promise +} + +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()) + const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState({ + client, + sessionId, + enabled, + connected + }) + + const mutate = useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record + ): Promise> => { + 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({ + 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 => { + 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({ + 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 => { + const target = structuredApprovalResponseTarget( + optionId, + stateRef.current.items.find(pendingStructuredApproval) ?? null + ) + if (!target) { + return false + } + const result = await mutate( + '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 => { + const target = structuredQuestionResponseTarget( + answer, + stateRef.current.items.find(pendingStructuredQuestion) ?? null + ) + if (!target) { + return false + } + const result = await mutate( + '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({ + 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 + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts new file mode 100644 index 00000000000..15d6bfe4226 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -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(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(client, 'agentSession.history', { + sessionId, + direction: 'before', + cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + .then((result) => { + if (result.ok) { + apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) + } + }) + .catch((error: unknown) => { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + .finally(() => setLoadingOlder(false)) + }, [apply, client, loadingOlder, sessionId]) + + return { state, stateRef, loadingOlder, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts new file mode 100644 index 00000000000..fa786867fc7 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -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 + 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 + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => Promise +} { + const { + acceptSend, + captureSendOrigin, + clearDraftForSend, + holdUnconfirmedSend, + onSendError, + restoreRejectedDraft, + sendStructured + } = args + const sendWithOutcome = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ): Promise => { + 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 } +} diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 36ed573aa5b..ca233bf33d3 100644 --- a/mobile/src/transport/direct-rpc-client.ts +++ b/mobile/src/transport/direct-rpc-client.ts @@ -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' diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index d5b547885cf..f08a44cdcdc 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -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)) }) diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index f242fce07ba..2d3b433807e 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -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) { diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts new file mode 100644 index 00000000000..5b3dc977240 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -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 })) +} diff --git a/mobile/src/transport/rpc-client-capabilities.test.ts b/mobile/src/transport/rpc-client-capabilities.test.ts new file mode 100644 index 00000000000..62bb85e1af0 --- /dev/null +++ b/mobile/src/transport/rpc-client-capabilities.test.ts @@ -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() + }) +}) diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index a7f3bb9d82a..e675be98558 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -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 { diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index adf9223b39a..684d6699fc1 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -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) }) }) diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 9c6e5cda532..88921efea3a 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -21,8 +21,15 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi tab: RuntimeMobileSessionAgentTab ): Promise { 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 diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 770ce0517a8..2f95dfeada1 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -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 installRuntimeFileCommandSurface(runtime, this.fileCommands) installRuntimeGitCommandSurface(runtime, this.gitCommands) diff --git a/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts new file mode 100644 index 00000000000..90be5c61ba2 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts @@ -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) => void)[] = [] + const runtime = new OrcaRuntimeService({ + onSettingsChanged: vi.fn((listener) => { + settingsListeners.push(listener as (updates: Record) => 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) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 52db9673705..12910fa14dc 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -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([ diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 975988d203c..5e669ab702e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -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. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index cc8322373b1..e3151c66b0e 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -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) => boolean | void registerBinaryStreamHandler?: ( diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 122e18f078a..c3febab1d62 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,8 +29,7 @@ import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } -// oxfmt-ignore -type DispatchCallOptions = Pick +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 ?? diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3bfb7303bd0..34596835294 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 1bdaf224397..ba77b94803e 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts new file mode 100644 index 00000000000..c0fb2f250bd --- /dev/null +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts @@ -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' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts new file mode 100644 index 00000000000..a1ab53267b3 --- /dev/null +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -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 } + } + }) +] diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 488ab69fd1e..226277f6ebd 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -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[]) { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index e713f74f057..4a61a99bc20 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -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', () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index ac8cc0b2164..375b3b499d5 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -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( 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') diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 50e56144f29..bd60ecd6ddf 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -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) } diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 6b9e953e4e3..ba7c41000d0 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -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[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) } diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index fba9a460e86..5ab29ae51b5 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -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[2] + clientCapabilities: Parameters[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) { diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index be61fc55edf..29131869fa0 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 3a322e33ed7..34d50a2a76b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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) } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 2dd317e08b0..33018c21f4d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -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 { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts new file mode 100644 index 00000000000..4fe38474ec6 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -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 & { + runtime?: Pick + structuredNativeChatEnabled?: boolean +} + +export function isStructuredNativeChatEnabled( + runtime: Pick +): 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) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c698cc7229c..3facfca3da8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -111,7 +111,7 @@ function hostStub(): StructuredAgentSessionHost { return hostCalls as unknown as StructuredAgentSessionHost } -function dispatcher(): RpcDispatcher { +function dispatcher(runtimeOverrides: Record = {}): 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 = {} ): Promise { 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 }) diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index c1f265cc4c8..4333713a445 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -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 ): Promise { - if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { - await runtime.restoreStructuredAgentSessionTabs() + if ( + supportsStructuredAgentSessions(context) && + typeof context.runtime.restoreStructuredAgentSessionTabs === 'function' + ) { + await context.runtime.restoreStructuredAgentSessionTabs() } } diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 75938d6573c..6eddcb748be 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -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, diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 41a251ce649..b9e6959c8da 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -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', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 461ec02ba69..63c71d495ab 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -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', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 86732e7430c..dd714b77528 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -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, diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 649a8ac49b7..6b9858bda0c 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -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, options?: { notifyListeners?: boolean; originWebContentsId?: number } ) => unknown + onSettingsChanged?: Store['onSettingsChanged'] } diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 91db232081c..77da19ffd20 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -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 } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index d1aad35829a..fead2f6c7bb 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -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. diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index aebc51c90e0..15c92d2efb7 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -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' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts index b7288d4a2a3..2c91621d30a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts @@ -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 diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index f5a9fa9807c..ea573964807 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -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) { diff --git a/src/shared/structured-agent-session-holder.ts b/src/shared/structured-agent-session-holder.ts new file mode 100644 index 00000000000..6da3be15611 --- /dev/null +++ b/src/shared/structured-agent-session-holder.ts @@ -0,0 +1,6 @@ +let holderOrdinal = 0 + +export function structuredAgentSessionHolderId(surface: string): string { + holderOrdinal += 1 + return `${surface}:${holderOrdinal}` +} diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts new file mode 100644 index 00000000000..c6735a8c772 --- /dev/null +++ b/src/shared/structured-agent-session-message-projection.ts @@ -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 + })) + ] +} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 0ce15ee6563..d79c99b679d 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -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 diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 1b0dc297f81..4d9e2de68ec 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -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 }