feat(mobile): structured native Codex chat (#18074)

* feat(mobile): finalize structured native Codex chat

* fix(mobile): close structured chat lifecycle gaps

* wip(mobile): fence stale structured inventory and bound operation-id retention

Fence local structured-session inventory and subscription responses with a
sync generation so a toggle-off clear, reconnect restore, or retry cannot
apply a mirror from a superseded instance. Bound mobile ambiguous
operation-ID retention at 128 with unmount cleanup.

Staged on the reconcile branch only: the sync module is now 312 lines and
needs a real split before this can reach the PR head.

* fix(ci): split the structured session-tabs sync and give static analysis mobile types

The local structured session-tabs sync module outgrew the 300-line cap once it
took on generation fencing, so split it along its real seams instead of raising
the cap: the generation/cursor fence, snapshot projection, snapshot apply,
inventory refresh, and the subscription loop. The original path stays as a
barrel so no importer moves.

Repoint the host-session-mirror settle census at the apply module, which owns
two receipts now — the snapshot it mirrors in, and the toggle-off teardown that
retracts what it published. The teardown receipt is named rather than anonymous
so the pin says which direction it settles.

The changed-code quality gate lints mobile files and resolves their types from
mobile/node_modules, but mobile is a separate pnpm project that the root install
never populates, so every mobile type degraded to an `error` type and the gate
reported phantom findings. Install mobile dependencies in static analysis when
the diff touches mobile, gated on a new classifier output.

* fix(mobile): let a slow capability handshake still reach connected

The mobile capability update is an advisory whose result is discarded, yet an
unanswered one was fatal while an explicit rejection was tolerated. A 5s timeout
on the direct client force-closed the socket, and on the relay path it failed
`confirmResume` before `connected` was ever published, so a consistently slow
link redialled forever. Both paths now share one helper that settles every
ambiguous outcome (timeout, mid-flight drop) like a rejection and rejects only
when the frame never reached the wire — the one case nothing else recovers from,
since the socket's own desync force-close is gated on already being connected.
The generation guard still keeps a replaced session from connecting.

Retained structured-session operation ids were capped at 128 with oldest-first
eviction, but every retained id belongs to a send whose outcome is unknown, so
eviction turned a user's retry into a second message on the host. Bound the map
by expiry against the id's own embedded timestamp instead, mirroring the host's
operation ledger, so no id is released while the host would still honour it.

Also give the mobile CI install the root install's lockfile drift guard (mobile's
lockfile carries patchedDependencies a silent rewrite would drop), gate
mobile_dependencies on should_run, and key the pnpm store cache on both lockfiles.

* refactor(mobile): extract the relay pending-request registry

The merge composed two independently-sized changes — this branch's capability
handshake settle and main's dial-stage tracking — pushing the relay session file
to 304 lines against a 300 cap. Neither side broke it alone.

