From b4bcd20caaef2ebd5055e98745c6e12da68bb806 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 9 Aug 2026 18:22:23 -0700
Subject: [PATCH] Add mobile structured session read flow
---
.../app/h/[hostId]/session/[worktreeId].tsx | 212 ++++++----------
mobile/src/session/MobileMarkdownReader.tsx | 132 ++++++++++
.../MobileStructuredAgentSessionView.tsx | 125 ++++++++++
.../src/session/mobile-session-route-types.ts | 10 +-
...structured-agent-session-coalescer.test.ts | 94 +++++++
...bile-structured-agent-session-coalescer.ts | 77 ++++++
...ile-structured-agent-session-projection.ts | 72 ++++++
...e-structured-agent-session-reducer.test.ts | 120 +++++++++
...mobile-structured-agent-session-reducer.ts | 137 +++++++++++
...le-structured-agent-session-view-styles.ts | 29 +++
...bile-structured-history-pagination.test.ts | 24 ++
.../mobile-structured-history-pagination.ts | 44 ++++
.../mobile-structured-session-create.test.ts | 44 ++++
.../mobile-structured-session-create.ts | 37 +++
...obile-structured-session-reconnect.test.ts | 39 +++
.../mobile-structured-session-reconnect.ts | 48 ++++
mobile/src/session/mobile-terminal-records.ts | 10 +
.../src/session/mobile-terminal-tab-agent.ts | 3 +
.../use-mobile-structured-agent-session.ts | 229 ++++++++++++++++++
.../use-mobile-structured-session-entry.ts | 115 +++++++++
.../mobile-e2ee-v2-physical-channel.test.ts | 12 +
.../mobile-e2ee-v2-physical-channel.ts | 4 +-
.../mobile-relay-reconnect-controller.test.ts | 55 +++++
.../mobile-relay-reconnect-controller.ts | 2 +
.../mobile-relay-rpc-session.test.ts | 21 ++
.../src/transport/mobile-relay-rpc-session.ts | 27 ++-
.../mobile-relay-structured-reconnect.ts | 10 +
.../rpc-client-structured-session.test.ts | 84 +++++++
.../transport/rpc-client-test-websocket.ts | 79 ++++++
mobile/src/transport/rpc-client.test.ts | 88 +------
mobile/src/transport/rpc-client.ts | 53 +++-
.../stable-logical-rpc-client.test.ts | 13 +
.../transport/stable-logical-rpc-client.ts | 10 +
.../codex-structured-item-translation.test.ts | 39 ++-
.../codex-structured-item-translation.ts | 47 +++-
...dex-structured-journal-translation.test.ts | 65 ++++-
.../codex-structured-journal-translation.ts | 89 +++++--
.../codex-structured-location-support.ts | 6 +
.../codex-structured-prompt-items.test.ts | 40 +++
.../codex/codex-structured-prompt-items.ts | 33 ++-
.../codex-structured-prompt-replies.test.ts | 21 +-
.../codex/codex-structured-prompt-replies.ts | 33 ++-
.../codex-structured-session-adapter.test.ts | 79 ++++++
.../codex/codex-structured-session-adapter.ts | 132 ++++------
.../codex/codex-structured-session-state.ts | 65 +++++
src/main/index.ts | 3 +
.../agent-session-history-page.test.ts | 1 +
.../agent-session-history-page.ts | 1 +
.../structured-agent-session-adapter.ts | 9 +-
.../structured-agent-session-attach-flow.ts | 36 ++-
.../structured-agent-session-attach.ts | 25 +-
...tructured-agent-session-event-sink.test.ts | 14 ++
.../structured-agent-session-event-sink.ts | 5 +
.../structured-agent-session-host.test.ts | 65 +++--
.../structured-agent-session-host.ts | 46 +++-
.../structured-agent-session-read-restore.ts | 59 +++++
.../structured-agent-session-turns.ts | 17 +-
src/main/runtime/mobile-rpc-allowlist.test.ts | 1 +
src/main/runtime/orca-runtime.ts | 212 +++++++++++++++-
.../methods/mobile-markdown-tab-methods.ts | 22 ++
...ession-tab-agent-status-projection.test.ts | 65 ++++-
.../session-tab-agent-status-projection.ts | 80 +++++-
.../session-tabs-move-validation.test.ts | 159 ++++++++++++
src/main/runtime/rpc/methods/session-tabs.ts | 45 ++--
.../structured-agent-session-schemas.ts | 17 ++
.../methods/structured-agent-session.test.ts | 97 +++++++-
.../rpc/methods/structured-agent-session.ts | 68 +++++-
.../methods/structured-session-tab-restore.ts | 11 +
src/main/runtime/runtime-rpc.ts | 5 +
.../startup/desktop-startup-ordering.test.ts | 14 ++
src/shared/agent-session-journal-types.ts | 2 +
src/shared/agent-session-wire.ts | 2 +
src/shared/runtime-types.ts | 17 +-
...ss-version-agent-session-wire.unit.test.ts | 2 +-
74 files changed, 3363 insertions(+), 445 deletions(-)
create mode 100644 mobile/src/session/MobileMarkdownReader.tsx
create mode 100644 mobile/src/session/MobileStructuredAgentSessionView.tsx
create mode 100644 mobile/src/session/mobile-structured-agent-session-coalescer.test.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-coalescer.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-projection.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-reducer.test.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-reducer.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-view-styles.ts
create mode 100644 mobile/src/session/mobile-structured-history-pagination.test.ts
create mode 100644 mobile/src/session/mobile-structured-history-pagination.ts
create mode 100644 mobile/src/session/mobile-structured-session-create.test.ts
create mode 100644 mobile/src/session/mobile-structured-session-create.ts
create mode 100644 mobile/src/session/mobile-structured-session-reconnect.test.ts
create mode 100644 mobile/src/session/mobile-structured-session-reconnect.ts
create mode 100644 mobile/src/session/use-mobile-structured-agent-session.ts
create mode 100644 mobile/src/session/use-mobile-structured-session-entry.ts
create mode 100644 mobile/src/transport/mobile-relay-structured-reconnect.ts
create mode 100644 mobile/src/transport/rpc-client-structured-session.test.ts
create mode 100644 mobile/src/transport/rpc-client-test-websocket.ts
create mode 100644 src/main/codex/codex-structured-location-support.ts
create mode 100644 src/main/codex/codex-structured-session-state.ts
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts
create mode 100644 src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts
create mode 100644 src/main/runtime/rpc/methods/structured-session-tab-restore.ts
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 755d6605501..22827bc9ce6 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -126,7 +126,10 @@ import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keybo
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
-import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
+import {
+ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
+ TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
+} from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
@@ -154,7 +157,6 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
-import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
@@ -224,6 +226,9 @@ import {
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
+import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView'
+import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry'
+import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create'
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller'
import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability'
@@ -267,7 +272,7 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
-import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
+import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
import {
@@ -275,7 +280,7 @@ import {
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
-import { colors, spacing } from '../../../../src/theme/mobile-theme'
+import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
@@ -302,132 +307,6 @@ import type {
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
-function MarkdownReader({
- documentId,
- doc,
- onRefresh,
- onChange,
- onSave,
- onCopy,
- onDiscard,
- keyboardLift
-}: {
- documentId: string
- doc: MarkdownDocState | undefined
- onRefresh: () => void
- onChange: (content: string) => void
- onSave: () => void
- onCopy: () => void
- onDiscard: () => void
- keyboardLift: number
-}) {
- // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
- const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
- const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
- if (!doc || doc.status === 'loading') {
- return (
-
-
-
- )
- }
- if (doc.status === 'error') {
- return (
-
- {doc.message}
-
-
- Retry
-
-
- )
- }
-
- const statusText = doc.saveError
- ? doc.saveError
- : doc.readOnlyReason
- ? 'Read only'
- : doc.stale
- ? 'Changed on desktop'
- : null
- const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
- const showCopy = doc.saveError || !doc.editable
- const showSave = doc.isDirty || doc.saving
- const showFloatingActions = statusText || showRefresh || showCopy || showSave
-
- return (
-
-
- {showFloatingActions ? (
-
- {statusText ? (
-
- {statusText}
-
- ) : null}
-
- {showCopy ? (
-
- Copy
-
- ) : null}
- {showRefresh ? (
-
-
- Refresh
-
- ) : null}
- {doc.isDirty ? (
-
- Discard
-
- ) : null}
- {showSave ? (
-
- {doc.saving ? (
-
- ) : (
- Save
- )}
-
- ) : null}
-
-
- ) : null}
-
- )
-}
-
function DiffLineRow({
line,
title,
@@ -1122,6 +1001,7 @@ export default function SessionScreen() {
null
)
const [quickCommandsSupported, setQuickCommandsSupported] = useState(null)
+ const [structuredAgentSessionSupported, setStructuredAgentSessionSupported] = useState(false)
// Why: stable callbacks (handleFileTap) read the live value via this ref, since
// the capability probe resolves after the callbacks are created.
const browserScreencastSupportedRef = useRef(browserScreencastSupported)
@@ -2424,6 +2304,29 @@ export default function SessionScreen() {
getApplicationRevision: getSessionTabsApplicationRevision,
...sessionTabsFetchReporting
})
+ const structuredSessionEntry = useMobileStructuredSessionEntry({
+ client,
+ connected: connState === 'connected',
+ drawerOpen: showCreateTabDrawer,
+ hostSupported: structuredAgentSessionSupported,
+ worktreeId,
+ sessionId: activeSessionTab?.type === 'agent-session' ? activeSessionTab.sessionId : null,
+ creationGuardRef: creatingTerminalRef,
+ setCreating,
+ setCreateError,
+ closeDrawer: () => setShowCreateTabDrawer(false),
+ onCreated: (tabId) => {
+ pendingActiveSessionTabIdRef.current = tabId
+ activeSessionTabTypeRef.current = 'agent-session'
+ setActiveSessionTabId(tabId)
+ scheduleDelayedAction(() => void fetchSessionTabs(), 100)
+ scheduleDelayedAction(() => void fetchSessionTabs(), 600)
+ },
+ onError: (message) => {
+ triggerError()
+ showToast(message, 1800)
+ }
+ })
useEffect(() => {
if (connState === 'connected') {
@@ -2445,6 +2348,7 @@ export default function SessionScreen() {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
+ setStructuredAgentSessionSupported(false)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
return
@@ -2454,6 +2358,7 @@ export default function SessionScreen() {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
+ setStructuredAgentSessionSupported(false)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
// Why: the probe retries — a relay→direct cutover or request timeout rejects
@@ -2462,6 +2367,9 @@ export default function SessionScreen() {
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
+ setStructuredAgentSessionSupported(
+ capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
+ )
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = capabilities.includes(
@@ -2952,6 +2860,9 @@ export default function SessionScreen() {
intent: 'user'
}).catch(() => {})
}
+ if (tab.type === 'agent-session') {
+ return
+ }
if (tab.type === 'browser') {
return
}
@@ -3151,7 +3062,7 @@ export default function SessionScreen() {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
- } else {
+ } else if (tab.type === 'browser') {
setBrowserActionTarget(tab)
}
}, [])
@@ -4177,6 +4088,13 @@ export default function SessionScreen() {
const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null
const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null
const activeBrowserTab = activeSessionTab?.type === 'browser' ? activeSessionTab : null
+ const activeStructuredTab = activeSessionTab?.type === 'agent-session' ? activeSessionTab : null
+ const showTerminalCommandDock =
+ !activeMarkdownTab &&
+ !activeFileTab &&
+ !activeBrowserTab &&
+ !activeStructuredTab &&
+ !showNativeChat
const activePendingTerminalTab =
activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string'
? activeSessionTab
@@ -4327,6 +4245,20 @@ export default function SessionScreen() {
}
]
: []
+ const structuredChatAction = showMobileStructuredChatChoice({
+ hostCapability: structuredAgentSessionSupported,
+ workspaceSupport: structuredSessionEntry.createSupported,
+ agent: createTabAgentOptions.some((option) => option.agent === 'codex') ? 'codex' : ''
+ })
+ ? [
+ {
+ label: 'Chat session',
+ hint: 'Codex · read-only preview',
+ icon: MessageSquare,
+ onPress: structuredSessionEntry.create
+ }
+ ]
+ : []
const sendDiffNotesAgentActions =
pendingDiffNotesDelivery === null
? []
@@ -4531,6 +4463,9 @@ export default function SessionScreen() {
{t.type === 'file' && (
)}
+ {t.type === 'agent-session' && (
+
+ )}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
@@ -4636,7 +4571,7 @@ export default function SessionScreen() {
) : activeMarkdownTab ? (
- void readMarkdownTab(activeMarkdownTab)}
@@ -4697,6 +4632,16 @@ export default function SessionScreen() {
)}
+ ) : activeStructuredTab ? (
+
) : activePendingTerminalTab ? (
@@ -4767,7 +4712,7 @@ export default function SessionScreen() {
)}
{/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
- {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && (
+ {showTerminalCommandDock && (
void
+ onChange: (content: string) => void
+ onSave: () => void
+ onCopy: () => void
+ onDiscard: () => void
+ keyboardLift: number
+}): React.JSX.Element {
+ const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
+ const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
+ if (!doc || doc.status === 'loading') {
+ return (
+
+
+
+ )
+ }
+ if (doc.status === 'error') {
+ return (
+
+ {doc.message}
+
+
+ Retry
+
+
+ )
+ }
+
+ const statusText = doc.saveError
+ ? doc.saveError
+ : doc.readOnlyReason
+ ? 'Read only'
+ : doc.stale
+ ? 'Changed on desktop'
+ : null
+ const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
+ const showCopy = doc.saveError || !doc.editable
+ const showSave = doc.isDirty || doc.saving
+ const showFloatingActions = statusText || showRefresh || showCopy || showSave
+
+ return (
+
+
+ {showFloatingActions ? (
+
+ {statusText ? (
+
+ {statusText}
+
+ ) : null}
+
+ {showCopy ? (
+
+ Copy
+
+ ) : null}
+ {showRefresh ? (
+
+
+ Refresh
+
+ ) : null}
+ {doc.isDirty ? (
+
+ Discard
+
+ ) : null}
+ {showSave ? (
+
+ {doc.saving ? (
+
+ ) : (
+ Save
+ )}
+
+ ) : null}
+
+
+ ) : null}
+
+ )
+}
diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx
new file mode 100644
index 00000000000..6cd38c3b267
--- /dev/null
+++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx
@@ -0,0 +1,125 @@
+import { useCallback, useRef } from 'react'
+import {
+ ActivityIndicator,
+ FlatList,
+ Platform,
+ Text,
+ View,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent
+} from 'react-native'
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import { colors } from '../theme/mobile-theme'
+import { MobileNativeChatMessage } from './MobileNativeChatMessage'
+import {
+ admitStructuredOlderPage,
+ beginStructuredUserScroll,
+ createMobileStructuredPaginationState,
+ finishStructuredPaginationMomentum,
+ settleStructuredOlderPage
+} from './mobile-structured-history-pagination'
+import { styles } from './mobile-structured-agent-session-view-styles'
+
+type Props = {
+ messages: NativeChatMessage[]
+ status: 'idle' | 'loading' | 'ready' | 'error'
+ error?: string
+ hasOlder: boolean
+ loadingOlder: boolean
+ onLoadOlder: () => Promise
+ onOpenFile?: (path: string) => void
+}
+
+export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element {
+ const listRef = useRef>(null)
+ const paginationRef = useRef(createMobileStructuredPaginationState())
+ const priorContentHeightRef = useRef(0)
+ const androidAnchorOffsetRef = useRef(0)
+ const data = props.messages.toReversed()
+
+ const loadOlder = useCallback(() => {
+ const pagination = paginationRef.current
+ if (!props.hasOlder || props.loadingOlder || !admitStructuredOlderPage(pagination)) {
+ return
+ }
+ void props.onLoadOlder().finally(() => settleStructuredOlderPage(pagination))
+ }, [props])
+
+ const onScroll = useCallback((event: NativeSyntheticEvent) => {
+ androidAnchorOffsetRef.current = event.nativeEvent.contentOffset.y
+ }, [])
+
+ if (props.status === 'loading' && data.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ item.id}
+ renderItem={({ item, index }) => (
+
+ )}
+ contentContainerStyle={styles.content}
+ maintainVisibleContentPosition={
+ Platform.OS === 'android' ? undefined : { minIndexForVisible: 0 }
+ }
+ onScroll={onScroll}
+ scrollEventThrottle={32}
+ onScrollBeginDrag={() => beginStructuredUserScroll(paginationRef.current)}
+ onMomentumScrollBegin={() => {
+ if (!paginationRef.current.programmaticMomentum) {
+ paginationRef.current.userMomentum = true
+ }
+ }}
+ onMomentumScrollEnd={() => {
+ const pagination = paginationRef.current
+ finishStructuredPaginationMomentum(pagination, !pagination.programmaticMomentum)
+ }}
+ onEndReached={loadOlder}
+ onEndReachedThreshold={0.2}
+ onContentSizeChange={(_width, height) => {
+ const priorHeight = priorContentHeightRef.current
+ priorContentHeightRef.current = height
+ if (Platform.OS !== 'android' || priorHeight === 0 || height <= priorHeight) {
+ return
+ }
+ paginationRef.current.programmaticMomentum = true
+ listRef.current?.scrollToOffset({
+ offset: androidAnchorOffsetRef.current + height - priorHeight,
+ animated: false
+ })
+ }}
+ ListFooterComponent={
+ props.loadingOlder ? (
+
+
+
+ ) : null
+ }
+ ListEmptyComponent={
+
+
+ {props.status === 'error' ? 'Conversation unavailable' : 'New Codex chat'}
+
+ {props.error ?? 'Messages will appear here.'}
+
+ }
+ />
+
+ Read only · sending arrives in the next update
+
+
+ )
+}
diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts
index 7e74a5d04c0..d540e370b7b 100644
--- a/mobile/src/session/mobile-session-route-types.ts
+++ b/mobile/src/session/mobile-session-route-types.ts
@@ -8,7 +8,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 =
| {
@@ -39,6 +39,14 @@ export type MobileSessionTab =
isActive: boolean
documentVersion: string
}
+ | {
+ type: 'agent-session'
+ id: string
+ title: string
+ sessionId: string
+ agent: 'codex'
+ isActive: boolean
+ }
| {
type: 'file'
id: string
diff --git a/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts b/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts
new file mode 100644
index 00000000000..da03cdb5202
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
+import { createMobileStructuredEventCoalescer } from './mobile-structured-agent-session-coalescer'
+
+function batch(sequence: number, text: string): AgentSessionSubscribeEvent {
+ return {
+ type: 'batch',
+ sessionId: 'session-a',
+ batch: {
+ cursor: { epoch: 'epoch-a', sequence },
+ items: [
+ {
+ itemId: 'message',
+ revision: sequence,
+ sequence: 1,
+ observedAt: 1,
+ body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
+ }
+ ],
+ removedItemIds: [],
+ submissions: []
+ }
+ }
+}
+
+describe('mobile structured event coalescer', () => {
+ it('re-coalesces item snapshots for 48ms and keeps the newest revision', () => {
+ vi.useFakeTimers()
+ const seen: AgentSessionSubscribeEvent[] = []
+ const coalescer = createMobileStructuredEventCoalescer((event) => seen.push(event))
+ coalescer.push(batch(1, 'a'))
+ coalescer.push(batch(2, 'ab'))
+ vi.advanceTimersByTime(47)
+ expect(seen).toEqual([])
+ vi.advanceTimersByTime(1)
+ expect(seen).toHaveLength(1)
+ expect(seen[0]).toMatchObject({ batch: { cursor: { sequence: 2 }, items: [{ revision: 2 }] } })
+ vi.useRealTimers()
+ })
+
+ it('flushes pending text before a lifecycle row', () => {
+ vi.useFakeTimers()
+ const seen: AgentSessionSubscribeEvent[] = []
+ const coalescer = createMobileStructuredEventCoalescer((event) => seen.push(event))
+ coalescer.push(batch(1, 'a'))
+ coalescer.push({
+ type: 'batch',
+ sessionId: 'session-a',
+ batch: {
+ cursor: { epoch: 'epoch-a', sequence: 2 },
+ items: [
+ {
+ itemId: 'status',
+ revision: 1,
+ sequence: 2,
+ observedAt: 2,
+ body: { kind: 'status', text: 'done' }
+ }
+ ],
+ removedItemIds: [],
+ submissions: []
+ }
+ })
+ expect(seen.map((event) => event.type)).toEqual(['batch', 'batch'])
+ vi.useRealTimers()
+ })
+
+ it('bypasses coalescing for tool lifecycle snapshots', () => {
+ vi.useFakeTimers()
+ const seen: AgentSessionSubscribeEvent[] = []
+ const coalescer = createMobileStructuredEventCoalescer((event) => seen.push(event))
+ coalescer.push({
+ type: 'batch',
+ sessionId: 'session-a',
+ batch: {
+ cursor: { epoch: 'epoch-a', sequence: 1 },
+ items: [
+ {
+ itemId: 'tool',
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: { kind: 'tool-call', name: 'shell', input: {}, state: 'running' }
+ }
+ ],
+ removedItemIds: [],
+ submissions: []
+ }
+ })
+
+ expect(seen).toHaveLength(1)
+ vi.useRealTimers()
+ })
+})
diff --git a/mobile/src/session/mobile-structured-agent-session-coalescer.ts b/mobile/src/session/mobile-structured-agent-session-coalescer.ts
new file mode 100644
index 00000000000..c6b8b0ac4e7
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-coalescer.ts
@@ -0,0 +1,77 @@
+import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
+
+const CLIENT_COALESCE_MS = 48
+
+function bypassCoalescing(event: AgentSessionSubscribeEvent): boolean {
+ return (
+ event.type !== 'batch' ||
+ event.batch.items.some((item) => item.body.kind !== 'message' || item.body.role !== 'assistant')
+ )
+}
+
+function mergeBatch(
+ left: Extract,
+ right: Extract
+): Extract {
+ const items = new Map(left.batch.items.map((item) => [item.itemId, item]))
+ for (const item of right.batch.items) {
+ items.set(item.itemId, item)
+ }
+ const submissions = new Map(
+ left.batch.submissions.map((submission) => [submission.clientMessageId, submission])
+ )
+ for (const submission of right.batch.submissions) {
+ submissions.set(submission.clientMessageId, submission)
+ }
+ return {
+ type: 'batch',
+ sessionId: right.sessionId,
+ batch: {
+ cursor: right.batch.cursor,
+ items: [...items.values()],
+ removedItemIds: [...new Set([...left.batch.removedItemIds, ...right.batch.removedItemIds])],
+ submissions: [...submissions.values()]
+ }
+ }
+}
+
+export function createMobileStructuredEventCoalescer(
+ emit: (event: AgentSessionSubscribeEvent) => void,
+ delayMs = CLIENT_COALESCE_MS
+): { push: (event: AgentSessionSubscribeEvent) => void; flush: () => void; dispose: () => void } {
+ let pending: Extract | null = null
+ let timer: ReturnType | null = null
+ const flush = (): void => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+ if (pending) {
+ const event = pending
+ pending = null
+ emit(event)
+ }
+ }
+ return {
+ push(event) {
+ if (bypassCoalescing(event)) {
+ flush()
+ emit(event)
+ return
+ }
+ if (event.type !== 'batch') {
+ return
+ }
+ pending = pending ? mergeBatch(pending, event) : event
+ timer ??= setTimeout(flush, delayMs)
+ },
+ flush,
+ dispose() {
+ if (timer) {
+ clearTimeout(timer)
+ }
+ timer = null
+ pending = null
+ }
+ }
+}
diff --git a/mobile/src/session/mobile-structured-agent-session-projection.ts b/mobile/src/session/mobile-structured-agent-session-projection.ts
new file mode 100644
index 00000000000..f7c56c861c3
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-projection.ts
@@ -0,0 +1,72 @@
+import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
+import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
+
+function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string {
+ return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head
+}
+
+function itemBlocks(item: AgentJournalRenderItem): {
+ role: NativeChatMessage['role']
+ blocks: NativeChatBlock[]
+} {
+ const body = item.body
+ if (body.kind === 'message') {
+ return { role: body.role, blocks: body.blocks }
+ }
+ if (body.kind === 'tool-call') {
+ return {
+ role: 'assistant',
+ blocks: [
+ { type: 'tool-call', name: body.name, input: body.input },
+ ...(body.output
+ ? [
+ {
+ type: 'tool-result' as const,
+ output: boundedText(body.output),
+ isError: body.state === 'failed'
+ }
+ ]
+ : [])
+ ]
+ }
+ }
+ if (body.kind === 'diff') {
+ return {
+ role: 'assistant',
+ blocks: [
+ { type: 'tool-call', name: 'Diff', input: { path: body.path } },
+ { type: 'tool-result', output: boundedText(body.patch) }
+ ]
+ }
+ }
+ if (body.kind === 'approval') {
+ const suffix = body.resolution.state === 'pending' ? 'Awaiting response' : body.resolution.state
+ return {
+ role: 'system',
+ blocks: [{ type: 'text', text: `${body.title}\n${body.detail ?? ''}\n${suffix}`.trim() }]
+ }
+ }
+ if (body.kind === 'question') {
+ const choices = body.options.map((option) => option.label).join(' · ')
+ return {
+ role: 'system',
+ blocks: [{ type: 'text', text: `${body.question}\n${choices}`.trim() }]
+ }
+ }
+ return { role: 'system', blocks: [{ type: 'text', text: body.text }] }
+}
+
+export function projectStructuredItemsToNativeChat(
+ items: readonly AgentJournalRenderItem[]
+): NativeChatMessage[] {
+ return items.map((item) => {
+ const projected = itemBlocks(item)
+ return {
+ id: item.itemId,
+ role: projected.role,
+ blocks: projected.blocks,
+ timestamp: item.observedAt,
+ source: 'transcript'
+ }
+ })
+}
diff --git a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts
new file mode 100644
index 00000000000..a647a5013fb
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
+import {
+ EMPTY_MOBILE_STRUCTURED_AGENT_SESSION,
+ reduceMobileStructuredAgentSession
+} from './mobile-structured-agent-session-reducer'
+
+function item(id: string, sequence: number, revision = 1): AgentJournalRenderItem {
+ return {
+ itemId: id,
+ revision,
+ sequence,
+ observedAt: sequence,
+ body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] }
+ }
+}
+
+function snapshot(epoch: string, items: AgentJournalRenderItem[]) {
+ return {
+ type: 'snapshot' as const,
+ sessionId: 'session-a',
+ fence: 1,
+ snapshot: { sessionId: 'session-a', cursor: { epoch, sequence: 50 }, items, submissions: [] }
+ }
+}
+
+describe('mobile structured session reducer', () => {
+ it('boots from a bounded tail page and retains its live resume cursor', () => {
+ const state = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, {
+ type: 'tail-page',
+ page: {
+ sessionId: 'session-a',
+ epoch: 'epoch-a',
+ direction: 'tail',
+ items: [item('recent', 40)],
+ removedItemIds: [],
+ submissions: [],
+ window: {
+ oldest: { epoch: 'epoch-a', sequence: 40 },
+ newest: { epoch: 'epoch-a', sequence: 40 },
+ nextCursor: { epoch: 'epoch-a', sequence: 40 }
+ },
+ liveCursor: { epoch: 'epoch-a', sequence: 72 },
+ hasOlder: true,
+ hasNewer: false
+ }
+ })
+
+ expect(state.items.map((entry) => entry.itemId)).toEqual(['recent'])
+ expect(state.cursor).toEqual({ epoch: 'epoch-a', sequence: 72 })
+ expect(state.hasOlder).toBe(true)
+ })
+
+ it('applies revisions, removals, and cursor resume batches once', () => {
+ const initial = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, {
+ type: 'event',
+ event: snapshot('epoch-a', [item('a', 1), item('b', 2)])
+ })
+ const updated = reduceMobileStructuredAgentSession(initial, {
+ type: 'event',
+ event: {
+ type: 'batch',
+ sessionId: 'session-a',
+ batch: {
+ cursor: { epoch: 'epoch-a', sequence: 51 },
+ items: [item('b', 2, 2), item('c', 3)],
+ removedItemIds: ['a'],
+ submissions: []
+ }
+ }
+ })
+ expect(updated.items.map((entry) => [entry.itemId, entry.revision])).toEqual([
+ ['b', 2],
+ ['c', 1]
+ ])
+ expect(
+ reduceMobileStructuredAgentSession(updated, {
+ type: 'event',
+ event: {
+ type: 'batch',
+ sessionId: 'session-a',
+ batch: {
+ cursor: { epoch: 'epoch-a', sequence: 50 },
+ items: [item('old', 4)],
+ removedItemIds: [],
+ submissions: []
+ }
+ }
+ })
+ ).toBe(updated)
+ })
+
+ it('discards an older page fetched under a stale epoch', () => {
+ const oldState = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, {
+ type: 'event',
+ event: snapshot('epoch-a', [item('new', 50)])
+ })
+ const reset = reduceMobileStructuredAgentSession(oldState, {
+ type: 'event',
+ event: { ...snapshot('epoch-b', [item('reset', 1)]), type: 'reset', reset: 'epoch_changed' }
+ })
+ const afterPage = reduceMobileStructuredAgentSession(reset, {
+ type: 'older-page',
+ requestedEpoch: 'epoch-a',
+ page: {
+ sessionId: 'session-a',
+ epoch: 'epoch-a',
+ direction: 'before',
+ items: [item('stale', 1)],
+ removedItemIds: [],
+ submissions: [],
+ window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-a', sequence: 0 } },
+ hasOlder: false,
+ hasNewer: true
+ }
+ })
+ expect(afterPage).toBe(reset)
+ expect(afterPage.items.map((entry) => entry.itemId)).toEqual(['reset'])
+ })
+})
diff --git a/mobile/src/session/mobile-structured-agent-session-reducer.ts b/mobile/src/session/mobile-structured-agent-session-reducer.ts
new file mode 100644
index 00000000000..0ef78af495c
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-reducer.ts
@@ -0,0 +1,137 @@
+import type {
+ AgentJournalCursor,
+ AgentJournalRenderItem,
+ AgentJournalSnapshot,
+ AgentJournalSubmission
+} from '../../../src/shared/agent-session-journal-types'
+import type {
+ AgentSessionHistoryPage,
+ AgentSessionSubscribeEvent
+} from '../../../src/shared/agent-session-wire'
+
+export type MobileStructuredAgentSessionState = {
+ epoch: string | null
+ cursor: AgentJournalCursor | null
+ items: AgentJournalRenderItem[]
+ submissions: AgentJournalSubmission[]
+ hasOlder: boolean
+ status: 'idle' | 'loading' | 'ready' | 'error'
+ error?: string
+}
+
+export type MobileStructuredAgentSessionAction =
+ | { type: 'loading' }
+ | { type: 'error'; message: string }
+ | { type: 'event'; event: AgentSessionSubscribeEvent }
+ | { type: 'tail-page'; page: AgentSessionHistoryPage }
+ | { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage }
+
+export const EMPTY_MOBILE_STRUCTURED_AGENT_SESSION: MobileStructuredAgentSessionState = {
+ epoch: null,
+ cursor: null,
+ items: [],
+ submissions: [],
+ hasOlder: false,
+ status: 'idle'
+}
+
+function replaceSnapshot(snapshot: AgentJournalSnapshot): MobileStructuredAgentSessionState {
+ return {
+ epoch: snapshot.cursor.epoch,
+ cursor: snapshot.cursor,
+ items: [...snapshot.items].sort((left, right) => left.sequence - right.sequence),
+ submissions: snapshot.submissions,
+ hasOlder: snapshot.items.length >= 40,
+ status: 'ready'
+ }
+}
+
+function mergeItems(
+ current: readonly AgentJournalRenderItem[],
+ incoming: readonly AgentJournalRenderItem[],
+ removedIds: readonly string[]
+): AgentJournalRenderItem[] {
+ const removed = new Set(removedIds)
+ const byId = new Map(
+ current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item])
+ )
+ for (const item of incoming) {
+ const prior = byId.get(item.itemId)
+ if (!prior || item.revision >= prior.revision) {
+ byId.set(item.itemId, item)
+ }
+ }
+ return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
+}
+
+function mergeSubmissions(
+ current: readonly AgentJournalSubmission[],
+ incoming: readonly AgentJournalSubmission[]
+): AgentJournalSubmission[] {
+ const byId = new Map(current.map((submission) => [submission.clientMessageId, submission]))
+ for (const submission of incoming) {
+ byId.set(submission.clientMessageId, submission)
+ }
+ return [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt)
+}
+
+export function reduceMobileStructuredAgentSession(
+ state: MobileStructuredAgentSessionState,
+ action: MobileStructuredAgentSessionAction
+): MobileStructuredAgentSessionState {
+ if (action.type === 'loading') {
+ return { ...EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, status: 'loading' }
+ }
+ if (action.type === 'error') {
+ return { ...state, status: 'error', error: action.message }
+ }
+ if (action.type === 'tail-page') {
+ return {
+ epoch: action.page.epoch,
+ cursor: action.page.liveCursor ?? null,
+ items: action.page.items,
+ submissions: action.page.submissions,
+ hasOlder: action.page.hasOlder,
+ status: 'ready'
+ }
+ }
+ if (action.type === 'older-page') {
+ if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) {
+ return state
+ }
+ return {
+ ...state,
+ items: mergeItems(state.items, action.page.items, action.page.removedItemIds),
+ submissions: mergeSubmissions(state.submissions, action.page.submissions),
+ hasOlder: action.page.hasOlder
+ }
+ }
+ const event = action.event
+ if (event.type === 'end') {
+ return state
+ }
+ if (event.type === 'snapshot' || event.type === 'reset') {
+ return replaceSnapshot(event.snapshot)
+ }
+ if (state.epoch !== event.batch.cursor.epoch) {
+ return state
+ }
+ if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) {
+ return state
+ }
+ return {
+ ...state,
+ cursor: event.batch.cursor,
+ items: mergeItems(state.items, event.batch.items, event.batch.removedItemIds),
+ submissions: mergeSubmissions(state.submissions, event.batch.submissions),
+ status: 'ready',
+ error: undefined
+ }
+}
+
+export function oldestMobileStructuredCursor(
+ state: MobileStructuredAgentSessionState
+): AgentJournalCursor | null {
+ const oldest = state.items[0]
+ return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null
+}
diff --git a/mobile/src/session/mobile-structured-agent-session-view-styles.ts b/mobile/src/session/mobile-structured-agent-session-view-styles.ts
new file mode 100644
index 00000000000..87cdfad2e38
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-view-styles.ts
@@ -0,0 +1,29 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+
+export const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: colors.bgBase },
+ content: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, flexGrow: 1 },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl },
+ title: {
+ color: colors.textPrimary,
+ fontSize: typography.titleSize,
+ fontWeight: '600',
+ textAlign: 'center'
+ },
+ subtitle: {
+ color: colors.textMuted,
+ fontSize: typography.bodySize,
+ textAlign: 'center',
+ marginTop: spacing.sm
+ },
+ loader: { paddingVertical: spacing.md },
+ readOnlyBanner: {
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ alignItems: 'center'
+ },
+ readOnlyText: { color: colors.textMuted, fontSize: typography.metaSize }
+})
diff --git a/mobile/src/session/mobile-structured-history-pagination.test.ts b/mobile/src/session/mobile-structured-history-pagination.test.ts
new file mode 100644
index 00000000000..b3d5d0c5052
--- /dev/null
+++ b/mobile/src/session/mobile-structured-history-pagination.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import {
+ admitStructuredOlderPage,
+ beginStructuredUserScroll,
+ createMobileStructuredPaginationState,
+ finishStructuredPaginationMomentum,
+ settleStructuredOlderPage
+} from './mobile-structured-history-pagination'
+
+describe('mobile structured pagination latch', () => {
+ it('requires user intent and ignores programmatic momentum ownership', () => {
+ const state = createMobileStructuredPaginationState()
+ expect(admitStructuredOlderPage(state)).toBe(false)
+ beginStructuredUserScroll(state)
+ expect(admitStructuredOlderPage(state)).toBe(true)
+ expect(admitStructuredOlderPage(state)).toBe(false)
+ settleStructuredOlderPage(state)
+ finishStructuredPaginationMomentum(state, false)
+ expect(state.phase).toBe('latched')
+ expect(admitStructuredOlderPage(state)).toBe(false)
+ beginStructuredUserScroll(state)
+ expect(admitStructuredOlderPage(state)).toBe(true)
+ })
+})
diff --git a/mobile/src/session/mobile-structured-history-pagination.ts b/mobile/src/session/mobile-structured-history-pagination.ts
new file mode 100644
index 00000000000..1d3a3592176
--- /dev/null
+++ b/mobile/src/session/mobile-structured-history-pagination.ts
@@ -0,0 +1,44 @@
+export type MobileStructuredPaginationState = {
+ phase: 'ready' | 'loading' | 'settling' | 'latched'
+ userMomentum: boolean
+ programmaticMomentum: boolean
+}
+
+export function createMobileStructuredPaginationState(): MobileStructuredPaginationState {
+ return { phase: 'ready', userMomentum: false, programmaticMomentum: false }
+}
+
+export function beginStructuredUserScroll(state: MobileStructuredPaginationState): void {
+ state.userMomentum = true
+ state.programmaticMomentum = false
+ if (state.phase === 'latched') {
+ state.phase = 'ready'
+ }
+}
+
+export function admitStructuredOlderPage(state: MobileStructuredPaginationState): boolean {
+ if (state.phase !== 'ready' || !state.userMomentum || state.programmaticMomentum) {
+ return false
+ }
+ state.phase = 'loading'
+ state.userMomentum = false
+ return true
+}
+
+export function settleStructuredOlderPage(state: MobileStructuredPaginationState): void {
+ state.phase = 'settling'
+ state.programmaticMomentum = true
+}
+
+export function finishStructuredPaginationMomentum(
+ state: MobileStructuredPaginationState,
+ userOwned: boolean
+): void {
+ if (!userOwned && state.programmaticMomentum) {
+ state.programmaticMomentum = false
+ state.phase = 'latched'
+ return
+ }
+ state.userMomentum = false
+ state.phase = 'latched'
+}
diff --git a/mobile/src/session/mobile-structured-session-create.test.ts b/mobile/src/session/mobile-structured-session-create.test.ts
new file mode 100644
index 00000000000..85024ad92b9
--- /dev/null
+++ b/mobile/src/session/mobile-structured-session-create.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import {
+ mobileStructuredCreateFingerprint,
+ showMobileStructuredChatChoice
+} from './mobile-structured-session-create'
+
+describe('mobile structured session opt-in', () => {
+ it('is invisible without both host and workspace support and only appears for Codex', () => {
+ expect(
+ showMobileStructuredChatChoice({
+ hostCapability: false,
+ workspaceSupport: true,
+ agent: 'codex'
+ })
+ ).toBe(false)
+ expect(
+ showMobileStructuredChatChoice({
+ hostCapability: true,
+ workspaceSupport: false,
+ agent: 'codex'
+ })
+ ).toBe(false)
+ expect(
+ showMobileStructuredChatChoice({
+ hostCapability: true,
+ workspaceSupport: true,
+ agent: 'claude'
+ })
+ ).toBe(false)
+ expect(
+ showMobileStructuredChatChoice({
+ hostCapability: true,
+ workspaceSupport: true,
+ agent: 'codex'
+ })
+ ).toBe(true)
+ })
+
+ it('matches the canonical create-by-intent fingerprint', () => {
+ expect(
+ mobileStructuredCreateFingerprint({ sessionId: 'session-alpha', worktree: 'id:wt-1' })
+ ).toBe('d1e7f0b14cbd06c4740e671fc6a230c66a1e4451c970aab7953f4f150bd107bb')
+ })
+})
diff --git a/mobile/src/session/mobile-structured-session-create.ts b/mobile/src/session/mobile-structured-session-create.ts
new file mode 100644
index 00000000000..93cac27e783
--- /dev/null
+++ b/mobile/src/session/mobile-structured-session-create.ts
@@ -0,0 +1,37 @@
+import { sha256 } from '@noble/hashes/sha256'
+
+function canonicalize(value: unknown): string {
+ if (value === null || typeof value !== 'object') {
+ return JSON.stringify(value ?? null)
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalize).join(',')}]`
+ }
+ return `{${Object.entries(value as Record)
+ .filter(([, entry]) => entry !== undefined)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`)
+ .join(',')}}`
+}
+
+export function mobileStructuredCreateFingerprint(input: {
+ sessionId: string
+ worktree: string
+}): string {
+ const bytes = new TextEncoder().encode(
+ canonicalize({
+ method: 'agentSession.create',
+ sessionId: input.sessionId,
+ fields: { worktree: input.worktree, agent: 'codex' }
+ })
+ )
+ return Array.from(sha256(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('')
+}
+
+export function showMobileStructuredChatChoice(input: {
+ hostCapability: boolean
+ workspaceSupport: boolean
+ agent: string
+}): boolean {
+ return input.hostCapability && input.workspaceSupport && input.agent === 'codex'
+}
diff --git a/mobile/src/session/mobile-structured-session-reconnect.test.ts b/mobile/src/session/mobile-structured-session-reconnect.test.ts
new file mode 100644
index 00000000000..83469195fa0
--- /dev/null
+++ b/mobile/src/session/mobile-structured-session-reconnect.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import {
+ createMobileStructuredReconnectState,
+ noteStructuredBackground,
+ noteStructuredStreamClosed,
+ noteStructuredStreamOpened,
+ resumeStructuredSession
+} from './mobile-structured-session-reconnect'
+
+describe('mobile structured reconnect supervision', () => {
+ it('replaces only a socket backgrounded for at least ten seconds and skips rung zero', () => {
+ const state = createMobileStructuredReconnectState()
+ noteStructuredBackground(state, 1_000)
+ expect(resumeStructuredSession(state, 10_999).reconnect).toBe(false)
+ noteStructuredBackground(state, 20_000)
+ expect(resumeStructuredSession(state, 30_000)).toEqual({
+ reconnect: true,
+ minimumBackoffAttempt: 1
+ })
+ })
+
+ it('counts background duration from the first inactive transition', () => {
+ const state = createMobileStructuredReconnectState()
+ noteStructuredBackground(state, 1_000)
+ noteStructuredBackground(state, 5_000)
+
+ expect(resumeStructuredSession(state, 11_000).reconnect).toBe(true)
+ })
+
+ it('resets backoff only after more than five seconds of stream longevity', () => {
+ const state = createMobileStructuredReconnectState()
+ noteStructuredStreamOpened(state, 0)
+ noteStructuredStreamClosed(state, 5_000)
+ expect(state.backoffAttempt).toBe(1)
+ noteStructuredStreamOpened(state, 10_000)
+ noteStructuredStreamClosed(state, 15_001)
+ expect(state.backoffAttempt).toBe(0)
+ })
+})
diff --git a/mobile/src/session/mobile-structured-session-reconnect.ts b/mobile/src/session/mobile-structured-session-reconnect.ts
new file mode 100644
index 00000000000..253660fe895
--- /dev/null
+++ b/mobile/src/session/mobile-structured-session-reconnect.ts
@@ -0,0 +1,48 @@
+export const STRUCTURED_BACKGROUND_RECONNECT_MS = 10_000
+export const STRUCTURED_STREAM_STABLE_MS = 5_000
+
+export type MobileStructuredReconnectState = {
+ backgroundedAt: number | null
+ streamOpenedAt: number | null
+ backoffAttempt: number
+}
+
+export function createMobileStructuredReconnectState(): MobileStructuredReconnectState {
+ return { backgroundedAt: null, streamOpenedAt: null, backoffAttempt: 0 }
+}
+
+export function noteStructuredBackground(state: MobileStructuredReconnectState, now: number): void {
+ state.backgroundedAt ??= now
+}
+
+export function resumeStructuredSession(
+ state: MobileStructuredReconnectState,
+ now: number
+): { reconnect: boolean; minimumBackoffAttempt: number } {
+ const elapsed = state.backgroundedAt === null ? 0 : now - state.backgroundedAt
+ state.backgroundedAt = null
+ if (elapsed < STRUCTURED_BACKGROUND_RECONNECT_MS) {
+ return { reconnect: false, minimumBackoffAttempt: state.backoffAttempt }
+ }
+ state.backoffAttempt = Math.max(1, state.backoffAttempt)
+ return { reconnect: true, minimumBackoffAttempt: state.backoffAttempt }
+}
+
+export function noteStructuredStreamOpened(
+ state: MobileStructuredReconnectState,
+ now: number
+): void {
+ state.streamOpenedAt = now
+}
+
+export function noteStructuredStreamClosed(
+ state: MobileStructuredReconnectState,
+ now: number
+): void {
+ if (state.streamOpenedAt !== null && now - state.streamOpenedAt > STRUCTURED_STREAM_STABLE_MS) {
+ state.backoffAttempt = 0
+ } else {
+ state.backoffAttempt++
+ }
+ state.streamOpenedAt = null
+}
diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts
index 4a9e4438c84..05d355003b4 100644
--- a/mobile/src/session/mobile-terminal-records.ts
+++ b/mobile/src/session/mobile-terminal-records.ts
@@ -46,6 +46,14 @@ type MobileSessionTabLike =
isDirty?: boolean
isActive?: boolean
}
+ | {
+ type: 'agent-session'
+ id: string
+ title?: string
+ sessionId: string
+ agent: 'codex'
+ isActive?: boolean
+ }
| {
type: 'browser'
id: string
@@ -120,6 +128,8 @@ function mobileSessionTabEqual(
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward
)
+ case 'agent-session':
+ return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent
}
}
diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts
index 731d5bf4a24..a78a313f4c4 100644
--- a/mobile/src/session/mobile-terminal-tab-agent.ts
+++ b/mobile/src/session/mobile-terminal-tab-agent.ts
@@ -31,6 +31,9 @@ export function resolveMobileTerminalTabAgentId(tab: {
}
export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
+ if (tab.type === 'agent-session') {
+ return tab.title || 'Codex Chat'
+ }
if (tab.type === 'browser') {
const title = tab.title.trim()
if (title && !isBlankBrowserUrl(title)) {
diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts
new file mode 100644
index 00000000000..ccad929e411
--- /dev/null
+++ b/mobile/src/session/use-mobile-structured-agent-session.ts
@@ -0,0 +1,229 @@
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
+import { AppState, type AppStateStatus } from 'react-native'
+import type {
+ AgentSessionHistoryResult,
+ AgentSessionSubscribeEvent
+} from '../../../src/shared/agent-session-wire'
+import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types'
+import type { RpcClient } from '../transport/rpc-client'
+import { createMobileStructuredEventCoalescer } from './mobile-structured-agent-session-coalescer'
+import { projectStructuredItemsToNativeChat } from './mobile-structured-agent-session-projection'
+import {
+ EMPTY_MOBILE_STRUCTURED_AGENT_SESSION,
+ oldestMobileStructuredCursor,
+ reduceMobileStructuredAgentSession
+} from './mobile-structured-agent-session-reducer'
+import {
+ createMobileStructuredReconnectState,
+ noteStructuredBackground,
+ noteStructuredStreamClosed,
+ noteStructuredStreamOpened,
+ resumeStructuredSession,
+ STRUCTURED_STREAM_STABLE_MS
+} from './mobile-structured-session-reconnect'
+
+export function useMobileStructuredAgentSession(args: {
+ client: RpcClient | null
+ sessionId: string | null
+}): {
+ messages: ReturnType
+ status: 'idle' | 'loading' | 'ready' | 'error'
+ error?: string
+ hasOlder: boolean
+ loadingOlder: boolean
+ loadOlder: () => Promise
+} {
+ const { client, sessionId } = args
+ const [state, dispatch] = useReducer(
+ reduceMobileStructuredAgentSession,
+ EMPTY_MOBILE_STRUCTURED_AGENT_SESSION
+ )
+ const stateRef = useRef(state)
+ stateRef.current = state
+ const resumeCursorRef = useRef(state.cursor)
+ resumeCursorRef.current = state.cursor
+ const [loadingOlder, setLoadingOlder] = useState(false)
+ const reconnectRef = useRef(createMobileStructuredReconnectState())
+ const longevityTimerRef = useRef | null>(null)
+
+ useEffect(() => {
+ dispatch({ type: 'loading' })
+ setLoadingOlder(false)
+ if (!client || !sessionId) {
+ dispatch({ type: 'error', message: '' })
+ return
+ }
+ let closed = false
+ let streamOpened = false
+ let unsubscribe = (): void => {}
+ const coalescer = createMobileStructuredEventCoalescer((event) => {
+ dispatch({ type: 'event', event })
+ })
+ const subscribe = (cursor: AgentJournalCursor | null): void => {
+ if (closed) {
+ return
+ }
+ streamOpened = true
+ noteStructuredStreamOpened(reconnectRef.current, Date.now())
+ longevityTimerRef.current = setTimeout(() => {
+ longevityTimerRef.current = null
+ if (!closed) {
+ client.confirmStructuredStreamLongevity?.()
+ }
+ }, STRUCTURED_STREAM_STABLE_MS + 1)
+ unsubscribe = client.subscribe(
+ 'agentSession.subscribe',
+ { sessionId, ...(cursor ? { cursor } : {}) },
+ (raw) => {
+ if (closed) {
+ return
+ }
+ const event = raw as AgentSessionSubscribeEvent | { type: 'error'; message?: string }
+ if (event.type === 'error') {
+ coalescer.flush()
+ dispatch({
+ type: 'error',
+ message: event.message ?? 'Conversation stream unavailable'
+ })
+ return
+ }
+ if (event.type === 'snapshot' || event.type === 'reset') {
+ setLoadingOlder(false)
+ resumeCursorRef.current = event.snapshot.cursor
+ } else if (event.type === 'batch') {
+ const current = resumeCursorRef.current
+ if (
+ !current ||
+ (current.epoch === event.batch.cursor.epoch &&
+ event.batch.cursor.sequence >= current.sequence)
+ ) {
+ resumeCursorRef.current = event.batch.cursor
+ }
+ }
+ coalescer.push(event)
+ },
+ {
+ paramsForReconnect: () => ({
+ sessionId,
+ ...(resumeCursorRef.current ? { cursor: resumeCursorRef.current } : {})
+ })
+ }
+ )
+ }
+ void client
+ .sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 })
+ .then((response) => {
+ if (closed) {
+ return
+ }
+ if (!response.ok) {
+ throw new Error(response.error.message)
+ }
+ const result = response.result as AgentSessionHistoryResult
+ if (result.ok) {
+ dispatch({ type: 'tail-page', page: result.page })
+ resumeCursorRef.current = result.page.liveCursor ?? null
+ subscribe(result.page.liveCursor ?? null)
+ return
+ }
+ dispatch({
+ type: 'event',
+ event: {
+ type: 'reset',
+ sessionId,
+ reset: result.reset,
+ snapshot: result.snapshot,
+ fence: 0
+ }
+ })
+ resumeCursorRef.current = result.snapshot.cursor
+ subscribe(result.snapshot.cursor)
+ })
+ .catch((error: unknown) => {
+ if (!closed) {
+ dispatch({
+ type: 'error',
+ message: error instanceof Error ? error.message : 'Conversation history unavailable'
+ })
+ }
+ })
+ return () => {
+ closed = true
+ if (longevityTimerRef.current) {
+ clearTimeout(longevityTimerRef.current)
+ longevityTimerRef.current = null
+ }
+ if (streamOpened) {
+ noteStructuredStreamClosed(reconnectRef.current, Date.now())
+ }
+ coalescer.dispose()
+ unsubscribe()
+ }
+ }, [client, sessionId])
+
+ useEffect(() => {
+ if (!client || !sessionId) {
+ return
+ }
+ const onAppState = (next: AppStateStatus): void => {
+ if (next === 'active') {
+ if (resumeStructuredSession(reconnectRef.current, Date.now()).reconnect) {
+ client.restartAfterStructuredBackground?.()
+ }
+ } else {
+ noteStructuredBackground(reconnectRef.current, Date.now())
+ }
+ }
+ const subscription = AppState.addEventListener('change', onAppState)
+ return () => subscription.remove()
+ }, [client, sessionId])
+
+ const loadOlder = useCallback(async (): Promise => {
+ const current = stateRef.current
+ const cursor = oldestMobileStructuredCursor(current)
+ if (!client || !sessionId || !cursor || !current.hasOlder || loadingOlder) {
+ return false
+ }
+ const requestedEpoch = cursor.epoch
+ setLoadingOlder(true)
+ try {
+ const response = await client.sendRequest('agentSession.history', {
+ sessionId,
+ direction: 'before',
+ cursor,
+ limit: 40
+ })
+ if (!response.ok) {
+ return false
+ }
+ const result = response.result as AgentSessionHistoryResult
+ if (!result.ok) {
+ dispatch({
+ type: 'event',
+ event: {
+ type: 'reset',
+ sessionId,
+ reset: result.reset,
+ snapshot: result.snapshot,
+ fence: 0
+ }
+ })
+ return false
+ }
+ dispatch({ type: 'older-page', requestedEpoch, page: result.page })
+ return stateRef.current.epoch === requestedEpoch && result.page.epoch === requestedEpoch
+ } finally {
+ setLoadingOlder(false)
+ }
+ }, [client, loadingOlder, sessionId])
+
+ const messages = useMemo(() => projectStructuredItemsToNativeChat(state.items), [state.items])
+ return {
+ messages,
+ status: !client || !sessionId ? 'idle' : state.status,
+ error: state.error || undefined,
+ hasOlder: state.hasOlder,
+ loadingOlder,
+ loadOlder
+ }
+}
diff --git a/mobile/src/session/use-mobile-structured-session-entry.ts b/mobile/src/session/use-mobile-structured-session-entry.ts
new file mode 100644
index 00000000000..e78469007f2
--- /dev/null
+++ b/mobile/src/session/use-mobile-structured-session-entry.ts
@@ -0,0 +1,115 @@
+import { useCallback, useEffect, useState, type MutableRefObject } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import { mobileStructuredCreateFingerprint } from './mobile-structured-session-create'
+import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
+
+export function useMobileStructuredSessionEntry(args: {
+ client: RpcClient | null
+ connected: boolean
+ drawerOpen: boolean
+ hostSupported: boolean
+ worktreeId: string
+ sessionId: string | null
+ creationGuardRef: MutableRefObject
+ setCreating: (creating: boolean) => void
+ setCreateError: (message: string) => void
+ closeDrawer: () => void
+ onCreated: (tabId: string) => void
+ onError: (message: string) => void
+}) {
+ const {
+ client,
+ connected,
+ drawerOpen,
+ hostSupported,
+ worktreeId,
+ sessionId,
+ creationGuardRef,
+ setCreating,
+ setCreateError,
+ closeDrawer,
+ onCreated,
+ onError
+ } = args
+ const [createSupported, setCreateSupported] = useState(false)
+ const session = useMobileStructuredAgentSession({
+ client,
+ sessionId
+ })
+
+ useEffect(() => {
+ if (!client || !connected || !drawerOpen || !hostSupported) {
+ setCreateSupported(false)
+ return
+ }
+ setCreateSupported(false)
+ let stale = false
+ void client
+ .sendRequest('agentSession.createSupport', {
+ worktree: `id:${worktreeId}`,
+ agent: 'codex'
+ })
+ .then((response) => {
+ if (!stale) {
+ setCreateSupported(
+ response.ok && (response.result as { supported?: boolean }).supported === true
+ )
+ }
+ })
+ .catch(() => !stale && setCreateSupported(false))
+ return () => {
+ stale = true
+ }
+ }, [client, connected, drawerOpen, hostSupported, worktreeId])
+
+ const create = useCallback(async (): Promise => {
+ if (!client || creationGuardRef.current || !createSupported) {
+ return
+ }
+ creationGuardRef.current = true
+ setCreating(true)
+ setCreateError('')
+ const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`
+ const sessionId = `mobile_${nonce}`
+ const worktree = `id:${worktreeId}`
+ try {
+ const response = await client.sendRequest('agentSession.create', {
+ envelope: {
+ sessionId,
+ clientOperationId: `mobile-structured:${nonce}`,
+ expectedRuntimeFence: null,
+ payloadFingerprint: mobileStructuredCreateFingerprint({ sessionId, worktree })
+ },
+ worktree,
+ agent: 'codex'
+ })
+ const result = response.ok
+ ? (response.result as { ok?: boolean; refusal?: { message?: string } })
+ : null
+ if (!response.ok || result?.ok !== true) {
+ throw new Error(result?.refusal?.message ?? 'Failed to create chat session')
+ }
+ closeDrawer()
+ onCreated(`agent-session:${sessionId}`)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to create chat session'
+ setCreateError(message)
+ onError(message)
+ } finally {
+ creationGuardRef.current = false
+ setCreating(false)
+ }
+ }, [
+ client,
+ closeDrawer,
+ createSupported,
+ creationGuardRef,
+ onCreated,
+ onError,
+ setCreateError,
+ setCreating,
+ worktreeId
+ ])
+
+ return { createSupported, create, session }
+}
diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts
index a7aae9e6d0f..d1c218b0dd4 100644
--- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts
+++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts
@@ -118,6 +118,18 @@ describe('mobile E2EE v2 physical channel', () => {
expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello)
expect(typeof ctx.sent[1]).toBe('string')
+ const auth = openMobileE2EEV2Frame({
+ frame: Buffer.from(ctx.sent[1] as string, 'base64'),
+ key: ctx.schedule.mobileToDesktopKey,
+ sessionId: ctx.schedule.sessionId,
+ direction: 'mobile-to-desktop',
+ payloadKind: 'text',
+ expectedCounter: 0n
+ })
+ expect(JSON.parse(new TextDecoder().decode(auth))).toMatchObject({
+ type: 'e2ee_auth',
+ clientCapabilities: ['agent-session.structured.v1']
+ })
expect(ctx.onAuthenticated).toHaveBeenCalledOnce()
expect(ctx.onError).not.toHaveBeenCalled()
})
diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts
index ec3b8088915..1bf3d59b85f 100644
--- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts
+++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts
@@ -2,6 +2,7 @@ import {
createWsOutboundBackpressureQueue,
type WsOutboundBackpressureQueue
} from '../../../src/shared/ws-outbound-backpressure-queue'
+import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
@@ -139,7 +140,8 @@ export class MobileE2EEV2PhysicalChannel {
type: 'e2ee_auth',
v: 2,
transcriptHashB64: this.args.session.transcriptHashB64,
- deviceToken: this.args.deviceToken
+ deviceToken: this.args.deviceToken,
+ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
})
})
}
diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts
index 4f50878c6d5..b8435657eed 100644
--- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts
+++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts
@@ -255,6 +255,61 @@ describe('relay reconnect controller', () => {
expect(onRetry).toHaveBeenCalledTimes(2)
})
+ it('skips the first backoff rung after a structured background restart', () => {
+ const onRetry = vi.fn()
+ const reconnect = createController(onRetry)
+ const session = {
+ getFailure: () => new Error('structured session background reconnect'),
+ consumeStructuredReconnectSignal: () => ({
+ backgroundRestart: true,
+ streamLongevityConfirmed: false
+ })
+ } as MobileRelayRpcSession
+ const logical = { getActivePath: () => 'relay' } as StableLogicalRpcClient
+
+ reconnect.setActiveSession(session)
+ reconnect.registerActiveFailure(logical)
+
+ vi.advanceTimersByTime(499)
+ expect(onRetry).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(1)
+ expect(onRetry).toHaveBeenCalledOnce()
+ })
+
+ it('resets structured backoff only after stream longevity is confirmed', () => {
+ const onRetry = vi.fn()
+ const reconnect = createController(onRetry)
+ const logical = { getActivePath: () => 'relay' } as StableLogicalRpcClient
+
+ reconnect.registerFailure(new Error('first failure'), false)
+ reconnect.registerFailure(new Error('second failure'), false)
+ reconnect.setActiveSession({
+ getFailure: () => new Error('short stream failure'),
+ consumeStructuredReconnectSignal: () => ({
+ backgroundRestart: false,
+ streamLongevityConfirmed: false
+ })
+ } as MobileRelayRpcSession)
+ reconnect.registerActiveFailure(logical)
+ vi.advanceTimersByTime(999)
+ expect(onRetry).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(1)
+ expect(onRetry).toHaveBeenCalledOnce()
+
+ reconnect.setActiveSession({
+ getFailure: () => new Error('long stream failure'),
+ consumeStructuredReconnectSignal: () => ({
+ backgroundRestart: false,
+ streamLongevityConfirmed: true
+ })
+ } as MobileRelayRpcSession)
+ reconnect.registerActiveFailure(logical)
+ vi.advanceTimersByTime(249)
+ expect(onRetry).toHaveBeenCalledOnce()
+ vi.advanceTimersByTime(1)
+ expect(onRetry).toHaveBeenCalledTimes(2)
+ })
+
it('uses grace only when the outer relay credential was rejected', () => {
const reconnect = createController(vi.fn())
diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts
index 2955819ea4a..1ae545de9a2 100644
--- a/mobile/src/transport/mobile-relay-reconnect-controller.ts
+++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts
@@ -7,6 +7,7 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
+import { relayStructuredFailures } from './mobile-relay-structured-reconnect'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, ForegroundNudgeReason } from './types'
@@ -190,6 +191,7 @@ export class RelayReconnectController {
return
}
const failure = this.activeSession?.getFailure()
+ this.consecutiveFailures = relayStructuredFailures(this.consecutiveFailures, this.activeSession)
this.activeSession = null
if (failure) {
// Why: active relay closes need the same cooldown as failed replacement dials.
diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts
index f5fa49a785e..4a41cd049b1 100644
--- a/mobile/src/transport/mobile-relay-rpc-session.test.ts
+++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts
@@ -139,6 +139,27 @@ describe('mobile relay RPC session', () => {
expect(fakes.sendText).not.toHaveBeenCalled()
})
+ it('tears down for a structured background restart and reports its retry signal', async () => {
+ const { session } = await authenticateSession()
+ const states: string[] = []
+ session.onStateChange((state) => states.push(state))
+
+ session.confirmStructuredStreamLongevity?.()
+ session.restartAfterStructuredBackground?.()
+
+ expect(fakes.close).toHaveBeenCalledOnce()
+ expect(states).toEqual(['disconnected'])
+ expect(session.getFailure()?.message).toBe('structured session background reconnect')
+ expect(session.consumeStructuredReconnectSignal()).toEqual({
+ backgroundRestart: true,
+ streamLongevityConfirmed: true
+ })
+ expect(session.consumeStructuredReconnectSignal()).toEqual({
+ backgroundRestart: false,
+ streamLongevityConfirmed: false
+ })
+ })
+
it('routes terminal and browser binary streams after confirmation', async () => {
const { session } = await authenticateSession()
const terminalListener = vi.fn()
diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts
index ac065012203..eee888d4802 100644
--- a/mobile/src/transport/mobile-relay-rpc-session.ts
+++ b/mobile/src/transport/mobile-relay-rpc-session.ts
@@ -25,6 +25,10 @@ export type MobileRelayRpcSession = RpcClient & {
getResumeExpiresAt(): number | null
getResumeConfirmation(): DeviceResumeConfirmed | null
getFailure(): Error | null
+ consumeStructuredReconnectSignal?(): {
+ backgroundRestart: boolean
+ streamLongevityConfirmed: boolean
+ }
}
export function connectMobileRelayRpcSession(args: {
@@ -47,6 +51,8 @@ export function connectMobileRelayRpcSession(args: {
let resumeExpiresAt: number | null = null
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
+ let structuredBackgroundRestart = false
+ let structuredStreamLongevityConfirmed = false
let closed = false
const streams = new MobileRelayRpcStreams({
nextId,
@@ -104,6 +110,16 @@ export function connectMobileRelayRpcSession(args: {
return () => stateListeners.delete(listener)
},
notifyForeground: () => {},
+ restartAfterStructuredBackground() {
+ if (closed) {
+ return
+ }
+ structuredBackgroundRestart = true
+ fail(new Error('structured session background reconnect'))
+ },
+ confirmStructuredStreamLongevity() {
+ structuredStreamLongevityConfirmed = true
+ },
close() {
if (closed) {
return
@@ -117,7 +133,16 @@ export function connectMobileRelayRpcSession(args: {
getAttachDeadlineAt: () => attachDeadlineAt,
getResumeExpiresAt: () => resumeExpiresAt,
getResumeConfirmation: () => resumeConfirmation,
- getFailure: () => failure
+ getFailure: () => failure,
+ consumeStructuredReconnectSignal() {
+ const signal = {
+ backgroundRestart: structuredBackgroundRestart,
+ streamLongevityConfirmed: structuredStreamLongevityConfirmed
+ }
+ structuredBackgroundRestart = false
+ structuredStreamLongevityConfirmed = false
+ return signal
+ }
}
return client
diff --git a/mobile/src/transport/mobile-relay-structured-reconnect.ts b/mobile/src/transport/mobile-relay-structured-reconnect.ts
new file mode 100644
index 00000000000..839002ccf19
--- /dev/null
+++ b/mobile/src/transport/mobile-relay-structured-reconnect.ts
@@ -0,0 +1,10 @@
+import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
+
+export function relayStructuredFailures(
+ consecutiveFailures: number,
+ session: MobileRelayRpcSession | null
+): number {
+ const signal = session?.consumeStructuredReconnectSignal?.()
+ const resetFailures = signal?.streamLongevityConfirmed ? 0 : consecutiveFailures
+ return signal?.backgroundRestart ? Math.max(1, resetFailures) : resetFailures
+}
diff --git a/mobile/src/transport/rpc-client-structured-session.test.ts b/mobile/src/transport/rpc-client-structured-session.test.ts
new file mode 100644
index 00000000000..1fe9b084ff7
--- /dev/null
+++ b/mobile/src/transport/rpc-client-structured-session.test.ts
@@ -0,0 +1,84 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { connect } from './rpc-client'
+import {
+ MockWebSocket,
+ mockSockets,
+ originalWebSocket,
+ sentRequest
+} from './rpc-client-test-websocket'
+
+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
+}))
+
+describe('structured session RPC transport', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ mockSockets.length = 0
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ globalThis.WebSocket = originalWebSocket
+ })
+
+ it('advertises structured agent sessions in encrypted authentication', () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ const socket = mockSockets[0]!
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ const auth = socket.sent
+ .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as Record)
+ .find((payload) => payload.type === 'e2ee_auth')
+
+ expect(auth).toEqual({
+ type: 'e2ee_auth',
+ deviceToken: 'token',
+ clientCapabilities: ['agent-session.structured.v1']
+ })
+ client.close()
+ })
+
+ it('rebuilds a structured stream cursor when the transport reconnects', () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ const first = mockSockets[0]!
+ first.open()
+ first.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ first.receive('encrypted:{"type":"e2ee_authenticated"}')
+ let sequence = 4
+ client.subscribe(
+ 'agentSession.subscribe',
+ { sessionId: 'session-a', cursor: { epoch: 'epoch-a', sequence } },
+ () => {},
+ {
+ paramsForReconnect: () => ({
+ sessionId: 'session-a',
+ cursor: { epoch: 'epoch-a', sequence }
+ })
+ }
+ )
+ sequence = 9
+ first.close()
+ vi.advanceTimersByTime(500)
+ const second = mockSockets[1]!
+ second.open()
+ second.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ second.receive('encrypted:{"type":"e2ee_authenticated"}')
+
+ expect(sentRequest(second, 'agentSession.subscribe').params).toEqual({
+ sessionId: 'session-a',
+ cursor: { epoch: 'epoch-a', sequence: 9 }
+ })
+ client.close()
+ })
+})
diff --git a/mobile/src/transport/rpc-client-test-websocket.ts b/mobile/src/transport/rpc-client-test-websocket.ts
new file mode 100644
index 00000000000..0fdbafa026c
--- /dev/null
+++ b/mobile/src/transport/rpc-client-test-websocket.ts
@@ -0,0 +1,79 @@
+import { vi } from 'vitest'
+
+export 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
+ onclose: (() => void) | null = null
+ onmessage: ((event: { data: unknown }) => void) | null = null
+ onerror: (() => void) | null = null
+ emitCloseOnClose = true
+ sent: string[] = []
+ close = vi.fn(() => {
+ if (this.readyState === MockWebSocket.CLOSED) {
+ return
+ }
+ this.readyState = MockWebSocket.CLOSED
+ if (this.emitCloseOnClose) {
+ this.onclose?.()
+ }
+ })
+
+ constructor(readonly endpoint: string) {
+ mockSockets.push(this)
+ }
+
+ send(payload: string): void {
+ this.sent.push(payload)
+ }
+
+ open(): void {
+ this.readyState = MockWebSocket.OPEN
+ this.onopen?.()
+ }
+
+ receive(payload: unknown): void {
+ this.onmessage?.({ data: payload })
+ }
+}
+
+export const mockSockets: MockWebSocket[] = []
+export const originalWebSocket = globalThis.WebSocket
+
+export function sentRequest(
+ socket: MockWebSocket,
+ method: string
+): { id: string; params?: unknown } {
+ const request = sentRequests(socket, method)[0]
+ if (!request) {
+ throw new Error(`Request not sent: ${method}`)
+ }
+ return request
+}
+
+export 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
+}
diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts
index 67e920ff1af..15dbd0cfc5d 100644
--- a/mobile/src/transport/rpc-client.test.ts
+++ b/mobile/src/transport/rpc-client.test.ts
@@ -1,6 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
+import {
+ MockWebSocket,
+ mockSockets,
+ originalWebSocket,
+ sentRequest,
+ sentRequests
+} from './rpc-client-test-websocket'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
@@ -15,87 +22,6 @@ vi.mock('./e2ee', () => ({
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
- onclose: (() => void) | null = null
- onmessage: ((event: { data: unknown }) => void) | null = null
- onerror: (() => void) | null = null
- emitCloseOnClose = true
- sent: string[] = []
- close = vi.fn(() => {
- if (this.readyState === MockWebSocket.CLOSED) {
- return
- }
- this.readyState = MockWebSocket.CLOSED
- if (this.emitCloseOnClose) {
- this.onclose?.()
- }
- })
-
- constructor(readonly endpoint: string) {
- mockSockets.push(this)
- }
-
- send(payload: string): void {
- this.sent.push(payload)
- }
-
- open(): void {
- this.readyState = MockWebSocket.OPEN
- this.onopen?.()
- }
-
- receive(payload: unknown): void {
- this.onmessage?.({ data: payload })
- }
-}
-
-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 }
- }
- }
- 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 encodeBrowserFrame(): Uint8Array {
const metadata = new TextEncoder().encode(JSON.stringify({ deviceWidth: 800, deviceHeight: 600 }))
const image = new Uint8Array([1, 2, 3, 4])
diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts
index 9b0afe937f3..47d64621912 100644
--- a/mobile/src/transport/rpc-client.ts
+++ b/mobile/src/transport/rpc-client.ts
@@ -40,6 +40,7 @@ import { isRpcResponse } from './rpc-response-shape'
import { createRpcActivityProbe } from './rpc-client-activity-probe'
import { isStaleForegroundDial } from './rpc-stale-dial'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
+import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
type PendingRequest = {
resolve: (response: RpcResponse) => void
@@ -69,6 +70,8 @@ export type SendRequestOptions = {
type SubscribeOptions = {
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
+ /** Rebuild cursor-bearing params after a transport reconnect. */
+ paramsForReconnect?: () => unknown
}
type StreamingListener = (result: unknown) => void
@@ -81,6 +84,7 @@ type StreamRequest = {
subscriptionId?: string
cancelled?: boolean
sent?: boolean
+ paramsForReconnect?: () => unknown
}
export type RpcClient = {
@@ -100,7 +104,7 @@ export type RpcClient = {
viewport: { cols: number; rows: number }
) => void
getState: () => ConnectionState
- // 0 means never failed (reset once the handshake authenticates); the UI escalates "Reconnecting…" to "Can't connect" past a threshold.
+ // 0 means never failed; structured streams reset only after proving >5s longevity.
getReconnectAttempt: () => number
// Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip".
getLastConnectedAt: () => number | null
@@ -108,6 +112,10 @@ export type RpcClient = {
// Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover.
// The reason routes relay handling (probe vs replace); the direct socket probes regardless.
notifyForeground: (reason?: ForegroundNudgeReason) => void
+ /** A long-background structured stream must replace the socket immediately. */
+ restartAfterStructuredBackground?: () => void
+ /** A structured stream, not authentication alone, proved the path durable. */
+ confirmStructuredStreamLongevity?: () => void
close: () => void
}
@@ -231,8 +239,13 @@ export function connect(
if (next === 'connected') {
lastConnectedAt = Date.now()
authenticationGeneration++
- // Why: only a completed E2EE handshake proves the path is healthy (issue #10119).
- reconnectAttempt = 0
+ // A structured conversation keeps its attempt count until the resumed
+ // stream itself survives the longevity threshold.
+ if (
+ ![...streamListeners.values()].some((stream) => stream.method === 'agentSession.subscribe')
+ ) {
+ reconnectAttempt = 0
+ }
// Why: a clean handshake proves the token is valid — reset the auth retry budget.
authRejectionCount = 0
for (const waiter of connectWaiters.splice(0)) {
@@ -395,7 +408,11 @@ export function connect(
const msg = JSON.parse(raw)
if (msg.type === 'e2ee_ready') {
emitLog('success', 'Received e2ee_ready', 'Sending device token')
- sendEncrypted({ type: 'e2ee_auth', deviceToken })
+ sendEncrypted({
+ type: 'e2ee_auth',
+ deviceToken,
+ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
+ })
return
}
} catch {
@@ -437,7 +454,12 @@ export function connect(
}
resetTerminalStreamRoutingForRequest(id)
if (
- sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
+ sendEncrypted({
+ id,
+ deviceToken,
+ method: stream.method,
+ params: stream.paramsForReconnect?.() ?? stream.params
+ })
) {
stream.sent = true
} else {
@@ -1032,7 +1054,8 @@ export function connect(
method,
params,
listener: onData,
- onBinaryFrame: options?.onBinaryFrame
+ onBinaryFrame: options?.onBinaryFrame,
+ paramsForReconnect: options?.paramsForReconnect
}
streamListeners.set(id, stream)
if (method === 'browser.screencast') {
@@ -1149,6 +1172,24 @@ export function connect(
}
},
+ restartAfterStructuredBackground(): void {
+ if (intentionallyClosed) {
+ return
+ }
+ reconnectAttempt = Math.max(1, reconnectAttempt)
+ const connectedSocket = ws
+ if (connectedSocket) {
+ closeAndSynthesize(connectedSocket)
+ } else {
+ setState('reconnecting')
+ }
+ redialNow(false)
+ },
+
+ confirmStructuredStreamLongevity(): void {
+ reconnectAttempt = 0
+ },
+
close() {
intentionallyClosed = true
if (reconnectTimer) {
diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts
index 2171a690b59..d06c3c79651 100644
--- a/mobile/src/transport/stable-logical-rpc-client.test.ts
+++ b/mobile/src/transport/stable-logical-rpc-client.test.ts
@@ -16,6 +16,8 @@ class FakeSession implements RpcClient {
readonly updateTerminalSubscriptionViewport =
vi.fn()
readonly notifyForeground = vi.fn()
+ readonly restartAfterStructuredBackground = vi.fn()
+ readonly confirmStructuredStreamLongevity = vi.fn()
readonly close = vi.fn()
private state: ConnectionState
private readonly stateListeners = new Set<(state: ConnectionState) => void>()
@@ -66,6 +68,17 @@ function deferred() {
}
describe('stable logical RPC client', () => {
+ it('forwards structured reconnect lifecycle signals to the active session', () => {
+ const session = new FakeSession('connected')
+ const client = createStableLogicalRpcClient(session, 'relay')
+
+ client.restartAfterStructuredBackground?.()
+ client.confirmStructuredStreamLongevity?.()
+
+ expect(session.restartAfterStructuredBackground).toHaveBeenCalledOnce()
+ expect(session.confirmStructuredStreamLongevity).toHaveBeenCalledOnce()
+ })
+
it('makes before break, rejects in-flight work, and replays subscriptions', async () => {
const oldSession = new FakeSession('connected')
const nextSession = new FakeSession('connecting')
diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts
index 9f472c1cd84..38677811959 100644
--- a/mobile/src/transport/stable-logical-rpc-client.ts
+++ b/mobile/src/transport/stable-logical-rpc-client.ts
@@ -159,6 +159,16 @@ export function createStableLogicalRpcClient(
activeSession.notifyForeground(reason)
}
},
+ restartAfterStructuredBackground: () => {
+ if (!suspended) {
+ activeSession.restartAfterStructuredBackground?.()
+ }
+ },
+ confirmStructuredStreamLongevity: () => {
+ if (!suspended) {
+ activeSession.confirmStructuredStreamLongevity?.()
+ }
+ },
close() {
if (closed) {
return
diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts
index 16618e6b62e..99e791bd4db 100644
--- a/src/main/codex/codex-structured-item-translation.test.ts
+++ b/src/main/codex/codex-structured-item-translation.test.ts
@@ -79,17 +79,17 @@ describe('codex item identity', () => {
it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => {
const ordinals = new CodexTurnOrdinals()
- ordinals.ordinalFor(TURN_ID, 'item-0')
+ ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
- expect(ordinals.ordinalFor(TURN_ID, 'item-1')).toBe(1)
- expect(ordinals.ordinalFor(TURN_ID, 'item-0')).toBe(0)
+ expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1)
+ expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')).toBe(0)
})
it('restarts numbering per turn', () => {
const ordinals = new CodexTurnOrdinals()
- ordinals.ordinalFor(TURN_ID, 'item-0')
+ ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
- expect(ordinals.ordinalFor('turn-2', 'item-1')).toBe(0)
+ expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0)
})
it('keys a non-message item and a turnless message in the orca namespace', () => {
@@ -122,6 +122,24 @@ describe('codex item bodies', () => {
])
})
+ it('keeps provider image echoes in mixed user content', () => {
+ expect(
+ codexMessageBlocks({
+ type: 'userMessage',
+ id: 'm',
+ content: [
+ { type: 'text', text: 'look' },
+ { type: 'image', url: 'https://example.test/a.png' },
+ { type: 'localImage', path: '/tmp/a.png' }
+ ]
+ })
+ ).toEqual([
+ { type: 'text', text: 'look' },
+ { type: 'image-ref', url: 'https://example.test/a.png' },
+ { type: 'image-ref', path: '/tmp/a.png' }
+ ])
+ })
+
it('maps a finished zero-exit command to a completed shell tool call', () => {
expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({
kind: 'tool-call',
@@ -163,6 +181,17 @@ describe('codex item bodies', () => {
expect(codexItemBody({ type: 'webSearch', id: 'w' })).toBeNull()
})
+ it('renders array-shaped reasoning content', () => {
+ expect(
+ codexItemBody({
+ type: 'reasoning',
+ id: 'r',
+ summary: ['first', 'second'],
+ content: [{ text: 'fallback' }]
+ })
+ ).toEqual({ kind: 'status', text: 'first\nsecond' })
+ })
+
it('refuses a value that is not a thread item at all', () => {
expect(readCodexThreadItem({ type: 'agentMessage' })).toBeNull()
expect(readCodexThreadItem(null)).toBeNull()
diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts
index 49e58cc4a64..d1c468db82a 100644
--- a/src/main/codex/codex-structured-item-translation.ts
+++ b/src/main/codex/codex-structured-item-translation.ts
@@ -55,9 +55,10 @@ export function readCodexThreadItem(value: unknown): CodexThreadItem | null {
export class CodexTurnOrdinals {
private readonly turns = new Map>()
- ordinalFor(turnId: string, codexItemId: string): number {
- const assigned = this.turns.get(turnId) ?? new Map()
- this.turns.set(turnId, assigned)
+ ordinalFor(threadId: string, turnId: string, codexItemId: string): number {
+ const turnKey = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`
+ const assigned = this.turns.get(turnKey) ?? new Map()
+ this.turns.set(turnKey, assigned)
const existing = assigned.get(codexItemId)
if (existing !== undefined) {
return existing
@@ -92,7 +93,7 @@ export function codexItemIdentity(input: {
provider: 'codex',
threadId: input.threadId,
turnId,
- ordinal: input.ordinals.ordinalFor(turnId, item.id)
+ ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id)
}
}
return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` }
@@ -103,6 +104,28 @@ function readString(source: Record, key: string): string | null
return typeof value === 'string' && value.length > 0 ? value : null
}
+function readTextContent(source: Record, key: string): string | null {
+ const direct = readString(source, key)
+ if (direct) {
+ return direct
+ }
+ const value = source[key]
+ if (!Array.isArray(value)) {
+ return null
+ }
+ const parts = value.flatMap((part) => {
+ if (typeof part === 'string') {
+ return part.length > 0 ? [part] : []
+ }
+ if (typeof part !== 'object' || part === null) {
+ return []
+ }
+ const text = readString(part as Record, 'text')
+ return text ? [text] : []
+ })
+ return parts.length > 0 ? parts.join('\n') : null
+}
+
/** `userMessage` carries structured content parts; `agentMessage` a flat text. */
export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] {
const text = readString(item, 'text')
@@ -121,6 +144,13 @@ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] {
const partText = readString(part as Record, 'text')
if (partText !== null) {
blocks.push({ type: 'text', text: partText })
+ continue
+ }
+ const record = part as Record
+ if (record.type === 'image' && typeof record.url === 'string') {
+ blocks.push({ type: 'image-ref', url: record.url })
+ } else if (record.type === 'localImage' && typeof record.path === 'string') {
+ blocks.push({ type: 'image-ref', path: record.path })
}
}
return blocks
@@ -179,8 +209,13 @@ export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | nul
}
}
if (item.type === 'reasoning') {
- const text = readString(item, 'text') ?? readString(item, 'summary')
- return text === null ? null : { kind: 'status', text }
+ const text =
+ readTextContent(item, 'text') ??
+ readTextContent(item, 'summary') ??
+ readTextContent(item, 'content')
+ return text === null
+ ? null
+ : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
}
return null
}
diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts
index beaf0bded98..a36b10ce759 100644
--- a/src/main/codex/codex-structured-journal-translation.test.ts
+++ b/src/main/codex/codex-structured-journal-translation.test.ts
@@ -20,7 +20,7 @@ type Row = { key: string; body: AgentJournalItemBody }
function recorder() {
const rows: Row[] = []
- const bound: [string, string][] = []
+ const bound: [string, string, string][] = []
let publishes = 0
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity: AgentJournalItemIdentity, body) =>
@@ -35,8 +35,8 @@ function recorder() {
rows,
bound,
publishes: () => publishes,
- bindPromptItemId: (journalItemId: string, promptKey: string) =>
- bound.push([journalItemId, promptKey])
+ bindPromptItemId: (journalItemId: string, threadId: string, promptKey: string) =>
+ bound.push([journalItemId, threadId, promptKey])
}
}
@@ -206,7 +206,7 @@ describe('codex journal translation', () => {
const approval = tap.rows.at(-1)
expect(approval?.key).toBe('orca:codex-prompt%3Athread-abc%3Aitem-2')
expect(approval?.body).toMatchObject({ kind: 'approval', detail: 'rm -rf build' })
- expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', 'item-2']])
+ expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', THREAD_ID, 'item-2']])
})
it('journals one row per approval when a tool item asks twice', () => {
@@ -241,7 +241,7 @@ describe('codex journal translation', () => {
])
// Both still name the command the shared item announced.
expect(approvals.every((row) => (row.body as { detail?: string }).detail === 'ls')).toBe(true)
- expect(tap.bound.map(([, promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b'])
+ expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b'])
})
it('journals and binds one row per question in a user-input request', () => {
@@ -267,7 +267,7 @@ describe('codex journal translation', () => {
'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq1',
'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq2'
])
- expect(tap.bound.map(([, promptKey]) => promptKey)).toEqual(['item-3', 'item-3'])
+ expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['item-3', 'item-3'])
})
it('starts a new turn at ordinal zero and refuses to adopt an ended turn', () => {
@@ -309,6 +309,59 @@ describe('codex journal translation', () => {
expect(tap.rows[0]?.key).toBe('codex:thread-abc:turn-9:0')
})
+ it('keeps interleaved thread turns, items, and deltas separate', () => {
+ const { translator, tap } = translatorWith()
+ const child = (method: string, params: unknown): CodexStructuredSessionEvent => ({
+ type: 'notification',
+ sessionId: SESSION_ID,
+ threadId: 'thread-child',
+ method,
+ params
+ })
+
+ translator.handle(TURN_STARTED)
+ translator.handle(child('turn/started', { threadId: 'thread-child', turnId: 'turn-child' }))
+ translator.handle(
+ notification('item/completed', {
+ item: { type: 'agentMessage', id: 'item-0', text: 'root' }
+ })
+ )
+ translator.handle(
+ child('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'child' } })
+ )
+ translator.handle(child('turn/completed', { turnId: 'turn-child' }))
+ translator.handle(
+ notification('item/completed', {
+ item: { type: 'agentMessage', id: 'item-1', text: 'still root' }
+ })
+ )
+
+ expect(tap.rows.map((row) => row.key)).toEqual([
+ 'codex:thread-abc:turn-1:0',
+ 'codex:thread-child:turn-child:0',
+ 'codex:thread-abc:turn-1:1'
+ ])
+ })
+
+ it('checkpoints long streams geometrically and flushes the final snapshot', () => {
+ const { translator, tap, window } = translatorWith()
+ translator.handle(TURN_STARTED)
+ translator.handle(
+ notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
+ )
+
+ for (let index = 0; index < 512; index += 1) {
+ translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'x' }))
+ window.fire()
+ }
+ translator.flush()
+
+ expect(tap.rows.length).toBeLessThan(40)
+ expect(tap.rows.at(-1)?.body).toMatchObject({
+ blocks: [{ type: 'text', text: 'x'.repeat(512) }]
+ })
+ })
+
it('publishes after every write so a subscriber never trails the journal', () => {
const { translator, tap } = translatorWith()
diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts
index 9b1bb0d5b79..2eed0a17e72 100644
--- a/src/main/codex/codex-structured-journal-translation.ts
+++ b/src/main/codex/codex-structured-journal-translation.ts
@@ -19,6 +19,7 @@ import {
codexQuestionItems
} from './codex-structured-prompt-items'
import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies'
+import { readCodexTurnId } from './codex-structured-thread-facts'
// The one place Codex events become journal rows.
//
@@ -32,13 +33,14 @@ const AGENT_MESSAGE_DELTA_METHOD = 'item/agentMessage/delta'
export type CodexJournalTranslatorDeps = {
sink: StructuredAgentSessionEventSink
/** Points an answered journal item back at the live Codex request. */
- bindPromptItemId?: (journalItemId: string, promptKey: string) => void
+ bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
coalesceMs?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
}
export type CodexJournalTranslator = {
handle: (event: CodexStructuredSessionEvent) => void
+ flush: () => void
dispose: () => void
}
@@ -60,18 +62,47 @@ export function createCodexJournalTranslator(
const identities = new Map()
/** What each announced item is, so an approval can name what it approves. */
const details = new Map()
- let currentTurnId: string | null = null
+ const currentTurnIds = new Map()
+ const latestStreamText = new Map()
+ const checkpointLengths = new Map()
+
+ const itemKey = (threadId: string, codexItemId: string): string =>
+ `${encodeURIComponent(threadId)}:${encodeURIComponent(codexItemId)}`
+
+ const persistStream = (key: string, text: string, force: boolean): void => {
+ latestStreamText.set(key, text)
+ const checkpointLength = checkpointLengths.get(key) ?? 0
+ const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125))
+ if (!force && checkpointLength > 0 && text.length < nextLength) {
+ return
+ }
+ checkpointLengths.set(key, text.length)
+ const identity = identities.get(key)
+ if (!identity) {
+ return
+ }
+ deps.sink.appendItem(identity, codexStreamingMessageBody(text))
+ deps.sink.publish()
+ }
+
+ const persistLatestStreams = (): void => {
+ for (const [key, text] of latestStreamText) {
+ if (checkpointLengths.get(key) !== text.length) {
+ persistStream(key, text, true)
+ }
+ }
+ }
+
+ const flushStreams = (): void => {
+ coalescer.flushAll()
+ persistLatestStreams()
+ }
const coalescer = createAgentSessionDeltaCoalescer({
windowMs: deps.coalesceMs,
schedule: deps.schedule,
- emit: (codexItemId, text) => {
- const identity = identities.get(codexItemId)
- if (!identity) {
- return
- }
- deps.sink.appendItem(identity, codexStreamingMessageBody(text))
- deps.sink.publish()
+ emit: (key, text) => {
+ persistStream(key, text, false)
}
})
@@ -80,12 +111,13 @@ export function createCodexJournalTranslator(
turnId: string | null,
item: { type: string; id: string }
): AgentJournalItemIdentity => {
- const existing = identities.get(item.id)
+ const key = itemKey(threadId, item.id)
+ const existing = identities.get(key)
if (existing) {
return existing
}
const identity = codexItemIdentity({ threadId, turnId, item, ordinals })
- identities.set(item.id, identity)
+ identities.set(key, identity)
return identity
}
@@ -95,16 +127,18 @@ export function createCodexJournalTranslator(
if (!item) {
return
}
- const turnId = readString(params, 'turnId') ?? currentTurnId
+ const turnId = readCodexTurnId(event.params) ?? currentTurnIds.get(event.threadId) ?? null
const identity = identityFor(event.threadId, turnId, item)
const body = codexItemBody(item)
const command = readString(item, 'command')
if (command) {
- details.set(item.id, command)
+ details.set(itemKey(event.threadId, item.id), command)
}
if (event.method === 'item/completed') {
// The completed body is authoritative; the coalesced text is now stale.
- coalescer.forget(item.id)
+ coalescer.forget(itemKey(event.threadId, item.id))
+ latestStreamText.delete(itemKey(event.threadId, item.id))
+ checkpointLengths.delete(itemKey(event.threadId, item.id))
}
if (!body) {
return
@@ -129,7 +163,11 @@ export function createCodexJournalTranslator(
params: event.params
})) {
deps.sink.appendItem(question.identity, question.body)
- deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.promptKey)
+ deps.bindPromptItemId?.(
+ agentJournalItemKey(question.identity),
+ event.threadId,
+ event.promptKey
+ )
}
deps.sink.publish()
return
@@ -143,17 +181,17 @@ export function createCodexJournalTranslator(
codexApprovalItem({
method: event.method,
params: event.params,
- detail: details.get(event.codexItemId) ?? null
+ detail: details.get(itemKey(event.threadId, event.codexItemId)) ?? null
})
)
- deps.bindPromptItemId?.(agentJournalItemKey(identity), event.promptKey)
+ deps.bindPromptItemId?.(agentJournalItemKey(identity), event.threadId, event.promptKey)
deps.sink.publish()
}
return {
handle: (event) => {
if (event.type === 'ended') {
- coalescer.flushAll()
+ flushStreams()
return
}
if (event.type === 'notification' && event.method === AGENT_MESSAGE_DELTA_METHOD) {
@@ -161,34 +199,41 @@ export function createCodexJournalTranslator(
const codexItemId = readString(params, 'itemId')
const delta = params.delta
if (codexItemId && typeof delta === 'string') {
- coalescer.append(codexItemId, delta)
+ coalescer.append(itemKey(event.threadId, codexItemId), delta)
}
return
}
// Lifecycle bypass: nothing may be journaled ahead of the text it follows.
- coalescer.flushAll()
+ flushStreams()
if (event.type === 'prompt') {
handlePrompt(event)
return
}
if (event.method === 'turn/started') {
- currentTurnId = readString(readRecord(readRecord(event.params).turn), 'id')
+ const turnId = readCodexTurnId(event.params)
+ if (turnId) {
+ currentTurnIds.set(event.threadId, turnId)
+ }
return
}
if (event.method === 'turn/completed') {
// A later item with no turn of its own belongs to no turn, not to the
// one that just ended.
- currentTurnId = null
+ currentTurnIds.delete(event.threadId)
return
}
if (event.method === 'item/started' || event.method === 'item/completed') {
handleItemEvent(event)
}
},
+ flush: flushStreams,
dispose: () => {
coalescer.dispose()
identities.clear()
details.clear()
+ currentTurnIds.clear()
+ latestStreamText.clear()
+ checkpointLengths.clear()
}
}
}
diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts
new file mode 100644
index 00000000000..618a415ebb2
--- /dev/null
+++ b/src/main/codex/codex-structured-location-support.ts
@@ -0,0 +1,6 @@
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
+
+export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean {
+ return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null
+}
diff --git a/src/main/codex/codex-structured-prompt-items.test.ts b/src/main/codex/codex-structured-prompt-items.test.ts
index 859502b3fe5..dceae9d535a 100644
--- a/src/main/codex/codex-structured-prompt-items.test.ts
+++ b/src/main/codex/codex-structured-prompt-items.test.ts
@@ -67,6 +67,23 @@ describe('codex approval items', () => {
expect(item.detail).toBe('writes outside the workspace')
})
+
+ it('prefers the approval request command and describes file-change grants', () => {
+ expect(
+ codexApprovalItem({
+ method: CODEX_COMMAND_APPROVAL_METHOD,
+ params: { command: ['git', 'status'] },
+ detail: 'parent command'
+ }).detail
+ ).toBe('git status')
+ expect(
+ codexApprovalItem({
+ method: CODEX_FILE_CHANGE_APPROVAL_METHOD,
+ params: { grantRoot: '/outside' },
+ detail: null
+ }).detail
+ ).toBe('"/outside"')
+ })
})
describe('codex question items', () => {
@@ -124,6 +141,29 @@ describe('codex question items', () => {
).toEqual([])
})
+ it('preserves a free-text path for null options and Other', () => {
+ const [withoutOptions, withOther] = codexQuestionItems({
+ threadId: THREAD_ID,
+ promptKey: CODEX_ITEM_ID,
+ params: {
+ questions: [
+ { id: 'q1', question: 'Describe it', options: null },
+ {
+ id: 'q2',
+ question: 'Pick or type',
+ options: [{ label: 'Known' }, { label: 'Other', isOther: true }]
+ }
+ ]
+ }
+ })
+
+ expect(withoutOptions?.body).toMatchObject({ options: [], freeTextQuestionId: 'q1' })
+ expect(withOther?.body).toMatchObject({
+ options: [{ id: 'q2:Known', label: 'Known' }],
+ freeTextQuestionId: 'q2'
+ })
+ })
+
it('keys an approval without a question id', () => {
expect(codexPromptIdentity({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID })).toEqual({
provider: 'orca',
diff --git a/src/main/codex/codex-structured-prompt-items.ts b/src/main/codex/codex-structured-prompt-items.ts
index 427a4f8ea08..6b6a873e63b 100644
--- a/src/main/codex/codex-structured-prompt-items.ts
+++ b/src/main/codex/codex-structured-prompt-items.ts
@@ -75,12 +75,28 @@ export function codexApprovalItem(input: {
: input.method === CODEX_COMMAND_APPROVAL_METHOD
? 'Run a command?'
: 'Approve this action?',
- detail: readString(params, 'reason') ?? input.detail,
+ detail: approvalDetail(params) ?? input.detail,
options: codexApprovalOptions(input.params),
resolution: { ...PENDING }
}
}
+function approvalDetail(params: Record): string | null {
+ const reason = readString(params, 'reason')
+ if (reason) {
+ return reason
+ }
+ const command = params.command
+ if (typeof command === 'string' && command.length > 0) {
+ return command
+ }
+ if (Array.isArray(command) && command.every((part) => typeof part === 'string')) {
+ return command.join(' ')
+ }
+ const detail = params.grantRoot ?? params.changes
+ return detail === undefined ? null : JSON.stringify(detail)
+}
+
export type CodexQuestionItem = {
questionId: string
identity: AgentJournalItemIdentity
@@ -117,6 +133,7 @@ export function codexQuestionItems(input: {
kind: 'question',
question: prompt,
options: questionOptions(question, questionId),
+ ...(questionAllowsFreeText(question) ? { freeTextQuestionId: questionId } : {}),
resolution: { ...PENDING }
}
})
@@ -124,6 +141,15 @@ export function codexQuestionItems(input: {
return items
}
+function questionAllowsFreeText(question: Record): boolean {
+ const options = question.options
+ return (
+ !Array.isArray(options) ||
+ options.length === 0 ||
+ options.some((option) => readParams(option).isOther === true)
+ )
+}
+
function questionOptions(
question: Record,
questionId: string
@@ -134,8 +160,9 @@ function questionOptions(
}
const mapped: AgentJournalPromptOption[] = []
for (const entry of options) {
- const label = readString(readParams(entry), 'label')
- if (label !== null) {
+ const option = readParams(entry)
+ const label = readString(option, 'label')
+ if (label !== null && option.isOther !== true) {
// The option id has to name its question: Codex's reply is a map keyed by
// question id, and the client only ever hands back an option id.
mapped.push({ id: encodeCodexQuestionOptionId(questionId, label), label })
diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts
index 55eea2fb543..75dfb954fca 100644
--- a/src/main/codex/codex-structured-prompt-replies.test.ts
+++ b/src/main/codex/codex-structured-prompt-replies.test.ts
@@ -72,7 +72,7 @@ describe('CodexPromptRegistry', () => {
it('addresses a prompt by its journal item id once bound, and forgets both', () => {
const registry = new CodexPromptRegistry()
const prompt = registry.register(userInputRequest(['q1']))
- registry.bindJournalItemId('codex:thread-1:turn-1:2', 'codex-item-1')
+ registry.bindJournalItemId('codex:thread-1:turn-1:2', 'thread-1', 'codex-item-1')
expect(registry.find('codex:thread-1:turn-1:2')).toBe(prompt)
expect(registry.find('codex-item-1')).toBe(prompt)
@@ -81,6 +81,25 @@ describe('CodexPromptRegistry', () => {
expect(registry.find('codex:thread-1:turn-1:2')).toBeNull()
expect(registry.find('codex-item-1')).toBeNull()
})
+
+ it('keeps identical item ids on different threads independently answerable', () => {
+ const registry = new CodexPromptRegistry()
+ const register = (id: number, threadId: string) =>
+ registry.register({
+ id,
+ method: 'item/commandExecution/requestApproval',
+ params: { itemId: 'item-2', threadId }
+ })
+
+ register(1, 'thread-root')
+ register(2, 'thread-child')
+ registry.bindJournalItemId('journal-root', 'thread-root', 'item-2')
+ registry.bindJournalItemId('journal-child', 'thread-child', 'item-2')
+
+ expect(registry.find('journal-root')?.requestId).toBe(1)
+ expect(registry.find('journal-child')?.requestId).toBe(2)
+ expect(registry.find('item-2')).toBeNull()
+ })
})
describe('applyCodexPromptAnswer', () => {
diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts
index 5dfd6ef638f..f15b06866bc 100644
--- a/src/main/codex/codex-structured-prompt-replies.ts
+++ b/src/main/codex/codex-structured-prompt-replies.ts
@@ -88,10 +88,14 @@ export function isCodexPromptMethod(method: string): boolean {
* became.
*/
export class CodexPromptRegistry {
- private readonly byPromptKey = new Map()
- /** Journal item id to prompt key. */
+ private readonly byAddress = new Map()
+ /** Journal item id to thread-scoped prompt address. */
private readonly journalItemIds = new Map()
+ private address(threadId: string, promptKey: string): string {
+ return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}`
+ }
+
/** Returns null for a request this build does not model, so the caller can
* refuse it instead of leaving Codex blocked on an answer forever. */
register(request: {
@@ -115,33 +119,40 @@ export class CodexPromptRegistry {
request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [],
answers: new Map()
}
- this.byPromptKey.set(prompt.promptKey, prompt)
+ this.byAddress.set(this.address(prompt.threadId, prompt.promptKey), prompt)
return prompt
}
/** Called by the translation module once the prompt has a journal id. */
- bindJournalItemId(journalItemId: string, promptKey: string): void {
- this.journalItemIds.set(journalItemId, promptKey)
+ bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void {
+ this.journalItemIds.set(journalItemId, this.address(threadId, promptKey))
}
/** Falls back to treating the id as a prompt key, which is what it is before
* any binding exists. */
find(journalItemId: string): CodexPendingPrompt | null {
- const promptKey = this.journalItemIds.get(journalItemId) ?? journalItemId
- return this.byPromptKey.get(promptKey) ?? null
+ const address = this.journalItemIds.get(journalItemId)
+ if (address) {
+ return this.byAddress.get(address) ?? null
+ }
+ const matches = [...this.byAddress.values()].filter(
+ (prompt) => prompt.promptKey === journalItemId
+ )
+ return matches.length === 1 ? matches[0]! : null
}
forget(prompt: CodexPendingPrompt): void {
- this.byPromptKey.delete(prompt.promptKey)
- for (const [journalItemId, promptKey] of this.journalItemIds) {
- if (promptKey === prompt.promptKey) {
+ const address = this.address(prompt.threadId, prompt.promptKey)
+ this.byAddress.delete(address)
+ for (const [journalItemId, boundAddress] of this.journalItemIds) {
+ if (boundAddress === address) {
this.journalItemIds.delete(journalItemId)
}
}
}
clear(): void {
- this.byPromptKey.clear()
+ this.byAddress.clear()
this.journalItemIds.clear()
}
}
diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts
index 5b90d1dbec9..d2a4fb89adc 100644
--- a/src/main/codex/codex-structured-session-adapter.test.ts
+++ b/src/main/codex/codex-structured-session-adapter.test.ts
@@ -10,6 +10,7 @@ import type {
CodexAppServerLaunch,
openCodexAppServerConnection
} from './codex-app-server-connection'
+import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies'
@@ -77,6 +78,7 @@ function fakeCodex(routes: Record = {}): {
respondWithError: (id, code, message) => connection.replies.push({ id, code, message }),
close: async () => {
connection.closeCount += 1
+ connection.closed = true
}
}
connections.push(connection)
@@ -276,6 +278,47 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
const bare = await acquired(withoutPath)
expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull()
})
+
+ it('lets closeAll cancel and reap an acquisition still opening', async () => {
+ const codex = fakeCodex()
+ let releaseOpen = (): void => {}
+ let markOpenEntered = (): void => {}
+ const gate = new Promise((resolve) => {
+ releaseOpen = resolve
+ })
+ const openEntered = new Promise((resolve) => {
+ markOpenEntered = resolve
+ })
+ const openConnection: typeof openCodexAppServerConnection = async (...args) => {
+ markOpenEntered()
+ await gate
+ return codex.openConnection(...args)
+ }
+ const adapter = new CodexStructuredSessionAdapter({
+ resolveLaunch: async () => ({
+ command: 'codex',
+ args: ['app-server'],
+ cwd: '/work/repo',
+ codexHome: null,
+ resumeThreadId: null
+ }),
+ openConnection,
+ readProcessStartTime: async () => 1_700_000_000_000
+ })
+ const acquiring = adapter.acquire({
+ identity: identityFor('session-1'),
+ fence: 7,
+ spawnToken: 'spawn-9'
+ })
+ await openEntered
+
+ const closing = adapter.closeAll()
+ releaseOpen()
+
+ await expect(acquiring).rejects.toThrow('superseded while being acquired')
+ await closing
+ expect(codex.connections[0]?.closeCount).toBe(1)
+ })
})
describe('CodexStructuredSessionAdapter.dispatch', () => {
@@ -749,4 +792,40 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
expect(connection.replies).toEqual([])
})
+
+ it('flushes the final coalesced text before a graceful close', async () => {
+ const codex = fakeCodex()
+ const bodies: AgentJournalMessageItem[] = []
+ const sink: StructuredAgentSessionEventSink = {
+ appendItem: (_identity, body) => {
+ if (body.kind === 'message') {
+ bodies.push(body)
+ }
+ },
+ appendTombstone: () => {},
+ publish: () => {}
+ }
+ const adapter = adapterFor(codex)
+ await adapter.acquire({
+ identity: identityFor('session-1'),
+ fence: 7,
+ spawnToken: 'spawn-9',
+ events: sink
+ })
+ const notify = codex.connections[0]!.handlers.onNotification
+ notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1' } })
+ notify?.('item/started', {
+ threadId: THREAD_ID,
+ item: { type: 'agentMessage', id: 'item-1', text: '' }
+ })
+ notify?.('item/agentMessage/delta', {
+ threadId: THREAD_ID,
+ itemId: 'item-1',
+ delta: 'last words'
+ })
+
+ await adapter.closeSession('session-1')
+
+ expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }])
+ })
})
diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts
index 87d2bbda215..33d4d3427a3 100644
--- a/src/main/codex/codex-structured-session-adapter.ts
+++ b/src/main/codex/codex-structured-session-adapter.ts
@@ -8,14 +8,10 @@ import type {
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
-import {
- createCodexJournalTranslator,
- type CodexJournalTranslator
-} from './codex-structured-journal-translation'
+import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import {
isCodexAppServerRequestError,
openCodexAppServerConnection,
- type CodexAppServerConnection,
type CodexAppServerServerRequest
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
@@ -24,11 +20,24 @@ import {
codexProcessIdentity,
codexProviderHandleLink
} from './codex-structured-owner-identity'
-import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import { answerCodexPrompt, receiveCodexPromptRequest } from './codex-structured-prompt-replies'
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
import { openCodexThread } from './codex-structured-thread-open'
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
+import { supportsCodexStructuredLocation } from './codex-structured-location-support'
+import {
+ createCodexAcquisitionAttempt,
+ type CodexAcquisitionAttempt,
+ type CodexSession,
+ type CodexStructuredSessionAdapterDeps,
+ type CodexStructuredSessionEvent
+} from './codex-structured-session-state'
+
+export type {
+ CodexStructuredLaunch,
+ CodexStructuredSessionAdapterDeps,
+ CodexStructuredSessionEvent
+} from './codex-structured-session-state'
// The Codex half of the structured agent-session wire: one long-lived
// `codex app-server` child per session, started or resumed under the lease the
@@ -36,70 +45,14 @@ import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn
// belongs to the wire; this adapter only starts the process, names the turn the
// provider accepted, and answers Codex's blocking prompt requests.
-export type CodexStructuredLaunch = {
- command: string
- args: string[]
- /** Thread working directory, passed to Codex rather than to the child. */
- cwd: string
- /** Pinned account home. Null inherits whatever CODEX_HOME the host has. */
- codexHome: string | null
- /** Thread this session already proved. Null starts a new one. Taken from the
- * durable handle chain, never from the client-declared identity. */
- resumeThreadId: string | null
-}
-
-export type CodexStructuredSessionEvent =
- | { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
- | {
- type: 'prompt'
- sessionId: string
- threadId: string
- method: string
- params: unknown
- /** The tool item the prompt is about, which several prompts can share. */
- codexItemId: string
- /** What answers address; distinct per prompt even when `codexItemId` is not. */
- promptKey: string
- }
- /** The child died without the host asking it to. The lease is now stale. */
- | { type: 'ended'; sessionId: string; reason: string }
-
-export type CodexStructuredSessionAdapterDeps = {
- /** Command, args, cwd, and pinned home for one session. The wire's adapter
- * contract carries none of these, so the runtime resolves them. */
- resolveLaunch: (input: {
- identity: AgentSessionJournalIdentity
- }) => Promise
- /** Every Codex notification and prompt, in arrival order. The translation
- * module subscribes here; without it the session still runs, unjournaled. */
- onEvent?: (event: CodexStructuredSessionEvent) => void
- openConnection?: typeof openCodexAppServerConnection
- readProcessStartTime?: (pid: number) => Promise
- mintLinkId?: () => string
- now?: () => number
- requestTimeoutMs?: number
-}
-
-type CodexSession = {
- connection: CodexAppServerConnection
- threadId: string
- historyPath: string | null
- /** The registry the acquisition window opened; the session inherits it so a
- * prompt that arrived before publication is still answerable. */
- prompts: CodexAcquisitionWindow['prompts']
- /** Applied to the next `turn/start`; Codex has no thread-settings write. */
- options: Map
- turnIdWaiters: ((turnId: string) => void)[]
- /** Absent when the caller supplied no sink; the session then runs unjournaled. */
- translator: CodexJournalTranslator | null
-}
-
export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map()
- private readonly acquiring = new Map()
+ private readonly acquiring = new Map()
constructor(private readonly deps: CodexStructuredSessionAdapterDeps) {}
+ supportsLocation = supportsCodexStructuredLocation
+
private get requestTimeoutMs(): number | undefined {
return this.deps.requestTimeoutMs
}
@@ -114,14 +67,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
await this.closeSession(input.identity.sessionId)
const sessionId = input.identity.sessionId
const launch = await this.deps.resolveLaunch({ identity: input.identity })
- const acquisition = new CodexAcquisitionWindow()
+ const attempt = createCodexAcquisitionAttempt()
+ const acquisition = attempt.window
// Registered before the spawn, because the handshake itself can emit.
- this.acquiring.set(sessionId, acquisition)
+ this.acquiring.set(sessionId, attempt)
const translator = input.events
? createCodexJournalTranslator({
sink: input.events,
- bindPromptItemId: (journalItemId, promptKey) =>
- acquisition.prompts.bindJournalItemId(journalItemId, promptKey)
+ bindPromptItemId: (journalItemId, threadId, promptKey) =>
+ acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
})
: null
const open = this.deps.openConnection ?? openCodexAppServerConnection
@@ -170,7 +124,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
if (connection.closed) {
throw new Error(`codex app-server for session ${sessionId} exited while being acquired`)
}
- if (this.acquiring.get(sessionId) !== acquisition) {
+ if (attempt.cancelled || this.acquiring.get(sessionId) !== attempt) {
throw new Error(`codex session ${sessionId} was superseded while being acquired`)
}
this.acquiring.delete(sessionId)
@@ -188,7 +142,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
return acquired
} catch (error) {
- if (this.acquiring.get(sessionId) === acquisition) {
+ if (this.acquiring.get(sessionId) === attempt) {
this.acquiring.delete(sessionId)
}
// Reap this attempt's child only. A replacement already published for the
@@ -198,12 +152,18 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
await acquisition.connection?.close()
}
throw error
+ } finally {
+ attempt.finish()
}
}
/** Buffers an event that arrived before the session was published, and drops
* one from a connection this session has already replaced. */
- private deliver(acquisition: CodexAcquisitionWindow, sessionId: string, event: () => void): void {
+ private deliver(
+ acquisition: CodexAcquisitionAttempt['window'],
+ sessionId: string,
+ event: () => void
+ ): void {
if (acquisition.buffer(event)) {
return
}
@@ -214,10 +174,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
/** Only the connection that currently owns the session may retire it, or a
* superseded child's death would evict its live replacement. */
- private handleExit(sessionId: string, acquisition: CodexAcquisitionWindow, error: Error): void {
+ private handleExit(
+ sessionId: string,
+ acquisition: CodexAcquisitionAttempt['window'],
+ error: Error
+ ): void {
acquisition.prompts.clear()
const session = this.sessions.get(sessionId)
- if (session?.connection !== acquisition.connection) {
+ if (!session || session.connection !== acquisition.connection) {
return
}
this.sessions.delete(sessionId)
@@ -272,7 +236,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
/** Lets the translation module address a live prompt by the journal item it
* became; until then the prompt key addresses it directly. */
bindPromptItemId(sessionId: string, journalItemId: string, promptKey: string): void {
- this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey)
+ const session = this.sessions.get(sessionId)
+ session?.prompts.bindJournalItemId(journalItemId, session.threadId, promptKey)
}
async dispatch(input: {
@@ -340,21 +305,30 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
/** Reaps one session's child. The proven handle chain is already durable, so
* a graceful close loses nothing. */
async closeSession(sessionId: string): Promise {
- // An attempt still acquiring is superseded by this close: dropping its slot
- // fails its publication check, so it reaps the child it spawned.
- this.acquiring.delete(sessionId)
+ const attempt = this.acquiring.get(sessionId)
+ if (attempt) {
+ attempt.cancelled = true
+ await attempt.window.connection?.close()
+ await attempt.finished
+ }
const session = this.sessions.get(sessionId)
if (!session) {
return
}
this.sessions.delete(sessionId)
session.prompts.clear()
+ session.translator?.flush()
session.translator?.dispose()
await session.connection.close()
}
async closeAll(): Promise {
- await Promise.all([...this.sessions.keys()].map((sessionId) => this.closeSession(sessionId)))
+ const sessionIds = new Set([...this.sessions.keys(), ...this.acquiring.keys()])
+ await Promise.all([...sessionIds].map((sessionId) => this.closeSession(sessionId)))
+ }
+
+ releaseAcquisition(input: { sessionId: string }): Promise {
+ return this.closeSession(input.sessionId)
}
private session(sessionId: string): CodexSession {
diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts
new file mode 100644
index 00000000000..a6d94b2c175
--- /dev/null
+++ b/src/main/codex/codex-structured-session-state.ts
@@ -0,0 +1,65 @@
+import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
+import type {
+ CodexAppServerConnection,
+ openCodexAppServerConnection
+} from './codex-app-server-connection'
+import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
+import type { CodexJournalTranslator } from './codex-structured-journal-translation'
+
+export type CodexStructuredLaunch = {
+ command: string
+ args: string[]
+ cwd: string
+ codexHome: string | null
+ resumeThreadId: string | null
+}
+
+export type CodexStructuredSessionEvent =
+ | { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
+ | {
+ type: 'prompt'
+ sessionId: string
+ threadId: string
+ method: string
+ params: unknown
+ codexItemId: string
+ promptKey: string
+ }
+ | { type: 'ended'; sessionId: string; reason: string }
+
+export type CodexStructuredSessionAdapterDeps = {
+ resolveLaunch: (input: {
+ identity: AgentSessionJournalIdentity
+ }) => Promise
+ onEvent?: (event: CodexStructuredSessionEvent) => void
+ openConnection?: typeof openCodexAppServerConnection
+ readProcessStartTime?: (pid: number) => Promise
+ mintLinkId?: () => string
+ now?: () => number
+ requestTimeoutMs?: number
+}
+
+export type CodexSession = {
+ connection: CodexAppServerConnection
+ threadId: string
+ historyPath: string | null
+ prompts: CodexAcquisitionWindow['prompts']
+ options: Map
+ turnIdWaiters: ((turnId: string) => void)[]
+ translator: CodexJournalTranslator | null
+}
+
+export type CodexAcquisitionAttempt = {
+ window: CodexAcquisitionWindow
+ cancelled: boolean
+ finished: Promise
+ finish: () => void
+}
+
+export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt {
+ let finish = (): void => {}
+ const finished = new Promise((resolve) => {
+ finish = resolve
+ })
+ return { window: new CodexAcquisitionWindow(), cancelled: false, finished, finish }
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index ed4e48b4327..214fe950cd3 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -267,6 +267,7 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
+import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime'
import { quitTeardownStartGate } from './quit-teardown-start-gate'
import { beginSshShutdown } from './ipc/ssh'
import { PluginService } from './plugins/plugin-service'
@@ -3109,6 +3110,7 @@ app.on('will-quit', (e) => {
pluginMarketplaceInstaller = null
const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve()
const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries()
+ const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()
pluginService = null
setUnreadDockBadgeCount(0)
agentHookServer.stop()
@@ -3169,6 +3171,7 @@ app.on('will-quit', (e) => {
{ name: 'ssh', promise: sshShutdown },
{ name: 'plugin-hosts', promise: pluginHostShutdown },
{ name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown },
+ { name: 'structured-agent-session', promise: structuredAgentSessionShutdown },
{ name: 'usage-cache', promise: usageCacheFlush },
{ name: 'stats', promise: statsFlush },
{ name: 'state', promise: storeFlush }
diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts
index b8950517c20..daeeffff16b 100644
--- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts
+++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts
@@ -91,6 +91,7 @@ describe('readAgentSessionHistory', () => {
expect(tail.page.items.map((entry) => entry.body)).toEqual([body('item-4'), body('item-5')])
expect(tail.page.hasOlder).toBe(true)
expect(tail.page.hasNewer).toBe(false)
+ expect(tail.page.liveCursor).toEqual(journal.cursor())
const older = readAgentSessionHistory(journal, {
sessionId: 'session-1',
diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts
index 2f7a398e5c5..bbda0446c81 100644
--- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts
+++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts
@@ -140,6 +140,7 @@ function buildPage(input: {
newest: newest ? { epoch, sequence: newest.sequence } : null,
nextCursor: input.nextCursor ?? input.fallbackCursor
},
+ liveCursor: input.snapshot.cursor,
hasOlder: input.hasOlder,
hasNewer: input.hasNewer
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
index 87a91bcf4d2..6c72b23b277 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
@@ -12,7 +12,10 @@ import type {
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle'
-import type { AgentSessionProcessIdentity } from '../../../shared/agent-session-record'
+import type {
+ AgentSessionExecutionLocation,
+ AgentSessionProcessIdentity
+} from '../../../shared/agent-session-record'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
/** What a reservation turns into once something is actually running under it:
@@ -30,6 +33,8 @@ export type AgentSessionDispatchOutcome =
| { state: 'unknown'; reason: string }
export type StructuredAgentSessionAdapter = {
+ /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */
+ supportsLocation?(location: AgentSessionExecutionLocation): boolean
/** Makes the reservation real. Called once per reservation, with the spawn
* token the lease was reserved under and the fence the handle must be minted
* at — the store rejects a link minted at any other fence. */
@@ -43,6 +48,8 @@ export type StructuredAgentSessionAdapter = {
* adapter that only answers calls needs no journal at all. */
events?: StructuredAgentSessionEventSink
}): Promise
+ /** Reaps an acquired provider when the host cannot commit or prove its lease. */
+ releaseAcquisition?(input: { sessionId: string }): Promise
dispatch(input: {
sessionId: string
clientMessageId: string
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
index d360fab80c9..010c14fce7a 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts
@@ -41,6 +41,8 @@ export type AttachFlowInput = {
/** Handed to the adapter so it can journal what the provider streams. The
* host owns it and binds it to the journal inside `onAttached`. */
eventSink?: StructuredAgentSessionEventSink
+ /** Stops acquisition-window events targeting the superseded journal. */
+ onAcquiring?: () => void
}
export async function performAttach(
@@ -117,27 +119,37 @@ async function acquireOwner(
if (!spawnToken) {
throw new Error('agent_session_ownership_unknown')
}
+ input.onAcquiring?.()
const acquired = await input.adapter.acquire({
- identity: journalIdentityFor(record.sessionId, input.params),
+ identity: journalIdentityFor(record, input.params),
fence,
// Retries must recover the original reservation, not mint a second child.
spawnToken,
...(input.eventSink ? { events: input.eventSink } : {})
})
- if (record.lease.ownerProcess === null) {
- await input.store.commitProcessIdentity({
+ try {
+ if (record.lease.ownerProcess === null) {
+ await input.store.commitProcessIdentity({
+ sessionId: record.sessionId,
+ fence,
+ process: acquired.process,
+ now: input.now()
+ })
+ } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
+ throw new Error('agent_session_ownership_unknown')
+ }
+ return await input.store.proveOwner({
sessionId: record.sessionId,
fence,
- process: acquired.process,
+ link: acquired.link,
now: input.now()
})
- } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
- throw new Error('agent_session_ownership_unknown')
+ } catch (error) {
+ try {
+ await input.adapter.releaseAcquisition?.({ sessionId: record.sessionId })
+ } catch {
+ // Preserve the lease failure; cleanup is best-effort and idempotent.
+ }
+ throw error
}
- return input.store.proveOwner({
- sessionId: record.sessionId,
- fence,
- link: acquired.link,
- now: input.now()
- })
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts
index facca4d142c..bad13a143b6 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts
@@ -29,6 +29,7 @@ import {
computeAgentSessionPayloadFingerprint
} from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
@@ -50,7 +51,8 @@ export type AgentSessionAttachParams = {
agent: AgentType
accountHome: AgentSessionAccountHome
runtimeKind: AgentSessionOwnerRuntimeKind
- providerHandle: Exclude
+ /** Omitted only for create-by-intent; the adapter proves the durable handle. */
+ providerHandle?: Exclude
}
/** Host-supplied half of the reservation. */
@@ -81,7 +83,7 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor
export function admitAttachOrRefuse(
params: AgentSessionAttachParams
): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } {
- if (params.providerHandle.kind !== params.provider) {
+ if (params.providerHandle && params.providerHandle.kind !== params.provider) {
return {
ok: false,
refusal: {
@@ -100,15 +102,26 @@ export function admitAttachOrRefuse(
}
export function journalIdentityFor(
- sessionId: string,
+ record: AgentSessionRecord,
params: AgentSessionAttachParams
): AgentSessionJournalIdentity {
+ const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
+ const providerHandle: AgentSessionProviderHandle =
+ head?.handle.provider === 'codex'
+ ? { kind: 'codex', threadId: head.handle.threadId }
+ : head?.handle.provider === 'claude'
+ ? {
+ kind: 'claude',
+ sessionId: head.handle.sessionId,
+ leafUuid: head.handle.leafUuid
+ }
+ : (params.providerHandle ?? { kind: 'opaque', agent: params.agent, value: 'pending' })
return {
- sessionId,
+ sessionId: record.sessionId,
workspaceId: params.location.workspaceId,
hostId: params.location.executionHostId,
agent: params.agent,
- providerHandle: params.providerHandle
+ providerHandle
}
}
@@ -130,7 +143,7 @@ export async function attachJournal(input: {
journalRoot: string
adapter: StructuredAgentSessionAdapter
}): Promise {
- const identity = journalIdentityFor(input.record.sessionId, input.params)
+ const identity = journalIdentityFor(input.record, input.params)
const fence = input.record.lease.runtimeFence
const historyFilePath = input.adapter.historyFilePath
? await input.adapter.historyFilePath({ identity })
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts
index 421f7885a79..6407a98f7f2 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts
@@ -84,6 +84,20 @@ describe('deferred structured agent-session event sink', () => {
])
})
+ it('buffers replacement-acquisition events while unbound', async () => {
+ const log: Recorded[] = []
+ const deferred = createDeferredStructuredAgentSessionEventSink()
+ deferred.bind(target(1, log))
+ deferred.unbind()
+
+ deferred.sink.appendItem(identity(0), BODY)
+ expect(log).toEqual([])
+ deferred.bind(target(2, log))
+ await deferred.drained()
+
+ expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }])
+ })
+
it('drops buffered and later writes once closed, and refuses to rebind', async () => {
const log: Recorded[] = []
const deferred = createDeferredStructuredAgentSessionEventSink()
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
index 26cc28a9945..1bfe625c58d 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
@@ -40,6 +40,8 @@ export type DeferredStructuredAgentSessionEventSink = {
/** Drains everything buffered so far, in order, then writes through. Called
* again on every re-attach to re-point the sink at the new journal. */
bind(target: StructuredAgentSessionEventTarget): void
+ /** Queues new provider events until a replacement journal is bound. */
+ unbind(): void
/** Permanently stops the sink. Queued writes are dropped rather than landing
* in a journal the host has already let go of. */
close(): void
@@ -106,6 +108,9 @@ export function createDeferredStructuredAgentSessionEventSink(
enqueue(operation)
}
},
+ unbind: () => {
+ target = null
+ },
close: () => {
closed = true
buffered.length = 0
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts
index 68ec4bcbce0..697bb20d2fa 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts
@@ -118,6 +118,8 @@ function ensureParams(fence: number): AgentSessionAttachParams {
let root: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
+let acquire: Mock
+let releaseAcquisition: Mock>
let dispatch: Mock
let cancelTurn: Mock
let answerPrompt: Mock
@@ -134,23 +136,8 @@ function accepted(): AgentSessionDispatchOutcome {
function adapter(): StructuredAgentSessionAdapter {
return {
- acquire: async ({ fence }) => ({
- process: {
- hostId: 'local',
- pid: 4242,
- processStartTimeMs: 1_700_000_000_000,
- spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
- },
- link: {
- linkId: `link-${fence}`,
- handle: { provider: 'codex', threadId: THREAD },
- // A restarted host re-proves the thread it inherited; only the first
- // owner of a session may claim to have created it.
- origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
- mintedAtFence: fence,
- observedAt: NOW
- }
- }),
+ acquire,
+ releaseAcquisition,
dispatch,
cancelTurn,
answerPrompt,
@@ -197,6 +184,24 @@ beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-host-'))
operations = 0
ordinal = 0
+ acquire = vi.fn(async ({ fence }) => ({
+ process: {
+ hostId: 'local',
+ pid: 4242,
+ processStartTimeMs: 1_700_000_000_000,
+ spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
+ },
+ link: {
+ linkId: `link-${fence}`,
+ handle: { provider: 'codex', threadId: THREAD },
+ // A restarted host re-proves the thread it inherited; only the first
+ // owner of a session may claim to have created it.
+ origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
+ mintedAtFence: fence,
+ observedAt: NOW
+ }
+ }))
+ releaseAcquisition = vi.fn(async () => undefined)
dispatch = vi.fn(async () => accepted())
cancelTurn = vi.fn(async () => ({ cancelled: true }))
answerPrompt = vi.fn(async () => undefined)
@@ -353,6 +358,15 @@ describe('attach', () => {
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true })
expect(acquire).toHaveBeenCalledTimes(2)
expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live')
+ expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION })
+ })
+
+ it('reaps an acquisition when process identity commit fails', async () => {
+ vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed'))
+
+ await expect(host.attach(CALLER, attachParams())).rejects.toThrow('commit failed')
+
+ expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION })
})
})
@@ -660,6 +674,23 @@ describe('restart', () => {
expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242)
})
+ it('restores durable journals for read-only history without acquiring a provider', async () => {
+ await attach()
+ const body = message('persisted conversation')
+ await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
+ await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' }))
+ acquire.mockClear()
+
+ await host.restoreReadableSessions()
+
+ expect(host.listSessionTabs()).toEqual([
+ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' }
+ ])
+ const history = host.history({ sessionId: SESSION, direction: 'tail' })
+ expect(history.ok && history.page.items).not.toHaveLength(0)
+ expect(acquire).not.toHaveBeenCalled()
+ })
+
it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => {
await attach()
const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
index bc20c8d2867..4c250979a5a 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
@@ -13,7 +13,10 @@ import type {
AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
-import type { AgentSessionRecord } from '../../../shared/agent-session-record'
+import type {
+ AgentSessionExecutionLocation,
+ AgentSessionRecord
+} from '../../../shared/agent-session-record'
import type {
AgentSessionAttachResult,
AgentSessionCancelResult,
@@ -53,6 +56,7 @@ import {
AgentSessionSubscribers,
type AgentSessionSubscriberEmit
} from './structured-agent-session-subscribers'
+import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore'
export type StructuredAgentSessionCaller = {
/** Stable per-client identity; scopes the operation ledger and records who
@@ -102,6 +106,45 @@ export class StructuredAgentSessionHost {
return this.sessions.has(sessionId)
}
+ supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean {
+ return agent === 'codex' && (this.deps.adapter.supportsLocation?.(location) ?? false)
+ }
+
+ listSessionTabs(): {
+ sessionId: string
+ workspaceId: string
+ agent: 'codex'
+ }[] {
+ return [...this.sessions.entries()].map(([sessionId, session]) => ({
+ sessionId,
+ workspaceId: session.params.location.workspaceId,
+ agent: 'codex' as const
+ }))
+ }
+
+ async restoreReadableSessions(): Promise {
+ await Promise.all(
+ this.deps.store
+ .listRecords()
+ .filter((record) => record.provider === 'codex')
+ .map((record) =>
+ this.serialize(record.sessionId, async () => {
+ if (this.sessions.has(record.sessionId)) {
+ return
+ }
+ const restored = await restoreStructuredAgentSessionRead(
+ this.deps.store,
+ this.deps.journalRoot,
+ record.sessionId
+ )
+ if (restored) {
+ this.sessions.set(record.sessionId, restored)
+ }
+ })
+ )
+ )
+ }
+
/** Per-session single file. A rejected task must not poison the chain. */
private serialize(sessionId: string, task: () => Promise): Promise {
const prior = this.chains.get(sessionId) ?? Promise.resolve()
@@ -133,6 +176,7 @@ export class StructuredAgentSessionHost {
adapter: this.deps.adapter,
journalRoot: this.deps.journalRoot,
eventSink: eventSink.sink,
+ onAcquiring: () => eventSink.unbind(),
authority: {
spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(),
claimKeyId: this.deps.claimKeyId,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts
new file mode 100644
index 00000000000..ee8c1a90891
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts
@@ -0,0 +1,59 @@
+import type { AgentSessionRecord } from '../../../shared/agent-session-record'
+import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import { loadJournal } from '../agent-session-journal/journal-open'
+import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
+import {
+ openAgentSessionJournal,
+ type AgentSessionJournal
+} from '../agent-session-journal/journal-store'
+import {
+ journalIdentityFor,
+ type AgentSessionAttachParams
+} from './structured-agent-session-attach'
+
+export type RestoredStructuredAgentSessionRead = {
+ journal: AgentSessionJournal
+ params: AgentSessionAttachParams
+ fence: number
+}
+
+export async function restoreStructuredAgentSessionRead(
+ store: AgentSessionRecordStore,
+ journalRoot: string,
+ sessionId: string
+): Promise {
+ const record = store.getRecord(sessionId)
+ if (!record || record.provider !== 'codex') {
+ return null
+ }
+ const params = attachParamsForReadableRestore(record)
+ const journalDir = journalDirectoryFor(journalRoot, {
+ workspaceId: record.location.workspaceId,
+ sessionId
+ })
+ const loaded = await loadJournal(journalDir, sessionId)
+ if (!loaded || loaded.corrupt) {
+ return null
+ }
+ const journal = await openAgentSessionJournal({
+ identity: journalIdentityFor(record, params),
+ journalDir
+ })
+ return { journal, params, fence: record.lease.runtimeFence }
+}
+
+function attachParamsForReadableRestore(record: AgentSessionRecord): AgentSessionAttachParams {
+ return {
+ envelope: {
+ sessionId: record.sessionId,
+ clientOperationId: `read-restore:${record.sessionId}`,
+ expectedRuntimeFence: record.lease.runtimeFence,
+ payloadFingerprint: ''
+ },
+ location: record.location,
+ provider: 'codex',
+ agent: 'codex',
+ accountHome: record.accountHome,
+ runtimeKind: record.lease.runtimeKind
+ }
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts
index c4515e104f7..33a022aa61f 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts
@@ -12,6 +12,7 @@ import type {
AgentJournalResolution
} from '../../../shared/agent-session-journal-types'
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
+import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies'
import type {
AgentSessionCancelResult,
AgentSessionOptionResult,
@@ -137,9 +138,11 @@ export async function performCancel(
return { ok: true, value: { turnId: input.turnId, cancelled } }
}
-function promptBodyOf(
- body: AgentJournalItemBody
-): { options: readonly { id: string }[]; resolution: AgentJournalResolution } | null {
+function promptBodyOf(body: AgentJournalItemBody): {
+ options: readonly { id: string }[]
+ freeTextQuestionId?: string
+ resolution: AgentJournalResolution
+} | null {
return body.kind === 'approval' || body.kind === 'question' ? body : null
}
@@ -188,7 +191,13 @@ export async function performPrompt(
}
}
}
- if (!prompt.options.some((option) => option.id === input.optionId)) {
+ const freeText = decodeCodexQuestionOptionId(input.optionId)
+ const acceptsFreeText =
+ item.body.kind === 'question' &&
+ prompt.freeTextQuestionId !== undefined &&
+ freeText?.questionId === prompt.freeTextQuestionId &&
+ freeText.answer.trim().length > 0
+ if (!acceptsFreeText && !prompt.options.some((option) => option.id === input.optionId)) {
return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`)
}
const identity = parseAgentJournalItemKey(input.itemId)
diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts
index 63957f874c0..e005f20188c 100644
--- a/src/main/runtime/mobile-rpc-allowlist.test.ts
+++ b/src/main/runtime/mobile-rpc-allowlist.test.ts
@@ -43,6 +43,7 @@ const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
// Why: shared-control unsubscribe methods are sent from generated cleanup
// paths, so literal mobile source scanning cannot discover every one.
'accounts.unsubscribe',
+ 'agentSession.unsubscribe',
'browser.screencast.unsubscribe',
'notifications.unsubscribe',
'runtime.clientEvents.unsubscribe',
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 77f7002e7fa..ff7962f6977 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -70,7 +70,10 @@ import {
type AgentSessionClaimSigner
} from './agent-session-claim-identity'
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime'
+import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
+import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
+import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import {
agentSessionPtyWriteGate,
type AgentSessionPtyWriteAdmittance
@@ -337,6 +340,7 @@ import {
type RuntimeMarkdownReadTabResult,
type RuntimeMarkdownSaveTabResult,
type RuntimeMobileSessionCreateTerminalResult,
+ type RuntimeMobileSessionAgentTab,
type RuntimeMobileSessionClientTab,
type RuntimeMobileSessionTabCloseResult,
type RuntimeMobileSessionMarkdownTab,
@@ -8767,6 +8771,25 @@ export class OrcaRuntimeService {
// Why: clients reorder the sanitized session.tabs.list model; raw groups
// can still contain stale browser ids hidden from paired web clients.
.filter((tabId) => returnedIds.has(tabId))
+ const structuredIds = expected.filter((tabId) =>
+ snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId)
+ )
+ if (structuredIds.some((tabId) => !seen.has(tabId))) {
+ if (structuredIds.some((tabId) => seen.has(tabId))) {
+ throw new Error('invalid_tab_order')
+ }
+ const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId))
+ if (
+ normalized.length !== visibleExpected.length ||
+ visibleExpected.some((tabId) => !seen.has(tabId))
+ ) {
+ throw new Error('invalid_tab_order')
+ }
+ for (const tabId of structuredIds) {
+ normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId)
+ }
+ return normalized
+ }
// Why: reorder is a pure permutation of one existing group. Missing or
// extra ids would let a paired web client silently move/lose host tabs.
if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) {
@@ -9168,6 +9191,150 @@ export class OrcaRuntimeService {
})
}
+ async getStructuredAgentSessionCreateSupport(
+ worktreeSelector: string,
+ agent: 'codex'
+ ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
+ if (agent !== 'codex') {
+ return { supported: false, reason: 'agent' }
+ }
+ const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector)
+ await this.ensureStructuredAgentSessionHost()
+ if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) {
+ return { supported: true }
+ }
+ return {
+ supported: false,
+ reason:
+ location.executionHostId !== LOCAL_EXECUTION_HOST_ID
+ ? 'remote'
+ : location.wslDistro
+ ? 'wsl'
+ : 'agent'
+ }
+ }
+
+ private async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
+ const target = await this.resolveRuntimeFileTarget(worktreeSelector)
+ const repo = this.store?.getRepo(target.worktree.repoId)
+ const wslDistro =
+ repo && !target.connectionId
+ ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
+ : null
+ const folderWorkspace = this.store
+ ?.getFolderWorkspaces?.()
+ .some((workspace) => workspace.id === target.worktree.id)
+ return {
+ executionHostId: getRuntimeFileTargetExecutionHostId({
+ worktree: target.worktree,
+ connectionId: target.connectionId
+ }),
+ wslDistro,
+ workspaceId: target.worktree.id,
+ workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const)
+ }
+ }
+
+ async resolveStructuredAgentSessionCreateIntent(input: {
+ envelope: { sessionId: string; clientOperationId: string }
+ worktree: string
+ agent: 'codex'
+ }): Promise {
+ const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent)
+ if (!support.supported) {
+ throw new Error('structured_agent_session_unsupported')
+ }
+ const settings = this.requireStore().getSettings()
+ const configuredHome = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv).CODEX_HOME
+ const location = await this.resolveStructuredAgentSessionLocation(input.worktree)
+ return {
+ envelope: {
+ sessionId: input.envelope.sessionId,
+ clientOperationId: input.envelope.clientOperationId,
+ expectedRuntimeFence: null,
+ payloadFingerprint: ''
+ },
+ location,
+ provider: 'codex',
+ agent: 'codex',
+ accountHome: {
+ variable: 'CODEX_HOME',
+ path: configuredHome?.trim() || getSystemCodexHomePath()
+ },
+ runtimeKind: 'native'
+ }
+ }
+
+ async restoreStructuredAgentSessionTabs(): Promise {
+ await this.ensureStructuredAgentSessionHost()
+ const host = getStructuredAgentSessionHost()
+ await host?.restoreReadableSessions()
+ for (const session of host?.listSessionTabs() ?? []) {
+ this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false })
+ }
+ }
+
+ publishStructuredAgentSessionTab(input: {
+ workspaceId: string
+ sessionId: string
+ agent: 'codex'
+ activate: boolean
+ notify?: boolean
+ }): void {
+ const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
+ const id = `agent-session:${input.sessionId}`
+ if (existing?.tabs.some((tab) => tab.id === id)) {
+ return
+ }
+ const tab: RuntimeMobileSessionAgentTab = {
+ type: 'agent-session',
+ id,
+ title: 'Codex Chat',
+ sessionId: input.sessionId,
+ agent: input.agent,
+ isActive: input.activate
+ }
+ const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({
+ ...candidate,
+ isActive: input.activate ? false : candidate.isActive
+ }))
+ tabs.push(tab)
+ const priorGroups = existing?.tabGroups ?? [
+ {
+ id: this.getHeadlessMobileSessionGroupId(input.workspaceId),
+ activeTabId: existing?.activeTabId ?? null,
+ tabOrder: []
+ }
+ ]
+ const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId)
+ ? existing!.activeGroupId!
+ : priorGroups[0]!.id
+ const tabGroups = priorGroups.map((group) =>
+ group.id === groupId
+ ? {
+ ...group,
+ activeTabId: input.activate ? id : group.activeTabId,
+ tabOrder: [...group.tabOrder, id]
+ }
+ : group
+ )
+ const snapshot: RuntimeMobileSessionTabsSnapshot = {
+ worktree: input.workspaceId,
+ publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`,
+ snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
+ activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId),
+ activeTabId: input.activate ? id : (existing?.activeTabId ?? null),
+ activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null),
+ tabGroups,
+ ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
+ tabs
+ }
+ this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot)
+ if (input.notify !== false) {
+ this.emitMobileSessionTabsSnapshot(snapshot)
+ }
+ }
+
private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{
worktree: ResolvedWorktree
repo?: Repo
@@ -30010,6 +30177,12 @@ export class OrcaRuntimeService {
const terminalTabs = tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
)
+ const tabGroups = this.mergeMobileSessionTabGroups(
+ snapshot.worktree,
+ snapshot.tabGroups ?? existing.tabGroups ?? [],
+ terminalTabs,
+ activeTab?.type === 'terminal' ? activeTab : null
+ )
return {
...snapshot,
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(
@@ -30020,16 +30193,40 @@ export class OrcaRuntimeService {
activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId,
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
- tabGroups: this.mergeMobileSessionTabGroups(
- snapshot.worktree,
- snapshot.tabGroups ?? existing.tabGroups ?? [],
- terminalTabs,
- activeTab?.type === 'terminal' ? activeTab : null
+ tabGroups: this.mergeStructuredAgentSessionTabGroups(
+ tabGroups,
+ existing.tabGroups ?? [],
+ normalizedPreservedTabs,
+ activeTab?.id ?? null
),
tabs
}
}
+ private mergeStructuredAgentSessionTabGroups(
+ groups: readonly RuntimeMobileSessionTabGroup[],
+ existingGroups: readonly RuntimeMobileSessionTabGroup[],
+ preservedTabs: readonly RuntimeMobileSessionSnapshotTab[],
+ activeTabId: string | null
+ ): RuntimeMobileSessionTabGroup[] {
+ const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session')
+ if (structuredTabs.length === 0) {
+ return [...groups]
+ }
+ const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
+ for (const tab of structuredTabs) {
+ const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id
+ const target = next.find((group) => group.id === priorGroupId) ?? next[0]
+ if (target && !target.tabOrder.includes(tab.id)) {
+ target.tabOrder.push(tab.id)
+ }
+ if (target && tab.id === activeTabId) {
+ target.activeTabId = tab.id
+ }
+ }
+ return next
+ }
+
private buildPreservedHeadlessMobileSessionSnapshot(
existing: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot | null {
@@ -30100,6 +30297,9 @@ export class OrcaRuntimeService {
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionSnapshotTab
): boolean {
+ if (tab.type === 'agent-session') {
+ return true
+ }
// Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph".
if (tab.type === 'browser') {
if (!this.offscreenBrowserBackend) {
@@ -30412,7 +30612,7 @@ export class OrcaRuntimeService {
})
continue
}
- if (tab.type === 'markdown' || tab.type === 'file') {
+ if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') {
tabs.push(tab)
continue
}
diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts
new file mode 100644
index 00000000000..dcba8b7b64e
--- /dev/null
+++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts
@@ -0,0 +1,22 @@
+import { defineMethod, type RpcAnyMethod } from '../core'
+import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas'
+
+export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [
+ defineMethod({
+ name: 'markdown.readTab',
+ params: ActivateTab,
+ handler: async (params, { runtime }) =>
+ runtime.readMobileMarkdownTab(params.worktree, params.tabId)
+ }),
+ defineMethod({
+ name: 'markdown.saveTab',
+ params: SaveMarkdownTab,
+ handler: async (params, { runtime }) =>
+ runtime.saveMobileMarkdownTab(
+ params.worktree,
+ params.tabId,
+ params.baseVersion,
+ params.content
+ )
+ })
+]
diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts
index 45f6613e354..d3ece3a8a1d 100644
--- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts
+++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import {
+ AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
+ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
+} from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
@@ -34,6 +37,66 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho
}
describe('projectSessionTabAgentStatus', () => {
+ it('projects structured tabs and dangling group focus out of old clients', () => {
+ const snapshot: RuntimeMobileSessionTabsSnapshot = {
+ ...makeSnapshot(false),
+ activeGroupId: 'group-a',
+ activeTabId: 'agent-session:session-a',
+ activeTabType: 'agent-session',
+ tabGroups: [
+ {
+ id: 'group-a',
+ activeTabId: 'agent-session:session-a',
+ tabOrder: ['tab-1::leaf-1', 'agent-session:session-a'],
+ recentTabIds: ['agent-session:session-a', 'tab-1::leaf-1']
+ },
+ {
+ id: 'group-b',
+ activeTabId: 'agent-session:session-b',
+ tabOrder: ['agent-session:session-b']
+ }
+ ],
+ tabGroupLayout: {
+ type: 'split',
+ direction: 'horizontal',
+ first: { type: 'leaf', groupId: 'group-a' },
+ second: { type: 'leaf', groupId: 'group-b' }
+ },
+ tabs: [
+ { ...makeSnapshot(false).tabs[0]!, isActive: false },
+ {
+ type: 'agent-session',
+ id: 'agent-session:session-a',
+ title: 'Codex Chat',
+ sessionId: 'session-a',
+ agent: 'codex',
+ isActive: true
+ },
+ {
+ type: 'agent-session',
+ id: 'agent-session:session-b',
+ title: 'Codex Chat',
+ sessionId: 'session-b',
+ agent: 'codex',
+ isActive: false
+ }
+ ]
+ }
+ const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', [])
+ expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal'])
+ expect(oldClient.activeTabId).toBe('tab-1::leaf-1')
+ expect(oldClient.activeTabType).toBe('terminal')
+ expect(oldClient.tabs[0]?.isActive).toBe(true)
+ expect(oldClient.tabGroups?.[0]?.tabOrder).toEqual(['tab-1::leaf-1'])
+ expect(oldClient.tabGroups).toHaveLength(1)
+ expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' })
+
+ const capable = projectSessionTabAgentStatus(snapshot, 'mobile', [
+ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
+ ])
+ expect(capable).toBe(snapshot)
+ })
+
it('withholds session boundaries from legacy paired clients', () => {
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [])
diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts
index 125e083db4f..a47ff3966ba 100644
--- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts
+++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts
@@ -1,9 +1,13 @@
-import type { RuntimeCapability } from '../../../../shared/protocol-version'
-import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import {
+ AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
+ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
+ type RuntimeCapability
+} from '../../../../shared/protocol-version'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../../../shared/runtime-types'
+import type { TabGroupLayoutNode } from '../../../../shared/types'
type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot
@@ -12,16 +16,20 @@ export function projectSessionTabAgentStatus {
+ const tabs = projected.tabs.map((tab) => {
if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) {
return tab
}
@@ -29,5 +37,67 @@ export function projectSessionTabAgentStatus(
+ payload: TPayload
+): TPayload {
+ const hiddenIds = new Set(
+ payload.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
+ )
+ if (hiddenIds.size === 0) {
+ return payload
+ }
+ const tabs = payload.tabs.filter((tab) => !hiddenIds.has(tab.id))
+ const groups = payload.tabGroups
+ ?.map((group) => {
+ const tabOrder = group.tabOrder.filter((id) => !hiddenIds.has(id))
+ if (tabOrder.length === 0) {
+ return null
+ }
+ const recentTabIds = group.recentTabIds?.filter((id) => !hiddenIds.has(id))
+ return {
+ ...group,
+ activeTabId:
+ group.activeTabId && tabOrder.includes(group.activeTabId)
+ ? group.activeTabId
+ : (recentTabIds?.find((id) => tabOrder.includes(id)) ?? tabOrder[0] ?? null),
+ tabOrder,
+ ...(recentTabIds ? { recentTabIds } : {})
+ }
+ })
+ .filter((group): group is NonNullable => group !== null)
+ const active =
+ tabs.find((tab) => tab.id === payload.activeTabId) ??
+ tabs.find((tab) => tab.isActive) ??
+ tabs[0] ??
+ null
+ const validGroupIds = new Set(groups?.map((group) => group.id) ?? [])
+ return {
+ ...payload,
+ activeGroupId:
+ groups?.find((group) => group.tabOrder.includes(active?.id ?? ''))?.id ??
+ groups?.[0]?.id ??
+ null,
+ activeTabId: active?.id ?? null,
+ activeTabType: active?.type ?? null,
+ ...(groups ? { tabGroups: groups } : { tabGroups: undefined }),
+ ...(payload.tabGroupLayout !== undefined
+ ? { tabGroupLayout: pruneStructuredTabGroupLayout(payload.tabGroupLayout, validGroupIds) }
+ : {}),
+ tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id }))
+ } as TPayload
+}
+
+function pruneStructuredTabGroupLayout(
+ layout: TabGroupLayoutNode | null,
+ validGroupIds: ReadonlySet
+): TabGroupLayoutNode | null {
+ if (!layout || layout.type === 'leaf') {
+ return layout && validGroupIds.has(layout.groupId) ? layout : null
+ }
+ const first = pruneStructuredTabGroupLayout(layout.first, validGroupIds)
+ const second = pruneStructuredTabGroupLayout(layout.second, validGroupIds)
+ return first && second ? { ...layout, first, second } : (first ?? second)
}
diff --git a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts
index f593e91e40d..384844141ed 100644
--- a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts
+++ b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts
@@ -13,6 +13,17 @@ function setMobileSessionSnapshot(
).mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
}
+function getMobileSessionSnapshot(
+ runtime: OrcaRuntimeService,
+ worktree: string
+): RuntimeMobileSessionTabsSnapshot | undefined {
+ return (
+ runtime as unknown as {
+ mobileSessionTabsByWorktree: Map
+ }
+ ).mobileSessionTabsByWorktree.get(worktree)
+}
+
function terminalTab() {
return {
type: 'terminal' as const,
@@ -50,6 +61,154 @@ function browserTab({
}
describe('session tab move validation', () => {
+ it('preserves a structured tab across renderer-authored snapshot sync', () => {
+ const runtime = new OrcaRuntimeService()
+ const structured = {
+ type: 'agent-session' as const,
+ id: 'agent-session:session-a',
+ title: 'Codex Chat',
+ sessionId: 'session-a',
+ agent: 'codex' as const,
+ isActive: false
+ }
+ setMobileSessionSnapshot(runtime, {
+ worktree: 'wt-1',
+ publicationEpoch: 'structured-epoch',
+ snapshotVersion: 2,
+ activeGroupId: 'group-1',
+ activeTabId: 'terminal-tab::leaf-1',
+ activeTabType: 'terminal',
+ tabGroups: [
+ {
+ id: 'group-1',
+ activeTabId: 'terminal-tab',
+ tabOrder: ['terminal-tab', structured.id]
+ }
+ ],
+ tabs: [terminalTab(), structured]
+ })
+ const incoming: RuntimeMobileSessionTabsSnapshot = {
+ worktree: 'wt-1',
+ publicationEpoch: 'renderer-epoch',
+ snapshotVersion: 1,
+ activeGroupId: 'group-1',
+ activeTabId: 'terminal-tab::leaf-1',
+ activeTabType: 'terminal',
+ tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }],
+ tabs: [terminalTab()]
+ }
+
+ ;(
+ runtime as unknown as {
+ syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[]): Set
+ }
+ ).syncMobileSessionTabs([incoming])
+
+ const snapshot = getMobileSessionSnapshot(runtime, 'wt-1')
+ expect(snapshot?.tabs).toContainEqual(structured)
+ expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab', 'agent-session:session-a'])
+ })
+
+ it('publishes a structured tab into the active group instead of the first group', () => {
+ const runtime = new OrcaRuntimeService()
+ setMobileSessionSnapshot(runtime, {
+ worktree: 'wt-1',
+ publicationEpoch: 'epoch-1',
+ snapshotVersion: 1,
+ activeGroupId: 'group-2',
+ activeTabId: 'file-tab',
+ activeTabType: 'file',
+ tabGroups: [
+ { id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] },
+ { id: 'group-2', activeTabId: 'file-tab', tabOrder: ['file-tab'] }
+ ],
+ tabs: [
+ terminalTab(),
+ {
+ type: 'file',
+ id: 'file-tab',
+ title: 'README.md',
+ filePath: 'README.md',
+ relativePath: 'README.md',
+ language: 'markdown',
+ isDirty: false,
+ isActive: true
+ }
+ ]
+ })
+
+ runtime.publishStructuredAgentSessionTab({
+ workspaceId: 'wt-1',
+ sessionId: 'session-a',
+ agent: 'codex',
+ activate: true
+ })
+
+ const snapshot = getMobileSessionSnapshot(runtime, 'wt-1')
+ expect(snapshot?.activeGroupId).toBe('group-2')
+ expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab'])
+ expect(snapshot?.tabGroups?.[1]).toMatchObject({
+ activeTabId: 'agent-session:session-a',
+ tabOrder: ['file-tab', 'agent-session:session-a']
+ })
+ })
+
+ it('preserves a capability-hidden structured tab during an old-client reorder', async () => {
+ const runtime = new OrcaRuntimeService()
+ const moveSessionTab = vi.fn()
+ runtime.setNotifier({ moveSessionTab } as never)
+ setMobileSessionSnapshot(runtime, {
+ worktree: 'wt-1',
+ publicationEpoch: 'epoch-1',
+ snapshotVersion: 1,
+ activeGroupId: 'group-1',
+ activeTabId: 'terminal-tab::leaf-1',
+ activeTabType: 'terminal',
+ tabGroups: [
+ {
+ id: 'group-1',
+ activeTabId: 'terminal-tab',
+ tabOrder: ['terminal-tab', 'agent-session:session-a', 'file-tab']
+ }
+ ],
+ tabs: [
+ terminalTab(),
+ {
+ type: 'agent-session',
+ id: 'agent-session:session-a',
+ title: 'Codex Chat',
+ sessionId: 'session-a',
+ agent: 'codex',
+ isActive: false
+ },
+ {
+ type: 'file',
+ id: 'file-tab',
+ title: 'README.md',
+ filePath: 'README.md',
+ relativePath: 'README.md',
+ language: 'markdown',
+ isDirty: false,
+ isActive: false
+ }
+ ]
+ })
+
+ await runtime.moveMobileSessionTab('id:wt-1', {
+ kind: 'reorder',
+ tabId: 'file-tab',
+ targetGroupId: 'group-1',
+ tabOrder: ['file-tab', 'terminal-tab']
+ })
+
+ expect(moveSessionTab).toHaveBeenCalledWith('wt-1', {
+ kind: 'reorder',
+ tabId: 'file-tab',
+ targetGroupId: 'group-1',
+ tabOrder: ['file-tab', 'agent-session:session-a', 'terminal-tab']
+ })
+ })
+
it('validates reorder moves against sanitized visible tab groups', async () => {
const runtime = new OrcaRuntimeService()
const moveSessionTab = vi.fn()
diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts
index 397753d609a..ba5dd29ecde 100644
--- a/src/main/runtime/rpc/methods/session-tabs.ts
+++ b/src/main/runtime/rpc/methods/session-tabs.ts
@@ -5,7 +5,6 @@ import {
ActivateTab,
CreateTerminalTab,
MoveTab,
- SaveMarkdownTab,
SessionTabsUnsubscribe,
SetTabProps,
UpdatePaneLayout,
@@ -13,26 +12,33 @@ import {
} from './session-tabs-schemas'
import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
+import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
+import { MOBILE_MARKDOWN_TAB_METHODS } from './mobile-markdown-tab-methods'
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.list',
params: WorktreeTabSelector,
- handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) =>
- projectSessionTabAgentStatus(
+ handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
+ await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
+ return projectSessionTabAgentStatus(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities
)
+ }
}),
defineMethod({
name: 'session.tabs.listAll',
params: null,
- handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({
- snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
- projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities)
- )
- })
+ handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
+ await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
+ return {
+ snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
+ projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities)
+ )
+ }
+ }
}),
defineMethod({
name: 'session.tabs.activate',
@@ -144,6 +150,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
let unsubscribe = (): void => {}
let closed = false
let initialized = false
+ await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
if (closed) {
return
@@ -222,8 +229,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
let initialized = false
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
- // Why: shared-control can carry multiple all-tab subscribers on one
- // socket; include the RPC id so closing one does not evict siblings.
+ // Why: include the RPC id so one shared-control stream cannot evict siblings.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -239,6 +245,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
if (closed) {
return
}
+ await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
const snapshots = await Promise.resolve(
runtime.listAllMobileSessionTabs(pairedDeviceId)
).catch((error) => {
@@ -285,21 +292,5 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
return { unsubscribed: true }
}
}),
- defineMethod({
- name: 'markdown.readTab',
- params: ActivateTab,
- handler: async (params, { runtime }) =>
- runtime.readMobileMarkdownTab(params.worktree, params.tabId)
- }),
- defineMethod({
- name: 'markdown.saveTab',
- params: SaveMarkdownTab,
- handler: async (params, { runtime }) =>
- runtime.saveMobileMarkdownTab(
- params.worktree,
- params.tabId,
- params.baseVersion,
- params.content
- )
- })
+ ...MOBILE_MARKDOWN_TAB_METHODS
]
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
index d766399202a..2e2c8db64c0 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
@@ -94,6 +94,23 @@ export const AttachParams = z
})
.strict()
+export const CreateIntentParams = z
+ .object({
+ envelope: MutationEnvelope,
+ worktree: Identifier('Invalid worktree selector'),
+ agent: z.literal('codex')
+ })
+ .strict()
+
+export const CreateParams = z.union([AttachParams, CreateIntentParams])
+
+export const CreateSupportParams = z
+ .object({
+ worktree: Identifier('Invalid worktree selector'),
+ agent: z.literal('codex')
+ })
+ .strict()
+
/** Clients may only author user turns. Accepting an assistant or tool role here
* would let one client write words into the agent's mouth in another's
* timeline, and the provider — not the client — owns those. */
diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts
index 85460a63448..d67ecda88d2 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts
@@ -14,6 +14,7 @@ import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { ALL_RPC_METHODS } from './index'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
+import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
const SESSION = 'session-alpha'
const FINGERPRINT = 'f'.repeat(64)
@@ -60,10 +61,27 @@ function request(method: string, params: unknown): RpcRequest {
}
let hostCalls: Record>
+let runtimeCalls: Record>
function hostStub(): StructuredAgentSessionHost {
hostCalls = {
- attach: vi.fn(async () => ({ ok: true, replayed: false })),
+ attach: vi.fn(async () => ({
+ ok: true,
+ replayed: false,
+ fence: 1,
+ cursor: { epoch: 'epoch-a', sequence: 0 },
+ value: {
+ sessionId: SESSION,
+ fence: 1,
+ snapshot: {
+ sessionId: SESSION,
+ cursor: { epoch: 'epoch-a', sequence: 0 },
+ items: [],
+ submissions: []
+ },
+ unconfirmedClientMessageIds: []
+ }
+ })),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
@@ -76,11 +94,29 @@ function hostStub(): StructuredAgentSessionHost {
}
function dispatcher(): RpcDispatcher {
+ runtimeCalls = {
+ getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
+ resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
+ envelope: params.envelope,
+ location: {
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ },
+ provider: 'codex',
+ agent: 'codex',
+ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
+ runtimeKind: 'native'
+ })),
+ publishStructuredAgentSessionTab: vi.fn()
+ }
const runtime = {
getRuntimeId: () => 'runtime-1',
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
- cleanupSubscriptionsByPrefix: vi.fn()
+ cleanupSubscriptionsByPrefix: vi.fn(),
+ ...runtimeCalls
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
@@ -134,7 +170,7 @@ describe('capability gating', () => {
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
expect(names).toContain(method.name)
}
- expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(10)
+ expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(11)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -149,6 +185,32 @@ describe('capability gating', () => {
expect(hostCalls.send).not.toHaveBeenCalled()
})
+ it('rejects create intent before resolving host-owned fields for an old client', async () => {
+ const worktree = 'id:workspace-1'
+ const response = await call(
+ 'agentSession.create',
+ {
+ envelope: envelope({
+ expectedRuntimeFence: null,
+ payloadFingerprint: computeAgentSessionPayloadFingerprint({
+ method: 'agentSession.create',
+ sessionId: SESSION,
+ fields: { worktree, agent: 'codex' }
+ })
+ }),
+ worktree,
+ agent: 'codex'
+ },
+ { clientKind: 'mobile', clientCapabilities: [] }
+ )
+
+ expect(response).toMatchObject({
+ ok: false,
+ error: { message: expect.stringContaining('structured_agent_session_unsupported') }
+ })
+ expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled()
+ })
+
it('serves a client that advertised it', async () => {
const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
expect(response).toMatchObject({ ok: true })
@@ -168,6 +230,35 @@ describe('capability gating', () => {
})
describe('method routing', () => {
+ it('creates from mobile intent while the host resolves paths and provider identity', 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 created = await call('agentSession.create', params, STRUCTURED_CLIENT)
+ expect(created).toMatchObject({ ok: true, result: { ok: true } })
+ expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
+ expect(hostCalls.attach).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }
+ })
+ )
+ expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle')
+ expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
+ expect.objectContaining({ sessionId: SESSION, activate: true })
+ )
+ })
+
it('separates create from ensure by the fence the client may declare', async () => {
const created = await call('agentSession.create', attachParams(), STRUCTURED_CLIENT)
expect(created).toMatchObject({ ok: true })
diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts
index c47be9f696c..a07646a211f 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session.ts
@@ -7,6 +7,10 @@
// structured session.
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import {
+ agentSessionFingerprintConflict,
+ computeAgentSessionPayloadFingerprint
+} from '../../../../shared/agent-session-mutation-envelope'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type {
StructuredAgentSessionCaller,
@@ -16,6 +20,8 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext
import {
AttachParams,
CancelParams,
+ CreateParams,
+ CreateSupportParams,
HistoryParams,
RespondParams,
SendParams,
@@ -37,8 +43,15 @@ function supportsStructuredSessions(ctx: RpcContext): boolean {
)
}
+function requireStructuredCapability(ctx: RpcContext): void {
+ if (!supportsStructuredSessions(ctx)) {
+ throw new Error('structured_agent_session_unsupported')
+ }
+}
+
function requireHost(ctx: RpcContext): StructuredAgentSessionHost {
- const host = supportsStructuredSessions(ctx) ? getStructuredAgentSessionHost() : null
+ requireStructuredCapability(ctx)
+ const host = getStructuredAgentSessionHost()
if (!host) {
throw new Error('structured_agent_session_unsupported')
}
@@ -74,12 +87,61 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string {
export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
- name: 'agentSession.create',
- params: AttachParams,
+ name: 'agentSession.createSupport',
+ params: CreateSupportParams,
handler: async (params, ctx) => {
+ if (!supportsStructuredSessions(ctx)) {
+ throw new Error('structured_agent_session_unsupported')
+ }
+ return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent)
+ }
+ }),
+ defineMethod({
+ name: 'agentSession.create',
+ params: CreateParams,
+ handler: async (params, ctx) => {
+ requireStructuredCapability(ctx)
if (params.envelope.expectedRuntimeFence !== null) {
throw new Error('agent_session_operation_invalid')
}
+ if ('worktree' in params) {
+ const intentFingerprint = computeAgentSessionPayloadFingerprint({
+ method: 'agentSession.create',
+ sessionId: params.envelope.sessionId,
+ fields: { worktree: params.worktree, agent: params.agent }
+ })
+ const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint)
+ if (conflict) {
+ return { ok: false, refusal: conflict }
+ }
+ const resolved = await ctx.runtime.resolveStructuredAgentSessionCreateIntent(params)
+ const hostFingerprint = computeAgentSessionPayloadFingerprint({
+ method: 'agentSession.attach',
+ sessionId: params.envelope.sessionId,
+ fields: {
+ location: resolved.location,
+ provider: resolved.provider,
+ agent: resolved.agent,
+ accountHome: resolved.accountHome,
+ runtimeKind: resolved.runtimeKind,
+ expectedRuntimeFence: null
+ }
+ })
+ await ensureHostInstalled(ctx)
+ const result = await requireHost(ctx).attach(callerFor(ctx), {
+ ...resolved,
+ envelope: { ...params.envelope, payloadFingerprint: hostFingerprint }
+ })
+ if (result.ok) {
+ ctx.runtime.publishStructuredAgentSessionTab({
+ workspaceId: resolved.location.workspaceId,
+ sessionId: result.value.sessionId,
+ agent: 'codex',
+ activate: true
+ })
+ }
+ return result
+ }
await ensureHostInstalled(ctx)
return requireHost(ctx).attach(callerFor(ctx), params)
}
diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts
new file mode 100644
index 00000000000..c1f265cc4c8
--- /dev/null
+++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts
@@ -0,0 +1,11 @@
+import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import type { RpcContext } from '../core'
+
+export async function restoreStructuredTabsIfSupported(
+ runtime: RpcContext['runtime'],
+ capabilities: readonly string[] | undefined
+): Promise {
+ if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
+ await runtime.restoreStructuredAgentSessionTabs()
+ }
+}
diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts
index b6cb7ecf259..c7f2b9ad279 100644
--- a/src/main/runtime/runtime-rpc.ts
+++ b/src/main/runtime/runtime-rpc.ts
@@ -185,6 +185,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'accounts.unsubscribe',
'aiVault.listSessions',
'aiVault.prepareSessionResume',
+ 'agentSession.create',
+ 'agentSession.createSupport',
+ 'agentSession.history',
+ 'agentSession.subscribe',
+ 'agentSession.unsubscribe',
'browser.back',
'browser.dialogAccept',
'browser.dialogDismiss',
diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts
index 001bc5252d1..39b60c36a6a 100644
--- a/src/main/startup/desktop-startup-ordering.test.ts
+++ b/src/main/startup/desktop-startup-ordering.test.ts
@@ -221,6 +221,20 @@ describe('startup ordering', () => {
expect(disposeIndex).toBeGreaterThan(commitIndex)
})
+ it('joins structured agent sessions to the committed quit barrier', () => {
+ const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
+ const willQuitStart = source.indexOf("app.on('will-quit'")
+ const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart)
+ const willQuit = source.slice(willQuitStart, willQuitEnd)
+
+ expect(willQuit).toContain(
+ 'const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()'
+ )
+ expect(willQuit).toContain(
+ "{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown }"
+ )
+ })
+
it('starts the automation scheduler before headless serve reports ready', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const serveStart = source.indexOf('if (serveOptions) {')
diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts
index 24bf7e57878..536df076de5 100644
--- a/src/shared/agent-session-journal-types.ts
+++ b/src/shared/agent-session-journal-types.ts
@@ -125,6 +125,8 @@ export type AgentJournalQuestionItem = {
kind: 'question'
question: string
options: AgentJournalPromptOption[]
+ /** Present when the provider accepts an answer outside the offered options. */
+ freeTextQuestionId?: string
resolution: AgentJournalResolution
}
diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts
index 4dcfe039525..31c441086fb 100644
--- a/src/shared/agent-session-wire.ts
+++ b/src/shared/agent-session-wire.ts
@@ -49,6 +49,8 @@ export type AgentSessionHistoryPage = {
newest: AgentJournalCursor | null
nextCursor: AgentJournalCursor
}
+ /** Current journal head for switching from a bounded page to live subscribe. */
+ liveCursor?: AgentJournalCursor
hasOlder: boolean
hasNewer: boolean
}
diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts
index a7da46fe53b..a94c7e36ac2 100644
--- a/src/shared/runtime-types.ts
+++ b/src/shared/runtime-types.ts
@@ -251,11 +251,23 @@ export type RuntimeMobileSessionBrowserTab = {
isActive: boolean
}
+export type RuntimeMobileSessionAgentTab = {
+ type: 'agent-session'
+ id: string
+ title: string
+ sessionId: string
+ agent: 'codex'
+ color?: string | null
+ isPinned?: boolean
+ isActive: boolean
+}
+
export type RuntimeMobileSessionSnapshotTab =
| RuntimeMobileSessionTerminalTab
| RuntimeMobileSessionMarkdownTab
| RuntimeMobileSessionFileTab
| RuntimeMobileSessionBrowserTab
+ | RuntimeMobileSessionAgentTab
export type RuntimeMobileSessionTerminalClientTab =
| (RuntimeMobileSessionTerminalTab & {
@@ -272,6 +284,7 @@ export type RuntimeMobileSessionClientTab =
| RuntimeMobileSessionMarkdownTab
| RuntimeMobileSessionFileTab
| RuntimeMobileSessionBrowserTab
+ | RuntimeMobileSessionAgentTab
export type RuntimeMobileSessionTabGroup = {
id: string
@@ -328,7 +341,7 @@ export type RuntimeMobileSessionTabsSnapshot = {
snapshotVersion: number
activeGroupId: string | null
activeTabId: string | null
- activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null
+ activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
tabs: RuntimeMobileSessionSnapshotTab[]
@@ -342,7 +355,7 @@ export type RuntimeMobileSessionTabsResult = {
navigationIntent?: 'follow'
activeGroupId: string | null
activeTabId: string | null
- activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null
+ activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
tabs: RuntimeMobileSessionClientTab[]
diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
index e9cc27678c0..738d4ecbc92 100644
--- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
+++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
@@ -274,7 +274,7 @@ describe('cross-version structured agent sessions', () => {
it('finds no structured method registered on the old build', () => {
expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([])
expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength(
- 10
+ 11
)
})