mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
Add mobile structured session read flow
This commit is contained in:
@@ -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 (
|
||||
<View style={styles.markdownState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (doc.status === 'error') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<Text style={styles.markdownError}>{doc.message}</Text>
|
||||
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
|
||||
<RefreshCw size={14} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownRefreshText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.markdownEditor}>
|
||||
<MobileRichMarkdownEditor
|
||||
key={documentId}
|
||||
content={doc.localContent}
|
||||
editable={doc.editable && !doc.saving}
|
||||
onChange={onChange}
|
||||
onKeyboardInsetChange={setWebviewKeyboardInset}
|
||||
/>
|
||||
{showFloatingActions ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.markdownFloatingBar,
|
||||
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
|
||||
{
|
||||
bottom: resolveMarkdownFloatingActionsBottom({
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
restingBottom: spacing.lg,
|
||||
liftedClearance: spacing.md
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
{statusText ? (
|
||||
<Text
|
||||
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{statusText}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.markdownFloatingActions}>
|
||||
{showCopy ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.markdownFloatingButton,
|
||||
styles.markdownSaveButton,
|
||||
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
|
||||
]}
|
||||
disabled={!doc.editable || !doc.isDirty || doc.saving}
|
||||
onPress={onSave}
|
||||
>
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffLineRow({
|
||||
line,
|
||||
title,
|
||||
@@ -1122,6 +1001,7 @@ export default function SessionScreen() {
|
||||
null
|
||||
)
|
||||
const [quickCommandsSupported, setQuickCommandsSupported] = useState<boolean | null>(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' && (
|
||||
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
{t.type === 'agent-session' && (
|
||||
<MobileAgentIcon agentId={t.agent} size={13} />
|
||||
)}
|
||||
{t.type === 'terminal' &&
|
||||
(() => {
|
||||
const agentId = resolveMobileTerminalTabAgentId(t)
|
||||
@@ -4636,7 +4571,7 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
) : activeMarkdownTab ? (
|
||||
<View style={styles.markdownFrame}>
|
||||
<MarkdownReader
|
||||
<MobileMarkdownReader
|
||||
documentId={activeMarkdownTab.id}
|
||||
doc={markdownDocs.get(activeMarkdownTab.id)}
|
||||
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
|
||||
@@ -4697,6 +4632,16 @@ export default function SessionScreen() {
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
) : activeStructuredTab ? (
|
||||
<MobileStructuredAgentSessionView
|
||||
messages={structuredSessionEntry.session.messages}
|
||||
status={structuredSessionEntry.session.status}
|
||||
error={structuredSessionEntry.session.error}
|
||||
hasOlder={structuredSessionEntry.session.hasOlder}
|
||||
loadingOlder={structuredSessionEntry.session.loadingOlder}
|
||||
onLoadOlder={structuredSessionEntry.session.loadOlder}
|
||||
onOpenFile={handleNativeChatFileTap}
|
||||
/>
|
||||
) : activePendingTerminalTab ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -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 && (
|
||||
<View
|
||||
style={[
|
||||
styles.commandDock,
|
||||
@@ -5129,6 +5074,7 @@ export default function SessionScreen() {
|
||||
title="New Tab"
|
||||
actions={[
|
||||
...createTabAgentActions,
|
||||
...structuredChatAction,
|
||||
{
|
||||
label: 'Terminal',
|
||||
icon: SquareTerminal,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { RefreshCw } from 'lucide-react-native'
|
||||
import { MobileRichMarkdownEditor } from '../components/MobileRichMarkdownEditor'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout'
|
||||
import type { MarkdownDocState } from './mobile-session-route-types'
|
||||
import { styles } from './mobile-session-styles'
|
||||
|
||||
export function MobileMarkdownReader({
|
||||
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
|
||||
}): React.JSX.Element {
|
||||
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
|
||||
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
|
||||
if (!doc || doc.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (doc.status === 'error') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<Text style={styles.markdownError}>{doc.message}</Text>
|
||||
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
|
||||
<RefreshCw size={14} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownRefreshText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.markdownEditor}>
|
||||
<MobileRichMarkdownEditor
|
||||
key={documentId}
|
||||
content={doc.localContent}
|
||||
editable={doc.editable && !doc.saving}
|
||||
onChange={onChange}
|
||||
onKeyboardInsetChange={setWebviewKeyboardInset}
|
||||
/>
|
||||
{showFloatingActions ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.markdownFloatingBar,
|
||||
{
|
||||
bottom: resolveMarkdownFloatingActionsBottom({
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
restingBottom: spacing.lg,
|
||||
liftedClearance: spacing.md
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
{statusText ? (
|
||||
<Text
|
||||
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{statusText}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.markdownFloatingActions}>
|
||||
{showCopy ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.markdownFloatingButton,
|
||||
styles.markdownSaveButton,
|
||||
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
|
||||
]}
|
||||
disabled={!doc.editable || !doc.isDirty || doc.saving}
|
||||
onPress={onSave}
|
||||
>
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>
|
||||
onOpenFile?: (path: string) => void
|
||||
}
|
||||
|
||||
export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element {
|
||||
const listRef = useRef<FlatList<NativeChatMessage>>(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<NativeScrollEvent>) => {
|
||||
androidAnchorOffsetRef.current = event.nativeEvent.contentOffset.y
|
||||
}, [])
|
||||
|
||||
if (props.status === 'loading' && data.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
inverted
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<MobileNativeChatMessage
|
||||
message={item}
|
||||
messageIndex={data.length - index - 1}
|
||||
onOpenFile={props.onOpenFile}
|
||||
/>
|
||||
)}
|
||||
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 ? (
|
||||
<View style={styles.loader}>
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.title}>
|
||||
{props.status === 'error' ? 'Conversation unavailable' : 'New Codex chat'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>{props.error ?? 'Messages will appear here.'}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
<View style={styles.readOnlyBanner}>
|
||||
<Text style={styles.readOnlyText}>Read only · sending arrives in the next update</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<AgentSessionSubscribeEvent, { type: 'batch' }>,
|
||||
right: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
|
||||
): Extract<AgentSessionSubscribeEvent, { type: 'batch' }> {
|
||||
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<AgentSessionSubscribeEvent, { type: 'batch' }> | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>)
|
||||
.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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<typeof projectStructuredItemsToNativeChat>
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
error?: string
|
||||
hasOlder: boolean
|
||||
loadingOlder: boolean
|
||||
loadOlder: () => Promise<boolean>
|
||||
} {
|
||||
const { client, sessionId } = args
|
||||
const [state, dispatch] = useReducer(
|
||||
reduceMobileStructuredAgentSession,
|
||||
EMPTY_MOBILE_STRUCTURED_AGENT_SESSION
|
||||
)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const resumeCursorRef = useRef<AgentJournalCursor | null>(state.cursor)
|
||||
resumeCursorRef.current = state.cursor
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const reconnectRef = useRef(createMobileStructuredReconnectState())
|
||||
const longevityTimerRef = useRef<ReturnType<typeof setTimeout> | 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<boolean> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>
|
||||
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<void> => {
|
||||
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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>)
|
||||
.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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,8 @@ class FakeSession implements RpcClient {
|
||||
readonly updateTerminalSubscriptionViewport =
|
||||
vi.fn<RpcClient['updateTerminalSubscriptionViewport']>()
|
||||
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<T>() {
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
@@ -159,6 +159,16 @@ export function createStableLogicalRpcClient(
|
||||
activeSession.notifyForeground(reason)
|
||||
}
|
||||
},
|
||||
restartAfterStructuredBackground: () => {
|
||||
if (!suspended) {
|
||||
activeSession.restartAfterStructuredBackground?.()
|
||||
}
|
||||
},
|
||||
confirmStructuredStreamLongevity: () => {
|
||||
if (!suspended) {
|
||||
activeSession.confirmStructuredStreamLongevity?.()
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -55,9 +55,10 @@ export function readCodexThreadItem(value: unknown): CodexThreadItem | null {
|
||||
export class CodexTurnOrdinals {
|
||||
private readonly turns = new Map<string, Map<string, number>>()
|
||||
|
||||
ordinalFor(turnId: string, codexItemId: string): number {
|
||||
const assigned = this.turns.get(turnId) ?? new Map<string, number>()
|
||||
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<string, number>()
|
||||
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<string, unknown>, key: string): string | null
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readTextContent(source: Record<string, unknown>, 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<string, unknown>, '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<string, unknown>, 'text')
|
||||
if (partText !== null) {
|
||||
blocks.push({ type: 'text', text: partText })
|
||||
continue
|
||||
}
|
||||
const record = part as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<string, AgentJournalItemIdentity>()
|
||||
/** What each announced item is, so an approval can name what it approves. */
|
||||
const details = new Map<string, string>()
|
||||
let currentTurnId: string | null = null
|
||||
const currentTurnIds = new Map<string, string>()
|
||||
const latestStreamText = new Map<string, string>()
|
||||
const checkpointLengths = new Map<string, number>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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, unknown>): 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<string, unknown>): boolean {
|
||||
const options = question.options
|
||||
return (
|
||||
!Array.isArray(options) ||
|
||||
options.length === 0 ||
|
||||
options.some((option) => readParams(option).isOther === true)
|
||||
)
|
||||
}
|
||||
|
||||
function questionOptions(
|
||||
question: Record<string, unknown>,
|
||||
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 })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -88,10 +88,14 @@ export function isCodexPromptMethod(method: string): boolean {
|
||||
* became.
|
||||
*/
|
||||
export class CodexPromptRegistry {
|
||||
private readonly byPromptKey = new Map<string, CodexPendingPrompt>()
|
||||
/** Journal item id to prompt key. */
|
||||
private readonly byAddress = new Map<string, CodexPendingPrompt>()
|
||||
/** Journal item id to thread-scoped prompt address. */
|
||||
private readonly journalItemIds = new Map<string, string>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, Route> = {}): {
|
||||
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<void>((resolve) => {
|
||||
releaseOpen = resolve
|
||||
})
|
||||
const openEntered = new Promise<void>((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' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<CodexStructuredLaunch>
|
||||
/** 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<number | null>
|
||||
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<string, string>
|
||||
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<string, CodexSession>()
|
||||
private readonly acquiring = new Map<string, CodexAcquisitionWindow>()
|
||||
private readonly acquiring = new Map<string, CodexAcquisitionAttempt>()
|
||||
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
return this.closeSession(input.sessionId)
|
||||
}
|
||||
|
||||
private session(sessionId: string): CodexSession {
|
||||
|
||||
@@ -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<CodexStructuredLaunch>
|
||||
onEvent?: (event: CodexStructuredSessionEvent) => void
|
||||
openConnection?: typeof openCodexAppServerConnection
|
||||
readProcessStartTime?: (pid: number) => Promise<number | null>
|
||||
mintLinkId?: () => string
|
||||
now?: () => number
|
||||
requestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type CodexSession = {
|
||||
connection: CodexAppServerConnection
|
||||
threadId: string
|
||||
historyPath: string | null
|
||||
prompts: CodexAcquisitionWindow['prompts']
|
||||
options: Map<string, string>
|
||||
turnIdWaiters: ((turnId: string) => void)[]
|
||||
translator: CodexJournalTranslator | null
|
||||
}
|
||||
|
||||
export type CodexAcquisitionAttempt = {
|
||||
window: CodexAcquisitionWindow
|
||||
cancelled: boolean
|
||||
finished: Promise<void>
|
||||
finish: () => void
|
||||
}
|
||||
|
||||
export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt {
|
||||
let finish = (): void => {}
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { window: new CodexAcquisitionWindow(), cancelled: false, finished, finish }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<AgentSessionAcquisition>
|
||||
/** Reaps an acquired provider when the host cannot commit or prove its lease. */
|
||||
releaseAcquisition?(input: { sessionId: string }): Promise<void>
|
||||
dispatch(input: {
|
||||
sessionId: string
|
||||
clientMessageId: string
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<AgentSessionProviderHandle, { kind: 'opaque' }>
|
||||
/** Omitted only for create-by-intent; the adapter proves the durable handle. */
|
||||
providerHandle?: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
|
||||
}
|
||||
|
||||
/** 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<AttachedJournal> {
|
||||
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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,8 @@ function ensureParams(fence: number): AgentSessionAttachParams {
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
let host: StructuredAgentSessionHost
|
||||
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
|
||||
let releaseAcquisition: Mock<NonNullable<StructuredAgentSessionAdapter['releaseAcquisition']>>
|
||||
let dispatch: Mock<StructuredAgentSessionAdapter['dispatch']>
|
||||
let cancelTurn: Mock<StructuredAgentSessionAdapter['cancelTurn']>
|
||||
let answerPrompt: Mock<StructuredAgentSessionAdapter['answerPrompt']>
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
|
||||
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,
|
||||
|
||||
@@ -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<RestoredStructuredAgentSessionRead | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AgentSessionAttachParams> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
]
|
||||
@@ -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', [])
|
||||
|
||||
|
||||
@@ -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<TPayload extends SessionTabsPayload
|
||||
clientKind: 'mobile' | 'runtime' | undefined,
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined
|
||||
): TPayload {
|
||||
const structuredVisible =
|
||||
clientKind === undefined ||
|
||||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
|
||||
const projected = structuredVisible ? payload : projectStructuredTabsOut(payload)
|
||||
// Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape.
|
||||
if (
|
||||
clientKind !== 'runtime' ||
|
||||
clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
return payload
|
||||
return projected
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const tabs = payload.tabs.map((tab) => {
|
||||
const tabs = projected.tabs.map((tab) => {
|
||||
if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) {
|
||||
return tab
|
||||
}
|
||||
@@ -29,5 +37,67 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
|
||||
const { agentStatus: _boundary, ...legacyTab } = tab
|
||||
return legacyTab
|
||||
})
|
||||
return changed ? ({ ...payload, tabs } as TPayload) : payload
|
||||
return changed ? ({ ...projected, tabs } as TPayload) : projected
|
||||
}
|
||||
|
||||
function projectStructuredTabsOut<TPayload extends SessionTabsPayload>(
|
||||
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<typeof group> => 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<string>
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -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<string, RuntimeMobileSessionTabsSnapshot>
|
||||
}
|
||||
).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<string>
|
||||
}
|
||||
).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()
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, ReturnType<typeof vi.fn>>
|
||||
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user