Move the in-flight request registry (id generation, tracking, settlement, and
reject-all with its delivery-ambiguity marking) into RelayPendingRequests,
matching the existing collaborator pattern alongside RelayDialStageTracker and
RpcSessionLivenessWatchdog. No behavior change.

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-03 15:19:26 -07:00
committed by GitHub
co-authored by Merge Sim
parent c79e1c097b
commit 98e77ef1a7
122 changed files with 5667 additions and 764 deletions
@@ -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
+20
View File
@@ -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 }}"
+9
View File
@@ -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
}
}
@@ -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()
+45 -36
View File
@@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
type MobileChatQuestion
} from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
@@ -18,6 +22,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const allowOther = question.allowOther !== false
const hasOptions = question.options.length > 0
const trimmedFreeText = freeText.trim()
@@ -42,8 +47,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
}
}
const answerSingle = async (option: string): Promise<void> => {
await sendAnswer(formatQuestionAnswer(question, [option]))
const answerSingle = async (option: string, optionIndex: number): Promise<void> => {
const token = question.optionTokens[optionIndex]
await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option]))
}
const submitMulti = async (): Promise<void> => {
@@ -57,14 +63,13 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
if (trimmedFreeText.length === 0) {
return
}
// Free text is an unknown entry; formatQuestionAnswer passes it through.
if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) {
if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) {
setFreeText('')
}
}
const canSubmitMulti = selected.length > 0 && !sending
const canSendFreeText = trimmedFreeText.length > 0 && !sending
const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
@@ -81,7 +86,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
{hasOptions ? (
<View style={styles.options}>
{optionRows.map(({ label, key }) => {
{optionRows.map(({ label, key }, optIndex) => {
const isSelected = selected.includes(label)
return (
<Pressable
@@ -93,7 +98,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))}
onPress={() =>
question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
@@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
</Pressable>
) : null}
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
</Pressable>
</View>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
) : null}
</View>
)
}
@@ -38,7 +38,7 @@ export function MobileSessionActiveContent({
browserScreencastSupported,
showToast,
nativeChatSendError,
nativeChatInputLockReason,
nativeChatOverlayInputLockReason,
nativeChatController,
dictation,
handleDictationToggle,
@@ -240,7 +240,7 @@ export function MobileSessionActiveContent({
dictationMode={dictationMode}
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
inputLockReason={nativeChatInputLockReason}
inputLockReason={nativeChatOverlayInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
@@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
@@ -43,6 +43,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
setFileActionTarget,
browserActionTarget,
setBrowserActionTarget,
agentSessionActionTarget,
setAgentSessionActionTarget,
discardMarkdownTarget,
setDiscardMarkdownTarget,
leaveDrafts,
@@ -261,6 +263,14 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
onCloseTab={handleCloseSessionTab}
bulkCloseActions={bulkCloseActions}
/>
<ActionSheetModal
visible={agentSessionActionTarget != null}
title={agentSessionActionTarget?.title || 'Chat'}
actions={closeWithBulkActions(agentSessionActionTarget, () =>
setAgentSessionActionTarget(null)
)}
onClose={() => setAgentSessionActionTarget(null)}
/>
<ActionSheetModal
visible={leaveDrafts != null}
title="Unsaved markdown changes"
+3 -1
View File
@@ -36,8 +36,10 @@ export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
activated: boolean
activationSeq: number
latestActivationSeq: number
sourceTerminalHandle: string
sourceTerminalHandle: string | null
activeTerminalHandle: string | null
sourceSessionTabId?: string | null
activeSessionTabId?: string | null
activeTabType: string | null
}
switchSessionTab: (tab: T) => void
@@ -58,7 +58,12 @@ export type MobileNativeChatController = {
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
deadline?: number
deadline?: number,
attachments?: readonly {
id?: string
path: string
previewUri: string
}[]
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
@@ -123,6 +123,30 @@ describe('resolveMobileNativeChat', () => {
expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull()
})
it('resolves Codex structured agent-session tabs directly', () => {
expect(
resolveMobileNativeChat({
type: 'agent-session',
sessionId: 'structured-1',
agent: 'codex'
})
).toEqual({
agent: 'codex',
sessionId: 'structured-1',
transcriptPath: null
})
})
it('rejects non-Codex structured agent-session tabs', () => {
expect(
resolveMobileNativeChat({
type: 'agent-session',
sessionId: 'structured-1',
agent: 'claude'
} as never)
).toBeNull()
})
it('canShowMobileNativeChat mirrors resolution', () => {
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
expect(canShowMobileNativeChat(null)).toBe(false)
@@ -32,6 +32,8 @@ export type MobileNativeChatTab = {
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
sessionId?: string | null
agent?: string | null
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -42,7 +44,15 @@ export function resolveMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): MobileNativeChatResolution | null {
if (!tab || tab.type !== 'terminal') {
if (!tab) {
return null
}
if (tab.type === 'agent-session') {
return tab.sessionId && tab.agent === 'codex'
? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null }
: null
}
if (tab.type !== 'terminal') {
return null
}
const liveAgent = tab.agentStatus?.agentType ?? null
@@ -71,3 +81,15 @@ export function canShowMobileNativeChat(
): boolean {
return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null
}
export function resolveMobileNativeChatFileSessionId(
tab: MobileNativeChatTab | null
): string | null {
if (tab?.type === 'agent-session') {
return tab.sessionId ?? null
}
if (tab?.type === 'terminal') {
return tab.agentStatus?.providerSession?.id ?? null
}
return null
}
@@ -0,0 +1,18 @@
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = []
export type MobileNativeChatImagesByScope = Record<string, PendingNativeChatImage[]>
export function withScopeAttachments(
byScope: MobileNativeChatImagesByScope,
scope: string,
next: PendingNativeChatImage[]
): MobileNativeChatImagesByScope {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatQuestionAnswer,
formatQuestionFreeTextAnswer,
mobileChatQuestionKey,
parseAgentQuestion,
type MobileChatQuestion
@@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => {
expect(formatQuestionAnswer(numbered, [])).toBe('')
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
})
it('prefixes free-text answers with an opaque prompt token when provided', () => {
expect(
formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ')
).toBe(`target:${encodeURIComponent('hi there')}`)
})
})
describe('mobileChatQuestionKey', () => {
@@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => {
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
mobileChatQuestionKey(first)
)
expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe(
mobileChatQuestionKey(first)
)
})
})
@@ -7,10 +7,14 @@ export type MobileChatQuestion = {
question: string
options: string[]
multiSelect: boolean
/** Structured questions hide the free-text row when the provider does not accept it. */
allowOther?: boolean
/** Per-option leading marker ("1", "b", …) when the source line carried one,
* parallel to `options`. Null where the option was a plain bullet. Used to
* echo the exact choice the agent listed back to the terminal. */
optionTokens: (string | null)[]
/** Opaque prefix used when free-text answers must target a specific prompt. */
freeTextToken?: string
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
@@ -152,3 +156,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str
return parts.join(question.multiSelect ? ', ' : ' ')
}
export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string {
const trimmed = text.trim()
if (trimmed.length === 0) {
return ''
}
return question.freeTextToken
? `${question.freeTextToken}:${encodeURIComponent(trimmed)}`
: formatQuestionAnswer(question, [trimmed])
}
@@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302'
const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0'
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec'
const HEAD_CALLBACK_BODY_SHA256 = '69dfda53fd700f4395a18a37ffdaa530e187bc24b4986d8fdc0184127c00b52d'
'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc'
const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75'
const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe'
'6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -79,9 +79,9 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447'
'1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54'
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
const HEAD_STYLE_REFERENCE_SHA256 =
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
@@ -472,10 +472,10 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(269)
expect(main.hooks).toHaveLength(266)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(78)
expect(main.callbacks).toHaveLength(77)
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
expect(main.effects).toHaveLength(24)
@@ -517,12 +517,12 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(537)
expect(strings).toHaveLength(545)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
expect(jsx.leaf).toHaveLength(59)
expect(jsx.leaf).toHaveLength(61)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(172)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
@@ -9,7 +9,7 @@ import type { TerminalRecord } from './mobile-terminal-records'
export type Terminal = TerminalRecord
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session'
export type MobileSessionTab =
| {
@@ -30,6 +30,14 @@ export type MobileSessionTab =
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
| {
type: 'agent-session'
id: string
title: string
sessionId: string
agent: 'codex'
isActive: boolean
}
| {
type: 'markdown'
id: string
@@ -0,0 +1,251 @@
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
export type StructuredApprovalItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' }>
}
export type StructuredQuestionItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'question' }>
}
export type StructuredPromptResponseTarget = {
itemId: string
expectedRevision: number
optionId: string
}
type PromptTokenPayload =
| {
kind: 'approval'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-option'
itemId: string
revision: number
optionId: string
}
| {
kind: 'question-free-text'
itemId: string
revision: number
questionId: string
}
const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:'
export function pendingStructuredApproval(
item: AgentJournalRenderItem
): item is StructuredApprovalItem {
return item.body.kind === 'approval' && item.body.resolution.state === 'pending'
}
export function pendingStructuredQuestion(
item: AgentJournalRenderItem
): item is StructuredQuestionItem {
return item.body.kind === 'question' && item.body.resolution.state === 'pending'
}
function encodeQuestionAnswer(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
function encodePromptToken(payload: PromptTokenPayload): string {
return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
}
function decodePromptToken(value: string): PromptTokenPayload | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
try {
const decoded = JSON.parse(
decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length))
) as Record<string, unknown>
if (
typeof decoded.itemId !== 'string' ||
typeof decoded.revision !== 'number' ||
!Number.isFinite(decoded.revision)
) {
return null
}
if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
optionId: decoded.optionId
}
}
if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') {
return {
kind: decoded.kind,
itemId: decoded.itemId,
revision: decoded.revision,
questionId: decoded.questionId
}
}
} catch {
return null
}
return null
}
function decodeQuestionFreeTextAnswer(value: string): {
payload: Extract<PromptTokenPayload, { kind: 'question-free-text' }>
answer: string
} | null {
if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
return null
}
const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length)
if (separator === -1) {
return null
}
const payload = decodePromptToken(value.slice(0, separator))
if (payload?.kind !== 'question-free-text') {
return null
}
return { payload, answer: decodeURIComponent(value.slice(separator + 1)) }
}
export function projectStructuredPermission(
prompt: StructuredApprovalItem | null
): MobileChatPermission | null {
if (prompt?.body.kind !== 'approval') {
return null
}
return {
title: prompt.body.title,
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
options: prompt.body.options.map((option) => ({
label: option.label,
send: encodePromptToken({
kind: 'approval',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: option.id
})
}))
}
}
export function projectStructuredQuestion(
prompt: StructuredQuestionItem | null
): MobileChatQuestion | null {
if (prompt?.body.kind !== 'question') {
return null
}
return {
question: prompt.body.question,
options: prompt.body.options.map((option) => option.label),
multiSelect: false,
allowOther: Boolean(prompt.body.freeTextQuestionId),
optionTokens: prompt.body.options.map((option) =>
encodePromptToken({
kind: 'question-option',
itemId: prompt.itemId,
revision: prompt.revision,
optionId: option.id
})
),
...(prompt.body.freeTextQuestionId
? {
freeTextToken: encodePromptToken({
kind: 'question-free-text',
itemId: prompt.itemId,
revision: prompt.revision,
questionId: prompt.body.freeTextQuestionId
})
}
: {})
}
}
export function structuredApprovalResponseTarget(
response: string,
currentPrompt: StructuredApprovalItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
if (token?.kind === 'approval') {
return {
itemId: token.itemId,
expectedRevision: token.revision,
optionId: token.optionId
}
}
if (token) {
return null
}
const option = currentPrompt?.body.options.find(
(candidate) => candidate.id === response || candidate.label === response
)
return currentPrompt && option
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: option.id
}
: null
}
export function structuredQuestionResponseTarget(
response: string,
currentPrompt: StructuredQuestionItem | null
): StructuredPromptResponseTarget | null {
const token = decodePromptToken(response)
if (token?.kind === 'question-option') {
return {
itemId: token.itemId,
expectedRevision: token.revision,
optionId: token.optionId
}
}
if (token) {
return null
}
const freeText = decodeQuestionFreeTextAnswer(response)
if (freeText) {
const answer = freeText.answer.trim()
return answer.length > 0
? {
itemId: freeText.payload.itemId,
expectedRevision: freeText.payload.revision,
optionId: encodeQuestionAnswer(freeText.payload.questionId, answer)
}
: null
}
if (!currentPrompt) {
return null
}
const trimmed = response.trim()
const option = currentPrompt.body.options.find(
(candidate) => candidate.id === response || candidate.label === trimmed
)
if (option) {
return {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: option.id
}
}
return currentPrompt.body.freeTextQuestionId && trimmed
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed)
}
: null
}
@@ -0,0 +1,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<typeof vi.fn> } {
let responseIndex = 0
const sendRequest = vi.fn(async () => responses[responseIndex++])
return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
const acceptedCreateResult = {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 0 },
value: {
sessionId: 'codex_session_1',
fence: 1,
page: {
sessionId: 'codex_session_1',
epoch: 'epoch-1',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } },
liveCursor: { epoch: 'epoch-1', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
}
const acceptedCreate = { ok: true, result: acceptedCreateResult }
describe('mobile structured Codex launch', () => {
it('creates through the structured agent-session intent after support is confirmed', async () => {
const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate)
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'created',
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/)
})
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
worktree: 'id:workspace-1',
agent: 'codex'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'agentSession.create',
expect.objectContaining({
worktree: 'id:workspace-1',
agent: 'codex',
envelope: expect.objectContaining({ expectedRuntimeFence: null })
}),
expect.objectContaining({ budgetSpansConnect: true })
)
const params = client.sendRequest.mock.calls[1]?.[1] as {
envelope: { sessionId: string; payloadFingerprint: string }
worktree: string
agent: 'codex'
}
expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/)
expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/)
})
it('reports unsupported without creating a terminal when the structured path is unavailable', async () => {
const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } })
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
kind: 'unsupported',
reason: 'remote'
})
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
client.sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost')))
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'unknown'
})
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.create',
'agentSession.create'
])
expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1])
})
it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
client.sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted'))
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
kind: 'unknown'
})
})
it('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'
})
})
})
@@ -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<MobileStructuredCodexLaunchResult> {
const worktree = `id:${worktreeId}`
let supportResponse
try {
supportResponse = await client.sendRequest('agentSession.createSupport', {
worktree,
agent: 'codex'
})
} catch {
// A support probe has no side effect; an unavailable probe safely degrades to terminal chat.
return { kind: 'unsupported' }
}
if (
!supportResponse ||
typeof supportResponse !== 'object' ||
typeof supportResponse.ok !== 'boolean' ||
!supportResponse.ok
) {
return { kind: 'unsupported' }
}
const support = supportResponse.result as StructuredCreateSupport | null
if (!support || typeof support !== 'object' || support.supported !== true) {
return { kind: 'unsupported', reason: support?.reason }
}
const params = createStructuredCodexSessionParams(worktreeId)
let response
try {
response = await client.sendRequest('agentSession.create', params, {
timeoutMs: 15_000,
budgetSpansConnect: true
})
} catch {
// Replay the durable envelope once so a lost acknowledgement cannot create a sibling.
try {
response = await client.sendRequest('agentSession.create', params, {
timeoutMs: 15_000,
budgetSpansConnect: true
})
} catch (retryError) {
// A second transport error cannot disprove the first attempt committed.
return unknownCreateResult(retryError)
}
}
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (!response.ok) {
if (
!response.error ||
typeof response.error !== 'object' ||
typeof response.error.code !== 'string'
) {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (response.error.code === 'agent_session_operation_unknown') {
return unknownCreateResult(new Error(response.error.message))
}
return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' }
}
const result = response.result as AgentSessionMutationResult<AgentSessionAttachResult>
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (!result.ok) {
if (
!result.refusal ||
typeof result.refusal !== 'object' ||
typeof result.refusal.code !== 'string'
) {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
if (result.refusal.code === 'agent_session_operation_unknown') {
return unknownCreateResult(new Error(result.refusal.message))
}
return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' }
}
if (
!result.value ||
typeof result.value.sessionId !== 'string' ||
!result.value.sessionId.trim()
) {
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
}
return { kind: 'created', sessionId: result.value.sessionId }
}
@@ -0,0 +1,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<TValue> =
| { status: 'accepted'; value: TValue }
| { status: 'refused'; message: string }
| { status: 'failed'; message: string }
| { status: 'unknown' }
export type StructuredAgentSessionMutationResult<TValue> =
| { status: 'accepted'; value: TValue; sameFence: boolean }
| { status: 'rejected' }
| { status: 'unknown' }
export type StructuredAgentSessionMutate = <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
) => Promise<StructuredAgentSessionMutationResult<TValue>>
export async function callAgentSession<TResult>(
client: RpcClient,
method: string,
params: unknown,
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS,
options?: { failWhenDisconnected?: boolean }
): Promise<TResult> {
const response = await client.sendRequest(method, params, {
timeoutMs,
budgetSpansConnect: true,
...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {})
})
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
export function structuredSessionOperationId(): string {
const randomUuid =
typeof globalThis.crypto?.randomUUID === 'function'
? () => globalThis.crypto.randomUUID()
: () => {
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(
''
)
}
return createStructuredAgentSessionOperationId(randomUuid)
}
/**
* 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<string, string>,
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<TValue>(args: {
client: RpcClient
method: string
fingerprintMethod: string
sessionId: string
expectedRuntimeFence: number
fields: Record<string, unknown>
clientOperationId?: string
retryUnknown?: boolean
timeoutMs?: number
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
const {
client,
method,
fingerprintMethod,
sessionId,
expectedRuntimeFence,
fields,
clientOperationId,
retryUnknown,
timeoutMs
} = args
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
client,
method,
{
envelope: {
sessionId,
clientOperationId: clientOperationId ?? structuredSessionOperationId(),
expectedRuntimeFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: fingerprintMethod,
sessionId,
fields
})
},
...(retryUnknown ? { retryUnknown: true } : {}),
...fields
},
timeoutMs
)
return result.ok
? { status: 'accepted', value: result.value }
: { status: 'refused', message: result.refusal.message }
} catch (error) {
if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) {
return { status: 'unknown' }
}
return {
status: 'failed',
message: error instanceof Error ? error.message : 'Request not sent'
}
}
}
@@ -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<string, string>()
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<string, string>()
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<string, string>()
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)
})
})
@@ -182,6 +182,20 @@ describe('mobile terminal records', () => {
).toBe(false)
})
it('treats structured agent-session identity changes as session-tab changes', () => {
const base = {
type: 'agent-session' as const,
id: 'agent-tab-1',
title: 'Codex',
sessionId: 'session-1',
agent: 'codex',
isActive: true
}
expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true)
expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false)
})
const record = (over: Partial<TerminalRecord> & { handle: string }): TerminalRecord => ({
title: 'Terminal',
terminalTheme: undefined,
@@ -62,6 +62,14 @@ type MobileSessionTabLike =
canGoForward?: boolean
isActive?: boolean
}
| {
type: 'agent-session'
id: string
title?: string
sessionId?: string
agent?: string
isActive?: boolean
}
export function mobileTerminalThemesEqual(
left: MobileTerminalTheme | null | undefined,
@@ -152,6 +160,8 @@ function mobileSessionTabEqual(
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward
)
case 'agent-session':
return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent
}
}
@@ -120,4 +120,17 @@ describe('getMobileSessionTabTitle', () => {
expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser')
})
it('labels structured agent-session tabs without terminal decoration rules', () => {
expect(
getMobileSessionTabTitle({
type: 'agent-session',
id: 'agent-tab-1',
title: 'Codex Chat',
sessionId: 'session-1',
agent: 'codex',
isActive: true
})
).toBe('Codex Chat')
})
})
@@ -62,6 +62,9 @@ export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
if (tab.type === 'file') {
return tab.title || 'File'
}
if (tab.type === 'agent-session') {
return tab.title || 'Chat'
}
// Why: strip the leading agent status glyph (✳ etc.) once the tab shows the
// provider icon. Mobile falls back for glyph-only titles because iOS can
// render the bare status glyph as a stray colored box beside the icon.
@@ -378,4 +378,19 @@ describe('shouldActivateOpenedMobileSessionTab', () => {
})
).toBe(false)
})
it('allows a structured agent-session tab to anchor chat file activation', () => {
expect(
shouldActivateOpenedMobileSessionTab({
activated: false,
activationSeq: 2,
latestActivationSeq: 2,
sourceTerminalHandle: null,
activeTerminalHandle: null,
sourceSessionTabId: 'agent-tab-1',
activeSessionTabId: 'agent-tab-1',
activeTabType: 'agent-session'
})
).toBe(true)
})
})
@@ -9,8 +9,10 @@ export type OpenedMobileSessionTabActivationState = {
activated: boolean
activationSeq: number
latestActivationSeq: number
sourceTerminalHandle: string
sourceTerminalHandle: string | null
activeTerminalHandle: string | null
sourceSessionTabId?: string | null
activeSessionTabId?: string | null
activeTabType: string | null
}
@@ -114,12 +116,15 @@ export async function activateOpenedSourceControlDiffTab<T extends OpenedMobileS
export function shouldActivateOpenedMobileSessionTab(
state: OpenedMobileSessionTabActivationState
): boolean {
return (
!state.activated &&
state.activationSeq === state.latestActivationSeq &&
state.activeTabType === 'terminal' &&
state.activeTerminalHandle === state.sourceTerminalHandle
)
const sourceStillActive =
state.activeTabType === 'agent-session'
? state.sourceSessionTabId !== null &&
state.sourceSessionTabId !== undefined &&
state.activeSessionTabId === state.sourceSessionTabId
: state.activeTabType === 'terminal' &&
state.sourceTerminalHandle !== null &&
state.activeTerminalHandle === state.sourceTerminalHandle
return !state.activated && state.activationSeq === state.latestActivationSeq && sourceStillActive
}
export async function activateOpenedMobileSessionTab<T extends OpenedMobileSessionTabCandidate>(
@@ -142,4 +142,31 @@ describe('useMobileFileTapHandlers', () => {
)
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
})
it('lets structured chat file taps resolve without a backing terminal handle', async () => {
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
const options = {
...createOptions(sendRequest),
activeHandleRef: { current: null as string | null },
getActiveSessionTabId: () => 'agent-tab-1',
getActiveSessionTabType: () => 'agent-session'
}
act(() => {
renderer = create(createElement(Harness, { options }))
})
handlers!.handleNativeChatFileTap('src/app.ts')
await act(async () => {})
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{
worktree: 'id:wt-1',
pathText: 'src/app.ts',
crossWorkspace: true,
nativeChatContext: { tabId: 'agent-tab-1', sessionId: 'session-1' }
},
{ timeoutMs: 10_000 }
)
})
})
@@ -141,15 +141,13 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
const handleNativeChatFileTap = useCallback((pathText: string) => {
const current = optionsRef.current
// The chat overlay rides on its backing terminal tab; that handle anchors
// the activation gate even though resolution ignores the terminal's cwd.
const sourceTerminalHandle = current.activeHandleRef.current
if (!current.client || !sourceTerminalHandle) {
const nativeChatSessionId = current.nativeChatSessionId
const nativeChatTabId = current.getActiveSessionTabId()
if (!current.client || (!sourceTerminalHandle && !(nativeChatSessionId && nativeChatTabId))) {
return
}
const activationSeq = ++activationSeqRef.current
const nativeChatSessionId = current.nativeChatSessionId
const nativeChatTabId = current.getActiveSessionTabId()
openMobileNativeChatFileTap<T>({
client: current.client,
hostId: current.hostId,
@@ -172,6 +170,8 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
latestActivationSeq: activationSeqRef.current,
sourceTerminalHandle,
activeTerminalHandle: current.activeHandleRef.current,
sourceSessionTabId: nativeChatTabId,
activeSessionTabId: current.getActiveSessionTabId(),
activeTabType: current.getActiveSessionTabType()
}),
switchSessionTab: current.switchSessionTab,
@@ -0,0 +1,83 @@
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
export function useMobileNativeChatActiveResolution(args: {
hostId: string
worktreeId: string
activeSessionTab: MobileNativeChatTab | null
activeSessionTabId: string | null
activeHandleRef: MutableRefObject<string | null>
nativeChatTranscriptIsLocalReadable: boolean
}): {
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
activeChatAgent: string | null
activeChatAgentRef: MutableRefObject<string | null>
activeChatSessionId: string | null
activeChatStructured: boolean
activeChatResolution: ReturnType<typeof resolveMobileNativeChat>
activeTabAgentWorking: boolean
nativeChatStatus: MobileNativeChatTab['agentStatus'] | null
sourceIdentity: string
streamIdentity: string
streamScopeKey: string
} {
const {
activeHandleRef,
activeSessionTab,
activeSessionTabId,
hostId,
nativeChatTranscriptIsLocalReadable,
worktreeId
} = args
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const tabWantsChat =
activeSessionTab?.type === 'agent-session' ||
(activeSessionTabId ? isTabChatView(activeSessionTabId) : false)
const activeChatResolution =
activeSessionTab && activeSessionTabId && tabWantsChat
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
const activeChatAgent = activeChatResolution?.agent ?? null
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const activeChatStructured =
activeChatResolution != null && activeSessionTab?.type === 'agent-session'
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? ''
const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}`
return {
isTabChatView,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
activeChatAgent,
activeChatAgentRef,
activeChatSessionId,
activeChatStructured,
activeChatResolution,
activeTabAgentWorking,
nativeChatStatus,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
streamIdentity,
streamScopeKey
}
}
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -14,6 +15,55 @@ const holdUnconfirmedSend = vi.fn()
// and transcript state; defaults keep the send-seam tests unchanged.
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const structuredSendWithOutcome = vi.fn()
const structuredCancel = vi.fn()
const structuredRespondPermission = vi.fn(async () => true)
const structuredRespondQuestion = vi.fn(async () => true)
const structuredSetOption = vi.fn(async () => true)
const structuredInvokeOption = vi.fn(async () => true)
const structuredOptionSnapshot: SessionOptionDescriptor[] = [
{
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'gpt-fast',
choices: [{ value: 'gpt-fast', label: 'GPT Fast' }]
},
valueSource: 'reported',
settable: true
}
]
const structuredOptionSurface = {
getSnapshot: () => structuredOptionSnapshot,
setOption: async () => ({ snapshot: structuredOptionSnapshot }),
invokeAction: async () => ({ snapshot: structuredOptionSnapshot }),
subscribe: () => () => {}
}
const structuredPermission = {
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ label: 'Allow once', send: 'allow-once' },
{ label: 'Deny', send: 'deny' }
]
}
const structuredQuestion = {
question: 'Pick destination',
options: ['Choice A', 'Choice B'],
allowOther: true,
optionTokens: ['choice-a', 'choice-b']
}
const structuredSessionState = {
messages: [] as unknown[],
status: 'ready',
transcriptLoading: false,
error: undefined,
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn()
}
const draftsArgs: Record<string, unknown>[] = []
const promptsState = {
permission: null as unknown,
@@ -33,6 +83,24 @@ vi.mock('./use-mobile-session-view-mode', () => ({
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => sessionState
}))
vi.mock('./use-mobile-structured-agent-session', () => ({
useMobileStructuredAgentSession: () => ({
session: structuredSessionState,
isWorking: false,
turnId: null,
sendWithOutcome: structuredSendWithOutcome,
cancel: structuredCancel,
permission: structuredPermission,
question: structuredQuestion,
optionSnapshot: structuredOptionSnapshot,
optionSurface: structuredOptionSurface,
pendingOptionId: 'model',
respondPermission: structuredRespondPermission,
respondQuestion: structuredRespondQuestion,
setStructuredOption: structuredSetOption,
invokeStructuredOption: structuredInvokeOption
})
}))
vi.mock('./use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
draftsArgs.push(args)
@@ -110,18 +178,28 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
// itself is mocked above).
const clientStub = { sendRequest: vi.fn() }
function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null {
function Harness({
connState = 'connected',
tab = null,
activeHandle = 'term-1',
inputLeaseReady = true
}: {
connState?: ConnectionState
tab?: unknown
activeHandle?: string | null
inputLeaseReady?: boolean
}): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState,
hostId: 'h',
worktreeId: 'w',
activeSessionTab: null,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
activeSessionTab: tab as never,
activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1',
activeHandleRef: { current: activeHandle },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
nativeChatInputLeaseReady: inputLeaseReady,
onSendError,
onSendResolved
})
@@ -138,6 +216,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
})
resetMobileNativeChatStaleInputForTests()
captureSendOrigin.mockReturnValue(ORIGIN)
structuredSendWithOutcome.mockResolvedValue('accepted')
act(() => {
renderer = create(createElement(Harness))
})
@@ -233,6 +312,80 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('routes structured agent-session sends away from terminal/nativeChat transports', async () => {
await act(async () => {
renderer?.update(
createElement(Harness, {
tab: {
type: 'agent-session',
id: 'agent-tab-1',
title: 'Codex Chat',
sessionId: 'session-structured',
agent: 'codex',
isActive: true
},
activeHandle: null,
inputLeaseReady: false
})
)
})
let accepted = false
await act(async () => {
accepted = await controller!.handleNativeChatSend('look')
})
expect(accepted).toBe(true)
expect(structuredSendWithOutcome).toHaveBeenCalledWith('look')
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(clientStub.sendRequest).not.toHaveBeenCalled()
})
it('exposes structured prompt cards and session options on structured tabs', async () => {
await act(async () => {
renderer?.update(
createElement(Harness, {
tab: {
type: 'agent-session',
id: 'agent-tab-1',
title: 'Codex Chat',
sessionId: 'session-structured',
agent: 'codex',
isActive: true
},
activeHandle: null,
inputLeaseReady: false
})
)
})
expect(controller!.nativeChatPermission).toEqual(structuredPermission)
expect(controller!.nativeChatQuestion).toEqual(structuredQuestion)
expect(controller!.nativeChatSessionOptions).not.toBeNull()
expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual(
structuredOptionSnapshot
)
await act(async () => {
expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true)
})
expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once')
expect(sendWithOutcome).not.toHaveBeenCalled()
await act(async () => {
expect(await controller!.handleNativeChatQuestionAnswer('choice-a')).toBe(true)
})
expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a')
expect(clientStub.sendRequest).not.toHaveBeenCalled()
await act(async () => {
expect(
await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast')
).toBe(true)
})
expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast')
})
it('pre-clears separately for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
@@ -1,9 +1,7 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
@@ -11,15 +9,17 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -58,36 +58,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<string | null>(activeChatAgent)
useLayoutEffect(() => {
showNativeChatRef.current = showNativeChat
activeChatAgentRef.current = activeChatAgent
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
// Same chat, but keyed off the tab rather than the view-gated resolution:
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
// and a scope that flips on a view toggle throws the gate's baseline away.
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
const {
activeChatAgent,
activeChatAgentRef,
activeChatResolution,
activeChatSessionId,
activeChatStructured,
activeTabAgentWorking,
isTabChatView,
nativeChatStatus,
showNativeChat,
showNativeChatRef,
sourceIdentity,
streamIdentity,
streamScopeKey,
toggleTabChatView
} = useMobileNativeChatActiveResolution({
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
nativeChatTranscriptIsLocalReadable
})
const legacyNativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity,
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
sessionId: activeChatStructured ? null : activeChatSessionId,
transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
})
const structuredNativeChat = useMobileStructuredAgentSession({
client,
sessionId: activeChatStructured ? activeChatSessionId : null,
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
}
}
@@ -1,18 +1,17 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import type { MobileImageSource } from './mobile-image-source-picker'
import {
appendPendingNativeChatImages,
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
import {
NO_NATIVE_CHAT_IMAGE_ATTACHMENTS,
withScopeAttachments,
type MobileNativeChatImagesByScope
} from './mobile-native-chat-image-scope-state'
import {
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
pasteMobileNativeChatImagePaths
@@ -31,6 +30,7 @@ import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload'
type CurrentRef<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -60,8 +60,11 @@ type Args = {
readonly baseSend: (
text: string,
imagePreviewUris?: string[],
deadline?: number
deadline?: number,
attachments?: readonly PendingNativeChatImage[]
) => Promise<MobileNativeChatSendOutcome>
/** Structured sessions send attachments without the terminal paste path. */
readonly structuredNativeChat: boolean
/** Launch-context text parked on the agent's TUI input line, or null. The
* paste's leading clear must cover every line of it, or the draft's earlier
* lines survive and ride along with the image. */
@@ -83,21 +86,6 @@ export type MobileNativeChatImageAttachments = {
readonly sendNativeChat: (text: string) => Promise<boolean>
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
byScope: Record<string, PendingNativeChatImage[]>,
scope: string,
next: PendingNativeChatImage[]
): Record<string, PendingNativeChatImage[]> {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
const defaultSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
@@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({
showToast,
onSendError,
baseSend,
structuredNativeChat,
readSeededLaunchDraft,
onAttachSuccess,
onError,
sleep = defaultSleep
}: Args): MobileNativeChatImageAttachments {
const [attachmentsByScope, setAttachmentsByScope] = useState<
Record<string, PendingNativeChatImage[]>
>({})
const [isAttaching, setIsAttaching] = useState(false)
const [attachmentsByScope, setAttachmentsByScope] = useState<MobileNativeChatImagesByScope>({})
const idCounter = useRef(0)
// Count in-flight uploads so an overlapping attach can't clear the flag early.
const attachingCount = useRef(0)
// Live connState for attachImage's catch: the closure's value was already
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
const attachments =
(scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
// The chip lands in the scope that initiated the pick, even if the user
// switches tabs while the upload is in flight.
const scope = scopeKey
if (!client || !scope || !activeHandleRef.current || connState !== 'connected') {
return
}
// Only this call's own increment may be undone in `finally`; a cancelled
// pick or pre-upload error never ran `onUploadStart`, so decrementing the
// shared counter would clear a concurrent upload's in-flight flag early.
let started = false
const uploadedImages: Omit<PendingNativeChatImage, 'id'>[] = []
let uploadError: unknown = null
try {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
}
const addUploadedImages = useCallback(
(scope: string, uploadedImages: Omit<PendingNativeChatImage, 'id'>[]) => {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
},
[
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
scopeKey,
showToast
]
[]
)
const { attachImage, isAttaching } = useMobileNativeChatImageUpload({
client,
activeHandleRef,
getActiveWorktreeConnectionId,
connState,
scopeKey,
structuredNativeChat,
showToast,
onImagesUploaded: addUploadedImages,
onAttachSuccess,
onError
})
const removeAttachment = useCallback(
(id: string): void => {
const scope = scopeKey
@@ -238,7 +168,32 @@ export function useMobileNativeChatImageAttachments({
const deadline = openMobileNativeChatSendBudget()
try {
const scope = scopeKey
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
const pendingImages =
(scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
if (structuredNativeChat && pendingImages.length > 0 && scope) {
if (!client || !enabled || connState !== 'connected') {
onError?.()
onSendError('Message not sent (disconnected)')
return false
}
const outcome = await baseSend(
text,
pendingImages.map((attachment) => attachment.previewUri),
deadline,
pendingImages
)
if (outcome !== 'rejected') {
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
setAttachmentsByScope((prev) =>
withScopeAttachments(
prev,
scope,
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
)
)
}
return outcome !== 'rejected'
}
if (pendingImages.length === 0 || !scope) {
// Heal a previously failed paste: a text-only send to that terminal would
// otherwise glue the stale image paste onto this message. Best-effort —
@@ -0,0 +1,126 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import {
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
type CurrentRef<T> = { readonly current: T }
type UploadedNativeChatImage = Omit<PendingNativeChatImage, 'id'>
type ShowToast = (message: string, durationMs?: number) => void
export function useMobileNativeChatImageUpload(args: {
client: RpcClient | null
activeHandleRef: CurrentRef<string | null>
getActiveWorktreeConnectionId: () => Promise<string | null>
connState: ConnectionState
scopeKey: string | null
structuredNativeChat: boolean
showToast: ShowToast
onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void
onAttachSuccess?: () => void
onError?: () => void
}): {
attachImage: (source: MobileImageSource) => Promise<void>
isAttaching: boolean
} {
const {
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
onImagesUploaded,
scopeKey,
showToast,
structuredNativeChat
} = args
const [isAttaching, setIsAttaching] = useState(false)
const attachingCount = useRef(0)
const connStateRef = useRef(connState)
useLayoutEffect(() => {
connStateRef.current = connState
}, [connState])
const attachImage = useCallback(
async (source: MobileImageSource): Promise<void> => {
const scope = scopeKey
if (
!client ||
!scope ||
connState !== 'connected' ||
(!activeHandleRef.current && !structuredNativeChat)
) {
return
}
let started = false
const uploadedImages: UploadedNativeChatImage[] = []
let uploadError: unknown = null
try {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
onImagesUploaded(scope, uploadedImages)
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
}
},
[
activeHandleRef,
client,
connState,
getActiveWorktreeConnectionId,
onAttachSuccess,
onError,
onImagesUploaded,
scopeKey,
showToast,
structuredNativeChat
]
)
return { attachImage, isAttaching }
}
@@ -0,0 +1,100 @@
import { useCallback, useMemo } from 'react'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import {
useMobileNativeChatSessionOptions,
type MobileNativeChatSessionOptionsController
} from './use-mobile-native-chat-session-options'
export function useMobileNativeChatSessionOptionController(args: {
activeChatStructured: boolean
activeSessionTabId: string | null
agent: string | null
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
hostId: string
isTabChatView: (tabId: string) => boolean
isWorking: boolean
reportedModel: string | null
structured: {
snapshot: SessionOptionDescriptor[]
pendingId: string | null
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeAction: (id: string) => Promise<boolean>
}
toggleTabChatView: (tabId: string) => void
worktreeId: string
}): {
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
recordCommand: (command: string) => void
} {
const {
activeChatStructured,
activeSessionTabId,
agent,
dispatchCommand,
hostId,
isTabChatView,
isWorking,
reportedModel,
structured,
toggleTabChatView,
worktreeId
} = args
const {
invokeAction: invokeStructuredAction,
pendingId: structuredPendingId,
setOption: setStructuredOption,
snapshot: structuredSnapshot
} = structured
const handleAgentPicker = useCallback(() => {
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
toggleTabChatView(activeSessionTabId)
}
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
const sessionOptions = useMobileNativeChatSessionOptions({
agent: activeChatStructured ? null : agent,
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
reportedModel,
dispatchCommand,
onAgentPicker: handleAgentPicker
})
const structuredController = useMemo<MobileNativeChatSessionOptionsController | null>(
() =>
activeChatStructured && structuredSnapshot.length > 0
? {
snapshot: structuredSnapshot,
pendingId: structuredPendingId,
setOption: setStructuredOption,
invokeAction: invokeStructuredAction,
recordCommand: () => {}
}
: null,
[
activeChatStructured,
invokeStructuredAction,
setStructuredOption,
structuredPendingId,
structuredSnapshot
]
)
const nativeChatSessionOptions = useMemo<MobileNativeChatSessionOptionPickersProps | null>(
() =>
activeChatStructured
? structuredController
? { controller: structuredController, isWorking }
: null
: sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking }
: null,
[activeChatStructured, isWorking, sessionOptions, structuredController]
)
return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand }
}
@@ -36,7 +36,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
nativeChatInputLeaseReady,
nativeChatController,
getActiveWorktreeConnectionId,
refreshCanPaste
refreshCanPaste,
activeSessionTab
} = scope
const handlePaste = useMobileTerminalPaste({
client,
@@ -80,6 +81,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
structuredNativeChat: activeSessionTab?.type === 'agent-session',
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
showToast,
onNativeChatSendError: nativeChatSendError.show,
@@ -1,6 +1,7 @@
import { useRef, useCallback } from 'react'
import { Linking } from 'react-native'
import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
import { resolveMobileNativeChatFileSessionId } from './mobile-native-chat-eligibility'
import { activateOpenedSourceControlDiffTab } from './opened-mobile-session-tab'
import type { MobileSessionTab } from './mobile-session-route-types'
import type { MobileSessionTerminalSendActionsModel } from './use-mobile-session-terminal-send-actions'
@@ -31,10 +32,7 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi
hostId,
worktreeId,
worktreeName: routeWorktreeName,
nativeChatSessionId:
activeSessionTab?.type === 'terminal'
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
: null,
nativeChatSessionId: resolveMobileNativeChatFileSessionId(activeSessionTab),
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
@@ -0,0 +1,123 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
const mocks = vi.hoisted(() => ({
useMobileImageAttachment: vi.fn(),
useMobileNativeChatImageAttachments: vi.fn()
}))
vi.mock('./use-mobile-image-attachment', () => ({
useMobileImageAttachment: mocks.useMobileImageAttachment
}))
vi.mock('./use-mobile-native-chat-image-attachments', () => ({
useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments
}))
type HookArgs = Parameters<typeof useMobileSessionImageAttachments>[0]
function baseArgs(overrides: Partial<HookArgs> = {}): HookArgs {
return {
client: {} as RpcClient,
activeHandle: 'term-1',
activeHandleRef: { current: null },
canSend: true,
connState: 'connected',
deviceTokenRef: { current: null },
nativeChatScopeKey: 'scope-1',
nativeChatInputLeaseReady: false,
getActiveWorktreeConnectionId: async () => 'conn-1',
beforeTerminalSend: async () => true,
nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'),
structuredNativeChat: true,
readSeededLaunchDraft: () => null,
showToast: vi.fn(),
onNativeChatSendError: vi.fn(),
onSuccess: vi.fn(),
onError: vi.fn(),
...overrides
}
}
describe('useMobileSessionImageAttachments', () => {
let renderer: ReactTestRenderer | null = null
function Harness({ args }: { args: HookArgs }): null {
useMobileSessionImageAttachments(args)
return null
}
beforeEach(() => {
mocks.useMobileImageAttachment.mockReturnValue({
attachImage: vi.fn(),
isAttaching: false
})
mocks.useMobileNativeChatImageAttachments.mockReturnValue({
attachments: [],
isAttaching: false,
attachImage: vi.fn(),
removeAttachment: vi.fn(),
sendNativeChat: vi.fn()
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.clearAllMocks()
})
function render(args: HookArgs): void {
act(() => {
renderer = create(createElement(Harness, { args }))
})
}
it('enables native-chat image sends for connected structured sessions without a terminal lease', () => {
render(baseArgs())
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
structuredNativeChat: true
})
)
})
it('keeps terminal-backed native-chat image sends gated on the input lease', () => {
render(
baseArgs({
activeHandleRef: { current: 'term-1' },
nativeChatInputLeaseReady: false,
structuredNativeChat: false
})
)
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
structuredNativeChat: false
})
)
})
it('disables structured native-chat image sends while disconnected', () => {
render(
baseArgs({
connState: 'connecting',
nativeChatInputLeaseReady: true,
structuredNativeChat: true
})
)
expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
structuredNativeChat: true
})
)
})
})
@@ -29,8 +29,15 @@ type Args = {
readonly nativeChatBaseSend: (
text: string,
images?: string[],
deadline?: number
deadline?: number,
attachments?: readonly {
id: string
path: string
previewUri: string
}[]
) => Promise<MobileNativeChatSendOutcome>
/** Structured agent sessions do not have a terminal paste path. */
readonly structuredNativeChat: boolean
/** Launch-context text parked on the agent's TUI input line, or null — sizes
* the image paste's leading clear so a multi-line draft cannot ride along. */
readonly readSeededLaunchDraft: () => string | null
@@ -57,6 +64,7 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
beforeTerminalSend,
nativeChatBaseSend,
structuredNativeChat,
readSeededLaunchDraft,
showToast,
onNativeChatSendError,
@@ -86,7 +94,8 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
connState,
scopeKey: nativeChatScopeKey,
enabled: nativeChatInputLeaseReady,
enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady,
structuredNativeChat,
showToast,
onSendError: onNativeChatSendError,
baseSend: nativeChatBaseSend,
@@ -77,6 +77,12 @@ export function useMobileSessionNativeChatDictation(
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
const nativeChatOverlayInputLockReason =
activeSessionTab?.type === 'agent-session'
? connState === 'connected'
? null
: 'disconnected'
: nativeChatInputLockReason
const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}`
const getSendCompletionGeneration = useMobileSendCompletionGeneration({
onBlur: resetLiveInputFocus,
@@ -211,6 +217,7 @@ export function useMobileSessionNativeChatDictation(
nativeChatInputLeaseReady,
nativeChatInputLeaseReadyRef,
nativeChatInputLockReason,
nativeChatOverlayInputLockReason,
markNativeChatInputLeaseReady,
clearNativeChatInputLease,
nativeChatController,
@@ -23,6 +23,7 @@ import type {
MobileSessionTab,
Terminal
} from './mobile-session-route-types'
import { useMobileSessionTabActionTargets } from './use-mobile-session-tab-action-targets'
import type { MobileSessionFoundationModel } from './use-mobile-session-foundation'
export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) {
@@ -90,19 +91,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'markdown' }
> | null>(null)
const [fileActionTarget, setFileActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'file' }
> | null>(null)
const [browserActionTarget, setBrowserActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'browser' }
> | null>(null)
const sessionTabActionTargets = useMobileSessionTabActionTargets()
const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState<Extract<
MobileSessionTab,
{ type: 'markdown' }
@@ -211,14 +200,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
setShowCreateBrowserModal,
showHeaderMoreActions,
setShowHeaderMoreActions,
actionTarget,
setActionTarget,
markdownActionTarget,
setMarkdownActionTarget,
fileActionTarget,
setFileActionTarget,
browserActionTarget,
setBrowserActionTarget,
...sessionTabActionTargets,
discardMarkdownTarget,
setDiscardMarkdownTarget,
leaveDrafts,
@@ -0,0 +1,85 @@
import {
useCallback,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import type { MobileSessionTab, Terminal } from './mobile-session-route-types'
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type FileTab = Extract<MobileSessionTab, { type: 'file' }>
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
type AgentSessionTab = Extract<MobileSessionTab, { type: 'agent-session' }>
type SetActionTarget<T> = Dispatch<SetStateAction<T | null>>
export function useMobileSessionTabActionTargets() {
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<MarkdownTab | null>(null)
const [fileActionTarget, setFileActionTarget] = useState<FileTab | null>(null)
const [browserActionTarget, setBrowserActionTarget] = useState<BrowserTab | null>(null)
const [agentSessionActionTarget, setAgentSessionActionTarget] = useState<AgentSessionTab | null>(
null
)
return {
actionTarget,
agentSessionActionTarget,
browserActionTarget,
fileActionTarget,
markdownActionTarget,
setActionTarget,
setAgentSessionActionTarget,
setBrowserActionTarget,
setFileActionTarget,
setMarkdownActionTarget
}
}
export function useMobileSessionTabActionSheetOpener(args: {
activeHandleRef: MutableRefObject<string | null>
setActionTarget: SetActionTarget<Terminal>
setMarkdownActionTarget: SetActionTarget<MarkdownTab>
setFileActionTarget: SetActionTarget<FileTab>
setBrowserActionTarget: SetActionTarget<BrowserTab>
setAgentSessionActionTarget: SetActionTarget<AgentSessionTab>
}): (tab: MobileSessionTab) => void {
const {
activeHandleRef,
setActionTarget,
setAgentSessionActionTarget,
setBrowserActionTarget,
setFileActionTarget,
setMarkdownActionTarget
} = args
return useCallback(
(tab: MobileSessionTab) => {
if (tab.type === 'terminal') {
if (typeof tab.terminal !== 'string') {
return
}
setActionTarget({
handle: tab.terminal,
title: tab.title,
isActive: tab.terminal === activeHandleRef.current
})
} else if (tab.type === 'markdown') {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
} else if (tab.type === 'agent-session') {
setAgentSessionActionTarget(tab)
} else {
setBrowserActionTarget(tab)
}
},
[
activeHandleRef,
setActionTarget,
setAgentSessionActionTarget,
setBrowserActionTarget,
setFileActionTarget,
setMarkdownActionTarget
]
)
}
@@ -136,6 +136,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
void readFileTab(tab)
return
}
if (tab.type === 'agent-session') {
return
}
const cached = markdownDocs.get(tab.id)
if (cached?.status === 'ready' && cached.isDirty) {
return
@@ -0,0 +1,231 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'
vi.mock('../platform/haptics', () => ({
triggerSuccess: vi.fn(),
triggerError: vi.fn()
}))
function clientReturning(...responses: unknown[]): RpcClient {
let responseIndex = 0
return {
sendRequest: vi.fn(async () => responses[responseIndex++])
} as unknown as RpcClient
}
function terminalCreateResponse() {
return {
ok: true,
result: {
tab: {
type: 'terminal',
id: 'terminal-tab-1',
title: 'Codex',
terminal: 'terminal-1',
isActive: true
}
}
}
}
function createScope(client: RpcClient) {
return {
worktreeId: 'workspace-1',
client,
connState: 'connected',
setTerminals: vi.fn(),
terminalsRef: { current: [] },
setSessionTabs: vi.fn(),
defaultTerminalHandlesToLiveInput: vi.fn(),
setActiveHandle: vi.fn(),
activeSessionTabId: 'existing-tab',
activeSessionTabIdRef: { current: 'existing-tab' },
setActiveSessionTabId: vi.fn(),
setCreating: vi.fn(),
creatingTerminalRef: { current: false },
creatingBrowser: false,
creatingMarkdown: false,
setCreateError: vi.fn(),
deviceTokenRef: { current: null },
initializedHandlesRef: { current: new Set<string>() },
activeHandleRef: { current: 'existing-terminal' },
activeSessionTabTypeRef: { current: 'terminal' },
pendingActiveSessionTabIdRef: { current: null },
pendingActiveTerminalHandleRef: { current: null },
scheduleDelayedAction: vi.fn(),
showToast: vi.fn(),
unsubscribeTerminal: vi.fn(),
subscribeToTerminal: vi.fn(),
fetchSessionTabs: vi.fn(async () => {})
}
}
describe('mobile + Codex tab creation routing', () => {
let renderer: ReactTestRenderer | undefined
afterEach(() => renderer?.unmount())
it('uses the structured agent-session path for a bare Codex launch', async () => {
const client = clientReturning(
{ ok: true, result: { supported: true } },
{
ok: true,
result: {
ok: true,
value: { sessionId: 'codex_session_1' }
}
}
)
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
worktree: 'id:workspace-1',
agent: 'codex'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'agentSession.create',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }),
expect.anything()
)
expect(client.sendRequest).not.toHaveBeenCalledWith(
'session.tabs.createTerminal',
expect.anything()
)
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('agent-session:codex_session_1')
expect(scope.setActiveHandle).toHaveBeenCalledWith(null)
expect(scope.unsubscribeTerminal).toHaveBeenCalledWith('existing-terminal')
})
it('keeps the legacy terminal path when structured support is disabled', async () => {
const client = clientReturning(
{ ok: false, error: { code: 'structured_agent_session_unsupported', message: 'off' } },
terminalCreateResponse()
)
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'session.tabs.createTerminal',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
})
it('falls back to a terminal when structured creation is refused', async () => {
const client = clientReturning(
{ ok: true, result: { supported: true } },
{
ok: true,
result: {
ok: false,
refusal: { code: 'agent_session_ownership_unknown', message: 'provider unavailable' }
}
},
terminalCreateResponse()
)
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
3,
'session.tabs.createTerminal',
expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' })
)
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
})
it('keeps prompted Codex launches on the legacy terminal path', async () => {
const client = clientReturning(terminalCreateResponse(), {
ok: true,
result: { send: { accepted: true } }
})
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex', { initialPrompt: 'Inspect this diff' })
})
expect(client.sendRequest).toHaveBeenCalledWith(
'session.tabs.createTerminal',
expect.objectContaining({ agent: 'codex' })
)
expect(client.sendRequest).not.toHaveBeenCalledWith(
'agentSession.createSupport',
expect.anything()
)
})
it('does not create a legacy sibling after an unknown structured outcome', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
const sendRequest = client.sendRequest as unknown as ReturnType<typeof vi.fn>
sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('still unknown')))
const scope = createScope(client)
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
function Harness() {
actions = useMobileSessionTerminalCreateActions(scope as never)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
await act(async () => {
await actions?.handleCreateTerminal('codex')
})
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'agentSession.createSupport',
'agentSession.create',
'agentSession.create'
])
expect(scope.setCreateError).toHaveBeenCalledWith('still unknown')
expect(scope.showToast).toHaveBeenCalledWith('still unknown', 1800)
})
})
@@ -10,6 +10,7 @@ import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { Terminal, TerminalCreateResult } from './mobile-session-route-types'
import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments'
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) {
const {
@@ -22,6 +23,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
defaultTerminalHandlesToLiveInput,
setActiveHandle,
activeSessionTabId,
activeSessionTabIdRef,
setActiveSessionTabId,
setCreating,
creatingTerminalRef,
@@ -61,6 +63,35 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
.slice(2, 10)}`
try {
// Bare Codex launches follow structured support; prompted launches keep their startup semantics.
if (agent === 'codex' && options === undefined) {
const structured = await createMobileStructuredCodexSession(client, worktreeId)
if (structured.kind === 'created') {
const previous = activeHandleRef.current
if (previous) {
unsubscribeTerminal(previous)
initializedHandlesRef.current.delete(previous)
}
const tabId = `agent-session:${structured.sessionId}`
pendingActiveSessionTabIdRef.current = tabId
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = 'agent-session'
activeSessionTabIdRef.current = tabId
setActiveSessionTabId(tabId)
activeHandleRef.current = null
setActiveHandle(null)
// Refresh if the create response beats its published tab frame.
scheduleDelayedAction(() => void fetchSessionTabs(), 500)
return
}
if (structured.kind === 'unknown') {
// Never create a legacy sibling when the host may already have committed.
setCreateError(structured.message)
triggerError()
showToast(structured.message, 1800)
return
}
}
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
@@ -16,6 +16,7 @@ import {
import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization'
import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'
import type { MobileSessionTab } from './mobile-session-route-types'
import { useMobileSessionTabActionSheetOpener } from './use-mobile-session-tab-action-targets'
import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview'
export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminalWebviewModel) {
@@ -27,6 +28,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
setMarkdownActionTarget,
setFileActionTarget,
setBrowserActionTarget,
setAgentSessionActionTarget,
keyboardHeight,
deviceTokenRef,
clientRef,
@@ -175,24 +177,14 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
sessionTabActionSheetKeyboardHideSubRef.current = null
}, [])
const openSessionTabActionSheet = useCallback((tab: MobileSessionTab) => {
if (tab.type === 'terminal') {
if (typeof tab.terminal !== 'string') {
return
}
setActionTarget({
handle: tab.terminal,
title: tab.title,
isActive: tab.terminal === activeHandleRef.current
})
} else if (tab.type === 'markdown') {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
} else {
setBrowserActionTarget(tab)
}
}, [])
const openSessionTabActionSheet = useMobileSessionTabActionSheetOpener({
activeHandleRef,
setActionTarget,
setMarkdownActionTarget,
setFileActionTarget,
setBrowserActionTarget,
setAgentSessionActionTarget
})
const openSessionTabActionSheetAfterKeyboardDismiss = useCallback(
(tab: MobileSessionTab) => {
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog'
import type {
AgentSessionOptionResult,
AgentSessionOptionsResult
} from '../../../src/shared/agent-session-wire'
import type {
SessionOptionDescriptor,
SessionOptionsSurface,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
applyStructuredAgentSessionOptions,
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionSnapshot
} from '../../../src/shared/structured-agent-session-options'
import type { RpcClient } from '../transport/rpc-client'
import {
callAgentSession,
type StructuredAgentSessionMutate
} from './mobile-structured-agent-session-rpc'
type StructuredOptionsController = {
optionSnapshot: SessionOptionDescriptor[]
optionSurface: SessionOptionsSurface
pendingOptionId: string | null
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
export function useMobileStructuredAgentOptions(args: {
agent: string | null
client: RpcClient | null
sessionId: string | null
enabled: boolean
fence: number | null
mutate: StructuredAgentSessionMutate
}): StructuredOptionsController {
const { agent, client, enabled, fence, mutate, sessionId } = args
const [optionState, setOptionState] = useState(() =>
createStructuredAgentSessionOptionState(agent ?? 'codex')
)
const activeOptionRecordRef = useRef(optionState.record)
const optionCatalog = useMemo(
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
useEffect(() => {
const next = createStructuredAgentSessionOptionState(agent ?? 'codex')
activeOptionRecordRef.current = next.record
setOptionState(next)
}, [agent, enabled, fence, sessionId])
useEffect(() => {
if (!client || !sessionId || !enabled || !optionCatalog) {
return
}
let stale = false
void callAgentSession<AgentSessionOptionsResult>(client, 'agentSession.options', { sessionId })
.then((result) => {
if (!stale) {
setOptionState((current) =>
current.record === activeOptionRecordRef.current
? applyStructuredAgentSessionOptions(current, optionCatalog, result)
: current
)
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [client, enabled, optionCatalog, sessionId, fence])
const optionSnapshot = useMemo(
() => structuredAgentSessionOptionSnapshot(optionState),
[optionState]
)
const setStructuredOption = useCallback(
async (id: string, value: SessionOptionValue): Promise<boolean> => {
if (
!canSetStructuredAgentSessionOption(optionState, id, value) ||
typeof value !== 'string'
) {
return false
}
const targetRecord = optionState.record
setOptionState((current) => ({ ...current, pendingId: id }))
try {
const result = await mutate<AgentSessionOptionResult>(
'agentSession.setOption',
'agentSession.setOption',
{ key: id, value }
)
if (activeOptionRecordRef.current !== targetRecord) {
return result.status !== 'rejected'
}
if (result.status === 'accepted') {
setOptionState((current) =>
current.record === targetRecord && result.sameFence
? commitStructuredAgentSessionOptionValues(
current,
result.value.options ?? { [id]: value }
)
: current
)
return true
}
if (result.status === 'unknown') {
setOptionState((current) =>
current.record === targetRecord
? commitStructuredAgentSessionOption(current, id, value)
: current
)
return true
}
return false
} finally {
setOptionState((current) =>
current.record === targetRecord && current.pendingId === id
? { ...current, pendingId: null }
: current
)
}
},
[mutate, optionState]
)
const invokeStructuredOption = useCallback(async () => false, [])
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return { snapshot: optionSnapshot }
},
[optionSnapshot, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
)
return {
optionSnapshot,
optionSurface,
pendingOptionId: optionState.pendingId,
setStructuredOption,
invokeStructuredOption
}
}
@@ -0,0 +1,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<string, unknown>) {
if (method === 'agentSession.send') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: { turnId: 'turn-1' }
})
}
if (method === 'agentSession.options') {
return ok({
models: [
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{
id: 'gpt-slow',
label: 'GPT Slow',
isDefault: false,
defaultEffort: 'high',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
],
current: {
model: 'gpt-fast',
effort: 'low'
}
})
}
if (method === 'agentSession.setOption') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: {
key: 'model',
value: 'gpt-fast',
options: { model: 'gpt-fast' }
}
})
}
if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 3 },
value: {
itemId: String(params?.itemId ?? ''),
revision: 2,
resolution: {
state: 'resolved',
selectedOptionId: String(params?.optionId ?? ''),
resolvedBy: 'mobile',
resolvedAt: 123
}
}
})
}
return ok({})
}
describe('useMobileStructuredAgentSession', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const onSendError = vi.fn()
const unsubscribe = vi.fn()
const sendRequest = vi.fn(defaultSendRequest)
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return unsubscribe
})
const client = {
sendRequest,
subscribe
} as unknown as RpcClient
function Harness({
sessionId = 'session-1',
agent = 'codex',
connected = true,
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([])
})
})
@@ -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<MobileNativeChatSendOutcome>
cancel: () => void
permission: MobileChatPermission | null
question: MobileChatQuestion | null
optionSnapshot: SessionOptionDescriptor[]
optionSurface: SessionOptionsSurface
pendingOptionId: string | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
export function useMobileStructuredAgentSession(args: {
client: RpcClient | null
sessionId: string | null
/** 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<string, string>())
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 <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
): Promise<StructuredAgentSessionMutationResult<TValue>> => {
const current = stateRef.current
if (!client || !sessionId || !enabled || current.fence === null) {
return { status: 'rejected' }
}
const targetFence = current.fence
const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}`
const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key))
const result = await requestStructuredAgentSessionMutation<TValue>({
client,
method,
fingerprintMethod,
sessionId,
expectedRuntimeFence: targetFence,
fields,
clientOperationId
})
if (result.status === 'accepted') {
operationIdsRef.current.delete(key)
return {
status: 'accepted',
value: result.value,
sameFence: stateRef.current.fence === targetFence
}
}
if (result.status === 'unknown') {
// Prompt/option/cancel plans cannot redispatch an unknown ledger row;
// issue a fresh id so a retry can be admitted after the user checks the
// stream. Sends opt into explicit retryUnknown below.
operationIdsRef.current.delete(key)
return result
}
operationIdsRef.current.delete(key)
onSendError(result.message)
return { status: 'rejected' }
},
[client, enabled, onSendError, sessionId, 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<MobileNativeChatSendOutcome> => {
const currentFence = stateRef.current.fence
if (!client || !sessionId || !enabled || currentFence === null) {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
const timeoutMs = timeoutForDeadline(deadline)
if (timeoutMs === null) {
onSendError('Message not sent')
return 'rejected'
}
if (attachments === undefined && images !== undefined && images.length > 0) {
onSendError('Message not sent')
return 'rejected'
}
const sendAttachments = attachments ?? []
const body = structuredAgentSessionSendBody(text, sendAttachments)
if (body.blocks.length === 0) {
return 'rejected'
}
const fields = { body }
const key = `${sessionKey}:agentSession.send:${JSON.stringify(fields)}`
const priorOperationId = operationIdsRef.current.get(key)
const clientOperationId = retainOperationId(key, priorOperationId)
const result = await requestStructuredAgentSessionMutation<AgentSessionSendResult>({
client,
method: 'agentSession.send',
fingerprintMethod: 'agentSession.send',
sessionId,
expectedRuntimeFence: currentFence,
fields,
clientOperationId,
...(priorOperationId ? { retryUnknown: true } : {}),
timeoutMs
})
if (result.status === 'accepted') {
operationIdsRef.current.delete(key)
return 'accepted'
}
if (result.status === 'unknown') {
return 'unknown'
}
operationIdsRef.current.delete(key)
onSendError(result.message === 'Request not sent' ? 'Message not sent' : result.message)
return 'rejected'
},
[client, enabled, onSendError, sessionId, sessionKey]
)
const respondPermission = useCallback(
async (optionId: string): Promise<boolean> => {
const target = structuredApprovalResponseTarget(
optionId,
stateRef.current.items.find(pendingStructuredApproval) ?? null
)
if (!target) {
return false
}
const result = await mutate<AgentSessionPromptResult>(
'agentSession.respondToApproval',
'agentSession.respondTo:approval',
target
)
if (result.status === 'unknown') {
onSendError('Response unconfirmed — check chat before retrying')
return false
}
return result.status === 'accepted'
},
[mutate, onSendError]
)
const respondQuestion = useCallback(
async (answer: string): Promise<boolean> => {
const target = structuredQuestionResponseTarget(
answer,
stateRef.current.items.find(pendingStructuredQuestion) ?? null
)
if (!target) {
return false
}
const result = await mutate<AgentSessionPromptResult>(
'agentSession.respondToQuestion',
'agentSession.respondTo:question',
target
)
if (result.status === 'unknown') {
onSendError('Answer unconfirmed — check chat before retrying')
return false
}
return result.status === 'accepted'
},
[mutate, onSendError]
)
const cancel = useCallback(() => {
const current = stateRef.current
const turnId = activeStructuredAgentSessionTurnId(current.items)
if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
onSendError('Stop not sent')
return
}
const fields = { turnId }
const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}`
const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key))
void requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
client,
method: 'agentSession.cancel',
fingerprintMethod: 'agentSession.cancel',
sessionId,
expectedRuntimeFence: current.fence,
fields,
clientOperationId
}).then((result) => {
if (result.status !== 'unknown') {
operationIdsRef.current.delete(key)
}
if (result.status === 'unknown') {
onSendError('Stop unconfirmed — check chat before retrying')
} else if (result.status === 'refused') {
onSendError(result.message)
} else if (result.status === 'failed') {
onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message)
}
})
}, [client, enabled, onSendError, sessionId, 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
}
}
@@ -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<Map<string, StructuredAgentSessionState>>(
() => 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<AgentSessionHistoryResult>(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 }
}
@@ -0,0 +1,100 @@
import { useCallback } from 'react'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
type StructuredNativeChatAttachment = {
id?: string
path: string
previewUri: string
}
export function useMobileStructuredNativeChatSendBridge(args: {
sendStructured: (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => Promise<MobileNativeChatSendOutcome>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
holdUnconfirmedSend: (
origin: MobileNativeChatSendOrigin,
text: string,
onUnconfirmed: () => void
) => void
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
onSendError: (message: string) => void
}): {
send: (text: string, images?: string[]) => Promise<boolean>
sendWithOutcome: (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => Promise<MobileNativeChatSendOutcome>
} {
const {
acceptSend,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
onSendError,
restoreRejectedDraft,
sendStructured
} = args
const sendWithOutcome = useCallback(
async (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
): Promise<MobileNativeChatSendOutcome> => {
const origin = captureSendOrigin(text.trimEnd())
if (!origin) {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
clearDraftForSend(origin, text)
const outcome =
attachments !== undefined
? await sendStructured(text, images, deadline, attachments)
: deadline !== undefined
? await sendStructured(text, images, deadline)
: images !== undefined
? await sendStructured(text, images)
: await sendStructured(text)
if (outcome === 'accepted') {
acceptSend(origin, text.trimEnd(), images)
return 'accepted'
}
if (outcome === 'unknown') {
holdUnconfirmedSend(origin, text.trimEnd(), () =>
onSendError('Delivery unconfirmed — check chat before retrying')
)
return 'unknown'
}
restoreRejectedDraft(origin, text)
return 'rejected'
},
[
acceptSend,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
onSendError,
restoreRejectedDraft,
sendStructured
]
)
const send = useCallback(
async (
text: string,
images?: string[],
deadline?: number,
attachments?: readonly StructuredNativeChatAttachment[]
) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected',
[sendWithOutcome]
)
return { send, sendWithOutcome }
}
@@ -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.
@@ -43,4 +43,8 @@ export class DirectConnectionLog {
{ code: 'liveness-timeout' }
)
}
connected = (): void => {
this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' })
}
}
+14 -8
View File
@@ -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 {
@@ -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.
@@ -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
@@ -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".
@@ -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<typeof setTimeout>
}
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<string, PendingRequest>()
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 {
@@ -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<typeof vi.fn>
onFailure: ReturnType<typeof vi.fn>
} {
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()
})
})
@@ -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<RpcResponse>
/**
* 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<void> {
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()
})
}
@@ -0,0 +1,44 @@
import {
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities'
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY
])
export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
'runtime.clientCapabilities.update' as const
export function mobileRuntimeClientCapabilityUpdateParams(): {
clientCapabilities: string[]
} {
return { clientCapabilities: [...MOBILE_RUNTIME_CLIENT_CAPABILITIES] }
}
export function mobileRuntimeClientCapabilityUpdateRequest(args: {
id: string
deviceToken: string
}): {
id: string
deviceToken: string
method: typeof MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD
params: { clientCapabilities: string[] }
} {
return {
id: args.id,
deviceToken: args.deviceToken,
method: MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD,
params: mobileRuntimeClientCapabilityUpdateParams()
}
}
export function advertiseMobileRuntimeClientCapabilities(
send: (request: unknown) => boolean | void,
id: string,
deviceToken: string
): void {
send(mobileRuntimeClientCapabilityUpdateRequest({ id, deviceToken }))
}
@@ -0,0 +1,53 @@
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import type { RpcResponse } from './types'
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
/** In-flight relay RPC requests awaiting their response frame, keyed by request id. */
export class RelayPendingRequests {
private readonly pending = new Map<string, PendingRequest>()
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()
}
}
@@ -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()
}
})
})
@@ -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
@@ -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
@@ -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
@@ -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<RpcResponse> {
return this.sendConnectedRequest(method, params, timeoutMs)
}
private sendConnectedRequest(
method: string,
params: unknown,
timeoutMs: number
): Promise<RpcResponse> {
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', {
@@ -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
@@ -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
@@ -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
@@ -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
+15 -26
View File
@@ -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 {
@@ -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
+28 -3
View File
@@ -36,7 +36,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
'github.resolveReviewThread',
'github.project.updateIssueCommentBySlug',
'github.project.deleteIssueCommentBySlug',
'hostedReview.forBranch'
'hostedReview.forBranch',
'runtime.clientCapabilities.update',
'agentSession.send',
'agentSession.cancel',
'agentSession.history',
'agentSession.hold',
'agentSession.release'
]
const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
@@ -145,9 +151,28 @@ describe('mobile RPC allowlist', () => {
).toEqual([])
})
it('does not expose structured agent sessions to mobile credentials', () => {
it('exposes only the mobile structured agent-session surface', () => {
expect(
[...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.'))
).toEqual([])
).toEqual([
'agentSession.createSupport',
'agentSession.create',
'agentSession.ensure',
'agentSession.send',
'agentSession.cancel',
'agentSession.close',
'agentSession.respondToApproval',
'agentSession.respondToQuestion',
'agentSession.setOption',
'agentSession.handoffStatus',
'agentSession.options',
'agentSession.history',
'agentSession.subscribe',
'agentSession.unsubscribe',
'agentSession.hold',
'agentSession.release'
])
expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false)
expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false)
})
})
@@ -21,8 +21,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
tab: RuntimeMobileSessionAgentTab
): Promise<void> {
const host = getStructuredAgentSessionHost()
if (typeof host?.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(tab.sessionId, false)
if (host) {
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
@@ -87,6 +87,11 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
) {
super()
this.store = store
store?.onSettingsChanged?.((updates) => {
if ('experimentalStructuredNativeChat' in updates) {
this.notifyMobileSessionTabsChanged()
}
})
const runtime = this as RuntimeCommandSurfaceHost<this>
installRuntimeFileCommandSurface(runtime, this.fileCommands)
installRuntimeGitCommandSurface(runtime, this.gitCommands)
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
describe('structured native chat settings', () => {
it('republishes mobile session tabs when the host visibility setting changes', () => {
const settingsListeners: ((updates: Record<string, unknown>) => void)[] = []
const runtime = new OrcaRuntimeService({
onSettingsChanged: vi.fn((listener) => {
settingsListeners.push(listener as (updates: Record<string, unknown>) => void)
return vi.fn()
})
} as never)
const notify = vi.spyOn(runtime, 'notifyMobileSessionTabsChanged').mockImplementation(() => {})
settingsListeners[0]?.({ compactWorktreeCards: true })
expect(notify).not.toHaveBeenCalled()
settingsListeners[0]?.({ experimentalStructuredNativeChat: true })
expect(notify).toHaveBeenCalledTimes(1)
})
})
@@ -205,6 +205,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([
+2
View File
@@ -77,6 +77,8 @@ export type RpcContext = {
clientKind?: 'mobile' | 'runtime'
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
clientCapabilities?: readonly RuntimeCapability[]
// Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth.
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
// Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields.
orchestrationCapability?: string
// Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles.
@@ -10,6 +10,7 @@ export type RpcDispatchStreamingOptions = {
pairedDeviceId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: readonly RuntimeCapability[]
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
pairing?: PairingRpcContext
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
registerBinaryStreamHandler?: (
+2 -2
View File
@@ -29,8 +29,7 @@ import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher'
export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] }
// oxfmt-ignore
type DispatchCallOptions = Pick<RpcDispatchStreamingOptions, 'signal' | 'connectionId' | 'clientId' | 'clientKind' | 'clientCapabilities' | 'authenticatedCallerFingerprint'>
type DispatchCallOptions = RpcDispatchStreamingOptions
export class RpcDispatcher {
private readonly runtime: OrcaRuntimeService
@@ -131,6 +130,7 @@ export class RpcDispatcher {
clientId: options?.clientId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
orchestrationCapability: request.orchestrationCapability,
authenticatedCallerFingerprint:
mutation?.identity.callerFingerprint ??
@@ -31,6 +31,7 @@ describe('client UI RPC methods', () => {
visibleTaskProviders: ['github', 'gitlab'],
defaultRepoSelection: ['repo-1'],
defaultLinearTeamSelection: ['team-1'],
experimentalStructuredNativeChat: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5',
@@ -60,6 +61,24 @@ describe('client UI RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { settings } })
})
it('rejects paired attempts to mutate the host-owned structured chat setting', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateClientSettings: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('settings.update', { experimentalStructuredNativeChat: true })
)
expect(response).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(runtime.updateClientSettings).not.toHaveBeenCalled()
})
it('persists the runtime host task source settings for mobile Tasks', async () => {
const settings = {
defaultTuiAgent: null,
+2
View File
@@ -38,6 +38,7 @@ import { PLUGIN_METHODS } from './plugins'
import { SKILL_METHODS } from './skills'
import { CLIPBOARD_METHODS } from './clipboard'
import { HOST_CAPABILITY_METHODS } from './host-capabilities'
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
import { EMULATOR_METHODS } from './emulator'
import { PAIRING_METHODS } from './pairing'
import { UPDATER_METHODS } from './updater'
@@ -91,6 +92,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...SKILL_METHODS,
...CLIPBOARD_METHODS,
...HOST_CAPABILITY_METHODS,
...RUNTIME_CLIENT_CAPABILITY_METHODS,
...CLIENT_EVENT_METHODS,
...CLIENT_UI_METHODS,
...EMULATOR_METHODS,
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
function makeRequest(params: unknown): RpcRequest {
return {
id: 'req-1',
authToken: 'tok',
method: 'runtime.clientCapabilities.update',
params
}
}
function dispatcher(): RpcDispatcher {
return new RpcDispatcher({
runtime: { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
methods: RUNTIME_CLIENT_CAPABILITY_METHODS
})
}
describe('runtime.clientCapabilities.update', () => {
it('updates the authenticated socket capability set after auth', async () => {
const updateClientCapabilities = vi.fn()
const response = await dispatcher().dispatch(
makeRequest({
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}),
{ clientKind: 'mobile', updateClientCapabilities }
)
expect(response).toMatchObject({
ok: true,
result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
})
expect(updateClientCapabilities).toHaveBeenCalledWith([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
})
it('rejects malformed upgrades without mutating authenticated state', async () => {
const updateClientCapabilities = vi.fn()
const response = await dispatcher().dispatch(
makeRequest({
clientCapabilities: [42]
}),
{ clientKind: 'mobile', updateClientCapabilities }
)
expect(response).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(updateClientCapabilities).not.toHaveBeenCalled()
})
it('fails closed when a transport has no post-auth updater', async () => {
const response = await dispatcher().dispatch(
makeRequest({
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}),
{ clientKind: 'runtime' }
)
expect(response).toMatchObject({
ok: false,
error: { message: 'client_capabilities_update_unsupported' }
})
})
})
@@ -0,0 +1,24 @@
import { z } from 'zod'
import type { RuntimeCapability } from '../../../../shared/protocol-version'
import { defineMethod, type RpcAnyMethod } from '../core'
const ClientCapabilitiesUpdate = z
.object({
clientCapabilities: z.array(z.string().min(1).max(128)).max(64)
})
.strict()
export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'runtime.clientCapabilities.update',
params: ClientCapabilitiesUpdate,
handler: (params, { updateClientCapabilities }) => {
if (!updateClientCapabilities) {
throw new Error('client_capabilities_update_unsupported')
}
const clientCapabilities = params.clientCapabilities as RuntimeCapability[]
updateClientCapabilities(clientCapabilities)
return { clientCapabilities }
}
})
]
@@ -75,6 +75,48 @@ describe('session tab structured capability mutations', () => {
expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled()
})
}
it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)(
'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)',
async (method) => {
const snapshot = agentSnapshot()
const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true })
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot),
closeMobileSessionTab
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const replies: string[] = []
await dispatcher.dispatchStreaming(
{
id: 'request-1',
authToken: 'token',
method,
params:
method === 'session.tabs.close'
? { worktree: 'id:wt-1', tabId: 'codex-session', reason: 'user' }
: {
worktree: 'id:wt-1',
tabId: 'codex-session',
reason: 'cleanup',
publicationEpoch: 'epoch-1',
terminal: 'pty-1'
}
},
(response) => replies.push(response),
{
clientKind: 'mobile',
pairedDeviceId: 'paired-mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(JSON.parse(replies[0]!).ok).toBe(true)
expect(closeMobileSessionTab).toHaveBeenCalledOnce()
}
)
})
function createFixture(capabilities: RuntimeCapability[]) {
@@ -96,6 +96,22 @@ describe('projectSessionTabAgentStatus', () => {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
).toEqual(oldClient)
expect(
projectSessionTabAgentStatus(
snapshot,
'mobile',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
false
)
).toEqual(oldClient)
const capableMobile = projectSessionTabAgentStatus(
snapshot,
'mobile',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
true
)
expect(capableMobile).toBe(snapshot)
const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
@@ -133,6 +149,14 @@ describe('projectSessionTabAgentStatus', () => {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
]).tabs.map((tab) => tab.id)
).toEqual(['agent-session:codex'])
expect(
projectSessionTabAgentStatus(
snapshot,
'mobile',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
true
).tabs.map((tab) => tab.id)
).toEqual(['agent-session:codex'])
})
it('withholds session boundaries from legacy paired clients', () => {
@@ -1,6 +1,5 @@
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import type {
@@ -9,18 +8,21 @@ import type {
RuntimeMobileSessionTabsSnapshot
} from '../../../../shared/runtime-types'
import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
import { structuredNativeChatProjectionEnabled } from './structured-agent-session-policy'
type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot
export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload>(
payload: TPayload,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: readonly RuntimeCapability[] | undefined
clientCapabilities: readonly RuntimeCapability[] | undefined,
structuredNativeChatEnabled?: boolean
): TPayload {
const structuredVisible =
clientKind !== 'mobile' &&
(clientKind === undefined ||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false))
const structuredVisible = structuredNativeChatProjectionEnabled({
clientKind,
clientCapabilities,
structuredNativeChatEnabled
})
let projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true)
if (structuredVisible && clientKind !== undefined) {
projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex')
@@ -4,6 +4,7 @@ import { defineMethod, type RpcAnyMethod } from '../core'
import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas'
import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection'
import { projectSessionTabsForClient } from './session-tabs-inventory'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
defineMethod({
@@ -14,7 +15,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
const visible = projectSessionTabsForClient(
await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId),
context.clientKind,
context.clientCapabilities
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -80,7 +84,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
const visible = projectSessionTabsForClient(
await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId),
context.clientKind,
context.clientCapabilities
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -6,6 +6,7 @@ import {
translateProjectedSessionTabMove
} from './session-tab-browser-placement-projection'
import { projectSessionTabsForClient } from './session-tabs-inventory'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas'
export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
@@ -17,7 +18,8 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
const visible = projectSessionTabsForClient(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -36,7 +38,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
})
}
)
return projectSessionTabsForMutationClient(result, clientKind, clientCapabilities)
return projectSessionTabsForMutationClient(
result,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
}
}),
defineMethod({
@@ -46,7 +53,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
let translated: Parameters<typeof translateProjectedSessionTabMove>[2] = params
if (clientKind) {
const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities)
const projected = projectSessionTabsForClient(
raw,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
translated = translateProjectedSessionTabMove(raw, projected, params)
}
const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId }
@@ -129,7 +141,8 @@ async function assertVisibleMutationTab(
const visible = projectSessionTabsForClient(
await runtime.listMobileSessionTabs(worktree, pairedDeviceId),
clientKind,
clientCapabilities
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
assertProjectedSessionTabVisible(visible, tabId)
}
@@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-
import type { RpcContext } from '../core'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
type SessionTabsInventory = {
snapshots: RuntimeMobileSessionTabsResult[]
@@ -26,21 +27,38 @@ function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean {
export function projectSessionTabsForClient(
snapshot: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2]
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2],
structuredNativeChatEnabled?: boolean
): RuntimeMobileSessionTabsResult {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
projectSessionTabAgentStatus(
snapshot,
clientKind,
clientCapabilities,
structuredNativeChatEnabled
),
clientCapabilities
)
}
function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined {
return context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
}
function projectInventory(
inventory: SessionTabsInventory,
context: RpcContext
): SessionTabsInventory {
return {
snapshots: inventory.snapshots.map((snapshot) =>
projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities)
projectSessionTabsForClient(
snapshot,
context.clientKind,
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
)
),
...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context)
? { authoritative: true as const }
@@ -109,7 +127,8 @@ export async function subscribeSessionTabsInventory(
projectSessionTabsForClient(
snapshot,
context.clientKind,
context.clientCapabilities
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
) as SessionTabsChange
const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => {
if (snapshot.navigationIntent === undefined) {
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { SESSION_TAB_METHODS } from './session-tabs'
function makeRequest(method: string, params?: unknown): RpcRequest {
@@ -10,6 +13,48 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('session tab RPC methods', () => {
it('does not restore structured tabs for mobile while the host setting is off', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled()
})
it('restores structured tabs for mobile only after capability and setting are present', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
})
it('routes mobile-only activation without notifying desktop clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+19 -7
View File
@@ -15,6 +15,7 @@ import {
import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods'
import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
@@ -22,11 +23,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.list',
params: WorktreeTabSelector,
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
return projectSessionTabsForClient(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
}
}),
@@ -34,7 +36,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.listAll',
params: null,
handler: async (_params, context) => {
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
await restoreStructuredTabsIfSupported(context)
return listSessionTabsInventory(context)
}
}),
@@ -89,7 +91,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
let unsubscribe = (): void => {}
let closed = false
let initialized = false
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
if (closed) {
return
@@ -115,7 +117,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
}
emit({
type: 'snapshot',
...projectSessionTabsForClient(initial, clientKind, clientCapabilities)
...projectSessionTabsForClient(
initial,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
})
initialized = true
if (closed) {
@@ -126,7 +133,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
if (snapshot.worktree === subscribedWorktree) {
emit({
type: 'updated',
...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities)
...projectSessionTabsForClient(
snapshot,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
)
})
}
}, pairedDeviceId)
@@ -157,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.subscribeAll',
params: null,
handler: async (_params, context, emit) => {
await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities)
await restoreStructuredTabsIfSupported(context)
return subscribeSessionTabsInventory(context, emit)
}
}),
@@ -5,21 +5,18 @@
// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST
// by calling into it, which is an observable side effect.
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
/**
* In-process callers are the same build as the host, so they carry no negotiated
* capability list; every remote client must say it can read structured sessions.
*/
export function supportsStructuredSessions(ctx: RpcContext): boolean {
return (
ctx.clientKind === undefined ||
(ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
)
return supportsStructuredAgentSessions(ctx)
}
export function requireStructuredCapability(ctx: RpcContext): void {
@@ -0,0 +1,47 @@
import {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcContext } from '../core'
type StructuredPolicyContext = Pick<RpcContext, 'clientCapabilities' | 'clientKind'> & {
runtime?: Pick<OrcaRuntimeService, 'getClientSettings'>
structuredNativeChatEnabled?: boolean
}
export function isStructuredNativeChatEnabled(
runtime: Pick<OrcaRuntimeService, 'getClientSettings'>
): boolean {
try {
return runtime.getClientSettings().experimentalStructuredNativeChat === true
} catch {
return false
}
}
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
if (context.clientKind === undefined) {
return true
}
const hasCapability =
context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true
if (!hasCapability) {
return false
}
if (context.clientKind !== 'mobile') {
return true
}
return (
context.structuredNativeChatEnabled === true ||
(context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false)
)
}
export function structuredNativeChatProjectionEnabled(args: {
clientKind: 'mobile' | 'runtime' | undefined
clientCapabilities: readonly RuntimeCapability[] | undefined
structuredNativeChatEnabled?: boolean
}): boolean {
return supportsStructuredAgentSessions(args)
}
@@ -111,7 +111,7 @@ function hostStub(): StructuredAgentSessionHost {
return hostCalls as unknown as StructuredAgentSessionHost
}
function dispatcher(): RpcDispatcher {
function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
runtimeCalls = {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
@@ -134,7 +134,8 @@ function dispatcher(): RpcDispatcher {
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls
...runtimeCalls,
...runtimeOverrides
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
@@ -151,10 +152,11 @@ async function call(
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
}
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher().dispatchStreaming(
await dispatcher(runtimeOverrides).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
@@ -170,6 +172,10 @@ const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
beforeEach(() => {
setStructuredAgentSessionHost(hostStub())
@@ -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 })
@@ -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 }
}
}),
@@ -1,11 +1,13 @@
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
export async function restoreStructuredTabsIfSupported(
runtime: RpcContext['runtime'],
capabilities: readonly string[] | undefined
context: Pick<RpcContext, 'runtime' | 'clientKind' | 'clientCapabilities'>
): Promise<void> {
if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
await runtime.restoreStructuredAgentSessionTabs()
if (
supportsStructuredAgentSessions(context) &&
typeof context.runtime.restoreStructuredAgentSessionTabs === 'function'
) {
await context.runtime.restoreStructuredAgentSessionTabs()
}
}
@@ -113,6 +113,7 @@ export class RpcStreamingDispatcher {
pairedDeviceId: options?.pairedDeviceId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
orchestrationCapability: request.orchestrationCapability,
authenticatedCallerFingerprint:
mutation?.identity.callerFingerprint ??
@@ -165,6 +166,7 @@ export class RpcStreamingDispatcher {
pairedDeviceId: options?.pairedDeviceId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
orchestrationCapability: request.orchestrationCapability,
pairing: options?.pairing,
sendBinary: options?.sendBinary,

Some files were not shown because too many files have changed in this diff Show More