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 ) })