diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 46edfc54111..e36ec4c65d8 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -39,6 +39,9 @@ runs: with: install: false + # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so + # jobs that also install mobile restored a store with none of the React Native tree + # in it and re-downloaded the lot on every run. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -46,6 +49,9 @@ runs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Setup requested Node.js id: requested-node @@ -54,6 +60,9 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Validate native runtime shell: bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bde0b5e05d8..d21b1a23784 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,6 +28,7 @@ jobs: outputs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -95,6 +96,25 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware + # Why: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Mobile is a separate pnpm project, + # so the root install above leaves it empty and every mobile type degrades to + # an `error` type — reported as phantom findings against the changed lines. + # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates + # the gitignored terminal/mermaid webview engine modules that tracked source imports, + # and skipping it degrades those very types the step exists to resolve. The drift + # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's + # lockfile carries patchedDependencies that a silent rewrite would drop. + - name: Install mobile dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' + working-directory: mobile + run: | + pnpm install --frozen-lockfile + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ + mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml + fi + - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8bc10fc5b72..7111531c35c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -263,6 +263,14 @@ export function shouldRunPrChecks(changedFiles) { return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file)) } +export function needsMobileDependencies(changedFiles) { + // Why: static analysis lints CHANGED files, mobile ones included, and its + // type-aware pass resolves types from mobile/node_modules. Mobile is a + // separate pnpm project, so without this the root-only install leaves every + // mobile type an `error` type and the gate reports phantom findings. + return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/')) +} + export function classifyPrJobs(changedFiles) { const emptyDiff = changedFiles.length === 0 const shouldRun = shouldRunPrChecks(changedFiles) @@ -276,6 +284,7 @@ export function classifyPrJobs(changedFiles) { return { should_run: shouldRun, native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), + mobile_dependencies: shouldRun && needsMobileDependencies(changedFiles), ...jobs } } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 1fe296af265..4642372135c 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -316,6 +316,24 @@ describe('per-job path classification', () => { } }) + // Why: static analysis lints changed mobile files with a type-aware pass, and + // mobile is a separate pnpm project. Without its node_modules every mobile type + // resolves to an `error` type and the changed-code gate fails on phantom + // findings, which is exactly how a react-test-renderer union broke a PR. + it('installs mobile dependencies exactly when mobile files change', () => { + expect(classifyPrJobs([]).mobile_dependencies).toBe(true) + expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false) + expect( + classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies + ).toBe(true) + // Why false: a mobile-only diff skips every desktop job, so the install step's own + // job never runs and claiming the install is needed contradicts should_run. + expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['mobile/package.json']).should_run).toBe(false) + expect(classifyPrJobs(['README.md', 'mobile/src/a.ts']).mobile_dependencies).toBe(false) + }) + it('keeps unit-test-only diffs out of packaging', () => { expectClassification(['src/main/git/git-status.test.ts'], { git_compatibility: true @@ -354,6 +372,20 @@ describe('PR Checks skip wiring', () => { } }) + it('gives static analysis the mobile types its type-aware pass resolves', () => { + expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( + '${{ steps.filter.outputs.mobile_dependencies }}' + ) + const steps = prWorkflow.jobs.static_analysis.steps + const install = steps.findIndex((step) => step.name === 'Install mobile dependencies') + const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(gate) + expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install]['working-directory']).toBe('mobile') + expect(steps[install].run).toContain('--frozen-lockfile') + }) + it('keeps the cheap root-directory guard on docs-only PRs', () => { expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined() expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() 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..54f9b5cbe88 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -0,0 +1,142 @@ +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('never creates a legacy sibling after an unclassified create exception', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValue(new Error('internal error after commit')) + + 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('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..ecad0410dfd --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -0,0 +1,158 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../src/shared/agent-session-wire' +import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' +import type { RpcClient } from '../transport/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.' + } +} + +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 { + // 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..a602122978e --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -0,0 +1,152 @@ +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + parseAgentSessionOperationTimestamp +} from '../../../src/shared/agent-session-host-authority' +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) +} + +/** + * Bounded by expiry, never by count: every retained id belongs to a send whose outcome is still + * unknown, so dropping one turns the user's retry into a second message on the host. Only an id + * the host would already refuse — unparseable, or past the window in which it can be admitted — + * is safe to release, which matches the host's own tombstone retention. + */ +export function retainStructuredSessionOperationId( + operationIds: Map, + key: string, + operationId = structuredSessionOperationId(), + now: number = Date.now() +): string { + operationIds.delete(key) + operationIds.set(key, operationId) + for (const [retainedKey, retainedId] of operationIds) { + if (retainedKey === key) { + continue + } + const timestamp = parseAgentSessionOperationTimestamp(retainedId) + if (timestamp === null || now - timestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + operationIds.delete(retainedKey) + } + } + return operationId +} + +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-structured-session-operation-retention.test.ts b/mobile/src/session/mobile-structured-session-operation-retention.test.ts new file mode 100644 index 00000000000..209f28f65c9 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-operation-retention.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../src/shared/agent-session-host-authority' +import { retainStructuredSessionOperationId } from './mobile-structured-agent-session-rpc' + +const NOW = 1_900_000_000_000 + +function operationIdAt(timestamp: number, entropy: string): string { + return `${timestamp}-${entropy.repeat(32).slice(0, 32)}` +} + +describe('structured session operation retention', () => { + it('keeps every unconfirmed operation id past the old 128-entry cap', () => { + const operationIds = new Map() + for (let index = 0; index < 400; index += 1) { + retainStructuredSessionOperationId( + operationIds, + `request-${index}`, + operationIdAt(NOW, 'a'), + NOW + ) + } + + expect(operationIds.size).toBe(400) + // Why: the first send is exactly the one a retry would duplicate if it were evicted. + expect(operationIds.get('request-0')).toBe(operationIdAt(NOW, 'a')) + }) + + it('releases only ids the host would already refuse as expired', () => { + const operationIds = new Map() + const expired = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, 'b') + const admissible = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, 'c') + retainStructuredSessionOperationId(operationIds, 'stale', expired, NOW) + retainStructuredSessionOperationId(operationIds, 'live', admissible, NOW) + + retainStructuredSessionOperationId(operationIds, 'fresh', operationIdAt(NOW, 'd'), NOW) + + expect(operationIds.has('stale')).toBe(false) + expect(operationIds.get('live')).toBe(admissible) + expect(operationIds.get('fresh')).toBe(operationIdAt(NOW, 'd')) + }) + + it('drops ids the host could never admit and re-keys a repeated send', () => { + const operationIds = new Map() + retainStructuredSessionOperationId(operationIds, 'unparseable', 'not-an-operation-id', NOW) + const reused = retainStructuredSessionOperationId( + operationIds, + 'send', + operationIdAt(NOW, 'e'), + NOW + ) + + // A retry of the same send reuses the retained id rather than minting a duplicate. + expect( + retainStructuredSessionOperationId(operationIds, 'send', operationIds.get('send'), NOW) + ).toBe(reused) + expect(operationIds.has('unparseable')).toBe(false) + }) +}) 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..bf944398e87 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,51 @@ 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, + sourceIdentity, + enabled: showNativeChat, + // Holds are connection-scoped; dropping this on transport loss lets the hook + // reacquire the provider without clearing the cached transcript. + connected: connState === 'connected', + agent: activeChatStructured ? activeChatAgent : null, + onSendError + }) + const nativeChatSession = activeChatStructured + ? structuredNativeChat.session + : legacyNativeChatSession const { composerText: chatComposerText, setComposerText: setChatComposerText, @@ -117,27 +132,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 +163,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 +172,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 +195,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 +212,7 @@ export function useMobileNativeChatController(args: { const handleNativeChatStop = useMobileNativeChatStop({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, streamIdentity, @@ -216,11 +233,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 +251,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 +307,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..83562839363 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -0,0 +1,849 @@ +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 snapshotWithMessage(): AgentSessionSubscribeEvent { + const event = snapshotEvent() + return { + ...event, + page: { + ...event.page, + items: [ + { + itemId: 'msg-1', + revision: 1, + sequence: 1, + observedAt: 10, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'sent before the blip' }] + } + } + ], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 2 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 } + } + } as AgentSessionSubscribeEvent +} + +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, + sourceIdentity = 'host-a\0workspace-a' + }: { + sessionId?: string | null + agent?: string | null + connected?: boolean + sourceIdentity?: string + }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId, + sourceIdentity, + 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) + ) + ) + }) + + it('keeps the transcript visible while reconnecting', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false })) + }) + expect(hook?.session.messages).toHaveLength(1) + expect(hook?.session.status).toBe('ready') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: true })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('restores the correct cached transcript when switching tabs offline', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true, sessionId: 'session-1' })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-2' })) + }) + expect(hook?.session.messages).toEqual([]) + expect(hook?.session.status).toBe('idle') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-1' })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('isolates matching provider session ids across host and workspace sources', async () => { + await act(async () => { + renderer = create( + createElement(Harness, { + connected: true, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a' + }) + ) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update( + createElement(Harness, { + connected: false, + sessionId: 'session-1', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + }) + expect(hook?.session.messages).toEqual([]) + }) +}) 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..d9cabf1f2d0 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, 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 { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +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, + retainStructuredSessionOperationId as retainStructuredOpId, + 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 + /** Host/workspace scope used to keep same provider ids isolated. */ + sourceIdentity?: string + 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, sourceIdentity = '', enabled, onSendError } = args + const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) + const operationIdsRef = useRef(new Map()) + useEffect(() => () => operationIdsRef.current.clear(), []) + const retainOperationId = (key: string, operationId?: string): string => + retainStructuredOpId(operationIdsRef.current, key, operationId) + const stateArgs = { client, sessionId, sessionKey, enabled, connected } + const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) + + 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 = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + 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, sessionKey] + ) + + 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 = `${sessionKey}:agentSession.send:${JSON.stringify(fields)}` + const priorOperationId = operationIdsRef.current.get(key) + const clientOperationId = retainOperationId(key, priorOperationId) + 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, sessionKey] + ) + + 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 = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` + const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + 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, sessionKey]) + + 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..52aefab24aa --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -0,0 +1,198 @@ +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' + +const MAX_RETAINED_SESSION_STATES = 32 + +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 + sessionKey: string | null + enabled: boolean + /** Live transport only. The hold dies with the connection and has to be retaken, + * but the transcript must survive the outage rather than blank out with it. */ + connected: boolean +}): { + state: StructuredAgentSessionState + stateRef: { readonly current: StructuredAgentSessionState } + loadingOlder: boolean + loadEarlier: () => void +} { + const { client, connected, enabled, sessionId, sessionKey } = args + // Keep a bounded cache so offline tab switches select the right transcript + // synchronously without growing for the lifetime of the app. + const [sessionStates, setSessionStates] = useState>( + () => new Map() + ) + const state = + enabled && sessionKey + ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) + : EMPTY_STRUCTURED_AGENT_SESSION + const [loadingOlder, setLoadingOlder] = useState(false) + const stateRef = useRef(state) + const sessionKeyRef = useRef(sessionKey) + const streamGenerationRef = useRef(0) + useLayoutEffect(() => { + stateRef.current = state + sessionKeyRef.current = sessionKey + }, [sessionKey, state]) + + const apply = useCallback( + (action: StructuredAgentSessionAction) => { + if (!sessionKey) { + return + } + setSessionStates((current) => { + const previous = current.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION + const next = reduceStructuredAgentSession(previous, action) + if (next === previous) { + return current + } + const updated = new Map(current) + updated.delete(sessionKey) + updated.set(sessionKey, next) + while (updated.size > MAX_RETAINED_SESSION_STATES) { + const oldest = updated.keys().next().value + if (oldest === undefined) { + break + } + updated.delete(oldest) + } + return updated + }) + }, + [sessionKey] + ) + + useEffect(() => { + streamGenerationRef.current += 1 + sessionKeyRef.current = sessionKey + setLoadingOlder(false) + if (!client || !sessionId || !enabled) { + return + } + if (!connected) { + // The cleanup above drops the dead hold and stream; keyed state keeps this + // session's transcript visible while another tab can be selected. + 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, sessionKey]) + + const loadEarlier = useCallback(() => { + const current = stateRef.current + if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) { + return + } + const cursor = oldestStructuredAgentSessionCursor(current) + if (!cursor) { + return + } + const requestSessionKey = sessionKey + const requestGeneration = streamGenerationRef.current + setLoadingOlder(true) + void callAgentSession(client, 'agentSession.history', { + sessionId, + direction: 'before', + cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + .then((result) => { + if ( + result.ok && + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) + } + }) + .catch((error: unknown) => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } + }) + .finally(() => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + setLoadingOlder(false) + } + }) + }, [apply, client, loadingOlder, sessionId, sessionKey]) + + 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/cellular-connecting-label-stall.test.ts b/mobile/src/transport/cellular-connecting-label-stall.test.ts index e9a46d3b406..358b0abab41 100644 --- a/mobile/src/transport/cellular-connecting-label-stall.test.ts +++ b/mobile/src/transport/cellular-connecting-label-stall.test.ts @@ -19,6 +19,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + type CarrierBehavior = // Carrier silently drops the SYN to a LAN/CGNAT destination: the socket sits // CONNECTING until the client's 12s connect timeout fires. diff --git a/mobile/src/transport/direct-connection-log.ts b/mobile/src/transport/direct-connection-log.ts index ef805e643c5..2630b008459 100644 --- a/mobile/src/transport/direct-connection-log.ts +++ b/mobile/src/transport/direct-connection-log.ts @@ -43,4 +43,8 @@ export class DirectConnectionLog { { code: 'liveness-timeout' } ) } + + connected = (): void => { + this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' }) + } } diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 36ed573aa5b..16306f95cdd 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 { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-' @@ -226,17 +227,22 @@ export class DirectRpcClient implements RpcClient { } private handleAuthenticated(session: RpcClientSocketSession): void { - console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() }) this.livenessSession = session this.liveness.start(session) - this.authenticationGeneration++ - this.reconnect.authenticated() - this.authenticationRetry.accepted() - this.connectionState.publish('connected') - this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', { - code: 'direct-connected' + const generation = ++this.authenticationGeneration + negotiateMobileRuntimeCapabilities({ + sendRequest: (method, params) => + this.requests.sendAuthenticatedRequest(method, params, 5_000), + current: () => this.socketSession === session && this.authenticationGeneration === generation, + onReady: () => { + this.reconnect.authenticated() + this.authenticationRetry.accepted() + this.connectionState.publish('connected') + this.connectionLog.connected() + this.streams.replayAfterAuthentication() + }, + onFailure: () => this.socketClose.forceClose(session) }) - this.streams.replayAfterAuthentication() } private handleRpcResponse(response: RpcResponse): void { diff --git a/mobile/src/transport/foreground-stale-dial-restart.test.ts b/mobile/src/transport/foreground-stale-dial-restart.test.ts index 8c9ebd94d7e..39410e8fc3f 100644 --- a/mobile/src/transport/foreground-stale-dial-restart.test.ts +++ b/mobile/src/transport/foreground-stale-dial-restart.test.ts @@ -25,6 +25,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + // Mirrors React Native's WebSocket: readyState lives in JS and only advances on // a delivered event, so a socket the OS killed while the app was suspended stays // CONNECTING forever from the client's point of view. diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index a3885a226c0..b811721e562 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -74,6 +74,16 @@ async function authenticateSession(onLog?: ConnectionLogSink) { _meta: { runtimeId: 'runtime-1' } }) ) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2)) + const capabilities = sentRequests()[1]! + fakes.linkOptions!.onText( + JSON.stringify({ + id: capabilities.id, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) await vi.waitFor(() => expect(session.getState()).toBe('connected')) fakes.sendText.mockClear() return session diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index 7f98436c63b..5887dffc73d 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -55,7 +55,7 @@ function openSession() { }) } -async function authenticateSession() { +async function confirmResume() { const session = openSession() fakes.linkOptions!.onHello({ type: 'relay-hello', @@ -93,9 +93,39 @@ async function authenticateSession() { _meta: { runtimeId: 'runtime-1' } }) ) + 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[] } + } + return { session, confirmationRequest: request, capabilityRequest } +} + +async function authenticateSession(capabilitySupported = true) { + const { session, confirmationRequest, capabilityRequest } = await confirmResume() + expect(session.getState()).toBe('handshaking') + fakes.linkOptions!.onText( + JSON.stringify( + capabilitySupported + ? { + id: capabilityRequest.id, + ok: true, + result: capabilityRequest.params, + _meta: { runtimeId: 'runtime-1' } + } + : { + id: capabilityRequest.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + ) + ) await vi.waitFor(() => expect(session.getState()).toBe('connected')) fakes.sendText.mockClear() - return { session, confirmationRequest: request } + return { session, confirmationRequest, capabilityRequest } } describe('mobile relay RPC session', () => { @@ -107,7 +137,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, @@ -121,9 +151,32 @@ 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)) }) + it('connects when an older runtime rejects capability negotiation', async () => { + const { session } = await authenticateSession(false) + + expect(session.getState()).toBe('connected') + expect(session.getFailure()).toBeNull() + }) + + it('connects when the relay never answers capability negotiation', async () => { + const { session } = await confirmResume() + + // Why: the advisory's own deadline used to fail confirmResume, so a link too slow to + // answer within the request timeout never published 'connected' — it just redialled. + await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 }) + expect(session.getFailure()).toBeNull() + }) + // Why: ConnectionState stays 'connecting' until relay-hello, so the migration bound // needs a separate signal to tell "cell never answered the upgrade" from "cell took // relay-auth and is still resolving the assignment". diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 40b93927139..947a1d23ce8 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -10,7 +10,9 @@ import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage' +import { RelayPendingRequests } from './relay-pending-requests' import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' +import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' import type { RpcClient } from './rpc-client' import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types' @@ -19,12 +21,6 @@ const RELAY_MISSED_PROBE_LIMIT = 2 const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000 let relayRpcSessionSequence = 0 -type PendingRequest = { - resolve: (response: RpcResponse) => void - reject: (error: Error) => void - timer: ReturnType -} - export type MobileRelayRpcSession = RpcClient & RelayDialStageSource & { // The cell's attach-reservation deadline (~10s). Diagnostics only — never @@ -47,10 +43,9 @@ export function connectMobileRelayRpcSession(args: { onLog?: ConnectionLogSink }): MobileRelayRpcSession { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 - const pending = new Map() + const pending = new RelayPendingRequests() const stateListeners = new Set<(state: ConnectionState) => void>() let state: ConnectionState = 'connecting' - let requestCounter = 0 let lastConnectedAt: number | null = null let attachDeadlineAt: number | null = null let resumeExpiresAt: number | null = null @@ -62,7 +57,7 @@ export function connectMobileRelayRpcSession(args: { const livenessIdentity = {} const dialStage = new RelayDialStageTracker() const streams = new MobileRelayRpcStreams({ - nextId, + nextId: () => pending.nextId(), sendFrame, waitForConnected: () => waitForConnected() }) @@ -137,7 +132,7 @@ export function connectMobileRelayRpcSession(args: { closed = true livenessWatchdog.stop(livenessIdentity) link.close() - rejectPending(new Error('Client closed')) + pending.rejectAll(new Error('Client closed')) streams.clear() publishState('disconnected') }, @@ -155,7 +150,8 @@ export function connectMobileRelayRpcSession(args: { missedProbeLimit: RELAY_MISSED_PROBE_LIMIT, voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS, sendProbe: () => - state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }), + state === 'connected' && + sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }), onTimeout: (evidence) => { args.onLog?.({ id: `relay-liveness-${logSessionId}-${++logSequence}`, @@ -190,6 +186,10 @@ export function connectMobileRelayRpcSession(args: { resumeConfirmation = result.resumeConfirmation resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt lastConnectedAt = Date.now() + // Why: an unanswered advisory must not keep a slow relay from ever reaching connected. + await settleMobileRuntimeCapabilities((method, params) => + sendRpc(method, params, requestTimeoutMs, true) + ) livenessWatchdog.start(livenessIdentity) publishState('connected') } catch (error) { @@ -206,17 +206,17 @@ export function connectMobileRelayRpcSession(args: { if (closed || (!beforeConnected && state !== 'connected')) { return Promise.reject(new Error('relay session not connected')) } - const id = nextId() + const id = pending.nextId() return new Promise((resolve, reject) => { const timer = setTimeout(() => { - pending.delete(id) + pending.drop(id) // Why: the frame was written long ago — the desktop may have processed it. reject(markRpcDeliveryUnknown(new Error(`relay RPC timed out: ${method}`))) }, timeoutMs) - pending.set(id, { resolve, reject, timer }) + pending.track(id, { resolve, reject, timer }) if (!sendFrame({ id, method, params })) { clearTimeout(timer) - pending.delete(id) + pending.drop(id) reject(new Error('relay E2EE channel not ready')) } }) @@ -236,11 +236,7 @@ export function connectMobileRelayRpcSession(args: { if (!isRpcResponse(value)) { return } - const request = pending.get(value.id) - if (request) { - clearTimeout(request.timer) - pending.delete(value.id) - request.resolve(value) + if (pending.settle(value)) { return } streams.handleResponse(value) @@ -296,28 +292,9 @@ export function connectMobileRelayRpcSession(args: { failure = error livenessWatchdog.stop(livenessIdentity) link.close() - rejectPending(error) + pending.rejectAll(error) publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected') } - - function rejectPending(error: Error): void { - if (pending.size === 0) { - return - } - // Why: pending entries only exist after their frame reached the authenticated - // link (sendFrame failures delete them synchronously), so the desktop may - // have processed them — mark the ambiguity for callers. - markRpcDeliveryUnknown(error) - for (const request of pending.values()) { - clearTimeout(request.timer) - request.reject(error) - } - pending.clear() - } - - function nextId(): string { - return `relay-rpc-${++requestCounter}-${Date.now()}` - } } function asError(error: unknown): Error { diff --git a/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts b/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts new file mode 100644 index 00000000000..6eb659d14a6 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +function negotiate(args: { reject: unknown; current?: boolean }): { + onReady: ReturnType + onFailure: ReturnType +} { + const onReady = vi.fn() + const onFailure = vi.fn() + negotiateMobileRuntimeCapabilities({ + sendRequest: () => Promise.reject(args.reject), + current: () => args.current ?? true, + onReady, + onFailure + }) + return { onReady, onFailure } +} + +describe('mobile runtime capability negotiation', () => { + it('proceeds when the host never answers, so a slow link still reaches connected', async () => { + const timedOut = markRpcDeliveryUnknown( + new Error('Request timed out: runtime.clientCapabilities.update') + ) + const { onReady, onFailure } = negotiate({ reject: timedOut }) + + await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('proceeds when the socket drops the request mid-flight', async () => { + const interrupted = markRpcDeliveryUnknown(new Error('Connection interrupted')) + const { onReady, onFailure } = negotiate({ reject: interrupted }) + + await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('fails a socket that could not put the advisory on the wire', async () => { + const { onReady, onFailure } = negotiate({ reject: new Error('Connection interrupted') }) + + await vi.waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1)) + expect(onReady).not.toHaveBeenCalled() + }) + + it('leaves a replaced session alone on an unanswered request', async () => { + const timedOut = markRpcDeliveryUnknown(new Error('Request timed out')) + const { onReady, onFailure } = negotiate({ reject: timedOut, current: false }) + + await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled()) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('leaves a replaced session alone on a successful response', async () => { + const onReady = vi.fn() + const onFailure = vi.fn() + negotiateMobileRuntimeCapabilities({ + sendRequest: () => + Promise.resolve({ id: 'capability-1', ok: true, result: {} } as RpcResponse), + current: () => false, + onReady, + onFailure + }) + + await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled()) + expect(onFailure).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-runtime-capability-negotiation.ts b/mobile/src/transport/mobile-runtime-capability-negotiation.ts new file mode 100644 index 00000000000..7221f8e085a --- /dev/null +++ b/mobile/src/transport/mobile-runtime-capability-negotiation.ts @@ -0,0 +1,57 @@ +import { + MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + mobileRuntimeClientCapabilityUpdateParams +} from './mobile-runtime-client-capabilities' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +type CapabilityRequest = (method: string, params: unknown) => Promise + +/** + * The advisory is one-way and its result is discarded, so an unanswered request says nothing about + * the link — only a frame that never reached the wire proves the socket cannot carry traffic. + * Everything else (timeout, mid-flight drop) settles like an explicit rejection: capabilities + * unavailable, proceed. Rejects for the unsent case alone. + */ +export async function settleMobileRuntimeCapabilities( + sendRequest: CapabilityRequest +): Promise { + let response: RpcResponse + try { + response = await sendRequest( + MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + mobileRuntimeClientCapabilityUpdateParams() + ) + } catch (error) { + if (!isRpcDeliveryUnknown(error)) { + throw error + } + console.warn('[net] mobile capability negotiation unanswered — proceeding', error) + return + } + if (!response.ok) { + console.warn('[net] mobile capability negotiation unavailable', response.error.code) + } +} + +export function negotiateMobileRuntimeCapabilities(args: { + sendRequest: CapabilityRequest + current: () => boolean + onReady: () => void + onFailure: () => void +}): void { + void settleMobileRuntimeCapabilities(args.sendRequest) + .then(() => { + if (args.current()) { + args.onReady() + } + }) + .catch((error: unknown) => { + if (!args.current()) { + return + } + // Why: nothing else force-closes a socket that cannot send before `connected` is published. + console.warn('[net] mobile capability negotiation could not be sent', error) + args.onFailure() + }) +} 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/relay-pending-requests.ts b/mobile/src/transport/relay-pending-requests.ts new file mode 100644 index 00000000000..8260869d73c --- /dev/null +++ b/mobile/src/transport/relay-pending-requests.ts @@ -0,0 +1,53 @@ +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +/** In-flight relay RPC requests awaiting their response frame, keyed by request id. */ +export class RelayPendingRequests { + private readonly pending = new Map() + private requestCounter = 0 + + nextId(): string { + return `relay-rpc-${++this.requestCounter}-${Date.now()}` + } + + track(id: string, request: PendingRequest): void { + this.pending.set(id, request) + } + + drop(id: string): void { + this.pending.delete(id) + } + + /** Settle the waiter for this response; false when no request owns it. */ + settle(response: RpcResponse): boolean { + const request = this.pending.get(response.id) + if (!request) { + return false + } + clearTimeout(request.timer) + this.pending.delete(response.id) + request.resolve(response) + return true + } + + rejectAll(error: Error): void { + if (this.pending.size === 0) { + return + } + // Why: pending entries only exist after their frame reached the authenticated + // link (sendFrame failures delete them synchronously), so the desktop may + // have processed them — mark the ambiguity for callers. + markRpcDeliveryUnknown(error) + for (const request of this.pending.values()) { + clearTimeout(request.timer) + request.reject(error) + } + this.pending.clear() + } +} 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..7107ae6717e --- /dev/null +++ b/mobile/src/transport/rpc-client-capabilities.test.ts @@ -0,0 +1,161 @@ +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('waits for mobile capability acknowledgement before replaying streams', async () => { + 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.some((payload) => payload.includes('session.tabs.subscribe'))).toBe(false) + + socket.receive( + `encrypted:${JSON.stringify({ + id: capabilityRequest.id, + ok: true, + result: capabilityRequest.params, + _meta: { runtimeId: 'runtime-1' } + })}` + ) + + await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()) + + client.close() + }) + + it('replays streams when an older runtime rejects capability negotiation', async () => { + 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') + socket.receive( + `encrypted:${JSON.stringify({ + id: capabilityRequest.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + })}` + ) + + await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()) + expect(client.getState()).toBe('connected') + + client.close() + }) + + it('reaches connected when a slow host never answers capability negotiation', async () => { + vi.useFakeTimers() + try { + 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"}') + sentRequest(socket, 'runtime.clientCapabilities.update') + + // Why: the 5s capability deadline used to force-close the socket, so a link + // this slow never left 'connecting' — it just redialled forever. + await vi.advanceTimersByTimeAsync(5_001) + + expect(client.getState()).toBe('connected') + expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined() + expect(socket.readyState).toBe(MockWebSocket.OPEN) + + client.close() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts index 24015ce829e..55a9dc60311 100644 --- a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 6fb0f7d3610..eca1eca03d1 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 2ed349f375a..05fe81d7944 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-request-tracker.ts b/mobile/src/transport/rpc-client-request-tracker.ts index 34edd2631e2..d96d1e97609 100644 --- a/mobile/src/transport/rpc-client-request-tracker.ts +++ b/mobile/src/transport/rpc-client-request-tracker.ts @@ -42,9 +42,28 @@ export class RpcClientRequestTracker { }) } + return this.sendConnectedRequest( + method, + params, + resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) + ) + } + + sendAuthenticatedRequest( + method: string, + params: unknown, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { + return this.sendConnectedRequest(method, params, timeoutMs) + } + + private sendConnectedRequest( + method: string, + params: unknown, + timeoutMs: number + ): Promise { return new Promise((resolve, reject) => { const id = this.options.nextId() - const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) const timeout = setTimeout(() => { this.pending.delete(id) console.log('[net] sendRequest TIMEOUT', { diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts index d18739423f7..04b2a90039e 100644 --- a/mobile/src/transport/rpc-client-runtime-events.test.ts +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class RuntimeEventTestSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts index 832180ad6c1..5898685bb70 100644 --- a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts +++ b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts @@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + // Why: close() deliberately never fires onclose — that is the wedged-transport bug being modelled. class WedgedWebSocket { static CONNECTING = 0 diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts index f382068b735..83f40113cae 100644 --- a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts index 464d0b95808..1ba79015331 100644 --- a/mobile/src/transport/rpc-client-unauthorized-close.test.ts +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index a7f3bb9d82a..e88e6c220e6 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -15,6 +15,11 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +// Capability ordering has dedicated coverage; keep connection tests focused on socket behavior. +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 @@ -64,36 +69,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/mobile/src/transport/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc-session-liveness-integration.test.ts index c446477c365..88e71a0862c 100644 --- a/mobile/src/transport/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc-session-liveness-integration.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 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..bd282b6575d 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,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi tab: RuntimeMobileSessionAgentTab ): Promise { const host = getStructuredAgentSessionHost() - if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) + if (host) { + 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 @@ -41,6 +43,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi } this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) + // Retire durable visibility and the runtime snapshot before stopping the provider. + if (typeof host?.close === 'function') { + await host.close(tab.sessionId) + } } // Why: a refused echoed close means the echoing client already pruned its 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..9e752330445 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,11 @@ 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 () => { + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs.some((tab) => tab.type === 'agent-session')).toBe(false) + }) + const setSessionTabVisibility = vi.fn(async () => undefined) runtime.setNotifier({ closeSessionTab } as never) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean @@ -221,6 +226,8 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, + close: closeStructuredSession, + setSessionTabVisibility, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -304,6 +311,11 @@ describe('structured session cold restoration', () => { 'structured-agent-session-restored-session', 'workspace-1' ) + expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') + expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) + expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( + closeStructuredSession.mock.invocationCallOrder[0]! + ) 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..b65e6eff825 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()) @@ -186,6 +192,18 @@ describe('capability gating', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) expect(hostCalls.close).toHaveBeenCalledWith(SESSION) expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) + expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( + hostCalls.close.mock.invocationCallOrder[0]! + ) + }) + + it('does not stop the provider when durable tab retirement fails', async () => { + hostCalls.setSessionTabVisibility.mockRejectedValueOnce(new Error('visibility write failed')) + + const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: false }) + expect(hostCalls.close).not.toHaveBeenCalled() }) it('advertises the capability without bumping the protocol version', () => { @@ -250,6 +268,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 }) @@ -292,6 +329,37 @@ describe('method routing', () => { ) }) + it('reports an unknown create outcome when attach commits before tab publication fails', async () => { + const worktree = 'id:workspace-1' + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + + const response = await call('agentSession.create', params, STRUCTURED_CLIENT, { + publishStructuredAgentSessionTab: vi.fn(async () => { + throw new Error('publish failed') + }) + }) + + expect(hostCalls.attach).toHaveBeenCalledOnce() + expect(response).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + } + }) + }) + it('separates create from ensure by the fence the client may declare', async () => { const created = await call('agentSession.create', attachParams()) expect(created).toMatchObject({ ok: true }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 8ea85aa0e87..ffd23499a3e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -91,12 +91,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } }) if (result.ok && resolved.agent === 'codex') { - await ctx.runtime.publishStructuredAgentSessionTab({ - workspaceId: resolved.location.workspaceId, - sessionId: result.value.sessionId, - agent: 'codex', - activate: true - }) + try { + await ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: resolved.location.workspaceId, + sessionId: result.value.sessionId, + agent: 'codex', + activate: true + }) + } catch (error) { + console.warn('[agent-session] create committed before tab publication failed', error) + return { + ok: false, + refusal: { + code: 'agent_session_operation_unknown', + message: 'The Codex chat may have been created, but its tab could not be confirmed.' + } + } + } } return result } @@ -129,11 +140,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: OptionsParams, handler: async (params, ctx) => { const host = requireHost(ctx) - await host.close(params.sessionId) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) } + await host.close(params.sessionId) return { ok: true as const } } }), 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 57f5a61af2f..767ca885234 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/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index b1480259599..7d6a0eadc3e 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -150,8 +150,9 @@ describe('host-session-mirror settle census', () => { 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': 1, // The eager post-create session.tabs.list refresh. 'runtime/web-runtime-session-snapshot.ts': 1, - // The local structured-session inventory/subscription frame. - 'runtime/local-structured-session-tabs-sync.ts': 1 + // The local structured-session mirror owns two: the inventory/subscription + // frame, and the toggle-off teardown that retracts the tabs it published. + 'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': 2 }) }) @@ -196,14 +197,19 @@ describe('host-session-mirror settle census', () => { // Hydration and mirror receipts remain pinned by their extracted owners: // the global singular frame owns two hydration completions and the global // inventory frame one, initial loading owns one, active subscription owns - // two mirror settles, and visibility resume repair owns one. + // two mirror settles, and visibility resume repair owns one. The local + // structured-session apply module owns one settle per direction: the + // snapshot it mirrors in, and the teardown that retracts it. 'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 }, 'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 2 }, 'runtime/web-session-tabs-sync/global-session-inventory-event.ts': { settleHydration: 1 }, 'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 }, 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': { settle: 1 }, 'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 }, - 'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 } + 'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': { + settleStructuredSessionClear: 1, + settleStructuredSessionMirror: 1 + } }) }) diff --git a/src/renderer/src/runtime/local-structured-session-tab-retirement.ts b/src/renderer/src/runtime/local-structured-session-tab-retirement.ts new file mode 100644 index 00000000000..81a8d0d5ef5 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tab-retirement.ts @@ -0,0 +1,64 @@ +import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync' +import type { WebSessionTabsSyncState } from './web-session-tabs-sync' + +export type StructuredSessionTabPublicationVersion = { + publicationEpoch: string + snapshotVersion: number +} + +export function knownStructuredSessionWorktreeIds( + state: WebSessionTabsSyncState & WorktreeRuntimeOwnerState +): Set { + const ids = new Set(Object.keys(state.unifiedTabsByWorktree)) + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { + for (const worktree of worktrees) { + ids.add(worktree.id) + } + } + for (const detected of Object.values(state.detectedWorktreesByRepo ?? {})) { + for (const worktree of detected.worktrees) { + ids.add(worktree.id) + } + } + for (const workspace of state.folderWorkspaces ?? []) { + ids.add(folderWorkspaceKey(workspace.id)) + } + return ids +} + +export function removeStructuredSessionTabsForVersions< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>( + state: State, + versions: Iterable, + owner: string, + now: number +): State { + let next = state + for (const [worktree, version] of versions) { + const patch = applyWebSessionTabsSnapshot( + next, + { + worktree, + publicationEpoch: version.publicationEpoch, + snapshotVersion: version.snapshotVersion + 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabGroups: [], + tabs: [] + }, + owner, + now, + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + next = patch === next ? next : ({ ...next, ...patch } as State) + } + return next +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index 59be9904e2a..7a6c713f37f 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -10,7 +10,10 @@ import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-un import { buildHydratedTabState } from '../store/slices/tabs-hydration' import { applyLocalStructuredSessionTabSnapshots, + clearLocalStructuredSessionTabs, projectLocalStructuredSessionTabs, + removeLocalStructuredSessionTabs, + refreshLocalStructuredSessionTabs, resetLocalStructuredSessionVersionForTests, startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync' @@ -160,6 +163,19 @@ function expectExactSplit(state: { } describe('local structured session tab projection', () => { + it('removes only locally mirrored structured tabs when the feature is disabled', () => { + const mirrored = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [ + structuredInventory('epoch-1', 1, 'codex-1') + ]) + + const disabled = removeLocalStructuredSessionTabs(mirrored) + + expect(disabled.unifiedTabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TERMINAL_ID, contentType: 'terminal' }) + ]) + expect(disabled.activeTabTypeByWorktree[WORKTREE_ID]).toBe('terminal') + }) + it('reconnects after a streaming subscription reports an error', async () => { vi.useFakeTimers() const priorApi = window.api @@ -217,6 +233,80 @@ describe('local structured session tab projection', () => { } }) + it('ignores an in-flight inventory response after toggle-off clears the mirror', async () => { + let resolveInventory: ((response: unknown) => void) | undefined + const pendingInventory = new Promise((resolve) => { + resolveInventory = resolve + }) + const priorApi = window.api + Object.defineProperty(window, 'api', { + configurable: true, + value: { + runtime: { + call: vi.fn().mockReturnValue(pendingInventory) + } + } + }) + try { + const refresh = refreshLocalStructuredSessionTabs() + clearLocalStructuredSessionTabs() + resolveInventory?.({ + ok: true, + result: { snapshots: [structuredInventory('epoch-1', 8, 'stale-session')] } + }) + await refresh + + const fresh = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [ + structuredInventory('epoch-1', 1, 'fresh-session') + ]) + expect(fresh.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'fresh-session' })]) + ) + } finally { + Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) + } + }) + + it('ignores a subscription frame after toggle-off clears the mirror', async () => { + const callbacks: ((response: unknown) => void)[] = [] + const priorApi = window.api + Object.defineProperty(window, 'api', { + configurable: true, + value: { + runtime: { + getStatus: vi.fn().mockResolvedValue({ + capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + call: vi.fn().mockResolvedValue({ ok: true, result: { snapshots: [] } }), + subscribe: vi.fn(async (_args: unknown, callback: (response: unknown) => void) => { + callbacks.push(callback) + return { unsubscribe: vi.fn() } + }) + } + } + }) + let unsubscribe = (): void => {} + try { + await startLocalStructuredSessionTabsSync({ + isDisposed: () => false, + setUnsubscribe: (next) => { + unsubscribe = next + } + }) + clearLocalStructuredSessionTabs() + callbacks[0]?.({ ok: true, result: structuredInventory('epoch-1', 8, 'stale-session') }) + const fresh = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [ + structuredInventory('epoch-1', 1, 'fresh-session') + ]) + expect(fresh.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'fresh-session' })]) + ) + } finally { + unsubscribe() + Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) + } + }) + it('accepts a newer session after merged content returns to the base epoch', () => { const state = createSnapshot() const base = { 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..722bd93af8b 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -1,293 +1,36 @@ import { useEffect } from 'react' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { useAppStore } from '../store' -import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' -import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner' -import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync' -import type { WebSessionTabsSyncState } from './web-session-tabs-sync' -import { - noteRetiredValue, - sameSessionTabsPublicationLineage -} from './web-session-tabs-sync/publisher-identity-fences' -import type { SessionTabsPublicationEpochHistory } from './web-session-tabs-sync/state' -import { refreshLocalRuntimeCapabilities } from './local-runtime-capabilities' +import { clearLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-apply' +import { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' -export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' -let localStructuredSessionTabsRestorePromise: Promise | null = null -const localStructuredSessionVersionByWorktree = new Map< - string, - { publicationEpoch: string; snapshotVersion: number } ->() -const localStructuredSessionEpochHistoryByWorktree = new Map< - string, - SessionTabsPublicationEpochHistory ->() - -export function resetLocalStructuredSessionVersionForTests(): void { - localStructuredSessionVersionByWorktree.clear() - localStructuredSessionEpochHistoryByWorktree.clear() -} - -type SessionTabsEvent = - | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } - | { type: 'end' } - -export function projectLocalStructuredSessionTabs( - snapshot: RuntimeMobileSessionTabsResult -): RuntimeMobileSessionTabsResult { - const structuredIds = new Set( - snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) - ) - const visibleHostTabIds = structuredIds - const visibleIds = structuredIds - const projectedTabGroups = snapshot.tabGroups - ?.map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)), - activeTabId: - group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null, - recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id)) - })) - .filter((group) => group.tabOrder.length > 0) - - return { - ...snapshot, - activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null, - activeTabType: - snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null, - activeGroupId: - snapshot.activeGroupId && - projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId) - ? snapshot.activeGroupId - : (projectedTabGroups?.[0]?.id ?? null), - tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)), - tabGroups: projectedTabGroups, - // Why: group membership locates chats; the renderer's split tree remains locally authoritative. - tabGroupLayout: undefined - } -} - -export function applyStructuredSessionTabSnapshots( - snapshots: readonly RuntimeMobileSessionTabsResult[], - owner = LOCAL_STRUCTURED_SESSION_OWNER -): void { - const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( - (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), - { frames: [] } - ) - settleStructuredSessionMirror() -} - -export function applyLocalStructuredSessionTabSnapshots< - State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState ->( - state: State, - snapshots: readonly RuntimeMobileSessionTabsResult[], - owner = LOCAL_STRUCTURED_SESSION_OWNER, - now = Date.now() -): State { - let next = state - for (const snapshot of snapshots) { - // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. - if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { - continue - } - const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree) - const sharesLineage = Boolean( - prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch) - ) - const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree) - if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) { - continue - } - if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) { - continue - } - const patch = applyWebSessionTabsSnapshot( - next, - projectLocalStructuredSessionTabs(snapshot), - owner, - now, - { - contentScope: 'agent-session', - preserveLocalLayout: true, - terminalPtyMode: 'local' - } - ) - next = patch === next ? next : ({ ...next, ...patch } as State) - localStructuredSessionVersionByWorktree.set(snapshot.worktree, { - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion - }) - localStructuredSessionEpochHistoryByWorktree.set( - snapshot.worktree, - noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8) - ) - } - // Drop publisher cursors for worktrees that no longer exist. Without this, - // every deleted worktree leaves an entry for the lifetime of the renderer. - const knownWorktreeIds = new Set(Object.keys(next.unifiedTabsByWorktree)) - for (const worktrees of Object.values(next.worktreesByRepo ?? {})) { - for (const worktree of worktrees) { - knownWorktreeIds.add(worktree.id) - } - } - for (const detected of Object.values(next.detectedWorktreesByRepo ?? {})) { - for (const worktree of detected.worktrees) { - knownWorktreeIds.add(worktree.id) - } - } - for (const workspace of next.folderWorkspaces ?? []) { - knownWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) { - if (!knownWorktreeIds.has(worktreeId)) { - localStructuredSessionVersionByWorktree.delete(worktreeId) - localStructuredSessionEpochHistoryByWorktree.delete(worktreeId) - } - } - return next -} - -export function restoreLocalStructuredSessionTabsOnce(): Promise { - localStructuredSessionTabsRestorePromise ??= refreshLocalRuntimeCapabilities() - .then(() => refreshLocalStructuredSessionTabs()) - .then(() => undefined) - .catch((error) => { - localStructuredSessionTabsRestorePromise = null - throw error - }) - return localStructuredSessionTabsRestorePromise -} - -/** Fetch the current host inventory even after the startup restore has settled. */ -export function refreshLocalStructuredSessionTabs(): Promise { - return window.api.runtime - .call({ method: 'session.tabs.listAll', params: {} }) - .then((response) => { - if (!response.ok) { - throw new Error('structured session inventory unavailable') - } - const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } - const snapshots = result.snapshots ?? [] - applyStructuredSessionTabSnapshots(snapshots) - return snapshots - }) -} - -export async function startLocalStructuredSessionTabsSync(args: { - isDisposed: () => boolean - setUnsubscribe: (unsubscribe: () => void) => void -}): Promise { - const capabilities = await refreshLocalRuntimeCapabilities() - if (args.isDisposed()) { - return - } - const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - await restoreLocalStructuredSessionTabsOnce() - if (args.isDisposed()) { - return - } - if (!supported) { - return - } - let subscriptionGeneration = 0 - let reconnectTimer: ReturnType | null = null - let reconnectAttempt = 0 - let activeHandle: { unsubscribe: () => void } | null = null - const scheduleSubscribeRetry = (): void => { - if (args.isDisposed() || reconnectTimer !== null) { - return - } - const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000) - reconnectAttempt += 1 - reconnectTimer = setTimeout(() => { - reconnectTimer = null - void refreshLocalStructuredSessionTabs() - .catch((error) => console.warn('[structured-session-tabs] resync failed', error)) - .finally(() => { - if (!args.isDisposed()) { - void subscribeCurrent().catch((error) => { - console.warn('[structured-session-tabs] resubscribe failed', error) - scheduleSubscribeRetry() - }) - } - }) - }, reconnectDelay) - } - const subscribeCurrent = async (): Promise => { - if (args.isDisposed()) { - return - } - const generation = ++subscriptionGeneration - let handle: { unsubscribe: () => void } | null = null - handle = await window.api.runtime.subscribe( - { method: 'session.tabs.subscribeAll', params: {} }, - (response) => { - if (args.isDisposed() || generation !== subscriptionGeneration) { - return - } - if (!response.ok) { - // A streaming RPC can terminate with an error response before its - // handle resolves; fence that generation and retry the subscription. - subscriptionGeneration += 1 - handle?.unsubscribe() - if (activeHandle === handle) { - activeHandle = null - } - scheduleSubscribeRetry() - return - } - const event = response.result as SessionTabsEvent - if (event.type === 'snapshots') { - applyStructuredSessionTabSnapshots(event.snapshots) - } else if (event.type === 'snapshot' || event.type === 'updated') { - applyStructuredSessionTabSnapshots([event]) - } else if (event.type === 'end' && generation === subscriptionGeneration) { - // Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs. - subscriptionGeneration += 1 - handle?.unsubscribe() - if (activeHandle === handle) { - activeHandle = null - } - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer) - } - scheduleSubscribeRetry() - } - } - ) - if (args.isDisposed() || generation !== subscriptionGeneration) { - handle.unsubscribe() - } else { - activeHandle = handle - } - } - args.setUnsubscribe(() => { - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - activeHandle?.unsubscribe() - activeHandle = null - }) - void subscribeCurrent().catch((error) => { - console.warn('[structured-session-tabs] subscribe failed', error) - scheduleSubscribeRetry() - }) -} +export { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence' +export { + refreshLocalStructuredSessionTabs, + restoreLocalStructuredSessionTabsOnce +} from './local-structured-session-tabs-sync/inventory-refresh' +export { + applyLocalStructuredSessionTabSnapshots, + applyStructuredSessionTabSnapshots, + clearLocalStructuredSessionTabs, + LOCAL_STRUCTURED_SESSION_OWNER, + removeLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync/snapshot-apply' +export { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-projection' +export { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' export function useLocalStructuredSessionTabsSync(): void { const ready = useAppStore( (state) => state.workspaceSessionReady && state.terminalStartupRestorationReady ) + const enabled = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) useEffect(() => { if (!ready) { return } + if (!enabled) { + clearLocalStructuredSessionTabs() + return + } let disposed = false let unsubscribe = (): void => {} void startLocalStructuredSessionTabsSync({ @@ -300,5 +43,5 @@ export function useLocalStructuredSessionTabsSync(): void { disposed = true unsubscribe() } - }, [ready]) + }, [enabled, ready]) } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts new file mode 100644 index 00000000000..8ab68385f9e --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts @@ -0,0 +1,57 @@ +import type { SessionTabsPublicationEpochHistory } from '../web-session-tabs-sync/state' +import type { StructuredSessionTabPublicationVersion } from '../local-structured-session-tab-retirement' + +// Everything a toggle-off must invalidate: which publisher instance the renderer +// is listening to, which publication it already accepted per worktree, and the +// one-shot startup restore. A response in flight for a superseded instance must +// never reach the mirror, so every async entry point carries the generation it +// was started under and re-checks it before applying. +let syncGeneration = 0 +let restorePromise: Promise | null = null + +export const localStructuredSessionVersionByWorktree = new Map< + string, + StructuredSessionTabPublicationVersion +>() +export const localStructuredSessionEpochHistoryByWorktree = new Map< + string, + SessionTabsPublicationEpochHistory +>() + +export function localStructuredSessionGeneration(): number { + return syncGeneration +} + +export function isCurrentLocalStructuredSessionGeneration(generation: number): boolean { + return generation === syncGeneration +} + +/** Retire the current publisher instance: responses already in flight stop applying. */ +export function supersedeLocalStructuredSessionGeneration(): void { + syncGeneration += 1 +} + +// Separate from superseding because a teardown still has to publish the retiring +// cursors as retracted tabs before it may forget them. +export function forgetLocalStructuredSessionPublicationCursors(): void { + localStructuredSessionVersionByWorktree.clear() + localStructuredSessionEpochHistoryByWorktree.clear() +} + +export function dropLocalStructuredSessionRestoreLatch(): void { + restorePromise = null +} + +/** Latch the startup restore, releasing it on failure so a retry can re-run it. */ +export function latchLocalStructuredSessionRestore(start: () => Promise): Promise { + restorePromise ??= start().catch((error: unknown) => { + restorePromise = null + throw error + }) + return restorePromise +} + +export function resetLocalStructuredSessionVersionForTests(): void { + supersedeLocalStructuredSessionGeneration() + forgetLocalStructuredSessionPublicationCursors() +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts new file mode 100644 index 00000000000..d0f29ec0cf8 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts @@ -0,0 +1,37 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities' +import { + isCurrentLocalStructuredSessionGeneration, + latchLocalStructuredSessionRestore, + localStructuredSessionGeneration +} from './inventory-generation-fence' +import { applyStructuredSessionTabSnapshots } from './snapshot-apply' + +export function restoreLocalStructuredSessionTabsOnce( + expectedGeneration = localStructuredSessionGeneration() +): Promise { + return latchLocalStructuredSessionRestore(() => + refreshLocalRuntimeCapabilities() + .then(() => refreshLocalStructuredSessionTabs(expectedGeneration)) + .then(() => undefined) + ) +} + +/** Fetch the current host inventory even after the startup restore has settled. */ +export function refreshLocalStructuredSessionTabs( + expectedGeneration = localStructuredSessionGeneration() +): Promise { + return window.api.runtime + .call({ method: 'session.tabs.listAll', params: {} }) + .then((response) => { + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshots = result.snapshots ?? [] + if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) { + applyStructuredSessionTabSnapshots(snapshots) + } + return snapshots + }) +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts new file mode 100644 index 00000000000..fc254de62dc --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -0,0 +1,118 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { WorktreeRuntimeOwnerState } from '../../lib/worktree-runtime-owner' +import { getExecutionHostIdForWorktree } from '../../lib/worktree-runtime-owner' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch +} from '../web-session-tabs-sync' +import type { WebSessionTabsSyncState } from '../web-session-tabs-sync' +import { + noteRetiredValue, + sameSessionTabsPublicationLineage +} from '../web-session-tabs-sync/publisher-identity-fences' +import { + knownStructuredSessionWorktreeIds, + removeStructuredSessionTabsForVersions +} from '../local-structured-session-tab-retirement' +import { + dropLocalStructuredSessionRestoreLatch, + forgetLocalStructuredSessionPublicationCursors, + localStructuredSessionEpochHistoryByWorktree, + localStructuredSessionVersionByWorktree, + supersedeLocalStructuredSessionGeneration +} from './inventory-generation-fence' +import { projectLocalStructuredSessionTabs } from './snapshot-projection' + +export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' + +export function applyStructuredSessionTabSnapshots( + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER +): void { + const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( + (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), + { frames: [] } + ) + settleStructuredSessionMirror() +} + +export function removeLocalStructuredSessionTabs< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State { + return removeStructuredSessionTabsForVersions( + state, + localStructuredSessionVersionByWorktree, + owner, + now + ) +} + +export function clearLocalStructuredSessionTabs(): void { + // Fence responses from the previous enabled instance before clearing its mirror. + supersedeLocalStructuredSessionGeneration() + const settleStructuredSessionClear = applyWebSessionTabsStorePatch( + (state) => removeLocalStructuredSessionTabs(state), + { frames: [] } + ) + settleStructuredSessionClear() + dropLocalStructuredSessionRestoreLatch() + forgetLocalStructuredSessionPublicationCursors() +} + +export function applyLocalStructuredSessionTabSnapshots< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>( + state: State, + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER, + now = Date.now() +): State { + let next = state + for (const snapshot of snapshots) { + // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. + if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { + continue + } + const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree) + const sharesLineage = Boolean( + prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch) + ) + const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree) + if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) { + continue + } + if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) { + continue + } + const patch = applyWebSessionTabsSnapshot( + next, + projectLocalStructuredSessionTabs(snapshot), + owner, + now, + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + next = patch === next ? next : ({ ...next, ...patch } as State) + localStructuredSessionVersionByWorktree.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion + }) + localStructuredSessionEpochHistoryByWorktree.set( + snapshot.worktree, + noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8) + ) + } + // Drop publisher cursors for worktrees that no longer exist. Without this, + // every deleted worktree leaves an entry for the lifetime of the renderer. + const knownWorktreeIds = knownStructuredSessionWorktreeIds(next) + for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) { + if (!knownWorktreeIds.has(worktreeId)) { + localStructuredSessionVersionByWorktree.delete(worktreeId) + localStructuredSessionEpochHistoryByWorktree.delete(worktreeId) + } + } + return next +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts new file mode 100644 index 00000000000..b6d7afc5048 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts @@ -0,0 +1,37 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +/** Narrow a host inventory snapshot to the structured agent-session tabs it publishes. */ +export function projectLocalStructuredSessionTabs( + snapshot: RuntimeMobileSessionTabsResult +): RuntimeMobileSessionTabsResult { + const structuredIds = new Set( + snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) + ) + const visibleHostTabIds = structuredIds + const visibleIds = structuredIds + const projectedTabGroups = snapshot.tabGroups + ?.map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)), + activeTabId: + group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null, + recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id)) + })) + .filter((group) => group.tabOrder.length > 0) + + return { + ...snapshot, + activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null, + activeTabType: + snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null, + activeGroupId: + snapshot.activeGroupId && + projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId) + ? snapshot.activeGroupId + : (projectedTabGroups?.[0]?.id ?? null), + tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)), + tabGroups: projectedTabGroups, + // Why: group membership locates chats; the renderer's split tree remains locally authoritative. + tabGroupLayout: undefined + } +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts new file mode 100644 index 00000000000..b074cfb1c41 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts @@ -0,0 +1,122 @@ +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities' +import { + isCurrentLocalStructuredSessionGeneration, + localStructuredSessionGeneration +} from './inventory-generation-fence' +import { + refreshLocalStructuredSessionTabs, + restoreLocalStructuredSessionTabsOnce +} from './inventory-refresh' +import { applyStructuredSessionTabSnapshots } from './snapshot-apply' + +type SessionTabsEvent = + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { type: 'end' } + +export async function startLocalStructuredSessionTabsSync(args: { + isDisposed: () => boolean + setUnsubscribe: (unsubscribe: () => void) => void +}): Promise { + const syncGeneration = localStructuredSessionGeneration() + const isCurrent = (): boolean => + !args.isDisposed() && isCurrentLocalStructuredSessionGeneration(syncGeneration) + const capabilities = await refreshLocalRuntimeCapabilities() + if (!isCurrent()) { + return + } + const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + await restoreLocalStructuredSessionTabsOnce(syncGeneration) + if (!isCurrent()) { + return + } + if (!supported) { + return + } + let subscriptionGeneration = 0 + let reconnectTimer: ReturnType | null = null + let reconnectAttempt = 0 + let activeHandle: { unsubscribe: () => void } | null = null + const scheduleSubscribeRetry = (): void => { + if (!isCurrent() || reconnectTimer !== null) { + return + } + const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000) + reconnectAttempt += 1 + reconnectTimer = setTimeout(() => { + reconnectTimer = null + void refreshLocalStructuredSessionTabs(syncGeneration) + .catch((error) => console.warn('[structured-session-tabs] resync failed', error)) + .finally(() => { + if (isCurrent()) { + void subscribeCurrent().catch((error) => { + console.warn('[structured-session-tabs] resubscribe failed', error) + scheduleSubscribeRetry() + }) + } + }) + }, reconnectDelay) + } + const subscribeCurrent = async (): Promise => { + if (!isCurrent()) { + return + } + const generation = ++subscriptionGeneration + let handle: { unsubscribe: () => void } | null = null + handle = await window.api.runtime.subscribe( + { method: 'session.tabs.subscribeAll', params: {} }, + (response) => { + if (!isCurrent() || generation !== subscriptionGeneration) { + return + } + if (!response.ok) { + // A streaming RPC can terminate with an error response before its + // handle resolves; fence that generation and retry the subscription. + subscriptionGeneration += 1 + handle?.unsubscribe() + if (activeHandle === handle) { + activeHandle = null + } + scheduleSubscribeRetry() + return + } + const event = response.result as SessionTabsEvent + if (event.type === 'snapshots') { + applyStructuredSessionTabSnapshots(event.snapshots) + } else if (event.type === 'snapshot' || event.type === 'updated') { + applyStructuredSessionTabSnapshots([event]) + } else if (event.type === 'end' && generation === subscriptionGeneration) { + // Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs. + subscriptionGeneration += 1 + handle?.unsubscribe() + if (activeHandle === handle) { + activeHandle = null + } + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + } + scheduleSubscribeRetry() + } + } + ) + if (!isCurrent() || generation !== subscriptionGeneration) { + handle.unsubscribe() + } else { + activeHandle = handle + } + } + args.setUnsubscribe(() => { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + activeHandle?.unsubscribe() + activeHandle = null + }) + void subscribeCurrent().catch((error) => { + console.warn('[structured-session-tabs] subscribe failed', error) + scheduleSubscribeRetry() + }) +} 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/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 24b500fc2b3..48029b5910e 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -95,7 +95,8 @@ export function reduceStructuredAgentSession( action: StructuredAgentSessionAction ): StructuredAgentSessionState { if (action.type === 'loading') { - return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'loading' } + // Keep the last transcript visible while a reconnect rehydrates the stream. + return { ...state, status: 'loading', error: undefined } } if (action.type === 'error') { return { ...state, status: 'error', error: action.message } 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 }