mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Merge native chat handoff and wire Claude structured sessions
This commit is contained in:
@@ -229,6 +229,7 @@ import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
|
||||
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
|
||||
import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView'
|
||||
import { MobileStructuredSessionCreateError } from '../../../../src/session/MobileStructuredSessionCreateError'
|
||||
import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send'
|
||||
import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry'
|
||||
import {
|
||||
isMobileStructuredAgent,
|
||||
@@ -4671,6 +4672,31 @@ export default function SessionScreen() {
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onTuiSend={async (text, restored) => {
|
||||
const terminal = mobileStructuredTuiSend.getMobileStructuredTuiTerminal(
|
||||
structuredSessionEntry.session.handoff
|
||||
)
|
||||
if (!client || connState !== 'connected' || !terminal) {
|
||||
showToast('Message not sent (disconnected)', 1800)
|
||||
return false
|
||||
}
|
||||
const outcome = await mobileStructuredTuiSend.sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
text,
|
||||
attachments: [...restored, ...structuredSessionEntry.attachments.attachments]
|
||||
})
|
||||
if (outcome === 'rejected') {
|
||||
showToast('Message not sent', 1800)
|
||||
return false
|
||||
}
|
||||
structuredSessionEntry.attachments.clear()
|
||||
if (outcome === 'unknown') {
|
||||
showToast('Delivery unconfirmed — check chat before retrying', 2400)
|
||||
}
|
||||
return true
|
||||
}}
|
||||
onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit}
|
||||
onRetry={structuredSessionEntry.writes.retry}
|
||||
onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt}
|
||||
@@ -4680,6 +4706,8 @@ export default function SessionScreen() {
|
||||
onAttachImage={() => void structuredSessionEntry.attachments.attach('library')}
|
||||
onRemoveAttachment={structuredSessionEntry.attachments.remove}
|
||||
onCancel={structuredSessionEntry.writes.cancel}
|
||||
handoff={structuredSessionEntry.session.handoff}
|
||||
onRequestHandoff={structuredSessionEntry.writes.requestHandoff}
|
||||
/>
|
||||
) : activePendingTerminalTab ? (
|
||||
<View style={styles.emptyState}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, type ComponentProps } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
|
||||
@@ -50,35 +50,49 @@ function options(): MobileNativeChatSessionOptionsController {
|
||||
describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const onSend = vi.fn(async () => true)
|
||||
const onTuiSend = vi.fn(async () => true)
|
||||
let controller: MobileNativeChatSessionOptionsController
|
||||
|
||||
function props(
|
||||
overrides: Partial<ComponentProps<typeof MobileStructuredAgentSessionView>> = {}
|
||||
): ComponentProps<typeof MobileStructuredAgentSessionView> {
|
||||
return {
|
||||
agent: 'codex',
|
||||
items: [],
|
||||
status: 'ready',
|
||||
hasOlder: false,
|
||||
loadingOlder: false,
|
||||
onLoadOlder: async () => false,
|
||||
outbox: [],
|
||||
onSend,
|
||||
onTuiSend,
|
||||
onTakeQueuedForEdit: async () => null,
|
||||
onRetry: async () => {},
|
||||
onRespondToPrompt: async () => true,
|
||||
sessionOptions: controller,
|
||||
attachments: [],
|
||||
isAttaching: false,
|
||||
onAttachImage: () => {},
|
||||
onRemoveAttachment: () => {},
|
||||
onCancel: async () => true,
|
||||
handoff: null,
|
||||
onRequestHandoff: async () => true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function render(overrides = {}): void {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileStructuredAgentSessionView, props(overrides)))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
onSend.mockClear()
|
||||
onTuiSend.mockClear()
|
||||
controller = options()
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileStructuredAgentSessionView, {
|
||||
agent: 'codex',
|
||||
items: [],
|
||||
status: 'ready',
|
||||
hasOlder: false,
|
||||
loadingOlder: false,
|
||||
onLoadOlder: async () => false,
|
||||
outbox: [],
|
||||
onSend,
|
||||
onTakeQueuedForEdit: async () => null,
|
||||
onRetry: async () => {},
|
||||
onRespondToPrompt: async () => true,
|
||||
sessionOptions: controller,
|
||||
attachments: [],
|
||||
isAttaching: false,
|
||||
onAttachImage: () => {},
|
||||
onRemoveAttachment: () => {},
|
||||
onCancel: async () => true
|
||||
})
|
||||
)
|
||||
})
|
||||
render()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -112,12 +126,7 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
|
||||
it('uses Claude branding and command suggestions for Claude sessions', () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(MobileStructuredAgentSessionView, {
|
||||
...renderer!.root.findByType(MobileStructuredAgentSessionView).props,
|
||||
agent: 'claude'
|
||||
})
|
||||
)
|
||||
renderer!.update(createElement(MobileStructuredAgentSessionView, props({ agent: 'claude' })))
|
||||
})
|
||||
|
||||
const composer = renderer!.root.findByType('MobileNativeChatComposer')
|
||||
@@ -134,4 +143,88 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
const empty = renderer!.root.findByType('FlatList').props.ListEmptyComponent
|
||||
expect(empty.props.children[0].props.children).toBe('New Claude chat')
|
||||
})
|
||||
|
||||
it('keeps the composer enabled and routes stable TUI ownership through its bridge', async () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(
|
||||
MobileStructuredAgentSessionView,
|
||||
props({
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderer!.root.findByType('MobileNativeChatComposer').props.disabled).toBe(false)
|
||||
await submit('hello from mobile')
|
||||
expect(onTuiSend).toHaveBeenCalledWith('hello from mobile', [])
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes stable TUI slash commands to typed terminal dispatch', async () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(
|
||||
MobileStructuredAgentSessionView,
|
||||
props({
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
await submit('/model')
|
||||
expect(onTuiSend).toHaveBeenCalledWith('/model', [])
|
||||
expect(controller.invokeAction).not.toHaveBeenCalled()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables and refuses composer sends while ownership is switching', async () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(
|
||||
MobileStructuredAgentSessionView,
|
||||
props({
|
||||
handoff: {
|
||||
owner: 'native',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'preparing',
|
||||
operationId: 'handoff-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const composer = renderer!.root.findByType('MobileNativeChatComposer')
|
||||
expect(composer.props.disabled).toBe(true)
|
||||
await act(async () => {
|
||||
expect(await composer.props.onSend('racing send')).toBe(false)
|
||||
})
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(onTuiSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps native ownership on the structured agent-session send path', async () => {
|
||||
await submit('native message')
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('native message', [])
|
||||
expect(onTuiSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
} from 'react-native'
|
||||
import { Square } from 'lucide-react-native'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
@@ -56,6 +61,7 @@ type Props = {
|
||||
onOpenFile?: (path: string) => void
|
||||
outbox: MobileStructuredOutboxEntry[]
|
||||
onSend: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
|
||||
onTuiSend?: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
|
||||
onTakeQueuedForEdit: (clientMessageId: string) => Promise<MobileStructuredOutboxEntry | null>
|
||||
onRetry: (clientMessageId: string) => Promise<void>
|
||||
onRespondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
|
||||
@@ -65,6 +71,12 @@ type Props = {
|
||||
onAttachImage: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onCancel: (turnId: string) => Promise<boolean>
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
onRequestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element {
|
||||
@@ -79,6 +91,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
const allAttachments = [...restored, ...props.attachments]
|
||||
const agentName = props.agent === 'claude' ? 'Claude' : 'Codex'
|
||||
const slashCommands = useMemo(() => mobileStructuredSlashCommands(props.agent), [props.agent])
|
||||
const stableTuiOwner =
|
||||
props.handoff?.owner === 'tui' &&
|
||||
props.handoff.phase === 'idle' &&
|
||||
Boolean(props.handoff.terminal?.handle) &&
|
||||
Boolean(props.onTuiSend)
|
||||
const stableNativeOwner =
|
||||
props.handoff === null ||
|
||||
props.handoff === undefined ||
|
||||
(props.handoff.owner === 'native' && props.handoff.phase === 'idle')
|
||||
const composerEnabled = props.status === 'ready' && (stableNativeOwner || stableTuiOwner)
|
||||
const rows = useMemo(
|
||||
() => buildMobileStructuredTimeline(props.items, props.outbox),
|
||||
[props.items, props.outbox]
|
||||
@@ -107,6 +129,11 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<MobileStructuredHandoffBanner
|
||||
status={props.handoff}
|
||||
isWorking={turnId !== null}
|
||||
onRequest={props.onRequestHandoff}
|
||||
/>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
inverted
|
||||
@@ -210,10 +237,23 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
value={composerText}
|
||||
onChangeText={setComposerText}
|
||||
onSend={async (text) => {
|
||||
if (!composerEnabled) {
|
||||
setCommandError('Session ownership is changing. Try again when switching finishes.')
|
||||
return false
|
||||
}
|
||||
if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text, props.agent)) {
|
||||
setCommandError('Remove attachments before using a chat-session command.')
|
||||
return false
|
||||
}
|
||||
if (stableTuiOwner) {
|
||||
setCommandError(null)
|
||||
const accepted = await props.onTuiSend!(text, restored)
|
||||
if (accepted) {
|
||||
setComposerText('')
|
||||
setRestored([])
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
const command = await dispatchMobileStructuredComposerCommand(
|
||||
text,
|
||||
props.sessionOptions,
|
||||
@@ -247,9 +287,101 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
}
|
||||
}}
|
||||
isAttaching={props.isAttaching}
|
||||
disabled={props.status !== 'ready'}
|
||||
placeholder={props.status === 'ready' ? `Message ${agentName}` : 'Reconnecting…'}
|
||||
disabled={!composerEnabled}
|
||||
placeholder={
|
||||
props.handoff?.owner === 'tui' && !stableTuiOwner
|
||||
? 'Agent is open in terminal'
|
||||
: props.status === 'ready'
|
||||
? `Message ${agentName}`
|
||||
: 'Reconnecting…'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileStructuredHandoffBanner(props: {
|
||||
status: AgentSessionHandoffStatus | null
|
||||
isWorking: boolean
|
||||
onRequest: Props['onRequestHandoff']
|
||||
}): React.JSX.Element | null {
|
||||
const status = props.status
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
if (status.owner === 'native' && status.phase === 'idle') {
|
||||
return (
|
||||
<View style={styles.handoffBar}>
|
||||
{props.isWorking ? (
|
||||
<>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
|
||||
onPress={() => void props.onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Switch after this turn</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
|
||||
onPress={() => void props.onRequest('to-tui', 'stop-turn')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Stop turn and switch</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
|
||||
onPress={() => void props.onRequest('to-tui', 'now')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Open agent TUI</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const copy =
|
||||
status.phase === 'waiting-for-exit'
|
||||
? 'Exit the agent terminal to continue in chat.'
|
||||
: status.phase === 'switching'
|
||||
? mobileHandoffStageCopy(status)
|
||||
: (status.error?.message ??
|
||||
`Agent is open in terminal${status.hostLabel ? ` on ${status.hostLabel}` : ''}`)
|
||||
return (
|
||||
<View style={[styles.handoffBanner, status.error ? styles.handoffError : null]}>
|
||||
<Text style={status.error ? styles.handoffErrorText : styles.handoffText}>{copy}</Text>
|
||||
{status.phase === 'queued' && status.direction ? (
|
||||
<Pressable
|
||||
onPress={() => void props.onRequest(status.direction!, 'after-turn', 'cancel-queued')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
) : status.owner === 'tui' && status.phase === 'idle' ? (
|
||||
<Pressable onPress={() => void props.onRequest('to-native', 'after-turn')}>
|
||||
<Text style={styles.handoffButtonText}>Return to chat</Text>
|
||||
</Pressable>
|
||||
) : status.phase === 'failed' &&
|
||||
status.direction &&
|
||||
status.error?.recoverableOwner !== 'none' ? (
|
||||
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'retry')}>
|
||||
<Text style={styles.handoffButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function mobileHandoffStageCopy(status: AgentSessionHandoffStatus): string {
|
||||
switch (status.stage) {
|
||||
case 'preparing':
|
||||
return status.direction === 'to-tui' ? 'Finishing chat session…' : 'Finishing agent terminal…'
|
||||
case 'old-owner-stopped':
|
||||
return status.direction === 'to-tui' ? 'Opening agent terminal…' : 'Resuming chat session…'
|
||||
case 'new-owner-proving':
|
||||
return status.direction === 'to-tui' ? 'Verifying agent terminal…' : 'Verifying chat session…'
|
||||
case 'recovering':
|
||||
return 'Recovering agent session…'
|
||||
case 'manual-recovery':
|
||||
return 'Agent session needs recovery'
|
||||
default:
|
||||
return 'Switching session owner…'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,42 @@ describe('mobile structured event coalescer', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('retains ownership projection while merging a reconnect batch', () => {
|
||||
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: [],
|
||||
removedItemIds: [],
|
||||
submissions: []
|
||||
},
|
||||
fence: 3,
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' }
|
||||
}
|
||||
})
|
||||
coalescer.push(batch(2, 'continued'))
|
||||
vi.advanceTimersByTime(48)
|
||||
|
||||
expect(seen).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'batch',
|
||||
fence: 3,
|
||||
handoff: expect.objectContaining({ owner: 'tui', phase: 'idle' })
|
||||
})
|
||||
])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('flushes pending text before a lifecycle row', () => {
|
||||
vi.useFakeTimers()
|
||||
const seen: AgentSessionSubscribeEvent[] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire'
|
||||
import {
|
||||
EMPTY_MOBILE_STRUCTURED_AGENT_SESSION,
|
||||
reduceMobileStructuredAgentSession
|
||||
@@ -25,6 +26,90 @@ function snapshot(epoch: string, items: AgentJournalRenderItem[]) {
|
||||
}
|
||||
|
||||
describe('mobile structured session reducer', () => {
|
||||
it('projects every stage across transfer, recovery, and return', () => {
|
||||
const handoffs: AgentSessionHandoffStatus[] = [
|
||||
{ owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null },
|
||||
{
|
||||
owner: 'native',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'preparing',
|
||||
operationId: 'forward'
|
||||
},
|
||||
{
|
||||
owner: 'none',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'old-owner-stopped',
|
||||
operationId: 'forward'
|
||||
},
|
||||
{
|
||||
owner: 'none',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'new-owner-proving',
|
||||
operationId: 'forward'
|
||||
},
|
||||
{
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' }
|
||||
},
|
||||
{
|
||||
owner: 'none',
|
||||
direction: null,
|
||||
phase: 'failed',
|
||||
stage: 'manual-recovery',
|
||||
operationId: null,
|
||||
error: { message: 'Recovery required', recoverableOwner: 'none' }
|
||||
},
|
||||
{
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-2', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' }
|
||||
},
|
||||
{
|
||||
owner: 'tui',
|
||||
direction: 'to-native',
|
||||
phase: 'switching',
|
||||
stage: 'preparing',
|
||||
operationId: 'reverse'
|
||||
},
|
||||
{ owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null }
|
||||
]
|
||||
let state = EMPTY_MOBILE_STRUCTURED_AGENT_SESSION
|
||||
const projected: Array<Pick<AgentSessionHandoffStatus, 'owner' | 'phase' | 'stage'>> = []
|
||||
|
||||
for (const [index, handoff] of handoffs.entries()) {
|
||||
state = reduceMobileStructuredAgentSession(state, {
|
||||
type: 'event',
|
||||
event: {
|
||||
...snapshot('epoch-a', []),
|
||||
fence: index + 1,
|
||||
snapshot: {
|
||||
...snapshot('epoch-a', []).snapshot,
|
||||
cursor: { epoch: 'epoch-a', sequence: index }
|
||||
},
|
||||
handoff
|
||||
}
|
||||
})
|
||||
projected.push({
|
||||
owner: state.handoff!.owner,
|
||||
phase: state.handoff!.phase,
|
||||
stage: state.handoff!.stage
|
||||
})
|
||||
}
|
||||
|
||||
expect(projected).toEqual(handoffs.map(({ owner, phase, stage }) => ({ owner, phase, stage })))
|
||||
expect(state.handoff).toMatchObject({ owner: 'native', phase: 'idle', stage: null })
|
||||
})
|
||||
|
||||
it('boots from a bounded tail page and retains its live resume cursor', () => {
|
||||
const state = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'tail-page',
|
||||
|
||||
@@ -41,5 +41,30 @@ export const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize }
|
||||
writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize },
|
||||
handoffBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
handoffBanner: {
|
||||
minHeight: 42,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
handoffError: { backgroundColor: colors.bgRaised },
|
||||
handoffText: { flex: 1, color: colors.textSecondary, fontSize: typography.metaSize },
|
||||
handoffErrorText: { flex: 1, color: colors.statusRed, fontSize: typography.metaSize },
|
||||
handoffButton: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs },
|
||||
handoffButtonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
|
||||
import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
|
||||
import {
|
||||
getMobileStructuredTuiTerminal,
|
||||
sendMobileStructuredTuiMessage
|
||||
} from './mobile-structured-tui-send'
|
||||
|
||||
function acceptingClient(): RpcClient {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
|
||||
describe('sendMobileStructuredTuiMessage', () => {
|
||||
it('accepts only an idle TUI handoff terminal', () => {
|
||||
expect(
|
||||
getMobileStructuredTuiTerminal({
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
|
||||
})
|
||||
).toBe('term-tui')
|
||||
expect(
|
||||
getMobileStructuredTuiTerminal({
|
||||
owner: 'tui',
|
||||
direction: 'to-native',
|
||||
phase: 'waiting-for-exit',
|
||||
stage: 'wait-for-tui-exit',
|
||||
operationId: 'handoff-1',
|
||||
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('routes stable TUI composer text through terminal.send', async () => {
|
||||
const client = acceptingClient()
|
||||
|
||||
await expect(
|
||||
sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-tui',
|
||||
deviceToken: 'mobile-1',
|
||||
text: 'hello',
|
||||
attachments: []
|
||||
})
|
||||
).resolves.toBe('accepted')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-tui',
|
||||
text: '\x15hello',
|
||||
enter: true,
|
||||
client: { id: 'mobile-1', type: 'mobile' }
|
||||
},
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('types stable TUI slash commands one key at a time', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = acceptingClient()
|
||||
|
||||
const sending = sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-tui',
|
||||
deviceToken: null,
|
||||
text: '/model',
|
||||
attachments: []
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(sending).resolves.toBe('accepted')
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.map(([, params]) => (params as { text: string }).text)
|
||||
).toEqual(['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'])
|
||||
expect(
|
||||
vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.every(
|
||||
([method, params]) =>
|
||||
method === 'terminal.send' && (params as { enter: boolean }).enter === false
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands'
|
||||
import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
|
||||
pasteMobileNativeChatImagePaths
|
||||
} from './mobile-native-chat-image-send'
|
||||
import {
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
typeMobileNativeChatCommandWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
|
||||
import {
|
||||
clearMobileNativeChatInputStale,
|
||||
healMobileNativeChatStaleInput,
|
||||
markMobileNativeChatInputStale
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
|
||||
type MobileStructuredTuiSendArgs = {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
deviceToken: string | null
|
||||
text: string
|
||||
attachments: readonly PendingNativeChatImage[]
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const wait = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
export function getMobileStructuredTuiTerminal(
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
): string | null {
|
||||
return handoff?.owner === 'tui' && handoff.phase === 'idle'
|
||||
? (handoff.terminal?.handle ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
/** Sends the structured transcript composer through its proven TUI owner. */
|
||||
export async function sendMobileStructuredTuiMessage(
|
||||
args: MobileStructuredTuiSendArgs
|
||||
): Promise<MobileNativeChatSendOutcome> {
|
||||
if (!acquireMobileNativeChatTerminalWrite(args.terminal)) {
|
||||
return 'rejected'
|
||||
}
|
||||
try {
|
||||
const deadline = openMobileNativeChatSendBudget()
|
||||
const mobileClient = args.deviceToken
|
||||
? { id: args.deviceToken, type: 'mobile' as const }
|
||||
: undefined
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
deviceToken: args.deviceToken,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
if (args.attachments.length > 0) {
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
deviceToken: args.deviceToken,
|
||||
imagePaths: args.attachments.map((attachment) => attachment.path),
|
||||
deadline
|
||||
})
|
||||
if (!pasted) {
|
||||
markMobileNativeChatInputStale(args.terminal)
|
||||
return 'rejected'
|
||||
}
|
||||
clearMobileNativeChatInputStale(args.terminal)
|
||||
await (args.sleep ?? wait)(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
|
||||
}
|
||||
const textDeadline =
|
||||
args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline
|
||||
const classification = classifyMobileNativeChatSend('codex', args.text)
|
||||
const outcome =
|
||||
classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0
|
||||
? await typeMobileNativeChatCommandWithOutcome({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
command: args.text,
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline: textDeadline
|
||||
})
|
||||
: await sendMobileNativeChatMessageWithOutcome({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
text: args.text,
|
||||
clearInputFirst: args.attachments.length === 0,
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline: textDeadline
|
||||
})
|
||||
if (args.attachments.length > 0 && outcome !== 'accepted') {
|
||||
markMobileNativeChatInputStale(args.terminal)
|
||||
}
|
||||
return outcome
|
||||
} catch {
|
||||
if (args.attachments.length > 0) {
|
||||
markMobileNativeChatInputStale(args.terminal)
|
||||
}
|
||||
return 'rejected'
|
||||
} finally {
|
||||
releaseMobileNativeChatTerminalWrite(args.terminal)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'r
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import type {
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types'
|
||||
@@ -57,6 +58,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
hasOlder: boolean
|
||||
loadingOlder: boolean
|
||||
loadOlder: () => Promise<boolean>
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
} {
|
||||
const { client, sessionId } = args
|
||||
const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
@@ -134,15 +136,23 @@ export function useMobileStructuredAgentSession(args: {
|
||||
}
|
||||
})
|
||||
}
|
||||
void client
|
||||
.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 })
|
||||
.then((response) => {
|
||||
void Promise.all([
|
||||
client.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }),
|
||||
client.sendRequest('agentSession.handoffStatus', { sessionId }).catch(() => null)
|
||||
])
|
||||
.then(([response, handoffResponse]) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
if (handoffResponse?.ok) {
|
||||
dispatch({
|
||||
type: 'handoff',
|
||||
handoff: handoffResponse.result as AgentSessionHandoffStatus
|
||||
})
|
||||
}
|
||||
const result = response.result as AgentSessionHistoryResult
|
||||
if (result.ok) {
|
||||
dispatch({ type: 'tail-page', page: result.page })
|
||||
@@ -240,6 +250,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
error: state.error || undefined,
|
||||
hasOlder: state.hasOlder,
|
||||
loadingOlder,
|
||||
loadOlder
|
||||
loadOlder,
|
||||
handoff: state.handoff
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ export function useMobileStructuredSessionEntry(args: {
|
||||
connected,
|
||||
sessionId,
|
||||
fence: session.fence,
|
||||
submissions: session.submissions
|
||||
submissions: session.submissions,
|
||||
handoffOperationId: session.handoff?.operationId
|
||||
})
|
||||
const sessionOptions = useMobileStructuredSessionOptions({
|
||||
client,
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
AgentJournalRenderItem
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionPromptResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
@@ -25,12 +27,18 @@ export type MobileStructuredSessionMutations = {
|
||||
respondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
|
||||
setOption: (key: string, value: string) => Promise<boolean>
|
||||
cancel: (turnId: string) => Promise<boolean>
|
||||
requestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useMobileStructuredSessionMutations(args: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
fence: number | null
|
||||
handoffOperationId?: string | null
|
||||
onRefusal: (message: string | null) => void
|
||||
}): MobileStructuredSessionMutations {
|
||||
const operationIdsRef = useRef(new Map<string, string>())
|
||||
@@ -44,12 +52,18 @@ export function useMobileStructuredSessionMutations(args: {
|
||||
activeContextRef.current = { client, fence, sessionId }
|
||||
}, [client, fence, sessionId])
|
||||
const mutate = useCallback(
|
||||
async <TValue>(method: string, fingerprintMethod: string, fields: Record<string, unknown>) => {
|
||||
async <TValue>(
|
||||
method: string,
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>,
|
||||
operationIdOverride?: string | null
|
||||
) => {
|
||||
if (!client || !sessionId || fence === null || client.getState() !== 'connected') {
|
||||
return null
|
||||
}
|
||||
const mutationKey = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}`
|
||||
const clientOperationId =
|
||||
operationIdOverride ??
|
||||
operationIdsRef.current.get(mutationKey) ??
|
||||
createStructuredAgentSessionOperationId(() => ExpoCrypto.randomUUID())
|
||||
operationIdsRef.current.set(mutationKey, clientOperationId)
|
||||
@@ -117,9 +131,22 @@ export function useMobileStructuredSessionMutations(args: {
|
||||
setOption: async (key: string, value: string) =>
|
||||
Boolean(await mutate('agentSession.setOption', 'agentSession.setOption', { key, value })),
|
||||
cancel: async (turnId: string) =>
|
||||
Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId }))
|
||||
Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })),
|
||||
requestHandoff: async (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action: 'start' | 'cancel-queued' | 'retry' = 'start'
|
||||
) =>
|
||||
Boolean(
|
||||
await mutate(
|
||||
'agentSession.requestHandoff',
|
||||
'agentSession.requestHandoff',
|
||||
{ direction, mode, action },
|
||||
action === 'retry' ? args.handoffOperationId : null
|
||||
)
|
||||
)
|
||||
}),
|
||||
[mutate]
|
||||
[args.handoffOperationId, mutate]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
sessionId: string | null
|
||||
fence: number | null
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
handoffOperationId?: string | null
|
||||
}): MobileStructuredSessionWrites {
|
||||
const { client, connected, sessionId, fence, submissions } = args
|
||||
const [outbox, setOutbox] = useState<MobileStructuredOutboxEntry[]>([])
|
||||
@@ -48,6 +49,7 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
client,
|
||||
sessionId,
|
||||
fence,
|
||||
handoffOperationId: args.handoffOperationId,
|
||||
onRefusal: setError
|
||||
})
|
||||
|
||||
|
||||
@@ -97,6 +97,41 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('AgentHookServer raw Claude proof envelopes', () => {
|
||||
it('publishes the authenticated SessionStart body before status normalization', async () => {
|
||||
const server = new AgentHookServer()
|
||||
const received: unknown[] = []
|
||||
const unsubscribe = server.subscribeClaudeHookEnvelopes((envelope) => received.push(envelope))
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const body = buildBody(
|
||||
{
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'resume',
|
||||
session_id: 'claude-session',
|
||||
transcript_path: '/tmp/claude-session.jsonl'
|
||||
},
|
||||
{ launchToken: 'claude-spawn-token' }
|
||||
)
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(received).toEqual([body])
|
||||
} finally {
|
||||
unsubscribe()
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('preserves Codex sibling and lead state across relay listener restarts', () => {
|
||||
const server = new AgentHookServer()
|
||||
|
||||
@@ -604,6 +604,7 @@ export class AgentHookServer {
|
||||
private paneStatusClearListeners = new Set<PaneStatusClearListener>()
|
||||
private statusChangeListeners = new Set<StatusChangeListener>()
|
||||
private providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
|
||||
private claudeHookEnvelopeListeners = new Set<(envelope: unknown) => void>()
|
||||
// Why: setListener is a single slot owned by the main-window fanout; the
|
||||
// plugin event bus (and future consumers) need an additive subscription
|
||||
// that also works in headless serve, where no window listener exists.
|
||||
@@ -674,6 +675,23 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
subscribeClaudeHookEnvelopes(listener: (envelope: unknown) => void): () => void {
|
||||
this.claudeHookEnvelopeListeners.add(listener)
|
||||
return () => {
|
||||
this.claudeHookEnvelopeListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private emitClaudeHookEnvelope(envelope: unknown): void {
|
||||
for (const listener of this.claudeHookEnvelopeListeners) {
|
||||
try {
|
||||
listener(envelope)
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] Claude hook envelope listener threw', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Multi-subscriber tap on every enriched status change (no replay). */
|
||||
subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void {
|
||||
this.enrichedStatusListeners.add(listener)
|
||||
@@ -2145,6 +2163,9 @@ export class AgentHookServer {
|
||||
|
||||
trackEmptyPaneKeyHook(body)
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
if (source === 'claude') {
|
||||
this.emitClaudeHookEnvelope(aliasedBody)
|
||||
}
|
||||
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
|
||||
const statusDisposition = normalized.event
|
||||
? this.getAgentStatusDisposition(normalized.event.paneKey, {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import {
|
||||
assertLegacyAiVaultResumeAllowed,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { openCodexAppServerConnection } from './codex-app-server-connection'
|
||||
import { openCodexThread } from './codex-structured-thread-open'
|
||||
import { proveCodexTuiRollout } from './codex-tui-rollout-proof'
|
||||
|
||||
const codexCommand = resolveCodexCommand()
|
||||
const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0
|
||||
const itWithCodex = codexAvailable ? it : it.skip
|
||||
const tempHomes: string[] = []
|
||||
|
||||
async function waitForTuiStart(proc: pty.IPty): Promise<string> {
|
||||
let output = ''
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)),
|
||||
15_000
|
||||
)
|
||||
proc.onData((data) => {
|
||||
output += data
|
||||
if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) {
|
||||
clearTimeout(timeout)
|
||||
resolve(output)
|
||||
}
|
||||
})
|
||||
proc.onExit(({ exitCode }) => {
|
||||
clearTimeout(timeout)
|
||||
reject(
|
||||
new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForRollout(path: string, threadId: string): Promise<string> {
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const rollout = await readFile(path, 'utf8')
|
||||
if (rollout.includes(threadId)) {
|
||||
return rollout
|
||||
}
|
||||
} catch {
|
||||
// The rollout is created asynchronously after thread/start.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error('Codex did not materialize the resumed rollout')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('real Codex structured-to-TUI resume', () => {
|
||||
itWithCodex(
|
||||
'resumes the exact isolated rollout and reaches the initial TUI screen',
|
||||
async () => {
|
||||
const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-'))
|
||||
tempHomes.push(codexHome)
|
||||
await writeFile(
|
||||
join(codexHome, 'config.toml'),
|
||||
[
|
||||
'model_provider = "orca-integration"',
|
||||
'model = "gpt-5"',
|
||||
'',
|
||||
'[model_providers.orca-integration]',
|
||||
'name = "Orca integration"',
|
||||
'base_url = "http://127.0.0.1:9/v1"',
|
||||
'wire_api = "responses"',
|
||||
'requires_openai_auth = false',
|
||||
'',
|
||||
`[projects.${JSON.stringify(process.cwd())}]`,
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const connection = await openCodexAppServerConnection({
|
||||
command: codexCommand,
|
||||
args: ['app-server'],
|
||||
env: { CODEX_HOME: codexHome }
|
||||
})
|
||||
const opened = await openCodexThread(
|
||||
connection,
|
||||
{ cwd: process.cwd(), resumeThreadId: null },
|
||||
15_000
|
||||
)
|
||||
await connection.request(
|
||||
'turn/start',
|
||||
{
|
||||
threadId: opened.threadId,
|
||||
clientUserMessageId: 'real-binary-resume-fixture',
|
||||
input: [{ type: 'text', text: 'materialize the isolated resume fixture' }]
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId)
|
||||
await connection.close()
|
||||
|
||||
expect(opened.historyPath).toContain(opened.threadId)
|
||||
expect(opened.historyPath).toContain(join(codexHome, 'sessions'))
|
||||
const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CODEX_HOME: codexHome,
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof',
|
||||
TERM: 'xterm-256color'
|
||||
}
|
||||
})
|
||||
const tuiExit = new Promise<number>((resolve) =>
|
||||
tui.onExit(({ exitCode }) => resolve(exitCode))
|
||||
)
|
||||
const kittyKeyboard = new TerminalKittyKeyboardModeTracker()
|
||||
let tuiOutput = ''
|
||||
let lastOutputAt: number | null = null
|
||||
tui.onData((data) => {
|
||||
tuiOutput += data
|
||||
lastOutputAt = Date.now()
|
||||
kittyKeyboard.scan(data)
|
||||
})
|
||||
try {
|
||||
await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i)
|
||||
const proof = await proveCodexTuiRollout({
|
||||
codexHome,
|
||||
threadId: opened.threadId,
|
||||
kittyKeyboardFlags: kittyKeyboard.flags,
|
||||
readOutput: () => ({ text: tuiOutput, lastOutputAt }),
|
||||
write: (data) => {
|
||||
tui.write(data)
|
||||
return true
|
||||
}
|
||||
})
|
||||
expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!))
|
||||
} finally {
|
||||
try {
|
||||
tui.kill()
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
await Promise.race([
|
||||
tuiExit,
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000)
|
||||
)
|
||||
])
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
codexTuiStatusProbeInput,
|
||||
parseCodexTuiStatusSessionId,
|
||||
proveCodexTuiRollout,
|
||||
resolvePinnedCodexRolloutProof
|
||||
} from './codex-tui-rollout-proof'
|
||||
|
||||
const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70'
|
||||
const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71'
|
||||
|
||||
describe('Codex TUI rollout proof', () => {
|
||||
it('parses the exact session shown by status', () => {
|
||||
expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD)
|
||||
expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD)
|
||||
expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD)
|
||||
expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Kitty Enter only while the TUI negotiated Kitty input', () => {
|
||||
expect(codexTuiStatusProbeInput(0)).toEqual({
|
||||
command: '\u001b[200~/status\u001b[201~',
|
||||
submit: '\r'
|
||||
})
|
||||
expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u')
|
||||
expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u')
|
||||
})
|
||||
|
||||
it('resolves only the exact session_meta rollout under the pinned account home', async () => {
|
||||
const files = async function* (): AsyncGenerator<string> {
|
||||
yield '/pinned/sessions/scratch/rollout-wrong.jsonl'
|
||||
yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
|
||||
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
|
||||
}
|
||||
const readSessionMetaId = vi.fn(async () => THREAD)
|
||||
|
||||
await expect(
|
||||
resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId })
|
||||
).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`)
|
||||
expect(readSessionMetaId).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a rollout whose session_meta names another thread', async () => {
|
||||
const files = async function* (): AsyncGenerator<string> {
|
||||
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
|
||||
}
|
||||
await expect(
|
||||
resolvePinnedCodexRolloutProof('/pinned', THREAD, {
|
||||
listFiles: files,
|
||||
readSessionMetaId: async () => OTHER_THREAD
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a different status session after filesystem proof', async () => {
|
||||
let reads = 0
|
||||
const write = vi.fn((_data: string) => true)
|
||||
await expect(
|
||||
proveCodexTuiRollout({
|
||||
codexHome: '/pinned',
|
||||
threadId: THREAD,
|
||||
kittyKeyboardFlags: 0,
|
||||
readOutput: () => ({
|
||||
text: `Session: ${OTHER_THREAD}`,
|
||||
lastOutputAt: reads++ > 0 ? 2 : 1
|
||||
}),
|
||||
write,
|
||||
resolveRollout: async () => '/pinned/sessions/rollout.jsonl',
|
||||
delay: async () => undefined
|
||||
})
|
||||
).rejects.toThrow('resumed a different Codex session')
|
||||
expect(write).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dismisses status only after the exact session is observed', async () => {
|
||||
let reads = 0
|
||||
const write = vi.fn((_data: string) => true)
|
||||
await expect(
|
||||
proveCodexTuiRollout({
|
||||
codexHome: '/pinned',
|
||||
threadId: THREAD,
|
||||
kittyKeyboardFlags: 1,
|
||||
readOutput: () => ({
|
||||
text: reads++ > 0 ? `Session: ${THREAD}` : '',
|
||||
lastOutputAt: reads > 1 ? 2 : 1
|
||||
}),
|
||||
write,
|
||||
resolveRollout: async () => '/pinned/sessions/rollout.jsonl',
|
||||
delay: async () => undefined
|
||||
})
|
||||
).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' })
|
||||
expect(write.mock.calls.map(([data]) => data)).toEqual([
|
||||
'\u001b[200~/status\u001b[201~',
|
||||
'\u001b[13u',
|
||||
'\u001b'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
|
||||
import { relativePathInsideRoot } from '../../shared/cross-platform-path'
|
||||
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
|
||||
|
||||
const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
const STATUS_SESSION_RE = new RegExp(`\\bSession(?: ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi')
|
||||
const ROLLOUT_READ_LIMIT = 64 * 1024
|
||||
const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~'
|
||||
const KITTY_ENTER = '\u001b[13u'
|
||||
|
||||
export type CodexTuiProofOutput = {
|
||||
text: string
|
||||
lastOutputAt: number | null
|
||||
}
|
||||
|
||||
export type CodexTuiRolloutProofOptions = {
|
||||
listFiles?: (sessionsRoot: string) => AsyncIterable<string>
|
||||
readSessionMetaId?: (filePath: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
export function parseCodexTuiStatusSessionId(output: string): string | null {
|
||||
let sessionId: string | null = null
|
||||
for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) {
|
||||
sessionId = match[1] ?? null
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string {
|
||||
return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r'
|
||||
}
|
||||
|
||||
export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): {
|
||||
command: string
|
||||
submit: string
|
||||
} {
|
||||
return {
|
||||
command: STATUS_COMMAND_PASTE,
|
||||
submit: codexTuiStatusSubmitInput(kittyKeyboardFlags)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePinnedCodexRolloutProof(
|
||||
codexHome: string,
|
||||
threadId: string,
|
||||
options: CodexTuiRolloutProofOptions = {}
|
||||
): Promise<string | null> {
|
||||
const sessionsRoot = join(codexHome, 'sessions')
|
||||
const listFiles =
|
||||
options.listFiles ??
|
||||
((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 }))
|
||||
const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId
|
||||
const expectedSuffix = `-${threadId}.jsonl`.toLowerCase()
|
||||
|
||||
for await (const filePath of listFiles(sessionsRoot)) {
|
||||
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/')
|
||||
if (
|
||||
!relativePath ||
|
||||
!/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) ||
|
||||
!relativePath.toLowerCase().endsWith(expectedSuffix)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if ((await readSessionMetaId(filePath)) === threadId) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function proveCodexTuiRollout(input: {
|
||||
codexHome: string
|
||||
threadId: string
|
||||
kittyKeyboardFlags: number
|
||||
readOutput: () => CodexTuiProofOutput
|
||||
write: (data: string) => boolean
|
||||
timeoutMs?: number
|
||||
resolveRollout?: (codexHome: string, threadId: string) => Promise<string | null>
|
||||
delay?: (ms: number) => Promise<void>
|
||||
}): Promise<{ transcriptPath: string }> {
|
||||
const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof
|
||||
const transcriptPath = await resolveRollout(input.codexHome, input.threadId)
|
||||
if (!transcriptPath) {
|
||||
throw new Error('The agent terminal did not prove the expected Codex rollout.')
|
||||
}
|
||||
|
||||
const baselineOutputAt = input.readOutput().lastOutputAt
|
||||
const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags)
|
||||
if (!input.write(probe.command)) {
|
||||
throw new Error('The agent terminal could not verify its Codex session.')
|
||||
}
|
||||
const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
|
||||
await delay(100)
|
||||
if (!input.write(probe.submit)) {
|
||||
throw new Error('The agent terminal could not verify its Codex session.')
|
||||
}
|
||||
|
||||
const deadline = Date.now() + (input.timeoutMs ?? 15_000)
|
||||
while (Date.now() < deadline) {
|
||||
const output = input.readOutput()
|
||||
if (output.lastOutputAt !== baselineOutputAt) {
|
||||
const observedThreadId = parseCodexTuiStatusSessionId(output.text)
|
||||
if (observedThreadId && observedThreadId !== input.threadId) {
|
||||
throw new Error('The agent terminal resumed a different Codex session.')
|
||||
}
|
||||
if (observedThreadId === input.threadId) {
|
||||
if (!input.write('\u001b')) {
|
||||
throw new Error('The agent terminal could not finish Codex session verification.')
|
||||
}
|
||||
return { transcriptPath }
|
||||
}
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error('The agent terminal did not prove the expected Codex rollout.')
|
||||
}
|
||||
|
||||
async function readCodexRolloutSessionMetaId(filePath: string): Promise<string | null> {
|
||||
const file = await open(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT)
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0)
|
||||
const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim()
|
||||
if (!firstLine) {
|
||||
return null
|
||||
}
|
||||
const record = JSON.parse(firstLine) as {
|
||||
type?: unknown
|
||||
id?: unknown
|
||||
payload?: { id?: unknown }
|
||||
}
|
||||
if (record.type !== 'session_meta') {
|
||||
return null
|
||||
}
|
||||
const id = record.payload?.id ?? record.id
|
||||
return typeof id === 'string' && id.length > 0 ? id : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
@@ -5338,6 +5338,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
const response = {
|
||||
id: result.id,
|
||||
...(result.pid ? { pid: result.pid } : {}),
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle)
|
||||
? {
|
||||
@@ -5389,6 +5390,17 @@ export function registerPtyHandlers(
|
||||
return false
|
||||
}
|
||||
},
|
||||
writeAgentSessionProof: (ptyId, data, authority) => {
|
||||
if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
getProviderForPty(ptyId).write(ptyId, data)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
probePtyLiveness: async (ptyId) => {
|
||||
try {
|
||||
// Why: no locally routed provider can authoritatively answer for a
|
||||
|
||||
@@ -85,6 +85,40 @@ describe('registerRuntimeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects Claude structured tabs to the same-version desktop client', async () => {
|
||||
const claudeTab = {
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:claude-1',
|
||||
title: 'Claude Chat',
|
||||
sessionId: 'claude-1',
|
||||
agent: 'claude',
|
||||
isActive: true
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
|
||||
restoreStructuredAgentSessionTabs: vi.fn(async () => undefined),
|
||||
listMobileSessionTabs: vi.fn(async () => ({
|
||||
worktree: 'workspace-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: claudeTab.id,
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }],
|
||||
tabs: [claudeTab]
|
||||
}))
|
||||
}
|
||||
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
|
||||
const result = await callRegistration
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } })
|
||||
})
|
||||
|
||||
it('registers project group runtime RPC methods for local desktop callers', async () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn(),
|
||||
|
||||
+11
-3
@@ -9,10 +9,18 @@ import type {
|
||||
} from '../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import { ALL_RPC_METHODS } from '../runtime/rpc/methods'
|
||||
|
||||
const LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES = [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
function boundTerminalFitRestore(pending: Promise<boolean>): Promise<boolean> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const deadline = new Promise<boolean>((resolve) => {
|
||||
@@ -62,7 +70,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
{
|
||||
clientId: 'desktop-renderer',
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
clientCapabilities: LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES
|
||||
}
|
||||
)) as RuntimeRpcResponse<unknown>
|
||||
}
|
||||
@@ -104,7 +112,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
signal: controller.signal,
|
||||
clientId: 'desktop-renderer',
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
clientCapabilities: LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES
|
||||
}
|
||||
)
|
||||
.finally(stop)
|
||||
|
||||
@@ -62,6 +62,15 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
historyFilePath = (input: { identity: AgentSessionJournalIdentity }) =>
|
||||
this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null)
|
||||
|
||||
async closeSession(sessionId: string): Promise<void> {
|
||||
const adapter = this.owners.get(sessionId)
|
||||
if (!adapter) {
|
||||
return
|
||||
}
|
||||
await adapter.closeSession?.(sessionId)
|
||||
this.owners.delete(sessionId)
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
this.owners.clear()
|
||||
await this.closeAdapters()
|
||||
|
||||
@@ -105,4 +105,6 @@ export type StructuredAgentSessionAdapter = {
|
||||
/** Transcript path for journal recovery. Omit to let the existing session-file
|
||||
* resolver discover it from the provider session id. */
|
||||
historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise<string | null>
|
||||
/** Gracefully stops the structured owner after its event stream is drained. */
|
||||
closeSession?(sessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffResult,
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
agentSessionFingerprintConflict,
|
||||
computeAgentSessionPayloadFingerprint
|
||||
} from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
|
||||
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
|
||||
|
||||
export type StructuredHandoffAdmission =
|
||||
| { decision: 'continue'; record: AgentSessionRecord; fingerprint: string }
|
||||
| { decision: 'replay'; outcome: AgentSessionOperationOutcome }
|
||||
| { decision: 'refused'; refusal: AgentSessionWireRefusal }
|
||||
|
||||
export async function admitStructuredHandoffRequest(input: {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
operationGuard: StructuredAgentSessionHandoffOperationGuard
|
||||
callerKey: string
|
||||
params: AgentSessionHandoffRequest
|
||||
record: AgentSessionRecord
|
||||
status?: AgentSessionHandoffStatus
|
||||
}): Promise<StructuredHandoffAdmission> {
|
||||
const action = input.params.action ?? 'start'
|
||||
const requestFingerprint = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.requestHandoff',
|
||||
sessionId: input.record.sessionId,
|
||||
fields: { direction: input.params.direction, mode: input.params.mode, action }
|
||||
})
|
||||
const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint)
|
||||
if (conflict) {
|
||||
return { decision: 'refused', refusal: conflict }
|
||||
}
|
||||
const fingerprint = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.requestHandoff.operation',
|
||||
sessionId: input.record.sessionId,
|
||||
fields: { direction: input.params.direction }
|
||||
})
|
||||
const operation = await input.operationGuard.check({
|
||||
callerKey: input.callerKey,
|
||||
sessionId: input.record.sessionId,
|
||||
operationId: input.params.envelope.clientOperationId,
|
||||
fingerprint,
|
||||
action,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
now: input.deps.now()
|
||||
})
|
||||
if (operation.decision === 'replay') {
|
||||
return { decision: 'replay', outcome: operation.outcome }
|
||||
}
|
||||
if (operation.decision === 'refused') {
|
||||
return {
|
||||
decision: 'refused',
|
||||
refusal: {
|
||||
code: operation.code as 'agent_session_operation_conflict',
|
||||
message: 'This handoff operation could not be admitted.'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) {
|
||||
await input.deps.store.recordOperationOutcome({
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.params.envelope.clientOperationId,
|
||||
outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' }
|
||||
})
|
||||
return {
|
||||
decision: 'refused',
|
||||
refusal: {
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
message: 'The session owner changed before the handoff request arrived.',
|
||||
currentFence: input.record.lease.runtimeFence
|
||||
}
|
||||
}
|
||||
}
|
||||
return { decision: 'continue', record: input.record, fingerprint }
|
||||
}
|
||||
|
||||
export function replayedStructuredHandoffRefusal(
|
||||
outcome: AgentSessionOperationOutcome
|
||||
): AgentSessionWireRefusal | null {
|
||||
if (
|
||||
outcome.status !== 'failed' ||
|
||||
!AGENT_SESSION_WIRE_REFUSAL_CODES.includes(
|
||||
outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number]
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number],
|
||||
message: 'This handoff request was previously refused.'
|
||||
}
|
||||
}
|
||||
|
||||
export async function refuseAdmittedStructuredHandoff(input: {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
callerKey: string
|
||||
params: AgentSessionHandoffRequest
|
||||
refusal: AgentSessionWireRefusal
|
||||
}): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
|
||||
await input.deps.store.recordOperationOutcome({
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.params.envelope.clientOperationId,
|
||||
outcome: { status: 'failed', code: input.refusal.code }
|
||||
})
|
||||
return { ok: false, refusal: input.refusal }
|
||||
}
|
||||
|
||||
export function structuredHandoffRetryIsAdmissible(
|
||||
status: AgentSessionHandoffStatus,
|
||||
params: AgentSessionHandoffRequest
|
||||
): boolean {
|
||||
return (
|
||||
status.phase === 'failed' &&
|
||||
status.direction === params.direction &&
|
||||
status.operationId === params.envelope.clientOperationId &&
|
||||
status.error?.recoverableOwner !== 'none'
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffDeps,
|
||||
StructuredAgentSessionHandoffFlowContext,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
export function createStructuredHandoffFlowContext(input: {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
owner: (sessionId: string) => StructuredTuiOwner | undefined
|
||||
retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void
|
||||
releaseOwner: (sessionId: string) => void
|
||||
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
|
||||
enterPreparing: StructuredAgentSessionHandoffFlowContext['enterPreparing']
|
||||
publishStage: StructuredAgentSessionHandoffFlowContext['publishStage']
|
||||
requireRecord: (sessionId: string) => AgentSessionRecord
|
||||
}): StructuredAgentSessionHandoffFlowContext {
|
||||
return input
|
||||
}
|
||||
|
||||
export async function stopStructuredNativeTurn(
|
||||
deps: StructuredAgentSessionHandoffDeps,
|
||||
sessionId: string,
|
||||
turnId: string
|
||||
): Promise<boolean> {
|
||||
const record = deps.store.getRecord(sessionId)
|
||||
if (!record || record.lease.runtimeKind !== 'native') {
|
||||
return false
|
||||
}
|
||||
const session = deps.session(sessionId)
|
||||
return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
|
||||
import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context'
|
||||
import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward'
|
||||
import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
|
||||
import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffDeps,
|
||||
StructuredAgentSessionHandoffFlowContext
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
export class StructuredAgentSessionHandoffFlowRunner {
|
||||
private readonly active = new Set<Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
operationGuard: StructuredAgentSessionHandoffOperationGuard
|
||||
flowContext: () => StructuredAgentSessionHandoffFlowContext
|
||||
fail: (params: AgentSessionHandoffRequest, error: unknown) => void
|
||||
}
|
||||
) {}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
await Promise.allSettled(this.active)
|
||||
}
|
||||
|
||||
begin(input: {
|
||||
callerKey: string
|
||||
params: AgentSessionHandoffRequest
|
||||
turnId: string | null
|
||||
fingerprint: string
|
||||
tuiAlreadyExited?: boolean
|
||||
}): void {
|
||||
const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input
|
||||
const sessionId = params.envelope.sessionId
|
||||
this.input.operationGuard.start(sessionId, {
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
fingerprint
|
||||
})
|
||||
const flow = this.run(params, turnId, tuiAlreadyExited)
|
||||
.then(() =>
|
||||
this.input.deps.store.recordOperationOutcome({
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
outcome: { status: 'succeeded', sessionId }
|
||||
})
|
||||
)
|
||||
.catch(async (error) => {
|
||||
await this.input.deps.store.recordOperationOutcome({
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
|
||||
})
|
||||
this.input.fail(params, error)
|
||||
})
|
||||
.finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId))
|
||||
this.active.add(flow)
|
||||
void flow.finally(() => this.active.delete(flow))
|
||||
}
|
||||
|
||||
private run(
|
||||
params: AgentSessionHandoffRequest,
|
||||
turnId: string | null,
|
||||
tuiAlreadyExited: boolean
|
||||
): Promise<void> {
|
||||
const sessionId = params.envelope.sessionId
|
||||
return this.input.deps.schedule(sessionId, async () => {
|
||||
if (turnId && params.mode === 'stop-turn') {
|
||||
const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId)
|
||||
if (!stopped) {
|
||||
throw new Error('The current turn did not acknowledge cancellation.')
|
||||
}
|
||||
}
|
||||
await (params.direction === 'to-tui'
|
||||
? handoffStructuredSessionToTui(this.input.flowContext(), params, params.action === 'retry')
|
||||
: handoffStructuredSessionToNative(
|
||||
this.input.flowContext(),
|
||||
params,
|
||||
params.action === 'retry',
|
||||
tuiAlreadyExited
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
abandonStoredAgentSessionHandoffAttempt,
|
||||
reserveStoredAgentSessionHandoffOwner,
|
||||
setStoredAgentSessionHandoffStage,
|
||||
stopStoredAgentSessionOwnerForHandoff
|
||||
} from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffFlowContext,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
export async function handoffStructuredSessionToTui(
|
||||
context: StructuredAgentSessionHandoffFlowContext,
|
||||
params: AgentSessionHandoffRequest,
|
||||
retry: boolean
|
||||
): Promise<void> {
|
||||
const { deps } = context
|
||||
const sessionId = params.envelope.sessionId
|
||||
const operationId = params.envelope.clientOperationId
|
||||
let record = context.requireRecord(sessionId)
|
||||
if (retry && record.lease.handoffStage === 'old-owner-stopped') {
|
||||
record = await recoverNativeAfterTuiFailure(context, sessionId, operationId)
|
||||
}
|
||||
if (record.lease.handoffStage === null) {
|
||||
await context.enterPreparing(record, operationId, 'to-tui')
|
||||
} else if (
|
||||
record.lease.handoffStage !== 'preparing' ||
|
||||
record.lease.handoffOperationId !== operationId
|
||||
) {
|
||||
throw new Error('agent_session_operation_conflict')
|
||||
}
|
||||
record = context.requireRecord(sessionId)
|
||||
await deps.suspendNative(sessionId)
|
||||
record = await stopStoredAgentSessionOwnerForHandoff(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId,
|
||||
now: deps.now()
|
||||
})
|
||||
context.publishStage(record, 'to-tui')
|
||||
const spawnToken = randomUUID()
|
||||
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
runtimeKind: 'tui',
|
||||
spawnToken,
|
||||
operationId,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
now: deps.now()
|
||||
})
|
||||
context.publishStage(record, 'to-tui')
|
||||
let owner: StructuredTuiOwner | null = null
|
||||
try {
|
||||
owner = await deps.transport!.launchTui({
|
||||
record,
|
||||
fence: record.lease.runtimeFence,
|
||||
spawnToken
|
||||
})
|
||||
await deps.store.commitProcessIdentity({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
process: owner.process,
|
||||
now: deps.now()
|
||||
})
|
||||
record = await deps.store.proveOwner({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
link: owner.link,
|
||||
now: deps.now()
|
||||
})
|
||||
} catch (error) {
|
||||
if (owner) {
|
||||
if (!deps.transport?.stopFailedTuiLaunch) {
|
||||
await markManualRecovery(context, sessionId, operationId)
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await deps.transport.stopFailedTuiLaunch(owner)
|
||||
} catch (stopError) {
|
||||
await markManualRecovery(context, sessionId, operationId)
|
||||
throw new AggregateError(
|
||||
[error, stopError],
|
||||
'The failed terminal launch could not be proven stopped.'
|
||||
)
|
||||
}
|
||||
}
|
||||
await recoverNativeAfterTuiFailure(context, sessionId, operationId)
|
||||
throw error
|
||||
}
|
||||
context.retainOwner(sessionId, owner)
|
||||
context.setStatus(sessionId, {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: record.lease.handoffStage,
|
||||
operationId: record.lease.handoffOperationId,
|
||||
terminal: owner.terminal,
|
||||
hostLabel: deps.transport?.hostLabel
|
||||
})
|
||||
}
|
||||
|
||||
async function markManualRecovery(
|
||||
context: StructuredAgentSessionHandoffFlowContext,
|
||||
sessionId: string,
|
||||
operationId: string
|
||||
): Promise<void> {
|
||||
const { deps } = context
|
||||
const record = context.requireRecord(sessionId)
|
||||
await setStoredAgentSessionHandoffStage(deps.store, {
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: 'manual-recovery',
|
||||
handoffOperationId: operationId,
|
||||
now: deps.now()
|
||||
})
|
||||
}
|
||||
|
||||
async function recoverNativeAfterTuiFailure(
|
||||
context: StructuredAgentSessionHandoffFlowContext,
|
||||
sessionId: string,
|
||||
operationId: string
|
||||
) {
|
||||
const { deps } = context
|
||||
let record = context.requireRecord(sessionId)
|
||||
if (record.lease.handoffStage === 'new-owner-proving') {
|
||||
record = await abandonStoredAgentSessionHandoffAttempt(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId,
|
||||
recoverableRuntimeKind: 'native',
|
||||
now: deps.now()
|
||||
})
|
||||
}
|
||||
const spawnToken = randomUUID()
|
||||
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
runtimeKind: 'native',
|
||||
spawnToken,
|
||||
operationId,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
now: deps.now()
|
||||
})
|
||||
try {
|
||||
return await deps.acquireNative({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
spawnToken
|
||||
})
|
||||
} catch (error) {
|
||||
const current = context.requireRecord(sessionId)
|
||||
if (current.lease.handoffStage === 'new-owner-proving') {
|
||||
await abandonStoredAgentSessionHandoffAttempt(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: current.lease.runtimeFence,
|
||||
operationId,
|
||||
recoverableRuntimeKind: 'native',
|
||||
now: deps.now()
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
|
||||
type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string }
|
||||
|
||||
export type HandoffOperationDecision =
|
||||
| { decision: 'new' }
|
||||
| { decision: 'replay'; outcome: AgentSessionOperationOutcome }
|
||||
| { decision: 'retry' }
|
||||
| { decision: 'refused'; code: 'agent_session_operation_conflict' | string }
|
||||
|
||||
export class StructuredAgentSessionHandoffOperationGuard {
|
||||
private readonly activeBySession = new Map<string, ActiveOperation>()
|
||||
|
||||
constructor(private readonly store: AgentSessionRecordStore) {}
|
||||
|
||||
async check(input: {
|
||||
callerKey: string
|
||||
sessionId: string
|
||||
operationId: string
|
||||
fingerprint: string
|
||||
action: 'start' | 'cancel-queued' | 'retry'
|
||||
status?: AgentSessionHandoffStatus
|
||||
now: number
|
||||
}): Promise<HandoffOperationDecision> {
|
||||
const active = this.activeBySession.get(input.sessionId)
|
||||
if (
|
||||
active?.operationId === input.operationId &&
|
||||
(active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey)
|
||||
) {
|
||||
return { decision: 'refused', code: 'agent_session_operation_conflict' }
|
||||
}
|
||||
const ledger = await this.store.admitOperation({
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.operationId,
|
||||
fingerprint: input.fingerprint,
|
||||
now: input.now
|
||||
})
|
||||
if (ledger.decision === 'refused') {
|
||||
return { decision: 'refused', code: ledger.code }
|
||||
}
|
||||
if (ledger.decision === 'admit') {
|
||||
return { decision: 'new' }
|
||||
}
|
||||
if (input.action === 'retry' && ledger.row.outcome.status === 'failed') {
|
||||
await this.store.recordOperationOutcome({
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.operationId,
|
||||
outcome: { status: 'pending' }
|
||||
})
|
||||
return { decision: 'retry' }
|
||||
}
|
||||
if (
|
||||
ledger.row.outcome.status === 'pending' &&
|
||||
!active &&
|
||||
input.status?.operationId !== input.operationId
|
||||
) {
|
||||
return { decision: 'new' }
|
||||
}
|
||||
return { decision: 'replay', outcome: ledger.row.outcome }
|
||||
}
|
||||
|
||||
start(sessionId: string, operation: ActiveOperation): void {
|
||||
this.activeBySession.set(sessionId, operation)
|
||||
}
|
||||
|
||||
finish(sessionId: string, operationId: string): void {
|
||||
if (this.activeBySession.get(sessionId)?.operationId === operationId) {
|
||||
this.activeBySession.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export class StructuredAgentSessionHandoffQueue {
|
||||
private readonly controllers = new Map<string, AbortController>()
|
||||
|
||||
cancel(sessionId: string): void {
|
||||
this.controllers.get(sessionId)?.abort()
|
||||
this.controllers.delete(sessionId)
|
||||
}
|
||||
|
||||
enqueue(
|
||||
sessionId: string,
|
||||
isIdle: (signal: AbortSignal) => boolean | Promise<boolean>,
|
||||
onReady: () => void
|
||||
): void {
|
||||
this.cancel(sessionId)
|
||||
const controller = new AbortController()
|
||||
this.controllers.set(sessionId, controller)
|
||||
void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => {
|
||||
if (ready) {
|
||||
onReady()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async waitUntilIdle(
|
||||
sessionId: string,
|
||||
controller: AbortController,
|
||||
isIdle: (signal: AbortSignal) => boolean | Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) {
|
||||
try {
|
||||
if (await isIdle(controller.signal)) {
|
||||
this.controllers.delete(sessionId)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation'
|
||||
import {
|
||||
abandonStoredAgentSessionHandoffAttempt,
|
||||
setStoredAgentSessionHandoffStage,
|
||||
stopStoredAgentSessionOwnerForHandoff,
|
||||
stopStoredRecoveringTuiOwnerForHandoff
|
||||
} from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward'
|
||||
import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse'
|
||||
import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffDeps,
|
||||
StructuredAgentSessionHandoffFlowContext,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
type RestartAccess = {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
requireRecord: (sessionId: string) => AgentSessionRecord
|
||||
flowContext: () => StructuredAgentSessionHandoffFlowContext
|
||||
retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void
|
||||
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
|
||||
}
|
||||
|
||||
export async function restoreStructuredAgentSessionHandoff(
|
||||
input: RestartAccess,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const initial = input.requireRecord(sessionId)
|
||||
const operationId = initial.lease.handoffOperationId
|
||||
const initialStage = initial.lease.handoffStage
|
||||
if (
|
||||
(initialStage === 'recovering' || initialStage === 'manual-recovery') &&
|
||||
!canRestoreLiveTuiOwner(initial)
|
||||
) {
|
||||
if (operationId) {
|
||||
await input.deps.store.recordOperationOutcome({
|
||||
operationId,
|
||||
outcome: { status: 'failed', code: 'agent_session_ownership_unknown' }
|
||||
})
|
||||
}
|
||||
input.setStatus(sessionId, idleStructuredHandoffStatus(initial))
|
||||
return
|
||||
}
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await restoreOnce(input, input.requireRecord(sessionId))
|
||||
const settled = input.requireRecord(sessionId)
|
||||
if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) {
|
||||
throw new Error('Restart handoff reconciliation did not settle the transfer.')
|
||||
}
|
||||
if (operationId) {
|
||||
await input.deps.store.recordOperationOutcome({
|
||||
operationId,
|
||||
outcome: { status: 'succeeded', sessionId }
|
||||
})
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = input.requireRecord(sessionId)
|
||||
const failed = await setStoredAgentSessionHandoffStage(input.deps.store, {
|
||||
sessionId,
|
||||
fence: current.lease.runtimeFence,
|
||||
stage: 'manual-recovery',
|
||||
handoffOperationId: current.lease.handoffOperationId,
|
||||
now: input.deps.now()
|
||||
})
|
||||
const status = idleStructuredHandoffStatus(failed)
|
||||
if (operationId) {
|
||||
await input.deps.store.recordOperationOutcome({
|
||||
operationId,
|
||||
outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
|
||||
})
|
||||
}
|
||||
input.setStatus(sessionId, {
|
||||
...status,
|
||||
...(status.error
|
||||
? {
|
||||
error: {
|
||||
...status.error,
|
||||
details: lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
|
||||
if (canRestoreLiveTuiOwner(record)) {
|
||||
await restoreRecoverableLiveTui(input, record)
|
||||
return
|
||||
}
|
||||
if (!input.deps.transport) {
|
||||
if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') {
|
||||
throw new Error('Agent TUI handoff recovery is unavailable on this host.')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') {
|
||||
await restoreLiveTui(input, record)
|
||||
return
|
||||
}
|
||||
if (!record.lease.handoffOperationId) {
|
||||
return
|
||||
}
|
||||
if (record.lease.handoffStage === 'preparing') {
|
||||
await restorePreparing(input, record)
|
||||
return
|
||||
}
|
||||
if (record.lease.handoffStage === 'new-owner-proving') {
|
||||
await restoreProving(input, record)
|
||||
return
|
||||
}
|
||||
if (record.lease.handoffStage === 'old-owner-stopped') {
|
||||
await continueHandoff(input, record)
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverUnavailableTuiAsNative(
|
||||
input: RestartAccess,
|
||||
record: AgentSessionRecord
|
||||
): Promise<void> {
|
||||
await input.deps.transport!.stopRecoveredOwner(record)
|
||||
const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now())
|
||||
const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, {
|
||||
sessionId: record.sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId,
|
||||
now: input.deps.now()
|
||||
})
|
||||
await continueHandoff(input, stopped)
|
||||
}
|
||||
|
||||
export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean {
|
||||
return (
|
||||
(record.lease.handoffStage === 'recovering' ||
|
||||
record.lease.handoffStage === 'manual-recovery') &&
|
||||
record.lease.runtimeKind === 'tui' &&
|
||||
record.lease.claimStatus === 'live' &&
|
||||
record.lease.ownerProcess !== null &&
|
||||
record.lease.handoffOperationId === null
|
||||
)
|
||||
}
|
||||
|
||||
async function restoreRecoverableLiveTui(
|
||||
input: RestartAccess,
|
||||
record: AgentSessionRecord
|
||||
): Promise<void> {
|
||||
if (!input.deps.transport) {
|
||||
throw new Error('Agent TUI handoff recovery is unavailable on this host.')
|
||||
}
|
||||
let owner: StructuredTuiOwner
|
||||
try {
|
||||
const recovered = await input.deps.transport.recoverTuiOwner(record)
|
||||
owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered })
|
||||
} catch {
|
||||
await recoverUnavailableTuiAsNative(input, record)
|
||||
return
|
||||
}
|
||||
await setStoredAgentSessionHandoffStage(input.deps.store, {
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: null,
|
||||
handoffOperationId: null,
|
||||
now: input.deps.now()
|
||||
})
|
||||
input.retainOwner(record.sessionId, owner)
|
||||
input.setStatus(record.sessionId, {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: owner.terminal,
|
||||
hostLabel: input.deps.transport.hostLabel
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
|
||||
const owner = await input.deps.transport!.recoverTuiOwner(record)
|
||||
input.retainOwner(record.sessionId, owner)
|
||||
input.setStatus(record.sessionId, {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: owner.terminal,
|
||||
hostLabel: input.deps.transport?.hostLabel
|
||||
})
|
||||
}
|
||||
|
||||
async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
|
||||
if (record.lease.runtimeKind === 'tui') {
|
||||
const owner = await input.deps.transport!.recoverTuiOwner(record)
|
||||
await input.deps.transport!.reproveTuiOwner({ record, owner })
|
||||
const settled = await setStoredAgentSessionHandoffStage(input.deps.store, {
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: null,
|
||||
handoffOperationId: null,
|
||||
now: input.deps.now()
|
||||
})
|
||||
await restoreLiveTui(input, settled)
|
||||
return
|
||||
}
|
||||
await input.deps.transport!.stopRecoveredOwner(record)
|
||||
const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, {
|
||||
sessionId: record.sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId: record.lease.handoffOperationId!,
|
||||
now: input.deps.now()
|
||||
})
|
||||
await continueHandoff(input, stopped)
|
||||
}
|
||||
|
||||
async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
|
||||
const operationId = record.lease.handoffOperationId!
|
||||
if (record.lease.runtimeKind === 'tui') {
|
||||
const owner = await input.deps.transport!.recoverTuiOwner(record)
|
||||
const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner })
|
||||
await input.deps.store.proveOwner({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
link: reproved.link,
|
||||
now: input.deps.now()
|
||||
})
|
||||
input.retainOwner(record.sessionId, reproved)
|
||||
input.setStatus(record.sessionId, {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: reproved.terminal,
|
||||
hostLabel: input.deps.transport?.hostLabel
|
||||
})
|
||||
return
|
||||
}
|
||||
await input.deps.transport!.stopRecoveredOwner(record)
|
||||
const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, {
|
||||
sessionId: record.sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId,
|
||||
recoverableRuntimeKind: 'tui',
|
||||
now: input.deps.now()
|
||||
})
|
||||
await continueHandoff(input, stopped)
|
||||
}
|
||||
|
||||
async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
|
||||
const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native'
|
||||
const operationId = record.lease.handoffOperationId!
|
||||
const params: AgentSessionHandoffRequest = {
|
||||
envelope: {
|
||||
sessionId: record.sessionId,
|
||||
clientOperationId: operationId,
|
||||
expectedRuntimeFence: record.lease.runtimeFence,
|
||||
payloadFingerprint: 'restart-reconciliation'
|
||||
},
|
||||
direction,
|
||||
mode: 'now',
|
||||
action: 'retry'
|
||||
}
|
||||
await (direction === 'to-tui'
|
||||
? handoffStructuredSessionToTui(input.flowContext(), params, true)
|
||||
: handoffStructuredSessionToNative(input.flowContext(), params, true))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
AgentSessionHandoffResult,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
|
||||
|
||||
export function structuredHandoffRefusal(
|
||||
code: AgentSessionWireRefusal['code'],
|
||||
message: string
|
||||
): AgentSessionWireRefusal {
|
||||
return { code, message }
|
||||
}
|
||||
|
||||
export function structuredHandoffSuccess(
|
||||
deps: StructuredAgentSessionHandoffDeps,
|
||||
sessionId: string,
|
||||
replayed: boolean,
|
||||
status: AgentSessionHandoffResult['status']
|
||||
): AgentSessionMutationResult<AgentSessionHandoffResult> {
|
||||
const record = deps.store.getRecord(sessionId)
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
replayed,
|
||||
fence: record.lease.runtimeFence,
|
||||
cursor: deps.session(sessionId).journal.cursor(),
|
||||
value: { status }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
abandonStoredAgentSessionHandoffAttempt,
|
||||
reserveStoredAgentSessionHandoffOwner,
|
||||
rollbackStoredAgentSessionHandoffPreparation,
|
||||
stopStoredAgentSessionOwnerForHandoff
|
||||
} from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types'
|
||||
|
||||
export async function handoffStructuredSessionToNative(
|
||||
context: StructuredAgentSessionHandoffFlowContext,
|
||||
params: AgentSessionHandoffRequest,
|
||||
retry: boolean,
|
||||
tuiAlreadyExited = false
|
||||
): Promise<void> {
|
||||
const { deps } = context
|
||||
const sessionId = params.envelope.sessionId
|
||||
const operationId = params.envelope.clientOperationId
|
||||
let record = context.requireRecord(sessionId)
|
||||
let owner = context.owner(sessionId)
|
||||
let transcriptPath = owner?.transcriptPath
|
||||
if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) {
|
||||
if (record.lease.handoffStage === null) {
|
||||
await context.enterPreparing(record, operationId, 'to-native')
|
||||
}
|
||||
record = context.requireRecord(sessionId)
|
||||
try {
|
||||
if (!owner) {
|
||||
throw new Error('The owning agent terminal could not be identified.')
|
||||
}
|
||||
if (!tuiAlreadyExited) {
|
||||
owner = await deps.transport!.reproveTuiOwner({ record, owner })
|
||||
context.retainOwner(sessionId, owner)
|
||||
}
|
||||
transcriptPath = owner.transcriptPath ?? transcriptPath
|
||||
context.setStatus(sessionId, {
|
||||
owner: 'tui',
|
||||
direction: 'to-native',
|
||||
phase: 'waiting-for-exit',
|
||||
stage: 'preparing',
|
||||
operationId,
|
||||
terminal: owner.terminal,
|
||||
hostLabel: deps.transport?.hostLabel
|
||||
})
|
||||
const exited = await deps.transport!.waitForTuiExit(owner, async (link) => {
|
||||
await deps.store.recordProviderHandle({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
link,
|
||||
now: deps.now()
|
||||
})
|
||||
})
|
||||
transcriptPath = exited.transcriptPath ?? owner.transcriptPath
|
||||
} catch (error) {
|
||||
const current = context.requireRecord(sessionId)
|
||||
if (
|
||||
current.lease.handoffStage === 'preparing' &&
|
||||
current.lease.handoffOperationId === operationId &&
|
||||
current.lease.claimStatus === 'live' &&
|
||||
current.lease.ownerProcess
|
||||
) {
|
||||
await rollbackStoredAgentSessionHandoffPreparation(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: current.lease.runtimeFence,
|
||||
operationId,
|
||||
now: deps.now()
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
record = await stopStoredAgentSessionOwnerForHandoff(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId,
|
||||
now: deps.now()
|
||||
})
|
||||
context.publishStage(record, 'to-native')
|
||||
} else if (
|
||||
record.lease.handoffStage !== 'old-owner-stopped' ||
|
||||
record.lease.handoffOperationId !== operationId
|
||||
) {
|
||||
throw new Error('agent_session_operation_conflict')
|
||||
}
|
||||
await deps.importTuiHistory({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
})
|
||||
const spawnToken = randomUUID()
|
||||
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
runtimeKind: 'native',
|
||||
spawnToken,
|
||||
operationId,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
now: deps.now()
|
||||
})
|
||||
context.publishStage(record, 'to-native')
|
||||
try {
|
||||
record = await deps.acquireNative({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
spawnToken
|
||||
})
|
||||
} catch (error) {
|
||||
const current = context.requireRecord(sessionId)
|
||||
if (current.lease.handoffStage === 'new-owner-proving') {
|
||||
await abandonStoredAgentSessionHandoffAttempt(deps.store, {
|
||||
sessionId,
|
||||
expectedFence: current.lease.runtimeFence,
|
||||
operationId,
|
||||
recoverableRuntimeKind: 'tui',
|
||||
now: deps.now()
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
context.releaseOwner(sessionId)
|
||||
context.setStatus(sessionId, {
|
||||
owner: 'native',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: record.lease.handoffStage,
|
||||
operationId: record.lease.handoffOperationId
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffTransport,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
export function structuredTuiStatus(
|
||||
owner: StructuredTuiOwner | undefined,
|
||||
transport: StructuredAgentSessionHandoffTransport | undefined
|
||||
): 'idle' | 'busy' {
|
||||
return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy'
|
||||
}
|
||||
|
||||
export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus {
|
||||
if (
|
||||
record.lease.handoffStage === 'old-owner-stopped' &&
|
||||
record.lease.claimStatus === 'released' &&
|
||||
record.lease.handoffOperationId
|
||||
) {
|
||||
return persistedFailedStructuredHandoffStatus(record)
|
||||
}
|
||||
if (record.lease.handoffStage === 'manual-recovery') {
|
||||
return {
|
||||
owner: 'none',
|
||||
direction: null,
|
||||
phase: 'failed',
|
||||
stage: 'manual-recovery',
|
||||
operationId: record.lease.handoffOperationId,
|
||||
error: {
|
||||
message: "Couldn't verify which runtime owns this session — manual recovery is required",
|
||||
recoverableOwner: 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (record.lease.handoffStage) {
|
||||
const direction =
|
||||
record.lease.handoffStage === 'preparing'
|
||||
? record.lease.runtimeKind === 'native'
|
||||
? 'to-tui'
|
||||
: 'to-native'
|
||||
: record.lease.runtimeKind === 'tui'
|
||||
? 'to-tui'
|
||||
: 'to-native'
|
||||
return {
|
||||
owner:
|
||||
record.lease.claimStatus === 'live' && record.lease.ownerProcess
|
||||
? record.lease.runtimeKind
|
||||
: 'none',
|
||||
direction,
|
||||
phase: 'switching',
|
||||
stage: record.lease.handoffStage,
|
||||
operationId: record.lease.handoffOperationId
|
||||
}
|
||||
}
|
||||
return {
|
||||
owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: record.lease.handoffStage,
|
||||
operationId: record.lease.handoffOperationId
|
||||
}
|
||||
}
|
||||
|
||||
function persistedFailedStructuredHandoffStatus(
|
||||
record: AgentSessionRecord
|
||||
): AgentSessionHandoffStatus {
|
||||
const recoverableOwner = record.lease.runtimeKind
|
||||
const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native'
|
||||
return {
|
||||
owner: recoverableOwner,
|
||||
direction,
|
||||
phase: 'failed',
|
||||
stage: 'old-owner-stopped',
|
||||
operationId: record.lease.handoffOperationId,
|
||||
error: {
|
||||
message:
|
||||
direction === 'to-tui'
|
||||
? "Couldn't open the agent terminal — chat still owns this session"
|
||||
: "Couldn't resume chat — the agent terminal still owns this session",
|
||||
recoverableOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean {
|
||||
return journal
|
||||
.snapshot()
|
||||
.items.some(
|
||||
(item) =>
|
||||
(item.body.kind === 'approval' || item.body.kind === 'question') &&
|
||||
item.body.resolution.state === 'pending'
|
||||
)
|
||||
}
|
||||
|
||||
export function switchingStructuredHandoffStatus(
|
||||
record: AgentSessionRecord,
|
||||
direction: 'to-tui' | 'to-native',
|
||||
hostLabel?: string
|
||||
): AgentSessionHandoffStatus {
|
||||
return {
|
||||
owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none',
|
||||
direction,
|
||||
phase: 'switching',
|
||||
stage: record.lease.handoffStage,
|
||||
operationId: record.lease.handoffOperationId,
|
||||
...(hostLabel ? { hostLabel } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function failedStructuredHandoffStatus(
|
||||
record: AgentSessionRecord,
|
||||
params: AgentSessionHandoffRequest,
|
||||
error: unknown,
|
||||
hostLabel?: string
|
||||
): AgentSessionHandoffStatus {
|
||||
const recoverableOwner =
|
||||
record.lease.handoffStage === 'manual-recovery'
|
||||
? 'none'
|
||||
: record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released'
|
||||
? record.lease.runtimeKind
|
||||
: record.lease.ownerProcess
|
||||
? record.lease.runtimeKind
|
||||
: params.direction === 'to-tui' && record.lease.runtimeKind === 'native'
|
||||
? 'native'
|
||||
: 'none'
|
||||
return {
|
||||
owner: recoverableOwner,
|
||||
direction: params.direction,
|
||||
phase: 'failed',
|
||||
stage: record.lease.handoffStage,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
...(hostLabel ? { hostLabel } : {}),
|
||||
error: {
|
||||
message:
|
||||
recoverableOwner === 'none'
|
||||
? "Couldn't verify which runtime owns this session — manual recovery is required"
|
||||
: params.direction === 'to-tui'
|
||||
? "Couldn't open the agent terminal — chat still owns this session"
|
||||
: "Couldn't resume chat — the agent terminal still owns this session",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
recoverableOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffTransport,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
type TestCoordinatorInput = {
|
||||
store: AgentSessionRecordStore
|
||||
journal: AgentSessionJournal
|
||||
sessionId: string
|
||||
provider: 'claude' | 'codex'
|
||||
claudeSessionId: string
|
||||
codexThreadId: string
|
||||
now: number
|
||||
launchTui: StructuredAgentSessionHandoffTransport['launchTui']
|
||||
reproveTuiOwner: StructuredAgentSessionHandoffTransport['reproveTuiOwner']
|
||||
stopRecoveredOwner: StructuredAgentSessionHandoffTransport['stopRecoveredOwner']
|
||||
waitForTuiExit: StructuredAgentSessionHandoffTransport['waitForTuiExit']
|
||||
waitForTuiIdleOrExit: StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit']
|
||||
stopFailedTuiLaunch: NonNullable<StructuredAgentSessionHandoffTransport['stopFailedTuiLaunch']>
|
||||
recoverTuiOwner: (record: AgentSessionRecord) => Promise<StructuredTuiOwner>
|
||||
tuiStatus: () => 'idle' | 'busy'
|
||||
acquireNative: (input: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
spawnToken: string
|
||||
}) => Promise<AgentSessionRecord>
|
||||
acquireNativeStop: (turnId: string) => Promise<boolean>
|
||||
takeImportFailure: () => Error | null
|
||||
statuses: AgentSessionHandoffStatus[]
|
||||
}
|
||||
|
||||
export function createStructuredAgentSessionHandoffTestCoordinator(
|
||||
input: TestCoordinatorInput
|
||||
): StructuredAgentSessionHandoffCoordinator {
|
||||
return new StructuredAgentSessionHandoffCoordinator({
|
||||
store: input.store,
|
||||
claimKeyId: 'key-1',
|
||||
transport: {
|
||||
hostLabel: 'Test host',
|
||||
launchTui: input.launchTui,
|
||||
reproveTuiOwner: input.reproveTuiOwner,
|
||||
recoverTuiOwner: input.recoverTuiOwner,
|
||||
stopRecoveredOwner: input.stopRecoveredOwner,
|
||||
waitForTuiExit: input.waitForTuiExit,
|
||||
waitForTuiIdleOrExit: input.waitForTuiIdleOrExit,
|
||||
tuiStatus: input.tuiStatus,
|
||||
stopFailedTuiLaunch: input.stopFailedTuiLaunch
|
||||
},
|
||||
session: () => ({
|
||||
journal: input.journal,
|
||||
fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1
|
||||
}),
|
||||
suspendNative: async () => undefined,
|
||||
acquireNative: input.acquireNative,
|
||||
acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId),
|
||||
importTuiHistory: async ({ fence }) => {
|
||||
const importFailure = input.takeImportFailure()
|
||||
if (importFailure) {
|
||||
throw importFailure
|
||||
}
|
||||
await input.journal.appendItem(
|
||||
input.provider === 'claude'
|
||||
? { provider: 'claude', sessionId: input.claudeSessionId, uuid: 'tui-turn' }
|
||||
: { provider: 'codex', threadId: input.codexThreadId, turnId: 'tui-turn', ordinal: 0 },
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] },
|
||||
{ fence, recovered: true }
|
||||
)
|
||||
},
|
||||
publish: (_sessionId, status) => input.statuses.push(status),
|
||||
schedule: async (_sessionId, task) => task(),
|
||||
now: () => input.now
|
||||
})
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
export type StructuredHandoffProviderCase = {
|
||||
provider: 'claude' | 'codex'
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; pathName: string }
|
||||
}
|
||||
|
||||
export const STRUCTURED_HANDOFF_PROVIDER_CASES: StructuredHandoffProviderCase[] = [
|
||||
{ provider: 'codex', accountHome: { variable: 'CODEX_HOME', pathName: 'codex-home' } },
|
||||
{
|
||||
provider: 'claude',
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', pathName: 'claude-home' }
|
||||
}
|
||||
]
|
||||
|
||||
export function structuredHandoffTestProcess(now: number, spawnToken: string, pid: number) {
|
||||
return { hostId: 'local', pid, processStartTimeMs: now - 1_000, spawnToken }
|
||||
}
|
||||
|
||||
export function structuredHandoffTestLink(input: {
|
||||
provider: 'claude' | 'codex'
|
||||
fence: number
|
||||
id: string
|
||||
now: number
|
||||
claudeSessionId: string
|
||||
codexThreadId: string
|
||||
}) {
|
||||
return {
|
||||
linkId: input.id,
|
||||
handle:
|
||||
input.provider === 'claude'
|
||||
? ({
|
||||
provider: 'claude' as const,
|
||||
sessionId: input.claudeSessionId,
|
||||
leafUuid: input.id.startsWith('native-link') ? 'tui-exit-leaf' : 'current-leaf'
|
||||
} as const)
|
||||
: ({ provider: 'codex' as const, threadId: input.codexThreadId } as const),
|
||||
origin: 'resumed' as const,
|
||||
mintedAtFence: input.fence,
|
||||
observedAt: input.now
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffRequest
|
||||
} from '../../../shared/agent-session-wire'
|
||||
|
||||
export type StructuredHandoffTestRequestOptions = {
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
export class StructuredHandoffTestRequests {
|
||||
private operations = 0
|
||||
|
||||
constructor(
|
||||
private readonly now: number,
|
||||
private readonly sessionId: string,
|
||||
private readonly readFence: () => number
|
||||
) {}
|
||||
|
||||
reset(): void {
|
||||
this.operations = 0
|
||||
}
|
||||
|
||||
operationId(): string {
|
||||
this.operations += 1
|
||||
return `${this.now}-${this.operations.toString(16).padStart(32, '0')}`
|
||||
}
|
||||
|
||||
request(
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
options: StructuredHandoffTestRequestOptions = {}
|
||||
): AgentSessionHandoffRequest {
|
||||
const action = options.action ?? 'start'
|
||||
const fields = { direction, mode, action }
|
||||
return {
|
||||
envelope: {
|
||||
sessionId: this.sessionId,
|
||||
clientOperationId: options.operationId ?? this.operationId(),
|
||||
expectedRuntimeFence: this.readFence(),
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.requestHandoff',
|
||||
sessionId: this.sessionId,
|
||||
fields
|
||||
})
|
||||
},
|
||||
...fields
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle'
|
||||
import type {
|
||||
AgentSessionProcessIdentity,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
export type StructuredTuiOwner = {
|
||||
terminal: { handle: string; tabId: string; paneKey: string; ptyId: string }
|
||||
process: AgentSessionProcessIdentity
|
||||
link: AgentSessionProviderHandleLink
|
||||
transcriptPath?: string
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHandoffTransport = {
|
||||
hostLabel: string
|
||||
launchTui(input: {
|
||||
record: AgentSessionRecord
|
||||
fence: number
|
||||
spawnToken: string
|
||||
}): Promise<StructuredTuiOwner>
|
||||
reproveTuiOwner(input: {
|
||||
record: AgentSessionRecord
|
||||
owner: StructuredTuiOwner
|
||||
}): Promise<StructuredTuiOwner>
|
||||
recoverTuiOwner(record: AgentSessionRecord): Promise<StructuredTuiOwner>
|
||||
stopRecoveredOwner(record: AgentSessionRecord): Promise<void>
|
||||
waitForTuiExit(
|
||||
owner: StructuredTuiOwner,
|
||||
persistHandle: (link: AgentSessionProviderHandleLink) => Promise<void>
|
||||
): Promise<{ transcriptPath?: string }>
|
||||
waitForTuiIdleOrExit(
|
||||
owner: StructuredTuiOwner,
|
||||
signal: AbortSignal
|
||||
): Promise<'idle' | 'exited' | null>
|
||||
tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy'
|
||||
stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise<void>
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHandoffDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
claimKeyId: string
|
||||
transport?: StructuredAgentSessionHandoffTransport
|
||||
session: (sessionId: string) => { journal: AgentSessionJournal; fence: number }
|
||||
suspendNative: (sessionId: string) => Promise<void>
|
||||
acquireNative: (input: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
spawnToken: string
|
||||
}) => Promise<AgentSessionRecord>
|
||||
acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise<boolean>
|
||||
importTuiHistory: (input: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
transcriptPath?: string
|
||||
}) => Promise<void>
|
||||
publish: (sessionId: string, status: AgentSessionHandoffStatus) => void
|
||||
schedule: (sessionId: string, task: () => Promise<void>) => Promise<void>
|
||||
now: () => number
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHandoffFlowContext = {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
owner: (sessionId: string) => StructuredTuiOwner | undefined
|
||||
retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void
|
||||
releaseOwner: (sessionId: string) => void
|
||||
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
|
||||
enterPreparing: (
|
||||
record: AgentSessionRecord,
|
||||
operationId: string,
|
||||
direction: 'to-tui' | 'to-native'
|
||||
) => Promise<void>
|
||||
publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void
|
||||
requireRecord: (sessionId: string) => AgentSessionRecord
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import {
|
||||
reserveStoredAgentSessionHandoffOwner,
|
||||
setStoredAgentSessionHandoffStage,
|
||||
stopStoredAgentSessionOwnerForHandoff
|
||||
} from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import { openAgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
|
||||
import { createStructuredAgentSessionHandoffTestCoordinator } from './structured-agent-session-handoff-test-coordinator'
|
||||
import {
|
||||
STRUCTURED_HANDOFF_PROVIDER_CASES,
|
||||
structuredHandoffTestLink,
|
||||
structuredHandoffTestProcess,
|
||||
type StructuredHandoffProviderCase
|
||||
} from './structured-agent-session-handoff-test-identities'
|
||||
import {
|
||||
StructuredHandoffTestRequests,
|
||||
type StructuredHandoffTestRequestOptions
|
||||
} from './structured-agent-session-handoff-test-requests'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffTransport,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const SESSION = 'session-handoff'
|
||||
const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
|
||||
const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61'
|
||||
|
||||
let providerCase: StructuredHandoffProviderCase
|
||||
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
let journal: Awaited<ReturnType<typeof openAgentSessionJournal>>
|
||||
let coordinator: StructuredAgentSessionHandoffCoordinator
|
||||
let statuses: AgentSessionHandoffStatus[]
|
||||
let tuiIdle: boolean
|
||||
let tuiReadiness: 'idle' | 'exited' | null
|
||||
let importFailure: Error | null
|
||||
let nativeAcquireFailure: Error | null
|
||||
let launchTui: ReturnType<typeof vi.fn<StructuredAgentSessionHandoffTransport['launchTui']>>
|
||||
let waitForTuiExit: ReturnType<
|
||||
typeof vi.fn<StructuredAgentSessionHandoffTransport['waitForTuiExit']>
|
||||
>
|
||||
let waitForTuiIdleOrExit: ReturnType<
|
||||
typeof vi.fn<StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit']>
|
||||
>
|
||||
let reproveTuiOwner: ReturnType<
|
||||
typeof vi.fn<StructuredAgentSessionHandoffTransport['reproveTuiOwner']>
|
||||
>
|
||||
let stopFailedTuiLaunch: ReturnType<
|
||||
typeof vi.fn<NonNullable<StructuredAgentSessionHandoffTransport['stopFailedTuiLaunch']>>
|
||||
>
|
||||
let acquireNativeStop: ReturnType<typeof vi.fn<(turnId: string) => Promise<boolean>>>
|
||||
let acquireNativeCalls: number
|
||||
let stopRecoveredOwner: ReturnType<
|
||||
typeof vi.fn<StructuredAgentSessionHandoffTransport['stopRecoveredOwner']>
|
||||
>
|
||||
const requests = new StructuredHandoffTestRequests(
|
||||
NOW,
|
||||
SESSION,
|
||||
() => store.getRecord(SESSION)?.lease.runtimeFence ?? 1
|
||||
)
|
||||
|
||||
const operationId = (): string => requests.operationId()
|
||||
|
||||
function request(
|
||||
direction: Parameters<StructuredHandoffTestRequests['request']>[0],
|
||||
mode: Parameters<StructuredHandoffTestRequests['request']>[1],
|
||||
options: StructuredHandoffTestRequestOptions = {}
|
||||
): AgentSessionHandoffRequest {
|
||||
return requests.request(direction, mode, options)
|
||||
}
|
||||
|
||||
function submit(params: AgentSessionHandoffRequest) {
|
||||
return coordinator.request('client-1', params)
|
||||
}
|
||||
|
||||
function process(spawnToken: string, pid: number) {
|
||||
return structuredHandoffTestProcess(NOW, spawnToken, pid)
|
||||
}
|
||||
|
||||
function link(fence: number, id: string) {
|
||||
return structuredHandoffTestLink({
|
||||
provider: providerCase.provider,
|
||||
fence,
|
||||
id,
|
||||
now: NOW,
|
||||
claudeSessionId: CLAUDE_SESSION,
|
||||
codexThreadId: THREAD
|
||||
})
|
||||
}
|
||||
|
||||
async function establishNativeOwner(): Promise<void> {
|
||||
const reserved = await store.reserveOwner({
|
||||
sessionId: SESSION,
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: providerCase.provider,
|
||||
accountHome: {
|
||||
variable: providerCase.accountHome.variable,
|
||||
path: join(root, providerCase.accountHome.pathName)
|
||||
},
|
||||
runtimeKind: 'native',
|
||||
expectedFence: null,
|
||||
spawnToken: 'native-initial',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: null,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' },
|
||||
now: NOW
|
||||
})
|
||||
const fence = reserved.record.lease.runtimeFence
|
||||
await store.commitProcessIdentity({
|
||||
sessionId: SESSION,
|
||||
fence,
|
||||
process: process('native-initial', 4100),
|
||||
now: NOW
|
||||
})
|
||||
await store.proveOwner({
|
||||
sessionId: SESSION,
|
||||
fence,
|
||||
link: { ...link(fence, 'initial-link'), origin: 'created' },
|
||||
now: NOW
|
||||
})
|
||||
}
|
||||
|
||||
function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner {
|
||||
return {
|
||||
terminal: {
|
||||
handle: 'term-tui',
|
||||
tabId: 'tab-tui',
|
||||
paneKey: 'tab-tui:leaf-tui',
|
||||
ptyId: 'pty-tui'
|
||||
},
|
||||
process: process(spawnToken, 4200),
|
||||
link: link(fence, `tui-link-${fence}`),
|
||||
transcriptPath: join(root, 'rollout.jsonl')
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireNative(input: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
spawnToken: string
|
||||
}): Promise<AgentSessionRecord> {
|
||||
acquireNativeCalls += 1
|
||||
if (providerCase.provider === 'claude') {
|
||||
const chain = store.getRecord(input.sessionId)?.providerHandleChain ?? []
|
||||
if (
|
||||
chain.some(
|
||||
(entry) => entry.handle.provider === 'claude' && entry.handle.leafUuid === 'tui-exit-leaf'
|
||||
)
|
||||
) {
|
||||
expect(chain.at(-1)?.handle).toEqual({
|
||||
provider: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
leafUuid: 'tui-exit-leaf'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (nativeAcquireFailure) {
|
||||
const error = nativeAcquireFailure
|
||||
nativeAcquireFailure = null
|
||||
throw error
|
||||
}
|
||||
await store.commitProcessIdentity({
|
||||
sessionId: input.sessionId,
|
||||
fence: input.fence,
|
||||
process: process(input.spawnToken, 4300 + acquireNativeCalls),
|
||||
now: NOW
|
||||
})
|
||||
return store.proveOwner({
|
||||
sessionId: input.sessionId,
|
||||
fence: input.fence,
|
||||
link: link(input.fence, `native-link-${input.fence}`),
|
||||
now: NOW
|
||||
})
|
||||
}
|
||||
|
||||
function createCoordinator(): StructuredAgentSessionHandoffCoordinator {
|
||||
return createStructuredAgentSessionHandoffTestCoordinator({
|
||||
store,
|
||||
journal,
|
||||
sessionId: SESSION,
|
||||
provider: providerCase.provider,
|
||||
claudeSessionId: CLAUDE_SESSION,
|
||||
codexThreadId: THREAD,
|
||||
now: NOW,
|
||||
launchTui,
|
||||
reproveTuiOwner,
|
||||
recoverTuiOwner: async (record) => {
|
||||
const owner = makeTuiOwner(
|
||||
record.lease.runtimeFence,
|
||||
record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered'
|
||||
)
|
||||
return { ...owner, process: record.lease.ownerProcess ?? owner.process }
|
||||
},
|
||||
stopRecoveredOwner,
|
||||
waitForTuiExit,
|
||||
waitForTuiIdleOrExit,
|
||||
tuiStatus: () => (tuiIdle ? 'idle' : 'busy'),
|
||||
stopFailedTuiLaunch,
|
||||
acquireNative,
|
||||
acquireNativeStop: (turnId) => acquireNativeStop(turnId),
|
||||
takeImportFailure: () => {
|
||||
if (importFailure) {
|
||||
const error = importFailure
|
||||
importFailure = null
|
||||
return error
|
||||
}
|
||||
return null
|
||||
},
|
||||
statuses
|
||||
})
|
||||
}
|
||||
|
||||
async function appendStatus(state: 'running' | 'completed'): Promise<void> {
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'turn-status' },
|
||||
{ kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } },
|
||||
{ fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 }
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPhase(phase: AgentSessionHandoffStatus['phase']): Promise<void> {
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).phase).toBe(phase))
|
||||
}
|
||||
|
||||
async function moveToTui(): Promise<void> {
|
||||
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui' }))
|
||||
}
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
root = await mkdtemp(join(tmpdir(), `orca-${providerCase.provider}-handoff-`))
|
||||
requests.reset()
|
||||
statuses = []
|
||||
tuiIdle = true
|
||||
tuiReadiness = 'idle'
|
||||
importFailure = null
|
||||
nativeAcquireFailure = null
|
||||
acquireNativeCalls = 0
|
||||
stopRecoveredOwner = vi.fn(async () => undefined)
|
||||
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
|
||||
await establishNativeOwner()
|
||||
journal = await openAgentSessionJournal({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: providerCase.provider,
|
||||
providerHandle:
|
||||
providerCase.provider === 'claude'
|
||||
? { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'current-leaf' }
|
||||
: { kind: 'codex', threadId: THREAD }
|
||||
},
|
||||
journalDir: join(root, 'journal')
|
||||
})
|
||||
launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken))
|
||||
waitForTuiExit = vi.fn(async (owner, persistHandle) => {
|
||||
if (owner.link.handle.provider === 'claude') {
|
||||
await persistHandle({
|
||||
...owner.link,
|
||||
linkId: `tui-exit-${owner.link.mintedAtFence}`,
|
||||
handle: { ...owner.link.handle, leafUuid: 'tui-exit-leaf' }
|
||||
})
|
||||
}
|
||||
return { transcriptPath: owner.transcriptPath }
|
||||
})
|
||||
waitForTuiIdleOrExit = vi.fn(async () => tuiReadiness)
|
||||
reproveTuiOwner = vi.fn(async ({ record, owner }) => {
|
||||
expect(record.lease.ownerProcess).toEqual(owner.process)
|
||||
if (record.lease.provenHandleLinkId !== null) {
|
||||
expect(record.lease.provenHandleLinkId).toBe(owner.link.linkId)
|
||||
}
|
||||
return owner
|
||||
})
|
||||
stopFailedTuiLaunch = vi.fn(async () => undefined)
|
||||
acquireNativeStop = vi.fn(async () => true)
|
||||
coordinator = createCoordinator()
|
||||
}
|
||||
|
||||
async function teardown(): Promise<void> {
|
||||
await coordinator.drain()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
|
||||
'$provider structured session ownership handoff',
|
||||
(currentProvider) => {
|
||||
beforeEach(async () => {
|
||||
providerCase = currentProvider
|
||||
await setup()
|
||||
})
|
||||
|
||||
afterEach(teardown)
|
||||
|
||||
it('completes native to TUI to native with one owner and journal continuity', async () => {
|
||||
await journal.appendItem(
|
||||
providerCase.provider === 'claude'
|
||||
? { provider: 'claude', sessionId: CLAUDE_SESSION, uuid: 'native-turn' }
|
||||
: { provider: 'codex', threadId: THREAD, turnId: 'native-turn', ordinal: 0 },
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'from native' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
await moveToTui()
|
||||
const forwardRecord = store.getRecord(SESSION)!
|
||||
expect(forwardRecord.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
|
||||
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native' }))
|
||||
|
||||
expect(reproveTuiOwner).toHaveBeenCalledWith({
|
||||
record: expect.objectContaining({
|
||||
lease: expect.objectContaining({
|
||||
ownerProcess: forwardRecord.lease.ownerProcess,
|
||||
provenHandleLinkId: forwardRecord.lease.provenHandleLinkId
|
||||
})
|
||||
}),
|
||||
owner: expect.objectContaining({
|
||||
process: forwardRecord.lease.ownerProcess,
|
||||
link: expect.objectContaining({ linkId: forwardRecord.lease.provenHandleLinkId })
|
||||
})
|
||||
})
|
||||
|
||||
const messages = journal.snapshot().items.filter((item) => item.body.kind === 'message')
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(new Set(messages.map((item) => item.itemId)).size).toBe(2)
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('queues a busy native turn, supports cancel, and starts only after idle', async () => {
|
||||
await appendStatus('running')
|
||||
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
|
||||
expect(coordinator.status(SESSION).phase).toBe('queued')
|
||||
expect(
|
||||
await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' }))
|
||||
).toMatchObject({ ok: true })
|
||||
await appendStatus('completed')
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
expect(launchTui).not.toHaveBeenCalled()
|
||||
|
||||
await appendStatus('running')
|
||||
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
|
||||
await appendStatus('completed')
|
||||
await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('durably replays a completed queued cancellation after restart', async () => {
|
||||
await appendStatus('running')
|
||||
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
|
||||
const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' })
|
||||
expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: false })
|
||||
await coordinator.drain()
|
||||
|
||||
store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
|
||||
expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(launchTui).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replays a duplicate active handoff without launching a second owner', async () => {
|
||||
let finishLaunch!: () => void
|
||||
launchTui.mockImplementationOnce(
|
||||
({ fence, spawnToken }) =>
|
||||
new Promise((resolve) => {
|
||||
finishLaunch = () => resolve(makeTuiOwner(fence, spawnToken))
|
||||
})
|
||||
)
|
||||
const operation = operationId()
|
||||
const first = request('to-tui', 'now', { operationId: operation })
|
||||
expect(await submit(first)).toMatchObject({ ok: true, replayed: false })
|
||||
await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce())
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'reserved',
|
||||
handoffStage: 'new-owner-proving'
|
||||
})
|
||||
expect(coordinator.status(SESSION).owner).not.toBe('tui')
|
||||
expect(await submit(first)).toMatchObject({ ok: true, replayed: true })
|
||||
finishLaunch()
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
|
||||
expect(launchTui).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('replays the same completed handoff operation after the coordinator restarts', async () => {
|
||||
const operation = operationId()
|
||||
const first = request('to-tui', 'now', { operationId: operation })
|
||||
expect(await submit(first)).toMatchObject({ ok: true, replayed: false })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
|
||||
await coordinator.drain()
|
||||
|
||||
store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
expect(await submit(first)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(launchTui).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('continues a persisted preparing stage after restart instead of stranding it', async () => {
|
||||
const operation = operationId()
|
||||
const first = request('to-tui', 'now', { operationId: operation })
|
||||
await store.admitOperation({
|
||||
callerKey: 'client-1',
|
||||
operationId: operation,
|
||||
fingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.requestHandoff.operation',
|
||||
sessionId: SESSION,
|
||||
fields: { direction: 'to-tui' }
|
||||
}),
|
||||
now: NOW
|
||||
})
|
||||
await setStoredAgentSessionHandoffStage(store, {
|
||||
sessionId: SESSION,
|
||||
fence: 1,
|
||||
stage: 'preparing',
|
||||
handoffOperationId: operation,
|
||||
now: NOW
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
|
||||
await coordinator.restore(SESSION)
|
||||
|
||||
expect(stopRecoveredOwner).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
await coordinator.drain()
|
||||
store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
expect(await submit(first)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(launchTui).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('finishes a live new-owner-proving stage after restart', async () => {
|
||||
const operation = operationId()
|
||||
let record = await setStoredAgentSessionHandoffStage(store, {
|
||||
sessionId: SESSION,
|
||||
fence: 1,
|
||||
stage: 'preparing',
|
||||
handoffOperationId: operation,
|
||||
now: NOW
|
||||
})
|
||||
record = await stopStoredAgentSessionOwnerForHandoff(store, {
|
||||
sessionId: SESSION,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
operationId: operation,
|
||||
now: NOW
|
||||
})
|
||||
const spawnToken = 'restarted-tui'
|
||||
record = await reserveStoredAgentSessionHandoffOwner(store, {
|
||||
sessionId: SESSION,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
runtimeKind: 'tui',
|
||||
spawnToken,
|
||||
operationId: operation,
|
||||
claimKeyId: 'key-1',
|
||||
now: NOW
|
||||
})
|
||||
await store.commitProcessIdentity({
|
||||
sessionId: SESSION,
|
||||
fence: record.lease.runtimeFence,
|
||||
process: process(spawnToken, 4400),
|
||||
now: NOW
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
|
||||
await coordinator.restore(SESSION)
|
||||
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' })
|
||||
})
|
||||
|
||||
it('clears stale recovery after re-proving the live TUI owner', async () => {
|
||||
await moveToTui()
|
||||
const record = store.getRecord(SESSION)!
|
||||
await setStoredAgentSessionHandoffStage(store, {
|
||||
sessionId: SESSION,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: 'manual-recovery',
|
||||
handoffOperationId: null,
|
||||
now: NOW
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
|
||||
await coordinator.restore(SESSION)
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'tui',
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
terminal: { handle: 'term-tui' }
|
||||
})
|
||||
expect(reproveTuiOwner).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers native ownership when a stressed TUI recovery process is gone', async () => {
|
||||
await moveToTui()
|
||||
const record = store.getRecord(SESSION)!
|
||||
await setStoredAgentSessionHandoffStage(store, {
|
||||
sessionId: SESSION,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: 'manual-recovery',
|
||||
handoffOperationId: null,
|
||||
now: NOW
|
||||
})
|
||||
reproveTuiOwner.mockRejectedValueOnce(new Error('The owning terminal was killed.'))
|
||||
coordinator = createCoordinator()
|
||||
|
||||
await coordinator.restore(SESSION)
|
||||
|
||||
expect(stopRecoveredOwner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: SESSION,
|
||||
lease: expect.objectContaining({ runtimeKind: 'tui' })
|
||||
})
|
||||
)
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'native',
|
||||
phase: 'idle',
|
||||
stage: null
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects cancellation once a queued handoff is no longer pending', async () => {
|
||||
expect(
|
||||
await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' }))
|
||||
).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } })
|
||||
expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' })
|
||||
})
|
||||
|
||||
it('durably replays a queued-cancellation refusal as a refusal', async () => {
|
||||
const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' })
|
||||
expect(await submit(cancellation)).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_conflict' }
|
||||
})
|
||||
|
||||
store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
coordinator = createCoordinator()
|
||||
|
||||
expect(await submit(cancellation)).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_conflict' }
|
||||
})
|
||||
expect(launchTui).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires acknowledged cancellation before stopping a native turn', async () => {
|
||||
await appendStatus('running')
|
||||
expect(await submit(request('to-tui', 'stop-turn'))).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
|
||||
expect(acquireNativeStop).toHaveBeenCalledWith('turn-1')
|
||||
expect(acquireNativeStop.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
launchTui.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
})
|
||||
|
||||
it('fires a queued TUI transfer from the turn-completion waiter', async () => {
|
||||
await moveToTui()
|
||||
tuiIdle = false
|
||||
tuiReadiness = null
|
||||
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
|
||||
expect(coordinator.status(SESSION).phase).toBe('queued')
|
||||
expect(waitForTuiExit).not.toHaveBeenCalled()
|
||||
tuiReadiness = 'idle'
|
||||
await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce())
|
||||
expect(tuiIdle).toBe(false)
|
||||
expect(waitForTuiIdleOrExit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('completes a mobile-originated queued reverse after the owning TUI exits', async () => {
|
||||
await moveToTui()
|
||||
tuiIdle = false
|
||||
tuiReadiness = null
|
||||
const reverse = request('to-native', 'after-turn')
|
||||
|
||||
expect(await coordinator.request('mobile-device-a', reverse)).toMatchObject({ ok: true })
|
||||
expect(coordinator.status(SESSION).phase).toBe('queued')
|
||||
tuiReadiness = 'exited'
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
|
||||
|
||||
expect(reproveTuiOwner).not.toHaveBeenCalled()
|
||||
expect(waitForTuiExit).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('re-proves the current TUI handle before accepting its exit and importing history', async () => {
|
||||
await moveToTui()
|
||||
const reverse = request('to-native', 'now')
|
||||
expect(await submit(reverse)).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
|
||||
|
||||
expect(reproveTuiOwner).toHaveBeenCalledOnce()
|
||||
expect(reproveTuiOwner.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
waitForTuiExit.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
})
|
||||
|
||||
it('does not close or import when the current TUI handle cannot be re-proved', async () => {
|
||||
await moveToTui()
|
||||
reproveTuiOwner.mockRejectedValueOnce(new Error('provider handle changed'))
|
||||
expect(await submit(request('to-native', 'now'))).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(waitForTuiExit).not.toHaveBeenCalled()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls a stale reverse handle back to one durable TUI owner and retries', async () => {
|
||||
await moveToTui()
|
||||
waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale'))
|
||||
const retryOperation = operationId()
|
||||
|
||||
expect(
|
||||
await submit(request('to-native', 'now', { operationId: retryOperation }))
|
||||
).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'tui',
|
||||
operationId: retryOperation,
|
||||
error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
|
||||
expect(
|
||||
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
|
||||
).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
|
||||
expect(waitForTuiExit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps one recoverable owner when a queued reverse auto-fire fails', async () => {
|
||||
await moveToTui()
|
||||
tuiIdle = false
|
||||
tuiReadiness = null
|
||||
waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale'))
|
||||
|
||||
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
|
||||
expect(coordinator.status(SESSION).phase).toBe('queued')
|
||||
tuiReadiness = 'idle'
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'tui',
|
||||
error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers native ownership when TUI launch or proof fails', async () => {
|
||||
launchTui.mockRejectedValueOnce(new Error('resume proof failed'))
|
||||
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'native',
|
||||
error: { recoverableOwner: 'native' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('stops a launched but uncommitted TUI before recovering native', async () => {
|
||||
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
|
||||
...makeTuiOwner(fence, spawnToken),
|
||||
link: link(fence + 1, 'bad-proof')
|
||||
}))
|
||||
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
expect(stopFailedTuiLaunch).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native')
|
||||
})
|
||||
|
||||
it('keeps a recoverable native owner after repeated transfers and a dead TUI relaunch', async () => {
|
||||
for (let cycle = 0; cycle < 2; cycle += 1) {
|
||||
await moveToTui()
|
||||
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
|
||||
}
|
||||
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
|
||||
...makeTuiOwner(fence, spawnToken),
|
||||
link: link(fence + 1, 'dead-relaunch')
|
||||
}))
|
||||
|
||||
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(stopFailedTuiLaunch).toHaveBeenCalledOnce()
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'native',
|
||||
error: { recoverableOwner: 'native' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('requires manual recovery when a failed TUI launch cannot be proven stopped', async () => {
|
||||
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
|
||||
...makeTuiOwner(fence, spawnToken),
|
||||
link: link(fence + 1, 'bad-proof')
|
||||
}))
|
||||
stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved'))
|
||||
const failedOperation = operationId()
|
||||
expect(
|
||||
await submit(request('to-tui', 'now', { operationId: failedOperation }))
|
||||
).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'none',
|
||||
stage: 'manual-recovery',
|
||||
error: { recoverableOwner: 'none' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery')
|
||||
await coordinator.restore(SESSION)
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'none',
|
||||
stage: 'manual-recovery'
|
||||
})
|
||||
expect(
|
||||
await submit(request('to-tui', 'now', { action: 'retry', operationId: failedOperation }))
|
||||
).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } })
|
||||
})
|
||||
|
||||
it('retains the stopped TUI session for retry when native resume fails', async () => {
|
||||
await moveToTui()
|
||||
importFailure = new Error('history unavailable')
|
||||
const retryOperation = operationId()
|
||||
expect(
|
||||
await submit(request('to-native', 'after-turn', { operationId: retryOperation }))
|
||||
).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'tui',
|
||||
operationId: retryOperation,
|
||||
error: { recoverableOwner: 'tui' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'released',
|
||||
handoffStage: 'old-owner-stopped'
|
||||
})
|
||||
|
||||
expect(
|
||||
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
|
||||
).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
|
||||
expect(waitForTuiExit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('abandons a failed native acquisition and retries from the stopped TUI', async () => {
|
||||
await moveToTui()
|
||||
nativeAcquireFailure = new Error('native proof unavailable')
|
||||
const retryOperation = operationId()
|
||||
expect(
|
||||
await submit(request('to-native', 'after-turn', { operationId: retryOperation }))
|
||||
).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'tui',
|
||||
operationId: retryOperation,
|
||||
error: { recoverableOwner: 'tui' }
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'released',
|
||||
handoffStage: 'old-owner-stopped'
|
||||
})
|
||||
|
||||
coordinator = createCoordinator()
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'tui',
|
||||
direction: 'to-native',
|
||||
phase: 'failed',
|
||||
operationId: retryOperation
|
||||
})
|
||||
expect(
|
||||
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
|
||||
).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
|
||||
expect(waitForTuiExit).toHaveBeenCalledOnce()
|
||||
expect(acquireNativeCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('publishes every durable stage so concurrent clients see the same transfer', async () => {
|
||||
await moveToTui()
|
||||
expect(statuses.map((status) => status.stage)).toEqual(
|
||||
expect.arrayContaining(['preparing', 'old-owner-stopped', 'new-owner-proving', null])
|
||||
)
|
||||
expect(statuses.filter((status) => status.phase === 'switching')).toHaveLength(3)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffResult,
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
|
||||
import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import {
|
||||
admitStructuredHandoffRequest,
|
||||
refuseAdmittedStructuredHandoff,
|
||||
replayedStructuredHandoffRefusal,
|
||||
structuredHandoffRetryIsAdmissible
|
||||
} from './structured-agent-session-handoff-admission'
|
||||
import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context'
|
||||
import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner'
|
||||
import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue'
|
||||
import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
|
||||
import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart'
|
||||
import {
|
||||
structuredHandoffRefusal as refusal,
|
||||
structuredHandoffSuccess
|
||||
} from './structured-agent-session-handoff-result'
|
||||
import {
|
||||
failedStructuredHandoffStatus,
|
||||
idleStructuredHandoffStatus,
|
||||
structuredSessionHasPendingPrompt,
|
||||
structuredTuiStatus,
|
||||
switchingStructuredHandoffStatus
|
||||
} from './structured-agent-session-handoff-status'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffDeps,
|
||||
StructuredAgentSessionHandoffFlowContext,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
export class StructuredAgentSessionHandoffCoordinator {
|
||||
private readonly statuses = new Map<string, AgentSessionHandoffStatus>()
|
||||
private readonly queue = new StructuredAgentSessionHandoffQueue()
|
||||
private readonly tuiOwners = new Map<string, StructuredTuiOwner>()
|
||||
private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard
|
||||
private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner
|
||||
|
||||
constructor(private readonly deps: StructuredAgentSessionHandoffDeps) {
|
||||
this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store)
|
||||
this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({
|
||||
deps,
|
||||
operationGuard: this.operationGuard,
|
||||
flowContext: () => this.flowContext(),
|
||||
fail: (params, error) => this.fail(params, error)
|
||||
})
|
||||
}
|
||||
|
||||
status(sessionId: string): AgentSessionHandoffStatus {
|
||||
return (
|
||||
this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.requireRecord(sessionId))
|
||||
)
|
||||
}
|
||||
|
||||
drain = (): Promise<void> => this.flowRunner.drain()
|
||||
|
||||
async restore(sessionId: string): Promise<void> {
|
||||
await restoreStructuredAgentSessionHandoff(
|
||||
{
|
||||
deps: this.deps,
|
||||
requireRecord: (id) => this.requireRecord(id),
|
||||
flowContext: () => this.flowContext(),
|
||||
retainOwner: (id, owner) => this.tuiOwners.set(id, owner),
|
||||
setStatus: (id, status) => this.setStatus(id, status)
|
||||
},
|
||||
sessionId
|
||||
)
|
||||
}
|
||||
|
||||
async request(
|
||||
callerKey: string,
|
||||
params: AgentSessionHandoffRequest
|
||||
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
|
||||
const record = this.requireRecord(params.envelope.sessionId)
|
||||
const currentStatus = this.statuses.get(record.sessionId)
|
||||
const admission = await admitStructuredHandoffRequest({
|
||||
deps: this.deps,
|
||||
operationGuard: this.operationGuard,
|
||||
callerKey,
|
||||
params,
|
||||
record,
|
||||
...(currentStatus ? { status: currentStatus } : {})
|
||||
})
|
||||
if (admission.decision === 'replay') {
|
||||
const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome)
|
||||
if (replayedRefusal) {
|
||||
return { ok: false, refusal: replayedRefusal }
|
||||
}
|
||||
return this.success(record.sessionId, true)
|
||||
}
|
||||
if (admission.decision === 'refused') {
|
||||
return { ok: false, refusal: admission.refusal }
|
||||
}
|
||||
const { fingerprint } = admission
|
||||
const action = params.action ?? 'start'
|
||||
if (action === 'cancel-queued') {
|
||||
if (currentStatus?.phase !== 'queued' || currentStatus.direction !== params.direction) {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'agent_session_operation_conflict',
|
||||
'No matching queued handoff exists.'
|
||||
)
|
||||
}
|
||||
this.queue.cancel(record.sessionId)
|
||||
this.setStatus(record.sessionId, idleStructuredHandoffStatus(record))
|
||||
await this.deps.store.recordOperationOutcome({
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
outcome: { status: 'succeeded', sessionId: record.sessionId }
|
||||
})
|
||||
return this.success(record.sessionId, false)
|
||||
}
|
||||
if (!this.deps.transport) {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'structured_agent_session_unsupported',
|
||||
'Agent TUI handoff is unavailable on this host.'
|
||||
)
|
||||
}
|
||||
if (action === 'retry') {
|
||||
if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'agent_session_operation_conflict',
|
||||
'This handoff is no longer retryable.'
|
||||
)
|
||||
}
|
||||
this.begin(callerKey, params, null, fingerprint)
|
||||
return this.success(record.sessionId, false)
|
||||
}
|
||||
const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui'
|
||||
if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'agent_session_conflict',
|
||||
`The ${expectedOwner} runtime does not own this session.`
|
||||
)
|
||||
}
|
||||
if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'agent_session_conflict',
|
||||
'Resolve the pending question or approval before switching.'
|
||||
)
|
||||
}
|
||||
const turnId = activeStructuredAgentSessionTurnId(
|
||||
this.deps.session(record.sessionId).journal.snapshot().items
|
||||
)
|
||||
const tuiOwner = this.tuiOwners.get(record.sessionId)
|
||||
const busy =
|
||||
expectedOwner === 'native'
|
||||
? turnId !== null
|
||||
: structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle'
|
||||
if (busy && params.mode === 'now') {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'agent_session_conflict',
|
||||
'The current turn must finish before switching.'
|
||||
)
|
||||
}
|
||||
if (busy && params.mode === 'after-turn') {
|
||||
this.queueAfterTurn(callerKey, params, fingerprint)
|
||||
return this.success(record.sessionId, false)
|
||||
}
|
||||
if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'structured_agent_session_unsupported',
|
||||
'Exit the agent terminal after this turn to continue in chat.'
|
||||
)
|
||||
}
|
||||
this.begin(callerKey, params, turnId, fingerprint)
|
||||
return this.success(record.sessionId, false)
|
||||
}
|
||||
|
||||
private async refuseAdmitted(
|
||||
callerKey: string,
|
||||
params: AgentSessionHandoffRequest,
|
||||
code: AgentSessionWireRefusal['code'],
|
||||
message: string
|
||||
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
|
||||
return refuseAdmittedStructuredHandoff({
|
||||
deps: this.deps,
|
||||
callerKey,
|
||||
params,
|
||||
refusal: refusal(code, message)
|
||||
})
|
||||
}
|
||||
|
||||
private success(sessionId: string, replayed: boolean) {
|
||||
return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId))
|
||||
}
|
||||
|
||||
private queueAfterTurn(
|
||||
callerKey: string,
|
||||
params: AgentSessionHandoffRequest,
|
||||
fingerprint: string
|
||||
): void {
|
||||
const sessionId = params.envelope.sessionId
|
||||
let tuiReadiness: 'idle' | 'exited' | null = null
|
||||
this.setStatus(sessionId, {
|
||||
owner: params.direction === 'to-tui' ? 'native' : 'tui',
|
||||
direction: params.direction,
|
||||
phase: 'queued',
|
||||
stage: null,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
hostLabel: this.deps.transport?.hostLabel
|
||||
})
|
||||
const tuiOwner = this.tuiOwners.get(sessionId)
|
||||
this.queue.enqueue(
|
||||
sessionId,
|
||||
async (signal) => {
|
||||
if (params.direction === 'to-tui') {
|
||||
return !activeStructuredAgentSessionTurnId(
|
||||
this.deps.session(sessionId).journal.snapshot().items
|
||||
)
|
||||
}
|
||||
tuiReadiness = tuiOwner
|
||||
? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null)
|
||||
: null
|
||||
return tuiReadiness !== null
|
||||
},
|
||||
() => {
|
||||
const next = { ...params, mode: 'now' as const }
|
||||
this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private begin(
|
||||
callerKey: string,
|
||||
params: AgentSessionHandoffRequest,
|
||||
turnId: string | null,
|
||||
fingerprint: string,
|
||||
tuiAlreadyExited = false
|
||||
): void {
|
||||
this.flowRunner.begin({
|
||||
callerKey,
|
||||
params,
|
||||
turnId,
|
||||
fingerprint,
|
||||
tuiAlreadyExited
|
||||
})
|
||||
}
|
||||
|
||||
private flowContext(): StructuredAgentSessionHandoffFlowContext {
|
||||
return createStructuredHandoffFlowContext({
|
||||
deps: this.deps,
|
||||
owner: (sessionId) => this.tuiOwners.get(sessionId),
|
||||
retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner),
|
||||
releaseOwner: (sessionId) => void this.tuiOwners.delete(sessionId),
|
||||
setStatus: (sessionId, status) => this.setStatus(sessionId, status),
|
||||
enterPreparing: (record, operationId, direction) =>
|
||||
this.enterPreparing(record, operationId, direction),
|
||||
publishStage: (record, direction) => this.publishStage(record, direction),
|
||||
requireRecord: (sessionId) => this.requireRecord(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
private async enterPreparing(
|
||||
record: AgentSessionRecord,
|
||||
operationId: string,
|
||||
direction: 'to-tui' | 'to-native'
|
||||
): Promise<void> {
|
||||
const prepared = await setStoredAgentSessionHandoffStage(this.deps.store, {
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: 'preparing',
|
||||
handoffOperationId: operationId,
|
||||
now: this.deps.now()
|
||||
})
|
||||
this.publishStage(prepared, direction)
|
||||
}
|
||||
|
||||
private publishStage(record: AgentSessionRecord, direction: 'to-tui' | 'to-native'): void {
|
||||
this.setStatus(
|
||||
record.sessionId,
|
||||
switchingStructuredHandoffStatus(record, direction, this.deps.transport?.hostLabel)
|
||||
)
|
||||
}
|
||||
|
||||
private fail(params: AgentSessionHandoffRequest, error: unknown): void {
|
||||
const record = this.requireRecord(params.envelope.sessionId)
|
||||
this.setStatus(
|
||||
record.sessionId,
|
||||
failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel)
|
||||
)
|
||||
}
|
||||
|
||||
private setStatus(sessionId: string, status: AgentSessionHandoffStatus): void {
|
||||
this.statuses.set(sessionId, status)
|
||||
this.deps.publish(sessionId, status)
|
||||
}
|
||||
|
||||
private requireRecord(sessionId: string): AgentSessionRecord {
|
||||
const record = this.deps.store.getRecord(sessionId)
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
return record
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
|
||||
import { journalIdentityFor } from './structured-agent-session-attach'
|
||||
import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart'
|
||||
import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
|
||||
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
|
||||
|
||||
type HostHandoffAccess = {
|
||||
session: (sessionId: string) => StructuredAgentSessionHostSession
|
||||
eventSink: (sessionId: string) => DeferredStructuredAgentSessionEventSink
|
||||
flush: (sessionId: string) => Promise<void>
|
||||
serialize: (sessionId: string, task: () => Promise<void>) => Promise<void>
|
||||
subscribers: AgentSessionSubscribers
|
||||
now: () => number
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator
|
||||
|
||||
export async function refreshRecoverableStructuredHandoffStatus(
|
||||
handoff: StructuredAgentSessionHostHandoff,
|
||||
store: StructuredAgentSessionHostDeps['store'],
|
||||
sessionId: string
|
||||
) {
|
||||
const record = store.getRecord(sessionId)
|
||||
if (record && canRestoreLiveTuiOwner(record)) {
|
||||
await handoff.restore(sessionId)
|
||||
}
|
||||
return handoff.status(sessionId)
|
||||
}
|
||||
|
||||
export function createStructuredAgentSessionHostHandoff(
|
||||
deps: StructuredAgentSessionHostDeps,
|
||||
host: HostHandoffAccess
|
||||
): StructuredAgentSessionHostHandoff {
|
||||
return new StructuredAgentSessionHandoffCoordinator({
|
||||
store: deps.store,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}),
|
||||
session: host.session,
|
||||
suspendNative: async (sessionId) => {
|
||||
await deps.adapter.closeSession?.(sessionId)
|
||||
await host.flush(sessionId)
|
||||
host.eventSink(sessionId).unbind()
|
||||
},
|
||||
acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input),
|
||||
acquireNativeStop: async (sessionId, turnId, fence) =>
|
||||
(await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled,
|
||||
importTuiHistory: (input) => importTuiHistory(deps, host, input),
|
||||
publish: (sessionId, status) => {
|
||||
const session = host.session(sessionId)
|
||||
const fence = deps.store.getRecord(sessionId)?.lease.runtimeFence ?? session.fence
|
||||
host.subscribers.handoff(sessionId, session.journal, fence, status)
|
||||
},
|
||||
schedule: host.serialize,
|
||||
now: host.now
|
||||
})
|
||||
}
|
||||
|
||||
async function importTuiHistory(
|
||||
deps: StructuredAgentSessionHostDeps,
|
||||
host: HostHandoffAccess,
|
||||
input: { sessionId: string; fence: number; transcriptPath?: string }
|
||||
): Promise<void> {
|
||||
const session = host.session(input.sessionId)
|
||||
const record = deps.store.getRecord(input.sessionId)
|
||||
const head = record?.providerHandleChain.at(-1)
|
||||
if (!record || !head) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
const providerSessionId =
|
||||
head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId
|
||||
const imported = await importLegacyTranscriptIntoJournal({
|
||||
journal: session.journal,
|
||||
agent: head.handle.provider,
|
||||
sessionId: providerSessionId,
|
||||
fence: input.fence,
|
||||
...(input.transcriptPath ? { options: { filePath: input.transcriptPath } } : {})
|
||||
})
|
||||
if (!imported.ok) {
|
||||
throw new Error(imported.error)
|
||||
}
|
||||
host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence)
|
||||
}
|
||||
|
||||
async function acquireNativeHandoffOwner(
|
||||
deps: StructuredAgentSessionHostDeps,
|
||||
host: HostHandoffAccess,
|
||||
input: { sessionId: string; fence: number; spawnToken: string }
|
||||
): Promise<AgentSessionRecord> {
|
||||
const session = host.session(input.sessionId)
|
||||
const record = deps.store.getRecord(input.sessionId)
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
const eventSink = host.eventSink(input.sessionId)
|
||||
eventSink.unbind()
|
||||
await eventSink.drained()
|
||||
const acquired = await deps.adapter.acquire({
|
||||
identity: journalIdentityFor(record, session.params),
|
||||
fence: input.fence,
|
||||
spawnToken: input.spawnToken,
|
||||
events: eventSink.sink
|
||||
})
|
||||
let proved: AgentSessionRecord
|
||||
try {
|
||||
await deps.store.commitProcessIdentity({
|
||||
sessionId: input.sessionId,
|
||||
fence: input.fence,
|
||||
process: acquired.process,
|
||||
now: host.now()
|
||||
})
|
||||
proved = await deps.store.proveOwner({
|
||||
sessionId: input.sessionId,
|
||||
fence: input.fence,
|
||||
link: acquired.link,
|
||||
now: host.now()
|
||||
})
|
||||
} catch (error) {
|
||||
await deps.adapter.releaseAcquisition?.({ sessionId: input.sessionId })
|
||||
throw error
|
||||
}
|
||||
session.fence = proved.lease.runtimeFence
|
||||
eventSink.bind({
|
||||
journal: session.journal,
|
||||
fence: proved.lease.runtimeFence,
|
||||
publish: () => host.subscribers.publish(input.sessionId, session.journal)
|
||||
})
|
||||
host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence)
|
||||
return proved
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
createDeferredStructuredAgentSessionEventSink,
|
||||
type DeferredStructuredAgentSessionEventSink
|
||||
} from './structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host'
|
||||
import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer'
|
||||
|
||||
export class StructuredAgentSessionHostRuntimeState {
|
||||
private readonly eventSinks = new Map<string, DeferredStructuredAgentSessionEventSink>()
|
||||
private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer
|
||||
|
||||
constructor(
|
||||
private readonly deps: StructuredAgentSessionHostDeps,
|
||||
onLeaseRenewed?: (record: AgentSessionRecord) => Promise<void>
|
||||
) {
|
||||
this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store: deps.store,
|
||||
probe: (record) => this.probeRecord(record),
|
||||
now: () => deps.now?.() ?? Date.now(),
|
||||
...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}),
|
||||
onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error })
|
||||
})
|
||||
}
|
||||
|
||||
startLeaseRenewal(): void {
|
||||
this.leaseRenewer.start()
|
||||
}
|
||||
|
||||
stopLeaseRenewal(): void {
|
||||
this.leaseRenewer.stop()
|
||||
}
|
||||
|
||||
eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink {
|
||||
const existing = this.eventSinks.get(sessionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = createDeferredStructuredAgentSessionEventSink({
|
||||
onError: (error) => this.deps.onEventSinkError?.({ sessionId, error })
|
||||
})
|
||||
this.eventSinks.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
discardEventSink(sessionId: string): void {
|
||||
this.eventSinks.delete(sessionId)
|
||||
}
|
||||
|
||||
flushEventSink(sessionId: string): Promise<void> {
|
||||
return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve()
|
||||
}
|
||||
|
||||
async flushAllEventSinks(): Promise<void> {
|
||||
await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained()))
|
||||
}
|
||||
|
||||
probeOwner(sessionId: string): Promise<AgentSessionOwnerProbe> {
|
||||
const record = this.deps.store.getRecord(sessionId)
|
||||
if (!record || record.lease.ownerProcess === null) {
|
||||
return Promise.resolve({ outcome: 'reservation-unused' })
|
||||
}
|
||||
return this.probeRecord(record)
|
||||
}
|
||||
|
||||
probeRecord(record: AgentSessionRecord): Promise<AgentSessionOwnerProbe> {
|
||||
return (
|
||||
this.deps.probeOwner?.(record) ??
|
||||
Promise.resolve({
|
||||
outcome: 'indeterminate',
|
||||
reason: 'This host cannot probe structured session owners.'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type StructuredAgentSessionTab = {
|
||||
sessionId: string
|
||||
workspaceId: string
|
||||
agent: 'codex'
|
||||
}
|
||||
|
||||
export function listStructuredAgentSessionTabs(
|
||||
sessions: ReadonlyMap<string, { params: { location: { workspaceId: string } } }>
|
||||
): StructuredAgentSessionTab[] {
|
||||
return [...sessions.entries()].map(([sessionId, session]) => ({
|
||||
sessionId,
|
||||
workspaceId: session.params.location.workspaceId,
|
||||
agent: 'codex'
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
|
||||
import { attachFingerprintFields } from './structured-agent-session-attach'
|
||||
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
|
||||
|
||||
export const HOST_TEST_NOW = 1_800_000_000_000
|
||||
export const HOST_TEST_SESSION = 'session-alpha'
|
||||
export const HOST_TEST_THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
|
||||
|
||||
export const HOST_TEST_LOCATION: AgentSessionExecutionLocation = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
}
|
||||
|
||||
let operations = 0
|
||||
|
||||
export function resetHostTestOperationIds(): void {
|
||||
operations = 0
|
||||
}
|
||||
|
||||
export function hostTestOperationId(): string {
|
||||
operations += 1
|
||||
return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}`
|
||||
}
|
||||
|
||||
export function hostTestMessage(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
export function hostTestAttachParams(
|
||||
expectedRuntimeFence: number | null,
|
||||
overrides: Partial<AgentSessionAttachParams> = {}
|
||||
): AgentSessionAttachParams {
|
||||
const params: AgentSessionAttachParams = {
|
||||
envelope: {
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence,
|
||||
payloadFingerprint: '0'.repeat(64)
|
||||
},
|
||||
location: HOST_TEST_LOCATION,
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
|
||||
runtimeKind: 'native',
|
||||
providerHandle: { kind: 'codex', threadId: HOST_TEST_THREAD },
|
||||
...overrides
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
envelope: {
|
||||
...params.envelope,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.attach',
|
||||
sessionId: params.envelope.sessionId,
|
||||
fields: attachFingerprintFields(params)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
|
||||
import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types'
|
||||
|
||||
export type StructuredAgentSessionCaller = { callerKey: string }
|
||||
|
||||
export type StructuredAgentSessionHostSession = {
|
||||
journal: AgentSessionJournal
|
||||
params: AgentSessionAttachParams
|
||||
fence: number
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHostDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
journalRoot: string
|
||||
claimKeyId: string
|
||||
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
mintSpawnToken?: () => string
|
||||
now?: () => number
|
||||
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
|
||||
handoffTransport?: StructuredAgentSessionHandoffTransport
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
// The host against a real record store and a real journal, with only the
|
||||
// provider adapter stubbed — admission, idempotency, and the journal writes are
|
||||
// exactly the ones that ship.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import type {
|
||||
AgentSessionExecutionLocation,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionMutationEnvelope,
|
||||
AgentSessionSubscribeEvent
|
||||
@@ -28,35 +20,20 @@ import type {
|
||||
AgentSessionDispatchOutcome,
|
||||
StructuredAgentSessionAdapter
|
||||
} from './structured-agent-session-adapter'
|
||||
import { attachFingerprintFields } from './structured-agent-session-attach'
|
||||
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const SESSION = 'session-alpha'
|
||||
const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
|
||||
|
||||
const LOCATION: AgentSessionExecutionLocation = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
}
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
let operations = 0
|
||||
|
||||
/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */
|
||||
function operationId(): string {
|
||||
operations += 1
|
||||
return `${NOW}-${operations.toString(16).padStart(32, '0')}`
|
||||
}
|
||||
|
||||
function message(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
function envelope(
|
||||
method: string,
|
||||
fields: Record<string, unknown>,
|
||||
@@ -64,9 +41,7 @@ function envelope(
|
||||
): AgentSessionMutationEnvelope {
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: operationId(),
|
||||
// A mutation names the fence it believes it is writing under; only attach
|
||||
// may leave it null.
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method,
|
||||
@@ -78,44 +53,11 @@ function envelope(
|
||||
}
|
||||
|
||||
function attachParams(overrides: Partial<AgentSessionAttachParams> = {}): AgentSessionAttachParams {
|
||||
const params: AgentSessionAttachParams = {
|
||||
envelope: {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: operationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: '0'.repeat(64)
|
||||
},
|
||||
location: LOCATION,
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
|
||||
runtimeKind: 'native',
|
||||
providerHandle: { kind: 'codex', threadId: THREAD },
|
||||
...overrides
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
envelope: {
|
||||
...params.envelope,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.attach',
|
||||
sessionId: params.envelope.sessionId,
|
||||
fields: attachFingerprintFields(params)
|
||||
})
|
||||
}
|
||||
}
|
||||
return hostTestAttachParams(null, overrides)
|
||||
}
|
||||
|
||||
/** `ensure`: the same transition as create, from a fence the client already holds. */
|
||||
function ensureParams(fence: number): AgentSessionAttachParams {
|
||||
return attachParams({
|
||||
envelope: {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: operationId(),
|
||||
expectedRuntimeFence: fence,
|
||||
payloadFingerprint: '0'.repeat(64)
|
||||
}
|
||||
})
|
||||
return hostTestAttachParams(fence)
|
||||
}
|
||||
|
||||
let root: string
|
||||
@@ -185,7 +127,7 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-wire-host-'))
|
||||
operations = 0
|
||||
resetHostTestOperationIds()
|
||||
ordinal = 0
|
||||
acquire = vi.fn(async ({ fence }) => ({
|
||||
process: {
|
||||
@@ -197,8 +139,6 @@ beforeEach(async () => {
|
||||
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
|
||||
@@ -221,6 +161,7 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -228,7 +169,6 @@ describe('attach', () => {
|
||||
it('reserves the lease, spawns through the adapter, and opens the journal', async () => {
|
||||
const result = await host.attach(CALLER, attachParams())
|
||||
expect(result).toMatchObject({ ok: true, replayed: false })
|
||||
expect(host.hasSession(SESSION)).toBe(true)
|
||||
const record = store.getRecord(SESSION)
|
||||
expect(record?.lease.ownerProcess?.pid).toBe(4242)
|
||||
expect(record?.lease.handoffStage).toBeNull()
|
||||
@@ -409,7 +349,7 @@ describe('attach', () => {
|
||||
describe('send', () => {
|
||||
it('writes the submission before dispatching and resolves it accepted', async () => {
|
||||
await attach()
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
const result = await host.send(CALLER, {
|
||||
envelope: envelope('agentSession.send', { body }),
|
||||
body
|
||||
@@ -427,7 +367,7 @@ describe('send', () => {
|
||||
it('settles a thrown dispatch as unknown, never as a rejection', async () => {
|
||||
await attach()
|
||||
dispatch.mockRejectedValueOnce(new Error('socket closed'))
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
const result = await host.send(CALLER, {
|
||||
envelope: envelope('agentSession.send', { body }),
|
||||
body
|
||||
@@ -437,7 +377,7 @@ describe('send', () => {
|
||||
|
||||
it('replays a retried send from the journal without dispatching twice', async () => {
|
||||
await attach()
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
const params = { envelope: envelope('agentSession.send', { body }), body }
|
||||
await host.send(CALLER, params)
|
||||
const retry = await host.send(CALLER, params)
|
||||
@@ -450,7 +390,7 @@ describe('send', () => {
|
||||
dispatch
|
||||
.mockRejectedValueOnce(new Error('socket closed'))
|
||||
.mockImplementationOnce(async () => accepted())
|
||||
const body = message('possibly delivered')
|
||||
const body = hostTestMessage('possibly delivered')
|
||||
const params = { envelope: envelope('agentSession.send', { body }), body }
|
||||
|
||||
const first = await host.send(CALLER, params)
|
||||
@@ -472,7 +412,7 @@ describe('send', () => {
|
||||
|
||||
it('refuses a stale fence and hands back the current one', async () => {
|
||||
const record = await attach()
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
const result = await host.send(CALLER, {
|
||||
envelope: envelope(
|
||||
'agentSession.send',
|
||||
@@ -489,7 +429,7 @@ describe('send', () => {
|
||||
|
||||
it('does not let a refused call leave a ledger row that replays past the fence', async () => {
|
||||
const record = await attach()
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
const params = {
|
||||
envelope: envelope(
|
||||
'agentSession.send',
|
||||
@@ -507,7 +447,7 @@ describe('send', () => {
|
||||
})
|
||||
|
||||
it('refuses any mutation against a session this host has not attached', async () => {
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
expect(
|
||||
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
|
||||
).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } })
|
||||
@@ -738,7 +678,7 @@ describe('restart', () => {
|
||||
|
||||
it('restores durable journals for read-only history without acquiring a provider', async () => {
|
||||
await attach()
|
||||
const body = message('persisted conversation')
|
||||
const body = hostTestMessage('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()
|
||||
@@ -753,13 +693,40 @@ describe('restart', () => {
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale TUI recovery when restart successfully reacquires the native owner', async () => {
|
||||
await attach()
|
||||
await store.transitionHandoff(SESSION, (record) => ({
|
||||
...record,
|
||||
lease: {
|
||||
...record.lease,
|
||||
runtimeKind: 'tui',
|
||||
handoffStage: 'manual-recovery'
|
||||
}
|
||||
}))
|
||||
await reboot(async () => ({ outcome: 'pid-absent' }))
|
||||
acquire.mockClear()
|
||||
|
||||
await host.restoreReadableSessions()
|
||||
|
||||
expect(acquire).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({
|
||||
owner: 'native',
|
||||
phase: 'idle',
|
||||
stage: null
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' }))
|
||||
|
||||
// Nothing proved the previous process dead, so the lease routes to manual
|
||||
// recovery rather than letting a second writer in behind it.
|
||||
expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_ownership_unknown' }
|
||||
@@ -776,7 +743,6 @@ describe('restart', () => {
|
||||
await reboot(probe)
|
||||
|
||||
await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded')
|
||||
// One unlucky startup must not strand the session for this host's lifetime.
|
||||
const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))
|
||||
expect(reattached).toMatchObject({ ok: true })
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
@@ -792,7 +758,7 @@ describe('subscribe', () => {
|
||||
sessionId: SESSION,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
|
||||
|
||||
expect(events[0]?.type).toBe('snapshot')
|
||||
@@ -807,7 +773,7 @@ describe('subscribe', () => {
|
||||
|
||||
it('resumes from a client cursor with only the rows it missed', async () => {
|
||||
await attach()
|
||||
const body = message('add a retry')
|
||||
const body = hostTestMessage('add a retry')
|
||||
const first = await host.send(CALLER, {
|
||||
envelope: envelope('agentSession.send', { body }),
|
||||
body
|
||||
@@ -823,10 +789,9 @@ describe('subscribe', () => {
|
||||
emit: (event) => events.push(event),
|
||||
cursor: first.cursor
|
||||
})
|
||||
// Nothing new since that cursor: a resume is silent, not a redundant replay.
|
||||
expect(events).toHaveLength(0)
|
||||
expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } })
|
||||
|
||||
const second = message('and a timeout')
|
||||
const second = hostTestMessage('and a timeout')
|
||||
await host.send(CALLER, {
|
||||
envelope: envelope('agentSession.send', { body: second }),
|
||||
body: second
|
||||
@@ -850,7 +815,7 @@ describe('subscribe', () => {
|
||||
sessionId: SESSION,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
const body = message('survive subscriber failure')
|
||||
const body = hostTestMessage('survive subscriber failure')
|
||||
|
||||
const result = await host.send(CALLER, {
|
||||
envelope: envelope('agentSession.send', { body }),
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
// The structured agent-session host: one place where the lease, the journal,
|
||||
// and the provider adapter meet.
|
||||
//
|
||||
// Every mutating call takes the same route — recompute the fingerprint, admit
|
||||
// through the durable operation ledger, check the lease, then act — so no
|
||||
// method can grow its own admission rules. Calls against one session are
|
||||
// serialized; the journal's own queue orders writes, but admission has to be
|
||||
// single-file too or two clients could both read one prompt as pending.
|
||||
// Structured agent-session host: where the lease, journal, and provider adapter meet.
|
||||
// Mutations share one durable admission path and serialize per session.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type {
|
||||
AgentSessionExecutionLocation,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionCancelResult,
|
||||
AgentSessionHistoryRequest,
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffResult,
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionMutationEnvelope,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionOptionResult,
|
||||
@@ -27,16 +19,9 @@ import type {
|
||||
AgentSessionSendResult,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { readAgentSessionHistory } from './agent-session-history-page'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
|
||||
import { performAttach } from './structured-agent-session-attach-flow'
|
||||
import {
|
||||
createDeferredStructuredAgentSessionEventSink,
|
||||
type DeferredStructuredAgentSessionEventSink
|
||||
} from './structured-agent-session-event-sink'
|
||||
import {
|
||||
admitAndRunAgentSessionMutation,
|
||||
AGENT_SESSION_NOT_ATTACHED,
|
||||
@@ -53,59 +38,59 @@ import {
|
||||
} from './structured-agent-session-mutation-plans'
|
||||
import {
|
||||
AgentSessionSubscribers,
|
||||
type AgentSessionSubscriberEmit
|
||||
type AgentSessionSubscribeInput
|
||||
} from './structured-agent-session-subscribers'
|
||||
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
|
||||
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
|
||||
import * as providerRouting from './structured-agent-session-provider-routing'
|
||||
import {
|
||||
createStructuredAgentSessionHostHandoff,
|
||||
refreshRecoverableStructuredHandoffStatus,
|
||||
type StructuredAgentSessionHostHandoff
|
||||
} from './structured-agent-session-host-handoff'
|
||||
import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
|
||||
export type StructuredAgentSessionCaller = {
|
||||
/** Stable per-client identity; scopes the operation ledger and records who
|
||||
* answered a prompt. */
|
||||
callerKey: string
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHostDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
journalRoot: string
|
||||
/** Key id the host's claims are minted under. */
|
||||
claimKeyId: string
|
||||
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
mintSpawnToken?: () => string
|
||||
now?: () => number
|
||||
/** A journal append the provider streamed that could not be written. Unset
|
||||
* drops it: the alternative is throwing inside the provider's notification
|
||||
* callback, which would take the connection down over one lost row. */
|
||||
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
|
||||
}
|
||||
|
||||
type SessionState = {
|
||||
journal: AgentSessionJournal
|
||||
params: AgentSessionAttachParams
|
||||
fence: number
|
||||
}
|
||||
export type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps
|
||||
} from './structured-agent-session-host-types'
|
||||
|
||||
export class StructuredAgentSessionHost {
|
||||
private readonly sessions = new Map<string, SessionState>()
|
||||
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
|
||||
private readonly subscribers = new AgentSessionSubscribers()
|
||||
private readonly tasks = new StructuredAgentSessionTaskQueue()
|
||||
private readonly eventSinks = new Map<string, DeferredStructuredAgentSessionEventSink>()
|
||||
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
|
||||
private readonly reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
|
||||
private readonly handoffs: StructuredAgentSessionHostHandoff
|
||||
|
||||
constructor(readonly deps: StructuredAgentSessionHostDeps) {
|
||||
this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) =>
|
||||
this.restoreRenewedHandoff(record.sessionId)
|
||||
)
|
||||
this.reconcileLeases = createRestartReconciler({
|
||||
store: deps.store,
|
||||
probe: (record) => this.probeRecord(record),
|
||||
probe: (record) => this.runtimeState.probeRecord(record),
|
||||
now: () => this.now()
|
||||
})
|
||||
this.handoffs = createStructuredAgentSessionHostHandoff(deps, {
|
||||
session: (sessionId) => this.requireSession(sessionId),
|
||||
eventSink: (sessionId) => this.runtimeState.eventSinkFor(sessionId),
|
||||
flush: (sessionId) => this.flushStreamedEvents(sessionId),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
subscribers: this.subscribers,
|
||||
now: this.now
|
||||
})
|
||||
this.runtimeState.startLeaseRenewal()
|
||||
}
|
||||
|
||||
private now = (): number => this.deps.now?.() ?? Date.now()
|
||||
|
||||
hasSession(sessionId: string): boolean {
|
||||
return this.sessions.has(sessionId)
|
||||
}
|
||||
hasSession = (sessionId: string): boolean => this.sessions.has(sessionId)
|
||||
|
||||
supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean {
|
||||
return providerRouting.adapterSupportsAgentSessionCreate(this.deps.adapter, location, agent)
|
||||
@@ -137,7 +122,8 @@ export class StructuredAgentSessionHost {
|
||||
),
|
||||
serialize: (sessionId, task) => this.serialize(sessionId, task),
|
||||
hasSession: (sessionId) => this.sessions.has(sessionId),
|
||||
onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored)
|
||||
onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored),
|
||||
restoreHandoff: (sessionId) => this.handoffs.restore(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,10 +131,14 @@ export class StructuredAgentSessionHost {
|
||||
return this.tasks.serialize(sessionId, task)
|
||||
}
|
||||
|
||||
/**
|
||||
* `create` and `ensure` in one: a null expected fence means the session must
|
||||
* not exist yet, any other value is a compare-and-swap against the lease.
|
||||
*/
|
||||
private restoreRenewedHandoff(sessionId: string): Promise<void> {
|
||||
return this.serialize(sessionId, async () => {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
attach(
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: AgentSessionAttachParams
|
||||
@@ -159,7 +149,7 @@ export class StructuredAgentSessionHost {
|
||||
if (unreconciled) {
|
||||
return refuseAgentSessionMutation(unreconciled)
|
||||
}
|
||||
const eventSink = this.eventSinkFor(sessionId)
|
||||
const eventSink = this.runtimeState.eventSinkFor(sessionId)
|
||||
const attached = await performAttach({
|
||||
store: this.deps.store,
|
||||
adapter: this.deps.adapter,
|
||||
@@ -174,7 +164,7 @@ export class StructuredAgentSessionHost {
|
||||
spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(),
|
||||
claimKeyId: this.deps.claimKeyId,
|
||||
handoffOperationId: params.envelope.clientOperationId,
|
||||
probe: await this.probeOwner(sessionId)
|
||||
probe: await this.runtimeState.probeOwner(sessionId)
|
||||
},
|
||||
callerKey: caller.callerKey,
|
||||
params,
|
||||
@@ -190,9 +180,6 @@ export class StructuredAgentSessionHost {
|
||||
} else {
|
||||
this.subscribers.publish(sessionId, attached.journal)
|
||||
}
|
||||
// Bound last: everything the provider streamed while the journal was
|
||||
// opening lands after the attach result the client is about to read,
|
||||
// never interleaved with it.
|
||||
eventSink.bind({
|
||||
journal: attached.journal,
|
||||
fence,
|
||||
@@ -200,66 +187,23 @@ export class StructuredAgentSessionHost {
|
||||
})
|
||||
}
|
||||
})
|
||||
// A refused attach that never reached a journal leaves the sink holding
|
||||
// writes nothing will ever drain.
|
||||
if (!attached.ok && !this.sessions.has(sessionId)) {
|
||||
eventSink.close()
|
||||
this.eventSinks.delete(sessionId)
|
||||
this.runtimeState.discardEventSink(sessionId)
|
||||
}
|
||||
return attached
|
||||
})
|
||||
return this.tasks.trackAttach(attaching)
|
||||
}
|
||||
|
||||
/** Settles every row the provider streamed. Callers that must read the
|
||||
* journal immediately after provider activity await this first. */
|
||||
flushStreamedEvents(sessionId: string): Promise<void> {
|
||||
return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve()
|
||||
return this.runtimeState.flushEventSink(sessionId)
|
||||
}
|
||||
|
||||
/** Settles final provider rows after every child has stopped producing them. */
|
||||
async flushAllStreamedEvents(): Promise<void> {
|
||||
// An acquired provider can still be opening its journal; bind before draining.
|
||||
this.runtimeState.stopLeaseRenewal()
|
||||
await this.tasks.drainAttaches()
|
||||
await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained()))
|
||||
}
|
||||
|
||||
/** One sink per session, rebound on every attach. A single identity lets an
|
||||
* adapter hold it across a re-acquire without noticing the new journal. */
|
||||
private eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink {
|
||||
const existing = this.eventSinks.get(sessionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = createDeferredStructuredAgentSessionEventSink({
|
||||
onError: (error) => this.deps.onEventSinkError?.({ sessionId, error })
|
||||
})
|
||||
this.eventSinks.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness of whatever currently owns the session. Never client-asserted: a
|
||||
* client that could claim the owner is gone could evict a live one. Without a
|
||||
* host prober the answer is `indeterminate`, which routes to manual recovery
|
||||
* rather than a silent takeover.
|
||||
*/
|
||||
private probeOwner(sessionId: string): Promise<AgentSessionOwnerProbe> {
|
||||
const record = this.deps.store.getRecord(sessionId)
|
||||
if (!record || record.lease.ownerProcess === null) {
|
||||
return Promise.resolve({ outcome: 'reservation-unused' })
|
||||
}
|
||||
return this.probeRecord(record)
|
||||
}
|
||||
|
||||
private probeRecord(record: AgentSessionRecord): Promise<AgentSessionOwnerProbe> {
|
||||
return (
|
||||
this.deps.probeOwner?.(record) ??
|
||||
Promise.resolve({
|
||||
outcome: 'indeterminate',
|
||||
reason: 'This host cannot probe structured session owners.'
|
||||
})
|
||||
)
|
||||
await this.runtimeState.flushAllEventSinks()
|
||||
}
|
||||
|
||||
send(
|
||||
@@ -325,11 +269,21 @@ export class StructuredAgentSessionHost {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged read. Throws `agent_session_ownership_unknown` for a session this host
|
||||
* has not attached — a reset would tell the client to reload something that
|
||||
* does not exist here.
|
||||
*/
|
||||
requestHandoff(
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: AgentSessionHandoffRequest
|
||||
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
|
||||
this.requireSession(params.envelope.sessionId)
|
||||
return this.handoffs.request(caller.callerKey, params)
|
||||
}
|
||||
|
||||
async handoffStatus(sessionId: string): Promise<AgentSessionHandoffStatus> {
|
||||
this.requireSession(sessionId)
|
||||
return this.serialize(sessionId, () =>
|
||||
refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult {
|
||||
const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request)
|
||||
const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence
|
||||
@@ -339,20 +293,20 @@ export class StructuredAgentSessionHost {
|
||||
return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence }
|
||||
}
|
||||
|
||||
subscribe(input: {
|
||||
id: string
|
||||
sessionId: string
|
||||
emit: AgentSessionSubscriberEmit
|
||||
cursor?: AgentJournalCursor
|
||||
}): () => void {
|
||||
subscribe(input: AgentSessionSubscribeInput): () => void {
|
||||
const session = this.requireSession(input.sessionId)
|
||||
const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0
|
||||
return this.subscribers.open({ ...input, journal: session.journal, fence })
|
||||
return this.subscribers.open({
|
||||
...input,
|
||||
journal: session.journal,
|
||||
fence,
|
||||
handoff: this.handoffs.status(input.sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id)
|
||||
|
||||
private requireSession(sessionId: string): SessionState {
|
||||
private requireSession(sessionId: string): StructuredAgentSessionHostSession {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error(AGENT_SESSION_NOT_ATTACHED.code)
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const roots: string[] = []
|
||||
|
||||
async function liveStore(): Promise<AgentSessionRecordStore> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-'))
|
||||
roots.push(root)
|
||||
const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' })
|
||||
const reserved = await store.reserveOwner({
|
||||
sessionId: 'session-renewal',
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: root },
|
||||
runtimeKind: 'native',
|
||||
expectedFence: null,
|
||||
spawnToken: 'spawn-renewal',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: null,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
operation: {
|
||||
callerKey: 'test',
|
||||
operationId: `${NOW}-00000000000000000000000000000001`,
|
||||
fingerprint: 'create'
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
await store.commitProcessIdentity({
|
||||
sessionId: 'session-renewal',
|
||||
fence: reserved.record.lease.runtimeFence,
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 4242,
|
||||
processStartTimeMs: NOW - 1_000,
|
||||
spawnToken: 'spawn-renewal'
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
await store.proveOwner({
|
||||
sessionId: 'session-renewal',
|
||||
fence: reserved.record.lease.runtimeFence,
|
||||
link: {
|
||||
linkId: 'link-renewal',
|
||||
handle: { provider: 'codex', threadId: 'thread-renewal' },
|
||||
origin: 'created',
|
||||
mintedAtFence: reserved.record.lease.runtimeFence,
|
||||
observedAt: NOW
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('structured agent-session lease renewal', () => {
|
||||
it('drives renewal on the production interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = await liveStore()
|
||||
let now = NOW
|
||||
const renewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store,
|
||||
probe: async () => ({
|
||||
outcome: 'identity-matched',
|
||||
matchedOn: ['process-start-time']
|
||||
}),
|
||||
now: () => now
|
||||
})
|
||||
try {
|
||||
renewer.start()
|
||||
now += 10_000
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(now)
|
||||
)
|
||||
} finally {
|
||||
renewer.stop()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renews every live owner only after re-proving its child identity', async () => {
|
||||
const store = await liveStore()
|
||||
const probe = vi.fn(async () => ({
|
||||
outcome: 'identity-matched' as const,
|
||||
matchedOn: ['process-start-time' as const]
|
||||
}))
|
||||
const onRenewed = vi.fn()
|
||||
const renewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store,
|
||||
probe,
|
||||
now: () => NOW + 10_000,
|
||||
onRenewed
|
||||
})
|
||||
|
||||
await renewer.renewNow()
|
||||
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000)
|
||||
expect(onRenewed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'session-renewal' })
|
||||
)
|
||||
})
|
||||
|
||||
it('stops extending the lease when child proof is no longer sufficient', async () => {
|
||||
const store = await liveStore()
|
||||
const onError = vi.fn()
|
||||
const renewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store,
|
||||
probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }),
|
||||
now: () => NOW + 10_000,
|
||||
onError
|
||||
})
|
||||
|
||||
await renewer.renewNow()
|
||||
|
||||
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW)
|
||||
expect(onError).toHaveBeenCalledWith({
|
||||
sessionId: 'session-renewal',
|
||||
error: expect.any(Error)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
AGENT_SESSION_LEASE_TTL_MS,
|
||||
type AgentSessionRecordStore
|
||||
} from '../../runtime/agent-session-record-store'
|
||||
|
||||
const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3)
|
||||
|
||||
export class StructuredAgentSessionLeaseRenewer {
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
private running = false
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
store: AgentSessionRecordStore
|
||||
probe: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
now: () => number
|
||||
onRenewed?: (record: AgentSessionRecord) => Promise<void>
|
||||
onError?: (input: { sessionId: string; error: unknown }) => void
|
||||
intervalMs?: number
|
||||
}
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setInterval(() => void this.renewNow(), this.input.intervalMs ?? RENEW_INTERVAL_MS)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
async renewNow(): Promise<void> {
|
||||
if (this.running) {
|
||||
return
|
||||
}
|
||||
this.running = true
|
||||
try {
|
||||
await Promise.all(
|
||||
this.input.store
|
||||
.listRecords()
|
||||
.filter(
|
||||
(record) =>
|
||||
!record.lease.unreconciled &&
|
||||
record.lease.claimStatus === 'live' &&
|
||||
record.lease.ownerProcess !== null
|
||||
)
|
||||
.map((record) => this.renewRecord(record))
|
||||
)
|
||||
} finally {
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
|
||||
private async renewRecord(record: AgentSessionRecord): Promise<void> {
|
||||
try {
|
||||
const renewed = await this.input.store.renewLease({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
childProbe: await this.input.probe(record),
|
||||
now: this.input.now()
|
||||
})
|
||||
await this.input.onRenewed?.(renewed)
|
||||
} catch (error) {
|
||||
this.input.onError?.({ sessionId: record.sessionId, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionOwnerRuntimeKind,
|
||||
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'
|
||||
@@ -49,7 +52,11 @@ export async function restoreStructuredAgentSessionRead(
|
||||
|
||||
export function attachParamsForRecord(
|
||||
record: AgentSessionRecord,
|
||||
input: { clientOperationId: string; expectedRuntimeFence: number }
|
||||
input: {
|
||||
clientOperationId: string
|
||||
expectedRuntimeFence: number
|
||||
runtimeKind?: AgentSessionOwnerRuntimeKind
|
||||
}
|
||||
): AgentSessionAttachParams {
|
||||
const params: AgentSessionAttachParams = {
|
||||
envelope: {
|
||||
@@ -62,7 +69,7 @@ export function attachParamsForRecord(
|
||||
provider: record.provider,
|
||||
agent: record.provider,
|
||||
accountHome: record.accountHome,
|
||||
runtimeKind: record.lease.runtimeKind
|
||||
runtimeKind: input.runtimeKind ?? record.lease.runtimeKind
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function restoreStructuredAgentSessionsOnRestart(input: {
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
hasSession: (sessionId: string) => boolean
|
||||
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
|
||||
restoreHandoff: (sessionId: string) => Promise<void>
|
||||
}): Promise<void> {
|
||||
await Promise.all(
|
||||
input.records.map(async ({ sessionId }) => {
|
||||
@@ -26,10 +27,12 @@ export async function restoreStructuredAgentSessionsOnRestart(input: {
|
||||
if (
|
||||
!unreconciled &&
|
||||
current?.lease.claimStatus === 'released' &&
|
||||
current.lease.handoffStage === null &&
|
||||
(await input.resume(
|
||||
attachParamsForRecord(current, {
|
||||
clientOperationId: input.operationId(),
|
||||
expectedRuntimeFence: current.lease.runtimeFence
|
||||
expectedRuntimeFence: current.lease.runtimeFence,
|
||||
runtimeKind: 'native'
|
||||
})
|
||||
))
|
||||
) {
|
||||
@@ -48,6 +51,9 @@ export async function restoreStructuredAgentSessionsOnRestart(input: {
|
||||
input.onReadable(sessionId, restored)
|
||||
}
|
||||
})
|
||||
if (input.hasSession(sessionId)) {
|
||||
await input.restoreHandoff(sessionId)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalResetReason
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { projectJournalBatch } from './agent-session-journal-batch'
|
||||
|
||||
export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void
|
||||
export type AgentSessionSubscribeInput = {
|
||||
id: string
|
||||
sessionId: string
|
||||
emit: AgentSessionSubscriberEmit
|
||||
cursor?: AgentJournalCursor
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
id: string
|
||||
@@ -34,6 +43,7 @@ export class AgentSessionSubscribers {
|
||||
fence: number
|
||||
emit: AgentSessionSubscriberEmit
|
||||
cursor?: AgentJournalCursor
|
||||
handoff?: AgentSessionHandoffStatus
|
||||
}): () => void {
|
||||
const snapshot = input.journal.snapshot()
|
||||
const subscriber: Subscriber = {
|
||||
@@ -48,13 +58,14 @@ export class AgentSessionSubscribers {
|
||||
this.bySession.set(input.sessionId, session)
|
||||
|
||||
if (input.cursor) {
|
||||
this.deliver(subscriber, input.journal)
|
||||
this.deliver(subscriber, input.journal, input.handoff)
|
||||
} else {
|
||||
this.emit(subscriber, {
|
||||
type: 'snapshot',
|
||||
sessionId: input.sessionId,
|
||||
snapshot,
|
||||
fence: input.fence
|
||||
fence: input.fence,
|
||||
...(input.handoff ? { handoff: input.handoff } : {})
|
||||
})
|
||||
subscriber.cursor = snapshot.cursor
|
||||
}
|
||||
@@ -107,11 +118,29 @@ export class AgentSessionSubscribers {
|
||||
}
|
||||
}
|
||||
|
||||
handoff(
|
||||
sessionId: string,
|
||||
journal: AgentSessionJournal,
|
||||
fence: number,
|
||||
handoff: AgentSessionHandoffStatus
|
||||
): void {
|
||||
const snapshot = journal.snapshot()
|
||||
for (const subscriber of this.subscribers(sessionId)) {
|
||||
this.emit(subscriber, { type: 'snapshot', sessionId, snapshot, fence, handoff })
|
||||
subscriber.cursor = snapshot.cursor
|
||||
subscriber.fence = fence
|
||||
}
|
||||
}
|
||||
|
||||
private subscribers(sessionId: string): Subscriber[] {
|
||||
return [...(this.bySession.get(sessionId)?.values() ?? [])]
|
||||
}
|
||||
|
||||
private deliver(subscriber: Subscriber, journal: AgentSessionJournal): void {
|
||||
private deliver(
|
||||
subscriber: Subscriber,
|
||||
journal: AgentSessionJournal,
|
||||
handoff?: AgentSessionHandoffStatus
|
||||
): void {
|
||||
const since = journal.readSince(subscriber.cursor)
|
||||
const snapshot = journal.snapshot()
|
||||
if (!since.ok) {
|
||||
@@ -120,12 +149,27 @@ export class AgentSessionSubscribers {
|
||||
sessionId: subscriber.sessionId,
|
||||
reset: since.reset,
|
||||
snapshot,
|
||||
fence: subscriber.fence
|
||||
fence: subscriber.fence,
|
||||
...(handoff ? { handoff } : {})
|
||||
})
|
||||
subscriber.cursor = snapshot.cursor
|
||||
return
|
||||
}
|
||||
if (since.rows.length === 0) {
|
||||
if (handoff) {
|
||||
this.emit(subscriber, {
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: {
|
||||
cursor: snapshot.cursor,
|
||||
items: [],
|
||||
removedItemIds: [],
|
||||
submissions: []
|
||||
},
|
||||
fence: subscriber.fence,
|
||||
handoff
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const projected = projectJournalBatch({
|
||||
@@ -139,7 +183,8 @@ export class AgentSessionSubscribers {
|
||||
sessionId: subscriber.sessionId,
|
||||
reset: projected.reset,
|
||||
snapshot,
|
||||
fence: subscriber.fence
|
||||
fence: subscriber.fence,
|
||||
...(handoff ? { handoff } : {})
|
||||
})
|
||||
subscriber.cursor = snapshot.cursor
|
||||
return
|
||||
@@ -147,7 +192,9 @@ export class AgentSessionSubscribers {
|
||||
this.emit(subscriber, {
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: projected.batch
|
||||
batch: projected.batch,
|
||||
...(handoff ? { fence: subscriber.fence } : {}),
|
||||
...(handoff ? { handoff } : {})
|
||||
})
|
||||
subscriber.cursor = projected.batch.cursor
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import { applyAgentSessionRestartAdjudication } from './agent-session-lease-transitions'
|
||||
|
||||
describe('agent session handoff restart transitions', () => {
|
||||
it('preserves the stopped owner and operation for durable retry', () => {
|
||||
const handoffOperationId = '1800000000000-00000000000000000000000000000001'
|
||||
const record = agentSessionRecordFixture(
|
||||
agentSessionLeaseFixture({
|
||||
runtimeKind: 'native',
|
||||
runtimeFence: 4,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId,
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
unreconciled: true
|
||||
})
|
||||
)
|
||||
|
||||
const next = applyAgentSessionRestartAdjudication({
|
||||
record,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
now: 1_800_000_001_000
|
||||
})
|
||||
|
||||
expect(next.lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
runtimeFence: 4,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId,
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null,
|
||||
unreconciled: false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
AgentSessionOwnerRuntimeKind,
|
||||
AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import { assertFence, reserveAgentSessionOwner, withLease } from './agent-session-lease-transitions'
|
||||
|
||||
export function stopAgentSessionOwnerForHandoff(args: {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
now: number
|
||||
}): AgentSessionRecord {
|
||||
const { record } = args
|
||||
assertFence(record.lease, args.expectedFence)
|
||||
if (
|
||||
record.lease.handoffStage !== 'preparing' ||
|
||||
record.lease.handoffOperationId !== args.operationId ||
|
||||
record.lease.ownerProcess === null
|
||||
) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return withLease(record, {
|
||||
...record.lease,
|
||||
runtimeFence: record.lease.runtimeFence + 1,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
processlessAt: null,
|
||||
claimStatus: 'released',
|
||||
lastRenewedAt: args.now,
|
||||
deathEvidence: {
|
||||
kind: 'exit-observed',
|
||||
detail: 'observed process exit',
|
||||
observedAt: args.now
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function rollbackAgentSessionHandoffPreparation(args: {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
now: number
|
||||
}): AgentSessionRecord {
|
||||
const { record } = args
|
||||
assertFence(record.lease, args.expectedFence)
|
||||
if (
|
||||
record.lease.handoffStage !== 'preparing' ||
|
||||
record.lease.handoffOperationId !== args.operationId ||
|
||||
record.lease.claimStatus !== 'live' ||
|
||||
record.lease.ownerProcess === null
|
||||
) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return withLease(record, {
|
||||
...record.lease,
|
||||
handoffStage: null,
|
||||
handoffOperationId: null,
|
||||
lastRenewedAt: args.now
|
||||
})
|
||||
}
|
||||
|
||||
export function stopRecoveringTuiOwnerForHandoff(args: {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
now: number
|
||||
}): AgentSessionRecord {
|
||||
const { record } = args
|
||||
assertFence(record.lease, args.expectedFence)
|
||||
if (
|
||||
(record.lease.handoffStage !== 'recovering' &&
|
||||
record.lease.handoffStage !== 'manual-recovery') ||
|
||||
record.lease.runtimeKind !== 'tui' ||
|
||||
record.lease.handoffOperationId !== null ||
|
||||
record.lease.claimStatus !== 'live' ||
|
||||
record.lease.ownerProcess === null
|
||||
) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return withLease(record, {
|
||||
...record.lease,
|
||||
runtimeFence: record.lease.runtimeFence + 1,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: args.operationId,
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
processlessAt: null,
|
||||
claimStatus: 'released',
|
||||
lastRenewedAt: args.now,
|
||||
deathEvidence: {
|
||||
kind: 'exit-observed',
|
||||
detail: 'recovery proved TUI process exit',
|
||||
observedAt: args.now
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function reserveAgentSessionHandoffOwner(args: {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
runtimeKind: AgentSessionOwnerRuntimeKind
|
||||
spawnToken: string
|
||||
operationId: string
|
||||
claimKeyId: string
|
||||
now: number
|
||||
leaseTtlMs: number
|
||||
}): AgentSessionRecord {
|
||||
return reserveAgentSessionOwner({
|
||||
record: args.record,
|
||||
expectedFence: args.expectedFence,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
reservation: {
|
||||
runtimeKind: args.runtimeKind,
|
||||
spawnToken: args.spawnToken,
|
||||
claimKeyId: args.claimKeyId,
|
||||
handoffOperationId: args.operationId,
|
||||
leaseTtlMs: args.leaseTtlMs,
|
||||
now: args.now
|
||||
}
|
||||
}).record
|
||||
}
|
||||
|
||||
export function abandonAgentSessionHandoffAttempt(args: {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
recoverableRuntimeKind: AgentSessionOwnerRuntimeKind
|
||||
now: number
|
||||
}): AgentSessionRecord {
|
||||
const { record } = args
|
||||
assertFence(record.lease, args.expectedFence)
|
||||
if (
|
||||
record.lease.handoffStage !== 'new-owner-proving' ||
|
||||
record.lease.handoffOperationId !== args.operationId
|
||||
) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return withLease(record, {
|
||||
...record.lease,
|
||||
runtimeKind: args.recoverableRuntimeKind,
|
||||
runtimeFence: record.lease.runtimeFence + 1,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
processlessAt: null,
|
||||
claimStatus: 'released',
|
||||
lastRenewedAt: args.now,
|
||||
deathEvidence: {
|
||||
kind: 'exit-observed',
|
||||
detail: 'handoff launch attempt stopped',
|
||||
observedAt: args.now
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { AgentSessionHandoffStage } from '../../shared/agent-session-record'
|
||||
import {
|
||||
abandonAgentSessionHandoffAttempt,
|
||||
reserveAgentSessionHandoffOwner,
|
||||
rollbackAgentSessionHandoffPreparation,
|
||||
stopAgentSessionOwnerForHandoff,
|
||||
stopRecoveringTuiOwnerForHandoff
|
||||
} from './agent-session-handoff-lease-transitions'
|
||||
import { setAgentSessionHandoffStage } from './agent-session-lease-transitions'
|
||||
import {
|
||||
AGENT_SESSION_LEASE_TTL_MS,
|
||||
type AgentSessionRecordStore
|
||||
} from './agent-session-record-store'
|
||||
|
||||
export function setStoredAgentSessionHandoffStage(
|
||||
store: AgentSessionRecordStore,
|
||||
args: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
stage: AgentSessionHandoffStage | null
|
||||
handoffOperationId: string | null
|
||||
now: number
|
||||
}
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
setAgentSessionHandoffStage({ ...args, record })
|
||||
)
|
||||
}
|
||||
|
||||
export function stopStoredAgentSessionOwnerForHandoff(
|
||||
store: AgentSessionRecordStore,
|
||||
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
stopAgentSessionOwnerForHandoff({ ...args, record })
|
||||
)
|
||||
}
|
||||
|
||||
export function rollbackStoredAgentSessionHandoffPreparation(
|
||||
store: AgentSessionRecordStore,
|
||||
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
rollbackAgentSessionHandoffPreparation({ ...args, record })
|
||||
)
|
||||
}
|
||||
|
||||
export function stopStoredRecoveringTuiOwnerForHandoff(
|
||||
store: AgentSessionRecordStore,
|
||||
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
stopRecoveringTuiOwnerForHandoff({ ...args, record })
|
||||
)
|
||||
}
|
||||
|
||||
export function reserveStoredAgentSessionHandoffOwner(
|
||||
store: AgentSessionRecordStore,
|
||||
args: {
|
||||
sessionId: string
|
||||
expectedFence: number
|
||||
runtimeKind: 'native' | 'tui'
|
||||
spawnToken: string
|
||||
operationId: string
|
||||
claimKeyId: string
|
||||
now: number
|
||||
leaseTtlMs?: number
|
||||
}
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
reserveAgentSessionHandoffOwner({
|
||||
...args,
|
||||
record,
|
||||
leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function abandonStoredAgentSessionHandoffAttempt(
|
||||
store: AgentSessionRecordStore,
|
||||
args: {
|
||||
sessionId: string
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
recoverableRuntimeKind: 'native' | 'tui'
|
||||
now: number
|
||||
}
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
abandonAgentSessionHandoffAttempt({ ...args, record })
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
* observed process identity, and a proved provider handle — in that order, at one fence.
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
adjudicateAgentSessionRestart,
|
||||
evaluateAgentSessionAcquisition,
|
||||
@@ -24,6 +23,7 @@ import type {
|
||||
AgentSessionProcessIdentity,
|
||||
AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication'
|
||||
|
||||
export type AgentSessionReservation = {
|
||||
runtimeKind: AgentSessionOwnerRuntimeKind
|
||||
@@ -34,11 +34,14 @@ export type AgentSessionReservation = {
|
||||
now: number
|
||||
}
|
||||
|
||||
function withLease(record: AgentSessionRecord, lease: AgentSessionLease): AgentSessionRecord {
|
||||
export function withLease(
|
||||
record: AgentSessionRecord,
|
||||
lease: AgentSessionLease
|
||||
): AgentSessionRecord {
|
||||
return { ...record, lease, updatedAt: lease.lastRenewedAt }
|
||||
}
|
||||
|
||||
function assertFence(lease: AgentSessionLease, fence: number): void {
|
||||
export function assertFence(lease: AgentSessionLease, fence: number): void {
|
||||
if (lease.runtimeFence !== fence) {
|
||||
throw new Error('agent_session_checkpoint_stale')
|
||||
}
|
||||
@@ -47,13 +50,6 @@ function assertFence(lease: AgentSessionLease, fence: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function agentSessionReconciliationTargetMatches(
|
||||
current: AgentSessionRecord,
|
||||
probed: AgentSessionRecord
|
||||
): boolean {
|
||||
return isDeepStrictEqual(current, probed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare-and-swap reservation. Writes the intent at fence + 1 before any process exists, so the
|
||||
* loser of a concurrent swap is refused and never spawns.
|
||||
@@ -242,6 +238,23 @@ export function applyAgentSessionRestartAdjudication(args: {
|
||||
now: number
|
||||
}): AgentSessionRecord {
|
||||
const { record } = args
|
||||
if (
|
||||
record.lease.handoffStage === 'old-owner-stopped' &&
|
||||
record.lease.claimStatus === 'released' &&
|
||||
record.lease.ownerProcess === null
|
||||
) {
|
||||
return withLease(record, {
|
||||
...record.lease,
|
||||
unreconciled: false,
|
||||
lastRenewedAt: args.now
|
||||
})
|
||||
}
|
||||
if (
|
||||
record.lease.handoffStage === 'preparing' ||
|
||||
record.lease.handoffStage === 'new-owner-proving'
|
||||
) {
|
||||
return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now)
|
||||
}
|
||||
const adjudication = adjudicateAgentSessionRestart({
|
||||
lease: record.lease,
|
||||
probe: args.probe,
|
||||
|
||||
@@ -154,13 +154,18 @@ describe('owner identity probe', () => {
|
||||
).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['spawn-token'] })
|
||||
})
|
||||
|
||||
it('returns no start time on Windows rather than guessing one', async () => {
|
||||
await expect(readProcessStartTimeMs(process.pid, 'win32')).resolves.toBeNull()
|
||||
it('reads a CIM-backed start time on Windows when running there', async () => {
|
||||
const observed = await readProcessStartTimeMs(process.pid, 'win32')
|
||||
expect(observed === null).toBe(process.platform !== 'win32')
|
||||
})
|
||||
|
||||
it('reads a start time for the current process on this platform', async () => {
|
||||
const observed = await readProcessStartTimeMs(process.pid)
|
||||
if (process.platform === 'linux' || process.platform === 'darwin') {
|
||||
if (
|
||||
process.platform === 'linux' ||
|
||||
process.platform === 'darwin' ||
|
||||
process.platform === 'win32'
|
||||
) {
|
||||
expect(observed).not.toBeNull()
|
||||
expect(Math.abs((observed as number) - (Date.now() - process.uptime() * 1000))).toBeLessThan(
|
||||
60_000
|
||||
|
||||
@@ -74,9 +74,26 @@ async function readDarwinProcessStartTimeMs(pid: number): Promise<number | null>
|
||||
}
|
||||
}
|
||||
|
||||
async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null> {
|
||||
try {
|
||||
const script =
|
||||
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
|
||||
'if ($p) { $p.CreationDate.ToUniversalTime().ToString("o") }'
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', script],
|
||||
{ timeout: PROCESS_START_TIME_TIMEOUT_MS, windowsHide: true }
|
||||
)
|
||||
const parsed = Date.parse(stdout.trim())
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows returns null: reading a start time needs a CIM query Orca does not make here, so a
|
||||
* Windows child without a token echo qualifies for manual recovery only.
|
||||
* Process start time is the cross-platform PID-reuse guard when no provider hook can echo the
|
||||
* spawn token back to the owner probe.
|
||||
*/
|
||||
export async function readProcessStartTimeMs(
|
||||
pid: number,
|
||||
@@ -88,6 +105,9 @@ export async function readProcessStartTimeMs(
|
||||
if (platform === 'darwin') {
|
||||
return readDarwinProcessStartTimeMs(pid)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return readWindowsProcessStartTimeMs(pid)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,49 @@ describe('admission through the store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)(
|
||||
'refuses TUI writes while handoff stage %s is active',
|
||||
(handoffStage) => {
|
||||
publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage }))
|
||||
const admission = gate.admit(PTY_ID)
|
||||
expect(admission.admitted).toBe(false)
|
||||
if (!admission.admitted) {
|
||||
expect(admission.refusal).toMatchObject({
|
||||
code: 'agent_session_conflict',
|
||||
handoffStage,
|
||||
ownerRuntimeKind: 'tui'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('admits only the reserved TUI proof token before ownership', () => {
|
||||
publish(
|
||||
agentSessionLeaseFixture({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'reserved',
|
||||
handoffStage: 'new-owner-proving',
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: 'proof-token'
|
||||
})
|
||||
)
|
||||
expect(gate.admit(PTY_ID).admitted).toBe(false)
|
||||
expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true)
|
||||
expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses proof input after ownership is live', () => {
|
||||
publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' }))
|
||||
expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when the record vanished from the store', () => {
|
||||
const admission = gate.admit(PTY_ID)
|
||||
expect(admission.admitted).toBe(false)
|
||||
|
||||
@@ -69,6 +69,22 @@ export class AgentSessionPtyWriteGate {
|
||||
return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId))
|
||||
}
|
||||
|
||||
/** Narrow pre-ownership input for the reserved TUI's provider identity probe. */
|
||||
admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean {
|
||||
const binding = this.binding(ptyId)
|
||||
const lease = binding?.record?.lease
|
||||
return Boolean(
|
||||
binding?.sessionId === authority.sessionId &&
|
||||
binding.record?.sessionId === authority.sessionId &&
|
||||
lease?.runtimeKind === 'tui' &&
|
||||
lease.claimStatus === 'reserved' &&
|
||||
lease.handoffStage === 'new-owner-proving' &&
|
||||
lease.ownerProcess === null &&
|
||||
lease.reservedSpawnToken === authority.spawnToken &&
|
||||
!lease.unreconciled
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-check a write already in flight against the fence it was admitted under. */
|
||||
readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission {
|
||||
if (admitted.sessionId === null && !this.enforcing) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
|
||||
export function agentSessionReconciliationTargetMatches(
|
||||
current: AgentSessionRecord,
|
||||
probed: AgentSessionRecord
|
||||
): boolean {
|
||||
return isDeepStrictEqual(current, probed)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
|
||||
import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions'
|
||||
import {
|
||||
AGENT_SESSION_CLAIM_KEY_RETENTION_MS,
|
||||
AgentSessionRecordStore
|
||||
@@ -754,7 +755,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => {
|
||||
it('rejects a handoff stage change under a different operation id', async () => {
|
||||
const store = await open()
|
||||
await establishOwner(store)
|
||||
await store.setHandoffStage({
|
||||
await setStoredAgentSessionHandoffStage(store, {
|
||||
sessionId: 'session-alpha',
|
||||
fence: 1,
|
||||
stage: 'preparing',
|
||||
@@ -762,7 +763,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => {
|
||||
now: NOW
|
||||
})
|
||||
await expect(
|
||||
store.setHandoffStage({
|
||||
setStoredAgentSessionHandoffStage(store, {
|
||||
sessionId: 'session-alpha',
|
||||
fence: 1,
|
||||
stage: 'old-owner-stopped',
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
pruneAgentSessionOperationRows,
|
||||
agentSessionOperationKey,
|
||||
pruneAgentSessionOperationRows,
|
||||
settleAgentSessionOperation,
|
||||
type AgentSessionOperationDecision,
|
||||
type AgentSessionOperationOutcome,
|
||||
type AgentSessionOperationRow
|
||||
@@ -25,22 +26,20 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-
|
||||
import {
|
||||
agentSessionScopeKey,
|
||||
type AgentSessionExecutionLocation,
|
||||
type AgentSessionHandoffStage,
|
||||
type AgentSessionJournalCheckpoint,
|
||||
type AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import {
|
||||
applyAgentSessionRestartAdjudication,
|
||||
agentSessionReconciliationTargetMatches,
|
||||
commitAgentSessionProcessIdentity,
|
||||
evictAgentSessionOwner,
|
||||
proveAgentSessionOwner,
|
||||
renewAgentSessionLease,
|
||||
setAgentSessionHandoffStage,
|
||||
setAgentSessionJournalCheckpoint,
|
||||
type AgentSessionProcessIdentityCommit
|
||||
} from './agent-session-lease-transitions'
|
||||
import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition'
|
||||
import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target'
|
||||
import {
|
||||
setAgentSessionReservationProcesslessProof,
|
||||
type AgentSessionReservationProcesslessProof
|
||||
@@ -238,14 +237,11 @@ export class AgentSessionRecordStore {
|
||||
return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record }))
|
||||
}
|
||||
|
||||
async setHandoffStage(args: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
stage: AgentSessionHandoffStage | null
|
||||
handoffOperationId: string | null
|
||||
now: number
|
||||
}): Promise<AgentSessionRecord> {
|
||||
return this.mutate(args.sessionId, (record) => setAgentSessionHandoffStage({ ...args, record }))
|
||||
async transitionHandoff(
|
||||
sessionId: string,
|
||||
transition: (record: AgentSessionRecord) => AgentSessionRecord
|
||||
): Promise<AgentSessionRecord> {
|
||||
return this.mutate(sessionId, transition)
|
||||
}
|
||||
|
||||
async setJournalCheckpoint(args: {
|
||||
@@ -304,16 +300,12 @@ export class AgentSessionRecordStore {
|
||||
}
|
||||
|
||||
async recordOperationOutcome(args: {
|
||||
callerKey: string
|
||||
callerKey?: string
|
||||
operationId: string
|
||||
outcome: AgentSessionOperationOutcome
|
||||
}): Promise<void> {
|
||||
await this.transact(() => {
|
||||
const key = agentSessionOperationKey(args.callerKey, args.operationId)
|
||||
const row = this.state.operations.get(key)
|
||||
if (row) {
|
||||
this.state.operations.set(key, { ...row, outcome: args.outcome })
|
||||
}
|
||||
this.state.operations = settleAgentSessionOperation(this.state.operations, args)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sessionId: 'session-restart',
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
provider: 'codex',
|
||||
providerHandleChain: [],
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' },
|
||||
lease: {
|
||||
sessionId: 'session-restart',
|
||||
runtimeKind: stage === 'preparing' ? 'native' : 'tui',
|
||||
runtimeFence: 4,
|
||||
handoffStage: stage,
|
||||
provenHandleLinkId: null,
|
||||
ownerProcess: {
|
||||
hostId: 'local',
|
||||
pid: 4242,
|
||||
processStartTimeMs: NOW - 1_000,
|
||||
spawnToken: 'spawn-restart'
|
||||
},
|
||||
reservedSpawnToken: 'spawn-restart',
|
||||
leaseDeadlineAt: NOW + 30_000,
|
||||
lastRenewedAt: NOW,
|
||||
handoffOperationId: 'handoff-op-1',
|
||||
journalCheckpoint: null,
|
||||
claimKeyId: 'key-1',
|
||||
claimStatus: stage === 'preparing' ? 'live' : 'reserved',
|
||||
unreconciled: true,
|
||||
deathEvidence: null
|
||||
},
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW
|
||||
}
|
||||
}
|
||||
|
||||
describe('restarted handoff adjudication', () => {
|
||||
it('continues a dead preparing owner at old-owner-stopped under the same operation', () => {
|
||||
expect(
|
||||
adjudicateRestartedAgentSessionHandoff(
|
||||
record('preparing'),
|
||||
{ outcome: 'pid-absent' },
|
||||
NOW + 1_000
|
||||
).lease
|
||||
).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
runtimeFence: 5,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: 'handoff-op-1',
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls a dead proving target back to the source kind before retry', () => {
|
||||
expect(
|
||||
adjudicateRestartedAgentSessionHandoff(
|
||||
record('new-owner-proving'),
|
||||
{ outcome: 'pid-absent' },
|
||||
NOW + 1_000
|
||||
).lease
|
||||
).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
runtimeFence: 5,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: 'handoff-op-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
adjudicateAgentSessionRestart,
|
||||
type AgentSessionOwnerProbe
|
||||
} from '../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
|
||||
export function adjudicateRestartedAgentSessionHandoff(
|
||||
record: AgentSessionRecord,
|
||||
probe: AgentSessionOwnerProbe,
|
||||
now: number
|
||||
): AgentSessionRecord {
|
||||
const adjudication = adjudicateAgentSessionRestart({
|
||||
lease: record.lease,
|
||||
probe,
|
||||
observedAt: now
|
||||
})
|
||||
if (adjudication.disposition === 'readopt') {
|
||||
return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now })
|
||||
}
|
||||
if (adjudication.disposition !== 'evicted') {
|
||||
return updateLease(record, {
|
||||
...record.lease,
|
||||
handoffStage: 'recovering',
|
||||
claimStatus:
|
||||
adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus,
|
||||
unreconciled: false,
|
||||
lastRenewedAt: now
|
||||
})
|
||||
}
|
||||
const provingTarget = record.lease.handoffStage === 'new-owner-proving'
|
||||
return updateLease(record, {
|
||||
...record.lease,
|
||||
runtimeKind: provingTarget
|
||||
? record.lease.runtimeKind === 'native'
|
||||
? 'tui'
|
||||
: 'native'
|
||||
: record.lease.runtimeKind,
|
||||
runtimeFence: adjudication.nextFence,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
processlessAt: null,
|
||||
claimStatus: 'released',
|
||||
unreconciled: false,
|
||||
lastRenewedAt: now,
|
||||
deathEvidence: adjudication.evidence
|
||||
})
|
||||
}
|
||||
|
||||
function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) {
|
||||
return { ...record, lease, updatedAt: lease.lastRenewedAt }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,9 +13,16 @@ import type {
|
||||
openClaudeStreamJsonConnection
|
||||
} from '../claude/claude-stream-json-connection'
|
||||
import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution'
|
||||
import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity'
|
||||
import {
|
||||
CLAUDE_SPAWN_TOKEN_ENV,
|
||||
claudeProviderHandleLink
|
||||
} from '../claude/claude-structured-owner-identity'
|
||||
import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffTransport,
|
||||
StructuredTuiOwner
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { RpcRequest, RpcResponse } from './rpc/core'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
@@ -142,7 +149,7 @@ function ensureParams(fence: number) {
|
||||
},
|
||||
provider: 'claude' as const,
|
||||
agent: 'claude',
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/accounts/claude' },
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: join(root, 'claude-home') },
|
||||
runtimeKind: 'native' as const,
|
||||
providerHandle: {
|
||||
kind: 'claude' as const,
|
||||
@@ -169,10 +176,20 @@ function ensureParams(fence: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function handoffParams(direction: 'to-native' | 'to-tui', fence: number) {
|
||||
const fields = { direction, mode: 'now' as const, action: 'start' as const }
|
||||
return {
|
||||
envelope: envelope('agentSession.requestHandoff', fields, fence),
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
let claude: ReturnType<typeof fakeClaude>
|
||||
let root: string
|
||||
let dispatcher: RpcDispatcher
|
||||
let cleanups: Map<string, () => void>
|
||||
let tuiOwner: StructuredTuiOwner | null
|
||||
let transcriptPath: string
|
||||
|
||||
async function call(method: string, params: unknown): Promise<RpcResponse> {
|
||||
const replies: RpcResponse[] = []
|
||||
@@ -237,8 +254,63 @@ function textOf(item: AgentJournalRenderItem): string {
|
||||
beforeEach(async () => {
|
||||
operations = 0
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-'))
|
||||
transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`)
|
||||
await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true })
|
||||
claude = fakeClaude()
|
||||
tuiOwner = null
|
||||
cleanups = new Map()
|
||||
const handoffTransport: StructuredAgentSessionHandoffTransport = {
|
||||
hostLabel: 'Scripted Claude host',
|
||||
launchTui: async ({ record, fence, spawnToken }) => {
|
||||
const head = record.providerHandleChain.at(-1)?.handle
|
||||
tuiOwner = {
|
||||
terminal: {
|
||||
handle: 'term-claude-tui',
|
||||
tabId: 'tab-claude-tui',
|
||||
paneKey: 'tab-claude-tui:leaf-claude-tui',
|
||||
ptyId: 'pty-claude-tui'
|
||||
},
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 7331,
|
||||
processStartTimeMs: 100,
|
||||
spawnToken
|
||||
},
|
||||
link: claudeProviderHandleLink({
|
||||
sessionId: PROVIDER_SESSION,
|
||||
leafUuid: head?.provider === 'claude' ? head.leafUuid : null,
|
||||
resumed: true,
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
}),
|
||||
transcriptPath
|
||||
}
|
||||
return tuiOwner
|
||||
},
|
||||
reproveTuiOwner: async ({ owner }) => owner,
|
||||
recoverTuiOwner: async () => {
|
||||
if (!tuiOwner) {
|
||||
throw new Error('scripted TUI owner missing')
|
||||
}
|
||||
return tuiOwner
|
||||
},
|
||||
stopRecoveredOwner: async () => {},
|
||||
waitForTuiExit: async (owner, persistHandle) => {
|
||||
await persistHandle(
|
||||
claudeProviderHandleLink({
|
||||
sessionId: PROVIDER_SESSION,
|
||||
leafUuid: 'tui-assistant',
|
||||
resumed: true,
|
||||
fence: owner.link.mintedAtFence,
|
||||
observedAt: Date.now()
|
||||
})
|
||||
)
|
||||
return { transcriptPath }
|
||||
},
|
||||
waitForTuiIdleOrExit: async () => 'idle',
|
||||
tuiStatus: () => 'idle',
|
||||
stopFailedTuiLaunch: async () => {}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
|
||||
@@ -260,7 +332,8 @@ beforeEach(async () => {
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
|
||||
}),
|
||||
openClaudeConnection: claude.openConnection
|
||||
openClaudeConnection: claude.openConnection,
|
||||
handoffTransport
|
||||
}).then(() => undefined),
|
||||
registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose),
|
||||
cleanupSubscription: (id: string) => cleanups.get(id)?.(),
|
||||
@@ -306,7 +379,7 @@ describe('a structured Claude session over agentSession.*', () => {
|
||||
expect(claude.live().launch.env).toEqual({
|
||||
ANTHROPIC_AUTH_TOKEN: 'configured-token',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
|
||||
CLAUDE_CONFIG_DIR: '/accounts/claude',
|
||||
CLAUDE_CONFIG_DIR: join(root, 'claude-home'),
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
|
||||
})
|
||||
const stream = await subscribe()
|
||||
@@ -398,4 +471,77 @@ describe('a structured Claude session over agentSession.*', () => {
|
||||
origin: 'resumed'
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a scripted native to TUI to native cycle with provider-history rehydration', async () => {
|
||||
const created = await ok<{ fence: number }>('agentSession.create', createIntentParams())
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
[
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'native-user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'NATIVE_USER' }] }
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'native-assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'NATIVE_ASSISTANT' }] }
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'tui-user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'TUI_USER' }] }
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'tui-assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'TUI_ASSISTANT' }] }
|
||||
},
|
||||
{ type: 'last-prompt', leafUuid: 'tui-assistant' }
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
await ok('agentSession.requestHandoff', handoffParams('to-tui', created.fence))
|
||||
const host = getStructuredAgentSessionHost()!
|
||||
await vi.waitFor(async () =>
|
||||
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' })
|
||||
)
|
||||
expect(claude.connections[0]?.closed).toBe(true)
|
||||
|
||||
const tuiFence = (
|
||||
host as unknown as {
|
||||
deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } }
|
||||
}
|
||||
).deps.store.getRecord(SESSION).lease.runtimeFence
|
||||
await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence))
|
||||
await vi.waitFor(async () =>
|
||||
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' })
|
||||
)
|
||||
|
||||
const frames = await subscribe()
|
||||
const texts = itemsOf(frames).map(textOf).filter(Boolean)
|
||||
expect(texts).toEqual(
|
||||
expect.arrayContaining(['NATIVE_USER', 'NATIVE_ASSISTANT', 'TUI_USER', 'TUI_ASSISTANT'])
|
||||
)
|
||||
expect(new Set(texts).size).toBe(texts.length)
|
||||
expect(claude.connections).toHaveLength(2)
|
||||
expect(claude.live().launch.args.slice(-2)).toEqual(['--resume', PROVIDER_SESSION])
|
||||
const record = (
|
||||
host as unknown as {
|
||||
deps: {
|
||||
store: {
|
||||
getRecord: (id: string) => {
|
||||
providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).deps.store.getRecord(SESSION)
|
||||
expect(record.providerHandleChain.at(-1)?.handle).toMatchObject({
|
||||
provider: 'claude',
|
||||
leafUuid: 'tui-assistant'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const { probeAgentSessionProcessIdentity, readStructuredTuiProcessIdentity } = vi.hoisted(() => ({
|
||||
probeAgentSessionProcessIdentity: vi.fn(),
|
||||
readStructuredTuiProcessIdentity: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity }))
|
||||
vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
probeAgentSessionProcessIdentity
|
||||
}))
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/tmp/structured-handoff'
|
||||
|
||||
function notifier(revealTerminalSession: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('structured TUI launch tab binding', () => {
|
||||
it('proves the published launch tab before returning its revealed renderer binding', async () => {
|
||||
let explicitStatus: {
|
||||
state: 'working' | 'done'
|
||||
prompt: string
|
||||
receivedAt: number
|
||||
stateStartedAt: number
|
||||
paneKey: string
|
||||
terminalHandle: string
|
||||
} | null = null
|
||||
const revealTerminalSession = vi.fn(
|
||||
(_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) =>
|
||||
Promise.resolve({ tabId: 'tab-renderer' })
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{
|
||||
getSettings: () => ({
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
} as never,
|
||||
undefined,
|
||||
{
|
||||
getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : [])
|
||||
}
|
||||
)
|
||||
runtime.setNotifier(notifier(revealTerminalSession) as never)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const internal = runtime as unknown as {
|
||||
createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport
|
||||
resolveTerminalWorkspaceLaunchScope(): Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: null
|
||||
repo: null
|
||||
folderWorkspace: null
|
||||
}>
|
||||
markLocalWorkspaceTrustedForAgent(): void
|
||||
waitForTerminal(): Promise<unknown>
|
||||
waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }>
|
||||
waitForStructuredTuiPtyExit(): Promise<void>
|
||||
handles: Map<
|
||||
string,
|
||||
{
|
||||
rendererGraphEpoch: number
|
||||
tabId: string
|
||||
leafId: string
|
||||
}
|
||||
>
|
||||
graphStatus: 'ready'
|
||||
}
|
||||
internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({
|
||||
id: WORKTREE_ID,
|
||||
path: '/tmp/structured-handoff',
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
}))
|
||||
internal.markLocalWorkspaceTrustedForAgent = vi.fn()
|
||||
const waitForTerminal = vi.fn(async () => ({}))
|
||||
internal.waitForTerminal = waitForTerminal
|
||||
const waitForStructuredTuiProof = vi.fn(async () => {
|
||||
const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
expect(snapshot.tabs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
parentTabId: expect.any(String),
|
||||
leafId: expect.any(String),
|
||||
ptyId: 'pty-structured',
|
||||
terminal: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(revealTerminalSession).not.toHaveBeenCalled()
|
||||
return { transcriptPath: '/tmp/rollout.jsonl' }
|
||||
})
|
||||
internal.waitForStructuredTuiProof = waitForStructuredTuiProof
|
||||
const waitForStructuredTuiPtyExit = vi.fn(async () => {})
|
||||
internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit
|
||||
readStructuredTuiProcessIdentity.mockResolvedValue({
|
||||
hostId: 'local',
|
||||
pid: 4243,
|
||||
processStartTimeMs: 10,
|
||||
spawnToken: 'spawn-token'
|
||||
})
|
||||
probeAgentSessionProcessIdentity.mockResolvedValue({
|
||||
outcome: 'identity-matched',
|
||||
matchedOn: ['process-start-time']
|
||||
})
|
||||
|
||||
const transport = internal.createStructuredAgentSessionHandoffTransport()
|
||||
const owner = await transport.launchTui({
|
||||
record: {
|
||||
sessionId: 'session-1',
|
||||
location: { workspaceId: WORKTREE_ID, executionHostId: 'local' },
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
|
||||
providerHandleChain: [
|
||||
{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }
|
||||
]
|
||||
} as never,
|
||||
fence: 3,
|
||||
spawnToken: 'spawn-token'
|
||||
})
|
||||
|
||||
const reveal = revealTerminalSession.mock.calls[0]?.[1] as {
|
||||
tabId: string
|
||||
leafId: string
|
||||
}
|
||||
expect(owner.terminal).toMatchObject({
|
||||
tabId: 'tab-renderer',
|
||||
paneKey: `${reveal.tabId}:${reveal.leafId}`,
|
||||
ptyId: 'pty-structured'
|
||||
})
|
||||
expect(waitForTerminal).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ condition: 'tui-idle' })
|
||||
)
|
||||
expect(waitForStructuredTuiProof).toHaveBeenCalledOnce()
|
||||
expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
revealTerminalSession.mock.invocationCallOrder[0]!
|
||||
)
|
||||
|
||||
Object.assign(internal.handles.get(owner.terminal.handle)!, {
|
||||
rendererGraphEpoch: -1,
|
||||
tabId: 'tab-retired',
|
||||
leafId: 'leaf-retired'
|
||||
})
|
||||
internal.graphStatus = 'ready'
|
||||
|
||||
explicitStatus = {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
paneKey: owner.terminal.paneKey,
|
||||
terminalHandle: owner.terminal.handle
|
||||
}
|
||||
expect(transport.tuiStatus(owner)).toBe('busy')
|
||||
await expect(
|
||||
transport.waitForTuiIdleOrExit(owner, new AbortController().signal)
|
||||
).resolves.toBeNull()
|
||||
|
||||
explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() }
|
||||
expect(transport.tuiStatus(owner)).toBe('idle')
|
||||
await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe(
|
||||
'idle'
|
||||
)
|
||||
|
||||
const pty = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { connected: boolean; launchToken: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-structured')!
|
||||
pty.launchToken = null
|
||||
const persistedRecord = {
|
||||
sessionId: 'session-1',
|
||||
providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }],
|
||||
lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId }
|
||||
} as never
|
||||
|
||||
const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner })
|
||||
expect(rebound.terminal).toMatchObject({
|
||||
ptyId: 'pty-structured',
|
||||
tabId: owner.terminal.tabId,
|
||||
paneKey: owner.terminal.paneKey
|
||||
})
|
||||
expect(rebound.terminal.handle).not.toBe(owner.terminal.handle)
|
||||
await transport.waitForTuiExit(rebound, async () => {})
|
||||
expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured')
|
||||
expect(waitForStructuredTuiProof).toHaveBeenCalledOnce()
|
||||
|
||||
explicitStatus = null
|
||||
pty.connected = false
|
||||
await expect(
|
||||
transport.waitForTuiIdleOrExit(rebound, new AbortController().signal)
|
||||
).resolves.toBe('exited')
|
||||
await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -74,6 +74,30 @@ import {
|
||||
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 type {
|
||||
StructuredAgentSessionHandoffTransport,
|
||||
StructuredTuiOwner
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { agentSessionProviderHandlesEqual } from '../../shared/agent-session-provider-handle'
|
||||
import { completeClaudeTuiExit } from '../claude/claude-tui-exit'
|
||||
import {
|
||||
createClaudeTuiResumeLaunchBuilder,
|
||||
type ClaudeTuiResumeLaunch
|
||||
} from '../claude/claude-tui-resume-launch'
|
||||
import {
|
||||
proveClaudeTuiResume,
|
||||
readClaudeTuiSessionStartEvidence
|
||||
} from '../claude/claude-tui-resume-proof'
|
||||
import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity'
|
||||
import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity'
|
||||
import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
|
||||
import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof'
|
||||
import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity'
|
||||
import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import {
|
||||
@@ -119,7 +143,7 @@ import {
|
||||
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
|
||||
import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { homedir, hostname } from 'node:os'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
@@ -427,7 +451,10 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import {
|
||||
buildAgentDraftLaunchPlan,
|
||||
buildAgentResumeStartupPlan,
|
||||
buildAgentStartupPlan
|
||||
buildAgentStartupPlan,
|
||||
buildShellCommandFromArgv,
|
||||
quoteStartupArg,
|
||||
resolveStartupShell
|
||||
} from '../../shared/tui-agent-startup'
|
||||
import { repoIsRemote } from '../../shared/agent-launch-remote'
|
||||
import {
|
||||
@@ -1390,6 +1417,7 @@ type TerminalCreateOptions = {
|
||||
// Why: only the host-derived structured resume path may attach provider
|
||||
// identity; opaque terminal.create commands remain ordinary shells.
|
||||
agentSessionClaim?: AgentSessionExecutionClaim
|
||||
structuredAgentSessionId?: string
|
||||
agentSessionCreateOperationId?: string
|
||||
signal?: AbortSignal
|
||||
// Why: idempotent create operations must retain their fence after the PTY
|
||||
@@ -1402,6 +1430,13 @@ type TerminalCreateOptions = {
|
||||
deferMobileSessionPublish?: boolean
|
||||
}
|
||||
|
||||
type StructuredAgentSessionHandoffAuthority = {
|
||||
spawnToken: string
|
||||
providerHome: string
|
||||
sessionId: string
|
||||
launch?: ClaudeTuiResumeLaunch
|
||||
}
|
||||
|
||||
function mergeTerminalEnvDeletionKeys(
|
||||
first: readonly string[] | undefined,
|
||||
second: readonly string[] | undefined
|
||||
@@ -1705,12 +1740,18 @@ type RuntimePtyController = {
|
||||
}
|
||||
}): Promise<{
|
||||
id: string
|
||||
pid?: number
|
||||
incarnationId?: PtyIncarnationId
|
||||
wslDistro?: string
|
||||
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
}>
|
||||
write(ptyId: string, data: string): boolean
|
||||
writeAgentSessionProof?(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
authority: { sessionId: string; spawnToken: string }
|
||||
): boolean
|
||||
/** Attach-only adoption of a live local daemon session so its output streams
|
||||
* to main without a renderer pane; never creates, resizes, or focuses.
|
||||
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
|
||||
@@ -9088,6 +9129,442 @@ export class OrcaRuntimeService {
|
||||
resolveClaudeLaunchEnv: () => {
|
||||
const settings = this.requireStore().getSettings()
|
||||
return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv)
|
||||
},
|
||||
handoffTransport: this.createStructuredAgentSessionHandoffTransport()
|
||||
})
|
||||
}
|
||||
|
||||
private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport {
|
||||
return {
|
||||
hostLabel: hostname(),
|
||||
launchTui: async ({ record, fence, spawnToken }) => {
|
||||
const head = record.providerHandleChain.at(-1)
|
||||
if (!head) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
const agent = head.handle.provider
|
||||
const providerSessionId =
|
||||
head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId
|
||||
let claudeProof:
|
||||
| { prove: () => ReturnType<typeof proveClaudeTuiResume>; dispose: () => void }
|
||||
| undefined
|
||||
let claudeLaunch: ClaudeTuiResumeLaunch | undefined
|
||||
if (head.handle.provider === 'claude') {
|
||||
const expectedTranscriptPath = await resolveSessionFilePath('claude', providerSessionId, {
|
||||
claudeProjectsDir: join(record.accountHome.path, 'projects')
|
||||
})
|
||||
if (!expectedTranscriptPath) {
|
||||
throw new Error('The Claude transcript could not be resolved before TUI resume.')
|
||||
}
|
||||
claudeLaunch = await createClaudeTuiResumeLaunchBuilder({
|
||||
resolveWorkspacePath: async (workspaceId) =>
|
||||
(await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path,
|
||||
resolveEnv: () => {
|
||||
const settings = this.requireStore().getSettings()
|
||||
return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv)
|
||||
}
|
||||
})({ record, spawnToken })
|
||||
claudeProof = this.createClaudeTuiResumeProofWaiter({
|
||||
expectedSessionId: providerSessionId,
|
||||
expectedTranscriptPath,
|
||||
expectedLaunchToken: spawnToken
|
||||
})
|
||||
}
|
||||
let launched: RuntimeEnsureAgentSessionResult
|
||||
try {
|
||||
launched = await this.ensureAgentSession(
|
||||
{
|
||||
kind: 'explicit',
|
||||
worktree: `id:${record.location.workspaceId}`,
|
||||
agent,
|
||||
providerSession: { key: 'session_id', id: providerSessionId },
|
||||
presentation: 'background'
|
||||
},
|
||||
{},
|
||||
{
|
||||
spawnToken,
|
||||
providerHome: record.accountHome.path,
|
||||
sessionId: record.sessionId,
|
||||
...(claudeLaunch ? { launch: claudeLaunch } : {})
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
claudeProof?.dispose()
|
||||
throw error
|
||||
}
|
||||
const terminal = launched.terminal
|
||||
try {
|
||||
if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) {
|
||||
throw new Error('The resumed terminal did not publish a process identity.')
|
||||
}
|
||||
await this.waitForTerminal(terminal.handle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
const proof =
|
||||
head.handle.provider === 'codex'
|
||||
? await this.waitForStructuredTuiProof({
|
||||
handle: terminal.handle,
|
||||
paneKey: terminal.paneKey,
|
||||
threadId: head.handle.threadId,
|
||||
spawnToken,
|
||||
codexHome: record.accountHome.path,
|
||||
sessionId: record.sessionId
|
||||
})
|
||||
: await claudeProof!.prove()
|
||||
const revealed = await this.focusTerminal(terminal.handle)
|
||||
return this.refreshStructuredTuiOwnerBinding({
|
||||
terminal: {
|
||||
handle: terminal.handle,
|
||||
tabId: revealed.tabId,
|
||||
paneKey: terminal.paneKey,
|
||||
ptyId: terminal.ptyId
|
||||
},
|
||||
process: await readStructuredTuiProcessIdentity({
|
||||
hostId: record.location.executionHostId,
|
||||
rootPid: terminal.processId,
|
||||
spawnToken,
|
||||
agent
|
||||
}),
|
||||
link:
|
||||
head.handle.provider === 'codex'
|
||||
? codexProviderHandleLink({
|
||||
threadId: head.handle.threadId,
|
||||
resumed: true,
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
})
|
||||
: claudeProviderHandleLink({
|
||||
sessionId: head.handle.sessionId,
|
||||
leafUuid: head.handle.leafUuid,
|
||||
resumed: true,
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
}),
|
||||
...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
await this.closeTerminal(terminal.handle)
|
||||
await this.waitForTerminal(terminal.handle, {
|
||||
condition: 'exit',
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
claudeProof?.dispose()
|
||||
}
|
||||
},
|
||||
waitForTuiExit: async (owner, persistHandle) => {
|
||||
if (owner.link.handle.provider === 'claude') {
|
||||
if (!owner.transcriptPath) {
|
||||
throw new Error('The Claude TUI owner did not retain its transcript path.')
|
||||
}
|
||||
const completed = await completeClaudeTuiExit({
|
||||
childPid: owner.process.pid,
|
||||
waitForChildExit: async () => {
|
||||
await this.waitForStructuredTuiOwnerExit(owner)
|
||||
return { pid: owner.process.pid, exitCode: null, signal: null }
|
||||
},
|
||||
sessionId: owner.link.handle.sessionId,
|
||||
transcriptPath: owner.transcriptPath,
|
||||
fence: owner.link.mintedAtFence,
|
||||
persistHandle
|
||||
})
|
||||
return { transcriptPath: completed.transcriptPath }
|
||||
}
|
||||
await this.waitForStructuredTuiOwnerExit(owner)
|
||||
return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {}
|
||||
},
|
||||
waitForTuiIdleOrExit: async (owner, signal) => {
|
||||
return this.waitForStructuredTuiIdleOrExit(owner, signal)
|
||||
},
|
||||
reproveTuiOwner: async ({ record, owner }) => {
|
||||
const current = this.refreshStructuredTuiOwnerBinding(owner)
|
||||
const persisted = record.lease.ownerProcess
|
||||
if (
|
||||
!persisted ||
|
||||
persisted.hostId !== current.process.hostId ||
|
||||
persisted.pid !== current.process.pid ||
|
||||
persisted.processStartTimeMs !== current.process.processStartTimeMs ||
|
||||
persisted.spawnToken !== current.process.spawnToken
|
||||
) {
|
||||
throw new Error('The owning terminal does not match the persisted launch identity.')
|
||||
}
|
||||
const proof = await probeAgentSessionProcessIdentity({ identity: current.process })
|
||||
if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) {
|
||||
throw new Error('The owning agent child process could not be re-proved.')
|
||||
}
|
||||
const head = record.providerHandleChain.at(-1)
|
||||
if (
|
||||
!head ||
|
||||
(record.lease.provenHandleLinkId !== null &&
|
||||
current.link.linkId !== record.lease.provenHandleLinkId) ||
|
||||
!agentSessionProviderHandlesEqual(current.link.handle, head.handle)
|
||||
) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
return current
|
||||
},
|
||||
recoverTuiOwner: async (record) => {
|
||||
const identity = record.lease.ownerProcess
|
||||
const head = record.providerHandleChain.at(-1)
|
||||
if (!identity || !head) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
const candidate = [...this.ptysById.values()].find(
|
||||
(pty) =>
|
||||
pty.connected &&
|
||||
pty.launchToken === identity.spawnToken &&
|
||||
pty.launchAgent === head.handle.provider &&
|
||||
pty.tabId &&
|
||||
pty.paneKey
|
||||
)
|
||||
const handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null
|
||||
if (!candidate?.tabId || !candidate.paneKey || !handle) {
|
||||
throw new Error('The owning agent terminal could not be recovered.')
|
||||
}
|
||||
const transcriptPath =
|
||||
head.handle.provider === 'codex'
|
||||
? (
|
||||
await this.waitForStructuredTuiProof({
|
||||
handle,
|
||||
paneKey: candidate.paneKey,
|
||||
threadId: head.handle.threadId,
|
||||
spawnToken: identity.spawnToken,
|
||||
codexHome: record.accountHome.path,
|
||||
sessionId: record.sessionId
|
||||
})
|
||||
).transcriptPath
|
||||
: await resolveSessionFilePath('claude', head.handle.sessionId, {
|
||||
claudeProjectsDir: join(record.accountHome.path, 'projects')
|
||||
})
|
||||
if (head.handle.provider === 'claude' && !transcriptPath) {
|
||||
throw new Error('The owning Claude transcript could not be recovered.')
|
||||
}
|
||||
return {
|
||||
terminal: {
|
||||
handle,
|
||||
tabId: candidate.tabId,
|
||||
paneKey: candidate.paneKey,
|
||||
ptyId: candidate.ptyId
|
||||
},
|
||||
process: identity,
|
||||
link:
|
||||
head.handle.provider === 'codex'
|
||||
? codexProviderHandleLink({
|
||||
threadId: head.handle.threadId,
|
||||
resumed: true,
|
||||
fence: record.lease.runtimeFence,
|
||||
observedAt: Date.now()
|
||||
})
|
||||
: head,
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
}
|
||||
},
|
||||
stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record),
|
||||
tuiStatus: (owner) => this.structuredTuiStatus(owner),
|
||||
stopFailedTuiLaunch: async (owner) => {
|
||||
if (this.ptysById.get(owner.terminal.ptyId)?.connected) {
|
||||
const current = this.refreshStructuredTuiOwnerBinding(owner)
|
||||
try {
|
||||
await this.closeTerminal(current.terminal.handle)
|
||||
} catch (error) {
|
||||
if (this.ptysById.get(owner.terminal.ptyId)?.connected) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.waitForStructuredTuiOwnerExit(owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner {
|
||||
const pty = this.ptysById.get(owner.terminal.ptyId)
|
||||
if (!pty?.connected) {
|
||||
throw new Error('The owning agent terminal lost its launch identity.')
|
||||
}
|
||||
const handle = this.issueStructuredTuiPtyHandle(pty)
|
||||
if (handle === owner.terminal.handle) {
|
||||
return owner
|
||||
}
|
||||
return { ...owner, terminal: { ...owner.terminal, handle } }
|
||||
}
|
||||
|
||||
private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string {
|
||||
const existingHandle = this.findHandleForPtyRecord(pty.ptyId)
|
||||
if (existingHandle) {
|
||||
this.handleByPtyId.set(pty.ptyId, existingHandle)
|
||||
return existingHandle
|
||||
}
|
||||
const handle = `term_${randomUUID()}`
|
||||
const syntheticId = `pty:${pty.ptyId}`
|
||||
this.syntheticTerminalHandles.add(handle)
|
||||
this.handles.set(handle, {
|
||||
handle,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: pty.worktreeId,
|
||||
tabId: syntheticId,
|
||||
leafId: syntheticId,
|
||||
ptyId: pty.ptyId,
|
||||
ptyGeneration: 0
|
||||
})
|
||||
this.handleByPtyId.set(pty.ptyId, handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
private async waitForStructuredTuiPtyExit(ptyId: string): Promise<void> {
|
||||
while (this.ptysById.get(ptyId)?.connected === true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise<void> {
|
||||
await waitForStructuredTuiExitProof({
|
||||
identity: owner.process,
|
||||
waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForStructuredTuiIdleOrExit(
|
||||
owner: StructuredTuiOwner,
|
||||
signal: AbortSignal
|
||||
): Promise<'idle' | 'exited' | null> {
|
||||
const deadline = Date.now() + 250
|
||||
while (!signal.aborted && Date.now() < deadline) {
|
||||
if (!this.ptysById.get(owner.terminal.ptyId)?.connected) {
|
||||
await this.waitForStructuredTuiOwnerExit(owner)
|
||||
return 'exited'
|
||||
}
|
||||
if (this.structuredTuiStatus(owner) === 'idle') {
|
||||
return 'idle'
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise<void> {
|
||||
const identity = record.lease.ownerProcess
|
||||
if (!identity) {
|
||||
return
|
||||
}
|
||||
const proof = await probeAgentSessionProcessIdentity({ identity })
|
||||
if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') {
|
||||
return
|
||||
}
|
||||
if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) {
|
||||
throw new Error('The recovered owner process could not be stopped safely.')
|
||||
}
|
||||
try {
|
||||
process.kill(identity.pid, 'SIGTERM')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
const current = await probeAgentSessionProcessIdentity({ identity })
|
||||
if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error('The recovered owner process did not exit.')
|
||||
}
|
||||
|
||||
private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' {
|
||||
const pty = this.ptysById.get(owner.terminal.ptyId)
|
||||
const paneKey = pty?.paneKey ?? owner.terminal.paneKey
|
||||
const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey)
|
||||
if (explicit) {
|
||||
return explicit.status === 'idle' ? 'idle' : 'busy'
|
||||
}
|
||||
if (pty?.connected) {
|
||||
const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
|
||||
return hasStructuredTuiIdleEvidence({
|
||||
blocked: detectTerminalWaitBlockedReason(text) !== null,
|
||||
status: pty.lastAgentStatus,
|
||||
statusObservedLive: pty.lastAgentStatusObservedLive
|
||||
})
|
||||
? 'idle'
|
||||
: 'busy'
|
||||
}
|
||||
return 'busy'
|
||||
}
|
||||
|
||||
private createClaudeTuiResumeProofWaiter(input: {
|
||||
expectedSessionId: string
|
||||
expectedTranscriptPath: string
|
||||
expectedLaunchToken: string
|
||||
}) {
|
||||
let resolveEnvelope!: (envelope: unknown) => void
|
||||
const envelope = new Promise<unknown>((resolve) => {
|
||||
resolveEnvelope = resolve
|
||||
})
|
||||
const dispose = agentHookServer.subscribeClaudeHookEnvelopes((candidate) => {
|
||||
const evidence = readClaudeTuiSessionStartEvidence(candidate)
|
||||
if (
|
||||
evidence?.launchToken === input.expectedLaunchToken &&
|
||||
evidence.sessionId === input.expectedSessionId
|
||||
) {
|
||||
resolveEnvelope(candidate)
|
||||
}
|
||||
})
|
||||
return {
|
||||
prove: () =>
|
||||
proveClaudeTuiResume({
|
||||
...input,
|
||||
waitForSessionStart: () => envelope
|
||||
}),
|
||||
dispose
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForStructuredTuiProof(input: {
|
||||
handle: string
|
||||
paneKey: string
|
||||
threadId: string
|
||||
spawnToken: string
|
||||
codexHome: string
|
||||
sessionId: string
|
||||
}): Promise<{ transcriptPath?: string }> {
|
||||
const readBoundPty = (): RuntimePtyWorktreeRecord => {
|
||||
const pty = this.getLivePtyForHandle(input.handle)?.pty
|
||||
if (
|
||||
!pty?.connected ||
|
||||
pty.paneKey !== input.paneKey ||
|
||||
pty.launchAgent !== 'codex' ||
|
||||
pty.launchToken !== input.spawnToken
|
||||
) {
|
||||
throw new Error('The resumed terminal lost its launch identity.')
|
||||
}
|
||||
return pty
|
||||
}
|
||||
const initialPty = readBoundPty()
|
||||
const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0
|
||||
return proveCodexTuiRollout({
|
||||
codexHome: input.codexHome,
|
||||
threadId: input.threadId,
|
||||
kittyKeyboardFlags,
|
||||
readOutput: () => {
|
||||
const pty = readBoundPty()
|
||||
return {
|
||||
text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview),
|
||||
lastOutputAt: pty.lastOutputAt
|
||||
}
|
||||
},
|
||||
write: (data) => {
|
||||
const pty = readBoundPty()
|
||||
return (
|
||||
this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, {
|
||||
sessionId: input.sessionId,
|
||||
spawnToken: input.spawnToken
|
||||
}) ?? false
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13820,6 +14297,7 @@ export class OrcaRuntimeService {
|
||||
this.intentionalHandlelessPtyStops.has(ptyId) &&
|
||||
(intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId)
|
||||
advertisedUrlWatcher.unbindPty(ptyId)
|
||||
agentSessionPtyWriteGate.unbindPty(ptyId)
|
||||
// Clean up new mobile state for this PTY
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
|
||||
@@ -17347,11 +17825,14 @@ export class OrcaRuntimeService {
|
||||
return true
|
||||
}
|
||||
|
||||
private getFreshExplicitAgentStatusForHandle(handle: string): {
|
||||
private getFreshExplicitAgentStatusForHandle(
|
||||
handle: string,
|
||||
paneKeyOverride?: string | null
|
||||
): {
|
||||
status: NonNullable<RuntimeTerminalAgentStatus['status']>
|
||||
updatedAt: number
|
||||
} | null {
|
||||
const paneKey = this.getPaneKeyForTerminalHandle(handle)
|
||||
const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle)
|
||||
const now = Date.now()
|
||||
let bestStatus: NonNullable<RuntimeTerminalAgentStatus['status']> | null = null
|
||||
let bestUpdatedAt = -1
|
||||
@@ -25346,7 +25827,8 @@ export class OrcaRuntimeService {
|
||||
|
||||
async ensureAgentSession(
|
||||
request: RuntimeEnsureAgentSessionRequest,
|
||||
_caller: RuntimeAgentSessionRpcCaller = {}
|
||||
_caller: RuntimeAgentSessionRpcCaller = {},
|
||||
handoffAuthority?: StructuredAgentSessionHandoffAuthority
|
||||
): Promise<RuntimeEnsureAgentSessionResult> {
|
||||
if (request.kind === 'automatic') {
|
||||
// Legacy renderer sleep records are migration evidence, not host authority.
|
||||
@@ -25356,7 +25838,11 @@ export class OrcaRuntimeService {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
|
||||
const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent)
|
||||
const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent)
|
||||
const namespace =
|
||||
resolvedNamespace && handoffAuthority
|
||||
? { ...resolvedNamespace, providerRoot: handoffAuthority.providerHome }
|
||||
: resolvedNamespace
|
||||
if (
|
||||
!namespace ||
|
||||
!(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal))
|
||||
@@ -25382,21 +25868,43 @@ export class OrcaRuntimeService {
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const startup = buildAgentResumeStartupPlan({
|
||||
agent: request.agent,
|
||||
providerSession: identity.providerSession,
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs:
|
||||
request.agentArgs !== undefined
|
||||
? request.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
|
||||
ompResumeFilePath: request.ompResumeFilePath,
|
||||
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
|
||||
platform,
|
||||
shell,
|
||||
isRemote
|
||||
})
|
||||
const startupShell = resolveStartupShell(platform, shell)
|
||||
const startup = handoffAuthority?.launch
|
||||
? {
|
||||
agent: request.agent,
|
||||
launchCommand: buildShellCommandFromArgv(
|
||||
[handoffAuthority.launch.command, ...handoffAuthority.launch.args],
|
||||
startupShell
|
||||
),
|
||||
expectedProcess: TUI_AGENT_CONFIG[request.agent].expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig: {
|
||||
agentCommand: handoffAuthority.launch.command,
|
||||
agentArgs: handoffAuthority.launch.args
|
||||
.map((arg) => quoteStartupArg(arg, startupShell))
|
||||
.join(' '),
|
||||
agentEnv: handoffAuthority.launch.env
|
||||
},
|
||||
env: handoffAuthority.launch.env
|
||||
}
|
||||
: buildAgentResumeStartupPlan({
|
||||
agent: request.agent,
|
||||
providerSession: identity.providerSession,
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs:
|
||||
request.agentArgs !== undefined
|
||||
? request.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
|
||||
agentEnv: {
|
||||
...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
|
||||
...(handoffAuthority ? { CODEX_HOME: handoffAuthority.providerHome } : {})
|
||||
},
|
||||
ompResumeFilePath: request.ompResumeFilePath,
|
||||
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
|
||||
platform,
|
||||
shell,
|
||||
isRemote
|
||||
})
|
||||
if (!startup) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
@@ -25414,6 +25922,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const terminal = await this.createTerminal(`id:${workspace.id}`, {
|
||||
command: startup.launchCommand,
|
||||
...(handoffAuthority?.launch ? { cwd: handoffAuthority.launch.cwd } : {}),
|
||||
env: startup.env,
|
||||
launchConfig: startup.launchConfig,
|
||||
launchAgent: request.agent,
|
||||
@@ -25422,6 +25931,12 @@ export class OrcaRuntimeService {
|
||||
leafId: request.placement?.leafId,
|
||||
persistHostSessionBinding: true,
|
||||
agentSessionClaim: claim,
|
||||
...(handoffAuthority
|
||||
? {
|
||||
launchToken: handoffAuthority.spawnToken,
|
||||
structuredAgentSessionId: handoffAuthority.sessionId
|
||||
}
|
||||
: {}),
|
||||
signal: _caller.signal
|
||||
})
|
||||
return {
|
||||
@@ -25925,6 +26440,9 @@ export class OrcaRuntimeService {
|
||||
leafId,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
||||
})
|
||||
if (launchOpts.structuredAgentSessionId) {
|
||||
agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId)
|
||||
}
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
if (persistHostSessionBinding) {
|
||||
@@ -26002,6 +26520,7 @@ export class OrcaRuntimeService {
|
||||
title: pty?.title ?? launchOpts.title ?? null,
|
||||
...this.getPtyExecutionHostMetadata(result.id),
|
||||
surface,
|
||||
...(result.pid ? { processId: result.pid } : {}),
|
||||
...(result.agentSessionEnsure
|
||||
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
|
||||
: {}),
|
||||
|
||||
@@ -191,3 +191,14 @@ export const UnsubscribeParams = z
|
||||
subscriptionId: Identifier('Invalid subscription id').optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const HandoffParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
direction: z.enum(['to-tui', 'to-native']),
|
||||
mode: z.enum(['now', 'after-turn', 'stop-turn']),
|
||||
action: z.enum(['start', 'cancel-queued', 'retry']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict()
|
||||
|
||||
@@ -94,6 +94,8 @@ function hostStub(): StructuredAgentSessionHost {
|
||||
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
requestHandoff: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
handoffStatus: vi.fn(async () => ({ owner: 'tui', phase: 'queued' })),
|
||||
readOptions: vi.fn(async () => ({
|
||||
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
|
||||
current: { model: 'gpt-live' }
|
||||
@@ -189,7 +191,7 @@ describe('capability gating', () => {
|
||||
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
|
||||
expect(names).toContain(method.name)
|
||||
}
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(12)
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(14)
|
||||
})
|
||||
|
||||
it('hides the surface from a declared client that did not advertise it', async () => {
|
||||
@@ -313,6 +315,22 @@ describe('method routing', () => {
|
||||
'question'
|
||||
])
|
||||
})
|
||||
|
||||
it('routes a mobile reverse request through the host-owned handoff coordinator', async () => {
|
||||
const params = {
|
||||
envelope: envelope(),
|
||||
direction: 'to-native',
|
||||
mode: 'after-turn',
|
||||
action: 'start'
|
||||
}
|
||||
const response = await call('agentSession.requestHandoff', params, {
|
||||
...STRUCTURED_CLIENT,
|
||||
clientId: 'mobile-device-a'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(hostCalls.requestHandoff).toHaveBeenCalledWith({ callerKey: 'mobile-device-a' }, params)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile image provenance', () => {
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
computeAgentSessionPayloadFingerprint
|
||||
} from '../../../../shared/agent-session-mutation-envelope'
|
||||
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHost
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
|
||||
import {
|
||||
AttachParams,
|
||||
@@ -23,6 +21,8 @@ import {
|
||||
CreateParams,
|
||||
CreateSupportParams,
|
||||
HistoryParams,
|
||||
HandoffParams,
|
||||
HandoffStatusParams,
|
||||
OptionsParams,
|
||||
RespondParams,
|
||||
SendParams,
|
||||
@@ -209,6 +209,16 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
params: SetOptionParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.requestHandoff',
|
||||
params: HandoffParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.handoffStatus',
|
||||
params: HandoffStatusParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.options',
|
||||
params: OptionsParams,
|
||||
|
||||
@@ -190,12 +190,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'agentSession.create',
|
||||
'agentSession.createSupport',
|
||||
'agentSession.cancel',
|
||||
'agentSession.handoffStatus',
|
||||
'agentSession.history',
|
||||
'agentSession.options',
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.send',
|
||||
'agentSession.setOption',
|
||||
'agentSession.requestHandoff',
|
||||
'agentSession.subscribe',
|
||||
'agentSession.unsubscribe',
|
||||
'browser.back',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}))
|
||||
|
||||
const SESSION_ID = 'structured-session-1'
|
||||
|
||||
afterEach(() => agentSessionPtyWriteGate.detachRecordLookup())
|
||||
|
||||
describe('structured handoff PTY binding', () => {
|
||||
it('binds before runtime writes and unbinds on process exit', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const internal = runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: null
|
||||
repo: null
|
||||
folderWorkspace: null
|
||||
}>
|
||||
}
|
||||
vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
|
||||
id: 'worktree-1',
|
||||
path: '/tmp/worktree-1',
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
})
|
||||
const write = vi.fn(() => true)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })),
|
||||
write,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const record = agentSessionRecordFixture(
|
||||
agentSessionLeaseFixture({
|
||||
sessionId: SESSION_ID,
|
||||
runtimeKind: 'native',
|
||||
handoffStage: 'new-owner-proving'
|
||||
})
|
||||
)
|
||||
agentSessionPtyWriteGate.attachRecordLookup((sessionId) =>
|
||||
sessionId === SESSION_ID ? record : null
|
||||
)
|
||||
|
||||
await runtime.createTerminal('id:worktree-1', {
|
||||
command: 'codex resume thread-1',
|
||||
structuredAgentSessionId: SESSION_ID
|
||||
})
|
||||
|
||||
expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID)
|
||||
await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
runtime.onPtyExit('pty-structured', 0)
|
||||
expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
} from '../codex/codex-structured-session-adapter'
|
||||
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
|
||||
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
|
||||
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
|
||||
/** Sibling of the journal tree rather than inside it: one file adjudicates every
|
||||
* session's lease, while a journal is per session. */
|
||||
@@ -48,6 +50,7 @@ export type StructuredAgentSessionRuntimeDeps = {
|
||||
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
|
||||
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
|
||||
onError?: (input: { scope: string; error: unknown }) => void
|
||||
handoffTransport?: StructuredAgentSessionHandoffTransport
|
||||
}
|
||||
|
||||
type InstalledRuntime = {
|
||||
@@ -74,6 +77,7 @@ export async function stopStructuredAgentSessionRuntime(): Promise<void> {
|
||||
const pending = installing
|
||||
installing = null
|
||||
setStructuredAgentSessionHost(null)
|
||||
agentSessionPtyWriteGate.detachRecordLookup()
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
@@ -93,56 +97,63 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME),
|
||||
hostId: deps.hostId
|
||||
})
|
||||
const codex = new CodexStructuredSessionAdapter({
|
||||
resolveLaunch: createCodexStructuredLaunchResolver({
|
||||
store,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
|
||||
}),
|
||||
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {})
|
||||
})
|
||||
const claude = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: createClaudeStructuredLaunchResolver({
|
||||
store,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
|
||||
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
|
||||
}),
|
||||
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
|
||||
persistHandle: async (observed) => {
|
||||
const record = store.getRecord(observed.sessionId)
|
||||
if (!record || record.provider !== 'claude') {
|
||||
return
|
||||
}
|
||||
const fence = record.lease.runtimeFence
|
||||
await store.recordProviderHandle({
|
||||
sessionId: observed.sessionId,
|
||||
fence,
|
||||
link: claudeProviderHandleLink({
|
||||
sessionId: observed.providerSessionId,
|
||||
leafUuid: observed.leafUuid,
|
||||
resumed: record.providerHandleChain.length > 0,
|
||||
agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId))
|
||||
try {
|
||||
const codex = new CodexStructuredSessionAdapter({
|
||||
resolveLaunch: createCodexStructuredLaunchResolver({
|
||||
store,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
|
||||
}),
|
||||
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {})
|
||||
})
|
||||
const claude = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: createClaudeStructuredLaunchResolver({
|
||||
store,
|
||||
resolveWorkspacePath: deps.resolveWorkspacePath,
|
||||
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
|
||||
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
|
||||
}),
|
||||
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
|
||||
persistHandle: async (observed) => {
|
||||
const record = store.getRecord(observed.sessionId)
|
||||
if (!record || record.provider !== 'claude') {
|
||||
return
|
||||
}
|
||||
const fence = record.lease.runtimeFence
|
||||
await store.recordProviderHandle({
|
||||
sessionId: observed.sessionId,
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
}),
|
||||
now: Date.now()
|
||||
})
|
||||
}
|
||||
})
|
||||
const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () =>
|
||||
Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined)
|
||||
)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter,
|
||||
journalRoot: deps.stateDirectory,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
|
||||
onEventSinkError: ({ sessionId, error }) =>
|
||||
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error })
|
||||
})
|
||||
setStructuredAgentSessionHost(host)
|
||||
return { host, adapter }
|
||||
link: claudeProviderHandleLink({
|
||||
sessionId: observed.providerSessionId,
|
||||
leafUuid: observed.leafUuid,
|
||||
resumed: record.providerHandleChain.length > 0,
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
}),
|
||||
now: Date.now()
|
||||
})
|
||||
}
|
||||
})
|
||||
const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () =>
|
||||
Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined)
|
||||
)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter,
|
||||
journalRoot: deps.stateDirectory,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
|
||||
onEventSinkError: ({ sessionId, error }) =>
|
||||
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }),
|
||||
...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {})
|
||||
})
|
||||
setStructuredAgentSessionHost(host)
|
||||
return { host, adapter }
|
||||
} catch (error) {
|
||||
agentSessionPtyWriteGate.detachRecordLookup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
|
||||
import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof'
|
||||
|
||||
const identity: AgentSessionProcessIdentity = {
|
||||
hostId: 'local',
|
||||
pid: 123,
|
||||
processStartTimeMs: 1_700_000_000_000,
|
||||
spawnToken: 'spawn-token'
|
||||
}
|
||||
|
||||
describe('structured TUI exit proof', () => {
|
||||
it('accepts the exact terminal exit event without probing', async () => {
|
||||
const probe = vi.fn()
|
||||
|
||||
await expect(
|
||||
waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe })
|
||||
).resolves.toBeUndefined()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ outcome: 'pid-absent' as const },
|
||||
{ outcome: 'identity-mismatch' as const, field: 'process-start-time' as const }
|
||||
])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => {
|
||||
await expect(
|
||||
waitForStructuredTuiExitProof({
|
||||
identity,
|
||||
waitForExit: async () => {
|
||||
throw new Error('terminal_handle_stale')
|
||||
},
|
||||
probe: async () => proof
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] },
|
||||
{ outcome: 'indeterminate' as const, reason: 'probe unavailable' }
|
||||
])('fails closed while the recorded process may still own: $outcome', async (proof) => {
|
||||
await expect(
|
||||
waitForStructuredTuiExitProof({
|
||||
identity,
|
||||
waitForExit: async () => {
|
||||
throw new Error('terminal_handle_stale')
|
||||
},
|
||||
probe: async () => proof,
|
||||
staleHandleProbeAttempts: 1
|
||||
})
|
||||
).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('retries the persisted process identity when handle retirement wins the exit race', async () => {
|
||||
const probe = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
outcome: 'identity-matched' as const,
|
||||
matchedOn: ['process-start-time' as const]
|
||||
})
|
||||
.mockResolvedValueOnce({ outcome: 'pid-absent' as const })
|
||||
|
||||
await expect(
|
||||
waitForStructuredTuiExitProof({
|
||||
identity,
|
||||
waitForExit: async () => {
|
||||
throw new Error('terminal_handle_stale')
|
||||
},
|
||||
probe,
|
||||
staleHandleProbeAttempts: 2,
|
||||
staleHandleProbeIntervalMs: 0
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(probe).toHaveBeenNthCalledWith(1, identity)
|
||||
expect(probe).toHaveBeenNthCalledWith(2, identity)
|
||||
})
|
||||
|
||||
it('preserves unrelated terminal wait failures', async () => {
|
||||
const probe = vi.fn()
|
||||
|
||||
await expect(
|
||||
waitForStructuredTuiExitProof({
|
||||
identity,
|
||||
waitForExit: async () => {
|
||||
throw new Error('timeout')
|
||||
},
|
||||
probe
|
||||
})
|
||||
).rejects.toThrow('timeout')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
|
||||
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
|
||||
|
||||
type ExitProofInput = {
|
||||
identity: AgentSessionProcessIdentity
|
||||
waitForExit: () => Promise<unknown>
|
||||
probe?: (identity: AgentSessionProcessIdentity) => Promise<AgentSessionOwnerProbe>
|
||||
staleHandleProbeAttempts?: number
|
||||
staleHandleProbeIntervalMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50
|
||||
const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100
|
||||
|
||||
function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean {
|
||||
return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch'
|
||||
}
|
||||
|
||||
async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise<void> {
|
||||
const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity }))
|
||||
const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS
|
||||
const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
if (provesRecordedProcessExited(await probe(input.identity))) {
|
||||
return
|
||||
}
|
||||
if (attempt + 1 < attempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||
}
|
||||
}
|
||||
throw staleError
|
||||
}
|
||||
|
||||
export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise<void> {
|
||||
try {
|
||||
await input.waitForExit()
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') {
|
||||
throw error
|
||||
}
|
||||
await waitForRecordedProcessExit(input, error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence'
|
||||
|
||||
describe('structured TUI idle evidence', () => {
|
||||
it('does not treat a ready prompt preview as proof that a turn is idle', () => {
|
||||
const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace'
|
||||
expect(readyPreview).toContain('OpenAI Codex')
|
||||
expect(
|
||||
hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires an explicit idle state and still rejects blocked prompts', () => {
|
||||
expect(
|
||||
hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true })
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not authorize a restored idle status before live observation', () => {
|
||||
expect(
|
||||
hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AgentStatus } from '../../shared/agent-title-core'
|
||||
|
||||
export function hasStructuredTuiIdleEvidence(input: {
|
||||
blocked: boolean
|
||||
status: AgentStatus | null
|
||||
statusObservedLive: boolean
|
||||
}): boolean {
|
||||
return !input.blocked && input.status === 'idle' && input.statusObservedLive
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity'
|
||||
|
||||
describe('structured TUI process identity', () => {
|
||||
it('binds the direct Codex child instead of the PTY shell pid', async () => {
|
||||
const readStartTime = vi.fn(async () => 1_700_000_000_000)
|
||||
await expect(
|
||||
readStructuredTuiProcessIdentity({
|
||||
hostId: 'local',
|
||||
rootPid: 100,
|
||||
spawnToken: 'spawn-1',
|
||||
agent: 'codex',
|
||||
platform: 'darwin',
|
||||
readPosixRows: async () => [
|
||||
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
|
||||
{ pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' },
|
||||
{ pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' }
|
||||
],
|
||||
readStartTime
|
||||
})
|
||||
).resolves.toEqual({
|
||||
hostId: 'local',
|
||||
pid: 101,
|
||||
processStartTimeMs: 1_700_000_000_000,
|
||||
spawnToken: 'spawn-1'
|
||||
})
|
||||
expect(readStartTime).toHaveBeenCalledWith(101, 'darwin')
|
||||
})
|
||||
|
||||
it('fails closed when sibling Codex children make the owner ambiguous', async () => {
|
||||
await expect(
|
||||
readStructuredTuiProcessIdentity({
|
||||
hostId: 'local',
|
||||
rootPid: 100,
|
||||
spawnToken: 'spawn-1',
|
||||
agent: 'codex',
|
||||
platform: 'win32',
|
||||
readWindowsRows: async () => [
|
||||
{ pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' },
|
||||
{ pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' },
|
||||
{ pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' }
|
||||
]
|
||||
})
|
||||
).rejects.toThrow('one exact Codex child process')
|
||||
})
|
||||
|
||||
it('fails closed when the process snapshot omitted the PTY root', async () => {
|
||||
await expect(
|
||||
readStructuredTuiProcessIdentity({
|
||||
hostId: 'local',
|
||||
rootPid: 100,
|
||||
spawnToken: 'spawn-1',
|
||||
agent: 'codex',
|
||||
platform: 'darwin',
|
||||
readPosixRows: async () => [
|
||||
{ pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' }
|
||||
]
|
||||
})
|
||||
).rejects.toThrow('root process was not present')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import {
|
||||
getFreshProcessTableSnapshot,
|
||||
type ProcessTableRow
|
||||
} from '../../shared/process-table-snapshot'
|
||||
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
|
||||
import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows'
|
||||
import { readProcessStartTimeMs } from './agent-session-process-identity-probe'
|
||||
|
||||
type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean }
|
||||
|
||||
function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] {
|
||||
const children = new Map<number, ProcessRow[]>()
|
||||
for (const row of rows) {
|
||||
children.set(row.ppid, [...(children.get(row.ppid) ?? []), row])
|
||||
}
|
||||
const found: (ProcessRow & { depth: number })[] = []
|
||||
const pending = [{ pid: rootPid, depth: 0 }]
|
||||
const seen = new Set<number>()
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()!
|
||||
if (seen.has(current.pid)) {
|
||||
continue
|
||||
}
|
||||
seen.add(current.pid)
|
||||
const row = rows.find((candidate) => candidate.pid === current.pid)
|
||||
if (row) {
|
||||
found.push({ ...row, depth: current.depth })
|
||||
}
|
||||
for (const child of children.get(current.pid) ?? []) {
|
||||
pending.push({ pid: child.pid, depth: current.depth + 1 })
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export function resolveStructuredTuiChildPid(
|
||||
rows: ProcessRow[],
|
||||
rootPid: number,
|
||||
agent: 'claude' | 'codex'
|
||||
): number | null {
|
||||
const candidates = descendants(rows, rootPid).filter(
|
||||
(row) => recognizeAgentProcessFromCommandLine(row.command)?.agent === agent
|
||||
)
|
||||
const foreground = candidates.filter((row) => row.foreground)
|
||||
const eligible = foreground.length > 0 ? foreground : candidates
|
||||
eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid)
|
||||
if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) {
|
||||
return null
|
||||
}
|
||||
return eligible[0]!.pid
|
||||
}
|
||||
|
||||
function posixRows(rows: ProcessTableRow[]): ProcessRow[] {
|
||||
return rows.map((row) => ({
|
||||
pid: row.pid,
|
||||
ppid: row.ppid,
|
||||
command: row.command,
|
||||
foreground: row.stat.includes('+')
|
||||
}))
|
||||
}
|
||||
|
||||
export async function readStructuredTuiProcessIdentity(input: {
|
||||
hostId: string
|
||||
rootPid: number
|
||||
spawnToken: string
|
||||
agent: 'claude' | 'codex'
|
||||
platform?: NodeJS.Platform
|
||||
readPosixRows?: () => Promise<ProcessTableRow[]>
|
||||
readWindowsRows?: typeof queryWindowsProcessRowsFresh
|
||||
readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise<number | null>
|
||||
}): Promise<AgentSessionProcessIdentity> {
|
||||
const platform = input.platform ?? process.platform
|
||||
const rows: ProcessRow[] =
|
||||
platform === 'win32'
|
||||
? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({
|
||||
pid: row.pid,
|
||||
ppid: row.ppid,
|
||||
command: row.command,
|
||||
foreground: false
|
||||
}))
|
||||
: posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)())
|
||||
if (!rows.some((row) => row.pid === input.rootPid)) {
|
||||
throw new Error('The terminal root process was not present in the process snapshot.')
|
||||
}
|
||||
const pid = resolveStructuredTuiChildPid(rows, input.rootPid, input.agent)
|
||||
if (pid === null) {
|
||||
const agentName = input.agent === 'claude' ? 'Claude' : 'Codex'
|
||||
throw new Error(`The resumed terminal did not expose one exact ${agentName} child process.`)
|
||||
}
|
||||
return {
|
||||
hostId: input.hostId,
|
||||
pid,
|
||||
processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform),
|
||||
spawnToken: input.spawnToken
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
|
||||
import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context'
|
||||
import { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
|
||||
|
||||
function encodeQuestionAnswer(questionId: string, answer: string): string {
|
||||
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
||||
@@ -99,6 +100,13 @@ export function NativeChatStructuredSession(props: {
|
||||
tabIndex={-1}
|
||||
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
|
||||
>
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={controller.handoff}
|
||||
isWorking={controller.isWorking}
|
||||
onRequest={(direction, mode, action) => {
|
||||
void controller.requestHandoff(direction, mode, action)
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
<NativeChatEmptyState kind="loading" />
|
||||
@@ -194,7 +202,9 @@ export function NativeChatStructuredSession(props: {
|
||||
{controller.error ?? composerError}
|
||||
</p>
|
||||
) : null}
|
||||
{prompt ? null : (
|
||||
{prompt ||
|
||||
controller.handoff?.owner === 'tui' ||
|
||||
controller.handoff?.phase === 'switching' ? null : (
|
||||
<NativeChatComposer
|
||||
terminalTabId={props.tabId}
|
||||
paneKey={paneKey}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Props = {
|
||||
status: AgentSessionHandoffStatus | null
|
||||
isWorking: boolean
|
||||
onRequest: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
) => void
|
||||
}
|
||||
|
||||
function handoffStageCopy(status: AgentSessionHandoffStatus): string {
|
||||
if (status.stage === 'preparing') {
|
||||
return status.direction === 'to-tui'
|
||||
? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…')
|
||||
: translate(
|
||||
'components.native-chat.handoff.stage.finishingTerminal',
|
||||
'Finishing agent terminal…'
|
||||
)
|
||||
}
|
||||
if (status.stage === 'old-owner-stopped') {
|
||||
return status.direction === 'to-tui'
|
||||
? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…')
|
||||
: translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…')
|
||||
}
|
||||
if (status.stage === 'new-owner-proving') {
|
||||
return status.direction === 'to-tui'
|
||||
? translate(
|
||||
'components.native-chat.handoff.stage.verifyingTerminal',
|
||||
'Verifying agent terminal…'
|
||||
)
|
||||
: translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…')
|
||||
}
|
||||
if (status.stage === 'recovering') {
|
||||
return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…')
|
||||
}
|
||||
if (status.stage === 'manual-recovery') {
|
||||
return translate(
|
||||
'components.native-chat.handoff.stage.manualRecovery',
|
||||
'Agent session needs recovery'
|
||||
)
|
||||
}
|
||||
return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…')
|
||||
}
|
||||
|
||||
export function StructuredAgentSessionHandoffChrome({
|
||||
status,
|
||||
isWorking,
|
||||
onRequest
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
const owner = status?.owner ?? 'native'
|
||||
const phase = status?.phase ?? 'idle'
|
||||
const switching = phase === 'switching' || phase === 'waiting-for-exit'
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-9 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<Badge variant="outline">
|
||||
{switching
|
||||
? translate('components.native-chat.handoff.mode.switching', 'Switching')
|
||||
: owner === 'tui'
|
||||
? translate('components.native-chat.handoff.mode.terminal', 'Terminal')
|
||||
: translate('components.native-chat.handoff.mode.chat', 'Chat')}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{phase === 'queued' && status?.direction ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status.direction === 'to-tui'
|
||||
? translate(
|
||||
'components.native-chat.handoff.switchingAfterTurn',
|
||||
'Switching after this turn'
|
||||
)
|
||||
: translate(
|
||||
'components.native-chat.handoff.returningAfterTurn',
|
||||
'Returning after this turn'
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest(status.direction!, 'after-turn', 'cancel-queued')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : owner === 'native' && !switching ? (
|
||||
isWorking ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
{translate(
|
||||
'components.native-chat.handoff.switchAfterTurn',
|
||||
'Switch after this turn'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-tui', 'stop-turn')}
|
||||
>
|
||||
{translate(
|
||||
'components.native-chat.handoff.stopTurnAndSwitch',
|
||||
'Stop turn and switch'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-tui', 'now')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
|
||||
</Button>
|
||||
)
|
||||
) : owner === 'tui' && !switching ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-native', 'after-turn')}
|
||||
>
|
||||
{isWorking
|
||||
? translate(
|
||||
'components.native-chat.handoff.returnAfterTurn',
|
||||
'Return after this turn'
|
||||
)
|
||||
: translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{owner === 'tui' && phase === 'idle' ? (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border bg-muted px-3 py-2 text-xs">
|
||||
<span>
|
||||
{status?.hostLabel
|
||||
? translate(
|
||||
'components.native-chat.handoff.agentOpenOnHost',
|
||||
'Agent is open in terminal on {{value0}}.',
|
||||
{ value0: status.hostLabel }
|
||||
)
|
||||
: translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-native', 'after-turn')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{switching ? (
|
||||
<div className="border-b border-border bg-muted px-3 py-3 text-center text-sm text-muted-foreground">
|
||||
{phase === 'waiting-for-exit'
|
||||
? translate(
|
||||
'components.native-chat.handoff.exitTerminal',
|
||||
'Exit the agent terminal to continue in chat.'
|
||||
)
|
||||
: status?.stage
|
||||
? handoffStageCopy(status)
|
||||
: translate(
|
||||
'components.native-chat.handoff.switchingOwner',
|
||||
'Switching session owner…'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{phase === 'failed' && status?.error ? (
|
||||
<div
|
||||
className="border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{status.error.message}</span>
|
||||
{status.direction && status.error.recoverableOwner !== 'none' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest(status.direction!, 'now', 'retry')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.retry', 'Retry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{status.error.details ? (
|
||||
<details className="mt-1">
|
||||
<summary>{translate('components.native-chat.handoff.details', 'Details')}</summary>
|
||||
<p className="mt-1 text-muted-foreground">{status.error.details}</p>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { AgentSessionHistoryResult } from '../../../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionHistoryResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import {
|
||||
projectStructuredAgentSessionStatus,
|
||||
structuredAgentSessionPaneKey
|
||||
@@ -19,6 +22,10 @@ import {
|
||||
callStructuredAgentSession,
|
||||
subscribeStructuredAgentSession
|
||||
} from '@/runtime/structured-agent-session-client'
|
||||
import {
|
||||
clearStructuredHandoff,
|
||||
publishStructuredHandoff
|
||||
} from '@/runtime/structured-agent-session-handoff-store'
|
||||
|
||||
type StructuredTab = Tab & { contentType: 'agent-session' }
|
||||
|
||||
@@ -87,6 +94,13 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
target,
|
||||
{ sessionId: tab.entityId, ...(state.cursor ? { cursor: state.cursor } : {}) },
|
||||
(event) => {
|
||||
if ('handoff' in event && event.handoff) {
|
||||
publishStructuredHandoff({
|
||||
sessionId: tab.entityId,
|
||||
fence: event.fence ?? state.fence ?? 0,
|
||||
status: event.handoff
|
||||
})
|
||||
}
|
||||
if (
|
||||
event.type === 'batch' &&
|
||||
!shouldAdvanceStructuredResumeCursor(state.cursor, event.batch.cursor)
|
||||
@@ -127,12 +141,17 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
opening = false
|
||||
}
|
||||
}
|
||||
void callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
|
||||
sessionId: tab.entityId,
|
||||
direction: 'tail',
|
||||
limit: 40
|
||||
})
|
||||
.then(async (result) => {
|
||||
void Promise.all([
|
||||
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
|
||||
sessionId: tab.entityId,
|
||||
direction: 'tail',
|
||||
limit: 40
|
||||
}),
|
||||
callStructuredAgentSession<AgentSessionHandoffStatus>(target, 'agentSession.handoffStatus', {
|
||||
sessionId: tab.entityId
|
||||
}).catch(() => null)
|
||||
])
|
||||
.then(async ([result, handoff]) => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
@@ -150,6 +169,14 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
}
|
||||
})
|
||||
}
|
||||
if (handoff) {
|
||||
apply({ type: 'handoff', handoff })
|
||||
publishStructuredHandoff({
|
||||
sessionId: tab.entityId,
|
||||
fence: result.ok ? (result.page.fence ?? 0) : (result.fence ?? 0),
|
||||
status: handoff
|
||||
})
|
||||
}
|
||||
await open()
|
||||
})
|
||||
.catch(scheduleReconnect)
|
||||
@@ -159,6 +186,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
clearTimeout(reconnectTimer)
|
||||
}
|
||||
unsubscribe()
|
||||
clearStructuredHandoff(tab.entityId)
|
||||
useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ import { recordWebSessionFocusIntent } from '@/runtime/web-session-focus-intent'
|
||||
|
||||
const LOCAL_OWNER = 'local-structured-session'
|
||||
|
||||
export function useStructuredAgentSessionCreate(worktreeId: string): {
|
||||
export function useStructuredAgentSessionCreate(
|
||||
worktreeId: string,
|
||||
agent: 'claude' | 'codex'
|
||||
): {
|
||||
supported: boolean
|
||||
creating: boolean
|
||||
create: () => Promise<boolean>
|
||||
@@ -55,14 +58,14 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
|
||||
const support = await callStructuredAgentSession<{ supported?: boolean }>(
|
||||
target,
|
||||
'agentSession.createSupport',
|
||||
{ worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' }
|
||||
{ worktree: toRuntimeWorktreeSelector(worktreeId), agent }
|
||||
)
|
||||
if (!stale) {
|
||||
setSupported(
|
||||
showStructuredAgentSessionChoice({
|
||||
hostCapability,
|
||||
workspaceSupport: support.supported === true,
|
||||
agent: 'codex'
|
||||
agent
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -70,7 +73,7 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [target, worktreeId])
|
||||
}, [agent, target, worktreeId])
|
||||
|
||||
const create = useCallback(async (): Promise<boolean> => {
|
||||
if (!supported || creating) {
|
||||
@@ -92,10 +95,14 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
|
||||
sessionId,
|
||||
clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: structuredAgentSessionCreateFingerprint({ sessionId, worktree })
|
||||
payloadFingerprint: structuredAgentSessionCreateFingerprint({
|
||||
sessionId,
|
||||
worktree,
|
||||
agent
|
||||
})
|
||||
},
|
||||
worktree,
|
||||
agent: 'codex'
|
||||
agent
|
||||
})
|
||||
if (!result.ok) {
|
||||
throw new Error(result.refusal.message)
|
||||
@@ -104,7 +111,7 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}, [creating, supported, target, worktreeId])
|
||||
}, [agent, creating, supported, target, worktreeId])
|
||||
|
||||
return { supported, creating, create }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import type {
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
@@ -129,11 +130,18 @@ export function useStructuredAgentSessionRead(args: {
|
||||
}
|
||||
}
|
||||
async function refreshTail(): Promise<void> {
|
||||
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
|
||||
target,
|
||||
'agentSession.history',
|
||||
{ sessionId, direction: 'tail', limit: 40 }
|
||||
)
|
||||
const [result, handoff] = await Promise.all([
|
||||
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
|
||||
sessionId,
|
||||
direction: 'tail',
|
||||
limit: 40
|
||||
}),
|
||||
callStructuredAgentSession<AgentSessionHandoffStatus>(
|
||||
target,
|
||||
'agentSession.handoffStatus',
|
||||
{ sessionId }
|
||||
).catch(() => null)
|
||||
])
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
@@ -153,6 +161,9 @@ export function useStructuredAgentSessionRead(args: {
|
||||
})
|
||||
resumeCursorRef.current = result.snapshot.cursor
|
||||
}
|
||||
if (handoff) {
|
||||
dispatch({ type: 'handoff', handoff })
|
||||
}
|
||||
}
|
||||
const refreshOnFocus = (): void => {
|
||||
if (!document.hasFocus()) {
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type {
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffResult,
|
||||
AgentSessionOptionsResult,
|
||||
AgentSessionPromptResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
@@ -61,13 +64,15 @@ export function useStructuredAgentSession(args: {
|
||||
async <T>(
|
||||
method: string,
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>
|
||||
fields: Record<string, unknown>,
|
||||
operationIdOverride?: string | null
|
||||
): Promise<T | null> => {
|
||||
if (stateRef.current.fence === null) {
|
||||
return null
|
||||
}
|
||||
const key = `${fingerprintMethod}:${JSON.stringify(fields)}`
|
||||
const clientOperationId = operationIds.current.get(key) ?? structuredSessionOperationId()
|
||||
const clientOperationId =
|
||||
operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId()
|
||||
operationIds.current.set(key, clientOperationId)
|
||||
const result = await callStructuredAgentSession<AgentSessionMutationResult<T>>(
|
||||
target,
|
||||
@@ -204,6 +209,18 @@ export function useStructuredAgentSession(args: {
|
||||
),
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
setStructuredOption
|
||||
setStructuredOption,
|
||||
requestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action: 'start' | 'cancel-queued' | 'retry' = 'start'
|
||||
) =>
|
||||
mutate<AgentSessionHandoffResult>(
|
||||
'agentSession.requestHandoff',
|
||||
'agentSession.requestHandoff',
|
||||
{ direction, mode, action },
|
||||
action === 'retry' ? stateRef.current.handoff?.operationId : null
|
||||
),
|
||||
handoff: state.handoff
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ const {
|
||||
storeState,
|
||||
openSettingsPageMock,
|
||||
openSettingsTargetMock,
|
||||
useDetectedAgentsMock
|
||||
useDetectedAgentsMock,
|
||||
useStructuredAgentSessionCreateMock
|
||||
} = vi.hoisted(() => ({
|
||||
shortcutLabelMock: vi.fn<() => string | null>(),
|
||||
storeState: {
|
||||
@@ -23,7 +24,12 @@ const {
|
||||
},
|
||||
openSettingsPageMock: vi.fn(),
|
||||
openSettingsTargetMock: vi.fn(),
|
||||
useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] }))
|
||||
useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] })),
|
||||
useStructuredAgentSessionCreateMock: vi.fn(() => ({
|
||||
supported: true,
|
||||
creating: false,
|
||||
create: vi.fn(async () => true)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useDetectedAgents', () => ({
|
||||
@@ -34,6 +40,10 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useOptionalShortcutLabel: shortcutLabelMock
|
||||
}))
|
||||
|
||||
vi.mock('../native-chat/use-structured-agent-session-create', () => ({
|
||||
useStructuredAgentSessionCreate: useStructuredAgentSessionCreateMock
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector: (state: typeof storeState) => unknown) => {
|
||||
@@ -117,6 +127,7 @@ beforeEach(() => {
|
||||
shortcutLabelMock.mockReset()
|
||||
shortcutLabelMock.mockReturnValue(null)
|
||||
useDetectedAgentsMock.mockClear()
|
||||
useStructuredAgentSessionCreateMock.mockClear()
|
||||
openSettingsPageMock.mockReset()
|
||||
openSettingsTargetMock.mockReset()
|
||||
storeState.settings.defaultTuiAgent = 'codex'
|
||||
@@ -206,6 +217,16 @@ describe('QuickLaunchAgentMenuItems', () => {
|
||||
storeState.settings.defaultTuiAgent = 'blank'
|
||||
expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"')
|
||||
})
|
||||
|
||||
it('offers explicit structured chat creation for Claude and Codex', () => {
|
||||
const html = renderAgentMenuItems()
|
||||
|
||||
expect(html.match(/Chat session/g) ?? []).toHaveLength(2)
|
||||
expect(html).toContain('Start Claude without a terminal')
|
||||
expect(html).toContain('Start Codex without a terminal')
|
||||
expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'claude')
|
||||
expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'codex')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowLaunchWatchdogTimeout', () => {
|
||||
|
||||
@@ -117,7 +117,8 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
|
||||
const structuredSession = useStructuredAgentSessionCreate(worktreeId)
|
||||
const claudeStructuredSession = useStructuredAgentSessionCreate(worktreeId, 'claude')
|
||||
const codexStructuredSession = useStructuredAgentSessionCreate(worktreeId, 'codex')
|
||||
|
||||
const openAgentSettings = useCallback(() => {
|
||||
openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
@@ -199,6 +200,12 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
{agents.map((agent) => {
|
||||
const entry = getCatalogEntry(agent)
|
||||
const label = entry?.label ?? agent
|
||||
const structuredSession =
|
||||
agent === 'claude'
|
||||
? claudeStructuredSession
|
||||
: agent === 'codex'
|
||||
? codexStructuredSession
|
||||
: null
|
||||
const showsDefaultAgentShortcut =
|
||||
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
|
||||
return (
|
||||
@@ -218,7 +225,7 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
{agent === 'codex' && structuredSession.supported ? (
|
||||
{structuredSession?.supported ? (
|
||||
<DropdownMenuItem
|
||||
disabled={structuredSession.creating}
|
||||
onSelect={() => {
|
||||
@@ -235,7 +242,8 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 pl-6 text-[12px] leading-5 font-medium"
|
||||
title={translate(
|
||||
'auto.components.tab.bar.QuickLaunchButton.a847779775',
|
||||
'Start Codex without a terminal'
|
||||
'Start {{value0}} without a terminal',
|
||||
{ value0: label }
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="size-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -179,7 +179,12 @@ export default function AiVaultSessionDropLayer({
|
||||
if (payload.structuredSession) {
|
||||
const { sessionId, workspaceId } = payload.structuredSession
|
||||
if (!activateStructuredAgentSessionById({ worktreeId: workspaceId, sessionId })) {
|
||||
toast.error('The structured agent session is not available yet. Retry in a moment.')
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.lib.activateAiVaultStructuredSession.unavailable',
|
||||
'The structured agent session is not available yet. Retry in a moment.'
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function StructuredAgentSessionTerminalReturnButton(props: {
|
||||
enabled: boolean
|
||||
onReturn?: () => void
|
||||
}): React.JSX.Element | null {
|
||||
if (!props.enabled) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pane-title-split-trigger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onReturn?.()
|
||||
}}
|
||||
>
|
||||
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -243,6 +243,13 @@ import {
|
||||
} from './regular-terminal-focus-ownership'
|
||||
import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts'
|
||||
import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh'
|
||||
import { useTerminalStructuredHandoff } from '@/runtime/structured-agent-session-handoff-store'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
createStructuredAgentSessionOperationId,
|
||||
structuredAgentSessionPayloadFingerprint
|
||||
} from '../../../../shared/structured-agent-session-mutation'
|
||||
|
||||
type TerminalPaneProps = {
|
||||
tabId: string
|
||||
@@ -340,6 +347,34 @@ function TerminalPane(
|
||||
const isRendererVisible = isVisible && isWorktreeActive
|
||||
const isVisibleRef = useRef(isRendererVisible)
|
||||
isVisibleRef.current = isRendererVisible
|
||||
const structuredHandoff = useTerminalStructuredHandoff(tabId)
|
||||
const returnStructuredSession = useCallback(() => {
|
||||
if (!structuredHandoff) {
|
||||
return
|
||||
}
|
||||
const direction = 'to-native' as const
|
||||
const mode = 'after-turn' as const
|
||||
const action = 'start' as const
|
||||
const sessionId = structuredHandoff.sessionId
|
||||
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
|
||||
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId })
|
||||
const clientOperationId = createStructuredAgentSessionOperationId(() => crypto.randomUUID())
|
||||
void callStructuredAgentSession(target, 'agentSession.requestHandoff', {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: structuredHandoff.fence,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.requestHandoff',
|
||||
sessionId,
|
||||
fields: { direction, mode, action }
|
||||
})
|
||||
},
|
||||
direction,
|
||||
mode,
|
||||
action
|
||||
})
|
||||
}, [structuredHandoff, worktreeId])
|
||||
const {
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
sshReconnectEnvironmentId,
|
||||
@@ -3174,6 +3209,8 @@ function TerminalPane(
|
||||
canToggleNativeChat={activePaneCanToggleChat}
|
||||
isChatViewMode={activePaneIsChatLeaf}
|
||||
onToggleNativeChat={handleToggleNativeChat}
|
||||
canReturnStructuredSession={structuredHandoff?.status.owner === 'tui'}
|
||||
onReturnStructuredSession={returnStructuredSession}
|
||||
canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession}
|
||||
onContinueAgentSessionInNewSession={(pane) =>
|
||||
contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StructuredAgentSessionTerminalReturnButton } from './StructuredAgentSessionTerminalReturnButton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
|
||||
@@ -50,6 +51,8 @@ type TerminalPaneHeaderOverlayProps = {
|
||||
isChatViewMode?: boolean
|
||||
/** Flip the active pane between the terminal and the native chat view. */
|
||||
onToggleNativeChat?: () => void
|
||||
canReturnStructuredSession?: boolean
|
||||
onReturnStructuredSession?: () => void
|
||||
canContinueAgentSessionInNewSession?: boolean
|
||||
onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void
|
||||
onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void
|
||||
@@ -88,6 +91,8 @@ export default function TerminalPaneHeaderOverlay({
|
||||
canToggleNativeChat,
|
||||
isChatViewMode,
|
||||
onToggleNativeChat,
|
||||
canReturnStructuredSession,
|
||||
onReturnStructuredSession,
|
||||
canContinueAgentSessionInNewSession,
|
||||
onContinueAgentSessionInNewSession,
|
||||
onSplitPane,
|
||||
@@ -244,6 +249,10 @@ export default function TerminalPaneHeaderOverlay({
|
||||
</button>
|
||||
) : null}
|
||||
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0">
|
||||
<StructuredAgentSessionTerminalReturnButton
|
||||
enabled={Boolean(canReturnStructuredSession && isActivePane)}
|
||||
onReturn={onReturnStructuredSession}
|
||||
/>
|
||||
{canContinueAgentSessionInNewSession && isActivePane ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -769,6 +769,9 @@
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
},
|
||||
"activateAiVaultStructuredSession": {
|
||||
"unavailable": "The structured agent session is not available yet. Retry in a moment."
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -15116,6 +15119,37 @@
|
||||
"status": {
|
||||
"responding": "Agent is responding"
|
||||
},
|
||||
"handoff": {
|
||||
"mode": {
|
||||
"switching": "Switching",
|
||||
"terminal": "Terminal",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"switchingAfterTurn": "Switching after this turn",
|
||||
"returningAfterTurn": "Returning after this turn",
|
||||
"cancel": "Cancel",
|
||||
"switchAfterTurn": "Switch after this turn",
|
||||
"stopTurnAndSwitch": "Stop turn and switch",
|
||||
"openAgentTui": "Open agent TUI",
|
||||
"returnAfterTurn": "Return after this turn",
|
||||
"returnToChat": "Return to chat",
|
||||
"agentOpen": "Agent is open in terminal.",
|
||||
"agentOpenOnHost": "Agent is open in terminal on {{value0}}.",
|
||||
"exitTerminal": "Exit the agent terminal to continue in chat.",
|
||||
"switchingOwner": "Switching session owner…",
|
||||
"retry": "Retry",
|
||||
"details": "Details",
|
||||
"stage": {
|
||||
"finishingChat": "Finishing chat session…",
|
||||
"finishingTerminal": "Finishing agent terminal…",
|
||||
"openingTerminal": "Opening agent terminal…",
|
||||
"resumingChat": "Resuming chat session…",
|
||||
"verifyingTerminal": "Verifying agent terminal…",
|
||||
"verifyingChat": "Verifying chat session…",
|
||||
"recovering": "Recovering agent session…",
|
||||
"manualRecovery": "Agent session needs recovery"
|
||||
}
|
||||
},
|
||||
"jumpToLatest": "Jump to latest",
|
||||
"toggle": {
|
||||
"showTerminal": "Show terminal",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { AiVaultSession } from '../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -15,7 +16,12 @@ export function activateAiVaultStructuredSession(session: AiVaultSession): boole
|
||||
sessionId: structured.sessionId
|
||||
})
|
||||
) {
|
||||
toast.error('The structured agent session is not available yet. Retry in a moment.')
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.lib.activateAiVaultStructuredSession.unavailable',
|
||||
'The structured agent session is not available yet. Retry in a moment.'
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync'
|
||||
|
||||
describe('local structured session tab projection', () => {
|
||||
it('keeps only structured tabs without replaying renderer-owned groups', () => {
|
||||
const snapshot = {
|
||||
worktree: 'workspace-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'agent-session:claude-1',
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'agent-session:claude-1',
|
||||
tabOrder: ['terminal-1', 'agent-session:claude-1']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal-1',
|
||||
parentTabId: 'terminal-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal',
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:claude-1',
|
||||
title: 'Claude Chat',
|
||||
sessionId: 'claude-1',
|
||||
agent: 'claude',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
} satisfies RuntimeMobileSessionTabsResult
|
||||
|
||||
expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({
|
||||
tabGroups: undefined,
|
||||
tabs: [expect.objectContaining({ type: 'agent-session', agent: 'claude' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,20 +11,17 @@ type SessionTabsEvent =
|
||||
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
|
||||
| { type: 'end' }
|
||||
|
||||
function structuredOnly(snapshot: RuntimeMobileSessionTabsResult): RuntimeMobileSessionTabsResult {
|
||||
export function projectLocalStructuredSessionTabs(
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
const structuredIds = new Set(
|
||||
snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
|
||||
)
|
||||
return {
|
||||
...snapshot,
|
||||
tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)),
|
||||
tabGroups: snapshot.tabGroups?.map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => structuredIds.has(id)),
|
||||
activeTabId:
|
||||
group.activeTabId && structuredIds.has(group.activeTabId) ? group.activeTabId : null,
|
||||
recentTabIds: group.recentTabIds?.filter((id) => structuredIds.has(id))
|
||||
}))
|
||||
// Why: replaying local terminal group ids under the structured owner clones the workspace layout.
|
||||
tabGroups: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +31,7 @@ function applySnapshots(snapshots: readonly RuntimeMobileSessionTabsResult[]): v
|
||||
for (const snapshot of snapshots) {
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
next,
|
||||
structuredOnly(snapshot),
|
||||
projectLocalStructuredSessionTabs(snapshot),
|
||||
LOCAL_STRUCTURED_SESSION_OWNER
|
||||
)
|
||||
next = patch === next ? next : ({ ...next, ...patch } as typeof state)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
|
||||
export type TerminalStructuredHandoff = {
|
||||
sessionId: string
|
||||
fence: number
|
||||
status: AgentSessionHandoffStatus
|
||||
}
|
||||
|
||||
const byTerminalTabId = new Map<string, TerminalStructuredHandoff>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export function publishStructuredHandoff(input: TerminalStructuredHandoff): void {
|
||||
for (const [tabId, current] of byTerminalTabId) {
|
||||
if (current.sessionId === input.sessionId && tabId !== input.status.terminal?.tabId) {
|
||||
byTerminalTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
if (input.status.terminal) {
|
||||
byTerminalTabId.set(input.status.terminal.tabId, input)
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStructuredHandoff(sessionId: string): void {
|
||||
let changed = false
|
||||
for (const [tabId, current] of byTerminalTabId) {
|
||||
if (current.sessionId === sessionId) {
|
||||
byTerminalTabId.delete(tabId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalStructuredHandoff(tabId: string): TerminalStructuredHandoff | null {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
() => byTerminalTabId.get(tabId) ?? null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { Tab } from '../../../shared/types'
|
||||
import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/worktree'
|
||||
const GROUP_ID = 'group-1'
|
||||
|
||||
function structuredTab(sessionId: string, sortOrder: number): Tab {
|
||||
return {
|
||||
id: `structured-agent-session-${sessionId}`,
|
||||
entityId: sessionId,
|
||||
groupId: GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'codex',
|
||||
label: 'Codex Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder,
|
||||
createdAt: sortOrder + 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('web session structured tab focus', () => {
|
||||
it('keeps the exact active structured tab across a host snapshot', () => {
|
||||
const first = structuredTab('session-1', 0)
|
||||
const second = structuredTab('session-2', 1)
|
||||
const state = {
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeFileId: null,
|
||||
activeFileIdByWorktree: {},
|
||||
activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID },
|
||||
activeTabId: null,
|
||||
activeTabIdByWorktree: {},
|
||||
activeTabType: 'terminal',
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' },
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
browserCertificateFailuresByPageId: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserTabsByWorktree: {},
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
activeTabId: second.id,
|
||||
tabOrder: [first.id, second.id]
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {},
|
||||
openFiles: [],
|
||||
ptyIdsByTabId: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] },
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] },
|
||||
unreadTerminalTabs: {},
|
||||
sortEpoch: 0
|
||||
} as WebSessionTabsSyncState
|
||||
const snapshot: RuntimeMobileSessionTabsResult = {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: GROUP_ID,
|
||||
activeTabId: 'agent-session:session-1',
|
||||
activeTabType: 'agent-session',
|
||||
tabGroups: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
activeTabId: 'agent-session:session-1',
|
||||
tabOrder: ['agent-session:session-1', 'agent-session:session-2']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:session-1',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-1',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:session-2',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'session-2',
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10)
|
||||
|
||||
expect(patch.groupsByWorktree?.[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id)
|
||||
})
|
||||
})
|
||||
@@ -2295,6 +2295,18 @@ function findCurrentVisibleUnifiedTabId(args: {
|
||||
if (!nextUnifiedTabs) {
|
||||
return null
|
||||
}
|
||||
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
|
||||
const activeGroupTabId = state.groupsByWorktree[worktreeId]?.find(
|
||||
(group) => group.id === activeGroupId
|
||||
)?.activeTabId
|
||||
if (
|
||||
activeGroupTabId &&
|
||||
nextUnifiedTabs.some(
|
||||
(tab) => tab.id === activeGroupTabId && tab.contentType === 'agent-session'
|
||||
)
|
||||
) {
|
||||
return activeGroupTabId
|
||||
}
|
||||
const currentVisibleType =
|
||||
state.activeTabTypeByWorktree[worktreeId] ??
|
||||
(state.activeWorktreeId === worktreeId ? state.activeTabType : null)
|
||||
|
||||
@@ -137,7 +137,12 @@ export function evaluateAgentSessionAcquisition(args: {
|
||||
// Why: the retry key is operation id + fence + stage; a different id is a different intent.
|
||||
return { decision: 'refused', code: 'agent_session_operation_conflict' }
|
||||
}
|
||||
if (lease.ownerProcess === null && lease.handoffStage === 'old-owner-stopped') {
|
||||
if (
|
||||
lease.ownerProcess === null &&
|
||||
lease.handoffStage === 'old-owner-stopped' &&
|
||||
lease.claimStatus === 'reserved' &&
|
||||
lease.reservedSpawnToken !== null
|
||||
) {
|
||||
// Why: an idempotent re-run of a reservation that already exists at this fence.
|
||||
return { decision: 'retry-reservation', fence: lease.runtimeFence }
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export function admitAgentSessionMutation(input: {
|
||||
/** Why the single admission oracle said no, mapped to what the client can do
|
||||
* about it. The predicate itself is never re-implemented here. */
|
||||
function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null {
|
||||
if (agentSessionLeaseAdmitsWriter(lease)) {
|
||||
if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) {
|
||||
return null
|
||||
}
|
||||
if (lease.unreconciled) {
|
||||
@@ -144,6 +144,12 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR
|
||||
message: `The session is mid-handoff (${lease.handoffStage}).`
|
||||
}
|
||||
}
|
||||
if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) {
|
||||
return {
|
||||
code: 'agent_session_conflict',
|
||||
message: 'The agent terminal owns this session.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: 'agent_session_ownership_unknown',
|
||||
message: 'The session has no live owner to accept writes.'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user