mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(native-chat): harden shared handoff recovery
This commit is contained in:
@@ -229,7 +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 { sendMobileStructuredTuiMessage } from '../../../../src/session/mobile-structured-tui-send'
|
||||
import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send'
|
||||
import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry'
|
||||
import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create'
|
||||
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
|
||||
@@ -4660,33 +4660,19 @@ export default function SessionScreen() {
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onTuiSend={async (text, restored) => {
|
||||
const handoff = structuredSessionEntry.session.handoff
|
||||
const terminal =
|
||||
handoff?.owner === 'tui' && handoff.phase === 'idle'
|
||||
? handoff.terminal?.handle
|
||||
: null
|
||||
if (!client || connState !== 'connected' || !terminal) {
|
||||
showToast('Message not sent (disconnected)', 1800)
|
||||
return false
|
||||
}
|
||||
const outcome = await sendMobileStructuredTuiMessage({
|
||||
onTuiSend={(text, restored) =>
|
||||
mobileStructuredTuiSend.sendMobileStructuredTuiComposerMessage({
|
||||
client,
|
||||
terminal,
|
||||
connected: connState === 'connected',
|
||||
agent: activeStructuredTab.agent,
|
||||
handoff: structuredSessionEntry.session.handoff,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
text,
|
||||
attachments: [...restored, ...structuredSessionEntry.attachments.attachments]
|
||||
attachments: [...restored, ...structuredSessionEntry.attachments.attachments],
|
||||
onAccepted: structuredSessionEntry.attachments.clear,
|
||||
onToast: showToast
|
||||
})
|
||||
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}
|
||||
|
||||
@@ -73,7 +73,7 @@ type Props = {
|
||||
onRequestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -350,6 +350,10 @@ function MobileStructuredHandoffBanner(props: {
|
||||
<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?.canRetryProof ? (
|
||||
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'recover')}>
|
||||
<Text style={styles.handoffButtonText}>Retry proof</Text>
|
||||
</Pressable>
|
||||
) : status.phase === 'failed' &&
|
||||
status.direction &&
|
||||
status.error?.recoverableOwner !== 'none' ? (
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 { sendMobileStructuredTuiMessage } from './mobile-structured-tui-send'
|
||||
import {
|
||||
sendMobileStructuredTuiComposerMessage,
|
||||
sendMobileStructuredTuiMessage
|
||||
} from './mobile-structured-tui-send'
|
||||
|
||||
function acceptingClient(): RpcClient {
|
||||
return {
|
||||
@@ -29,6 +32,7 @@ describe('sendMobileStructuredTuiMessage', () => {
|
||||
sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-tui',
|
||||
agent: 'codex',
|
||||
deviceToken: 'mobile-1',
|
||||
text: 'hello',
|
||||
attachments: []
|
||||
@@ -54,6 +58,7 @@ describe('sendMobileStructuredTuiMessage', () => {
|
||||
const sending = sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-tui',
|
||||
agent: 'codex',
|
||||
deviceToken: null,
|
||||
text: '/model',
|
||||
attachments: []
|
||||
@@ -75,4 +80,52 @@ describe('sendMobileStructuredTuiMessage', () => {
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('types Claude-only slash commands through the provider command path', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = acceptingClient()
|
||||
|
||||
const sending = sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-claude',
|
||||
agent: 'claude',
|
||||
deviceToken: null,
|
||||
text: '/clear',
|
||||
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', '/', 'c', 'l', 'e', 'a', 'r', '\r'])
|
||||
})
|
||||
|
||||
it('settles the composer only after its proven TUI owner accepts the send', async () => {
|
||||
const onAccepted = vi.fn()
|
||||
|
||||
await expect(
|
||||
sendMobileStructuredTuiComposerMessage({
|
||||
client: acceptingClient(),
|
||||
connected: true,
|
||||
agent: 'claude',
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-claude', tabId: 'tab-claude', paneKey: 'pane-claude' }
|
||||
},
|
||||
deviceToken: null,
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
onAccepted,
|
||||
onToast: vi.fn()
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(onAccepted).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
type MobileStructuredTuiSendArgs = {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
agent: 'claude' | 'codex'
|
||||
deviceToken: string | null
|
||||
text: string
|
||||
attachments: readonly PendingNativeChatImage[]
|
||||
@@ -36,6 +38,14 @@ const wait = (ms: number): Promise<void> =>
|
||||
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
|
||||
@@ -75,7 +85,7 @@ export async function sendMobileStructuredTuiMessage(
|
||||
}
|
||||
const textDeadline =
|
||||
args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline
|
||||
const classification = classifyMobileNativeChatSend('codex', args.text)
|
||||
const classification = classifyMobileNativeChatSend(args.agent, args.text)
|
||||
const outcome =
|
||||
classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0
|
||||
? await typeMobileNativeChatCommandWithOutcome({
|
||||
@@ -106,3 +116,38 @@ export async function sendMobileStructuredTuiMessage(
|
||||
releaseMobileNativeChatTerminalWrite(args.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMobileStructuredTuiComposerMessage(args: {
|
||||
client: RpcClient | null
|
||||
connected: boolean
|
||||
agent: 'claude' | 'codex'
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
deviceToken: string | null
|
||||
text: string
|
||||
attachments: readonly PendingNativeChatImage[]
|
||||
onAccepted: () => void
|
||||
onToast: (message: string, durationMs: number) => void
|
||||
}): Promise<boolean> {
|
||||
const terminal = getMobileStructuredTuiTerminal(args.handoff)
|
||||
if (!args.client || !args.connected || !terminal) {
|
||||
args.onToast('Message not sent (disconnected)', 1800)
|
||||
return false
|
||||
}
|
||||
const outcome = await sendMobileStructuredTuiMessage({
|
||||
client: args.client,
|
||||
terminal,
|
||||
agent: args.agent,
|
||||
deviceToken: args.deviceToken,
|
||||
text: args.text,
|
||||
attachments: args.attachments
|
||||
})
|
||||
if (outcome === 'rejected') {
|
||||
args.onToast('Message not sent', 1800)
|
||||
return false
|
||||
}
|
||||
args.onAccepted()
|
||||
if (outcome === 'unknown') {
|
||||
args.onToast('Delivery unconfirmed — check chat before retrying', 2400)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export type MobileStructuredSessionMutations = {
|
||||
requestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function useMobileStructuredSessionMutations(args: {
|
||||
requestHandoff: async (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action: 'start' | 'cancel-queued' | 'retry' = 'start'
|
||||
action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start'
|
||||
) =>
|
||||
Boolean(
|
||||
await mutate(
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-p
|
||||
import type {
|
||||
AgentSessionAccountHome,
|
||||
AgentSessionExecutionLocation,
|
||||
AgentSessionLaunchEnv,
|
||||
AgentSessionOwnerRuntimeKind,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
@@ -61,6 +62,7 @@ export type AgentSessionAttachAuthority = {
|
||||
claimKeyId: string
|
||||
handoffOperationId: string | null
|
||||
probe: AgentSessionOwnerProbe
|
||||
launchEnv?: AgentSessionLaunchEnv
|
||||
}
|
||||
|
||||
/** The fields that define WHICH session this call would attach to. Deliberately
|
||||
@@ -177,6 +179,7 @@ export function reserveRequestFor(input: {
|
||||
location: params.location,
|
||||
provider: params.provider,
|
||||
accountHome: params.accountHome,
|
||||
...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}),
|
||||
runtimeKind: params.runtimeKind,
|
||||
expectedFence: params.envelope.expectedRuntimeFence,
|
||||
spawnToken: authority.spawnToken,
|
||||
|
||||
+6
-2
@@ -24,6 +24,11 @@ export class StructuredAgentSessionHandoffFlowRunner {
|
||||
await Promise.allSettled(this.active)
|
||||
}
|
||||
|
||||
track(task: Promise<void>): void {
|
||||
this.active.add(task)
|
||||
void task.finally(() => this.active.delete(task))
|
||||
}
|
||||
|
||||
begin(input: {
|
||||
callerKey: string
|
||||
params: AgentSessionHandoffRequest
|
||||
@@ -55,8 +60,7 @@ export class StructuredAgentSessionHandoffFlowRunner {
|
||||
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))
|
||||
this.track(flow)
|
||||
}
|
||||
|
||||
private run(
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export class StructuredAgentSessionHandoffOperationGuard {
|
||||
sessionId: string
|
||||
operationId: string
|
||||
fingerprint: string
|
||||
action: 'start' | 'cancel-queued' | 'retry'
|
||||
action: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
status?: AgentSessionHandoffStatus
|
||||
now: number
|
||||
}): Promise<HandoffOperationDecision> {
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
|
||||
import type {
|
||||
StructuredAgentSessionHandoffDeps,
|
||||
StructuredTuiOwner
|
||||
} from './structured-agent-session-handoff-types'
|
||||
|
||||
export class StructuredAgentSessionHandoffQueue {
|
||||
private readonly controllers = new Map<string, AbortController>()
|
||||
|
||||
@@ -42,3 +52,37 @@ export class StructuredAgentSessionHandoffQueue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueueStructuredHandoffAfterTurn(input: {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
queue: StructuredAgentSessionHandoffQueue
|
||||
params: AgentSessionHandoffRequest
|
||||
tuiOwner: StructuredTuiOwner | undefined
|
||||
setStatus: (status: AgentSessionHandoffStatus) => void
|
||||
begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void
|
||||
}): void {
|
||||
const { deps, params, queue, tuiOwner } = input
|
||||
const sessionId = params.envelope.sessionId
|
||||
let tuiReadiness: 'idle' | 'exited' | null = null
|
||||
input.setStatus({
|
||||
owner: params.direction === 'to-tui' ? 'native' : 'tui',
|
||||
direction: params.direction,
|
||||
phase: 'queued',
|
||||
stage: null,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
hostLabel: deps.transport?.hostLabel
|
||||
})
|
||||
queue.enqueue(
|
||||
sessionId,
|
||||
async (signal) => {
|
||||
if (params.direction === 'to-tui') {
|
||||
return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items)
|
||||
}
|
||||
tuiReadiness = tuiOwner
|
||||
? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null)
|
||||
: null
|
||||
return tuiReadiness !== null
|
||||
},
|
||||
() => input.begin({ ...params, mode: 'now' }, tuiReadiness === 'exited')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,15 +25,21 @@ export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSe
|
||||
return persistedFailedStructuredHandoffStatus(record)
|
||||
}
|
||||
if (record.lease.handoffStage === 'manual-recovery') {
|
||||
const canRetryProof =
|
||||
record.lease.runtimeKind === 'tui' &&
|
||||
record.lease.ownerProcess !== null &&
|
||||
((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) ||
|
||||
(record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null))
|
||||
return {
|
||||
owner: 'none',
|
||||
direction: null,
|
||||
direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native',
|
||||
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'
|
||||
recoverableOwner: 'none',
|
||||
...(canRetryProof ? { canRetryProof: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +134,12 @@ export function failedStructuredHandoffStatus(
|
||||
: params.direction === 'to-tui' && record.lease.runtimeKind === 'native'
|
||||
? 'native'
|
||||
: 'none'
|
||||
const canRetryProof =
|
||||
record.lease.handoffStage === 'manual-recovery' &&
|
||||
record.lease.runtimeKind === 'tui' &&
|
||||
record.lease.ownerProcess !== null &&
|
||||
((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) ||
|
||||
(record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null))
|
||||
return {
|
||||
owner: recoverableOwner,
|
||||
direction: params.direction,
|
||||
@@ -143,7 +155,8 @@ export function failedStructuredHandoffStatus(
|
||||
? "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
|
||||
recoverableOwner,
|
||||
...(canRetryProof ? { canRetryProof: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,27 +774,24 @@ describe('structured session ownership handoff', () => {
|
||||
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
|
||||
})
|
||||
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
|
||||
await waitForPhase('failed')
|
||||
|
||||
expect(coordinator.status(SESSION)).toMatchObject({
|
||||
owner: 'none',
|
||||
stage: 'manual-recovery',
|
||||
error: { recoverableOwner: 'none' }
|
||||
error: { recoverableOwner: 'none', canRetryProof: true }
|
||||
})
|
||||
expect(await submit(request('to-tui', 'now', { action: 'recover' }))).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
expect(acquireNativeCalls).toBe(0)
|
||||
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 () => {
|
||||
|
||||
@@ -16,7 +16,14 @@ import {
|
||||
} 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 {
|
||||
beginStructuredManualRecovery,
|
||||
structuredManualRecoveryIsAdmissible
|
||||
} from './structured-agent-session-manual-recovery'
|
||||
import {
|
||||
enqueueStructuredHandoffAfterTurn,
|
||||
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 {
|
||||
@@ -126,6 +133,28 @@ export class StructuredAgentSessionHandoffCoordinator {
|
||||
'Agent TUI handoff is unavailable on this host.'
|
||||
)
|
||||
}
|
||||
if (action === 'recover') {
|
||||
if (!structuredManualRecoveryIsAdmissible(record, currentStatus)) {
|
||||
return this.refuseAdmitted(
|
||||
callerKey,
|
||||
params,
|
||||
'agent_session_operation_conflict',
|
||||
'This session no longer has a recoverable TUI proof.'
|
||||
)
|
||||
}
|
||||
const recovery = beginStructuredManualRecovery({
|
||||
deps: this.deps,
|
||||
operationGuard: this.operationGuard,
|
||||
callerKey,
|
||||
params,
|
||||
fingerprint,
|
||||
requireRecord: (sessionId) => this.requireRecord(sessionId),
|
||||
restore: (sessionId) => this.restore(sessionId),
|
||||
setStatus: (sessionId, status) => this.setStatus(sessionId, status)
|
||||
})
|
||||
this.flowRunner.track(recovery)
|
||||
return this.success(record.sessionId, false)
|
||||
}
|
||||
if (action === 'retry') {
|
||||
if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) {
|
||||
return this.refuseAdmitted(
|
||||
@@ -172,7 +201,14 @@ export class StructuredAgentSessionHandoffCoordinator {
|
||||
)
|
||||
}
|
||||
if (busy && params.mode === 'after-turn') {
|
||||
this.queueAfterTurn(callerKey, params, fingerprint)
|
||||
enqueueStructuredHandoffAfterTurn({
|
||||
deps: this.deps,
|
||||
queue: this.queue,
|
||||
params,
|
||||
tuiOwner,
|
||||
setStatus: (status) => this.setStatus(record.sessionId, status),
|
||||
begin: (next, exited) => this.begin(callerKey, next, null, fingerprint, exited)
|
||||
})
|
||||
return this.success(record.sessionId, false)
|
||||
}
|
||||
if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') {
|
||||
@@ -201,45 +237,8 @@ export class StructuredAgentSessionHandoffCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
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 success = (sessionId: string, replayed: boolean) =>
|
||||
structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId))
|
||||
|
||||
private begin(
|
||||
callerKey: string,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { structuredTuiTranscriptImportOptions } from './structured-agent-session-host-handoff'
|
||||
|
||||
function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord {
|
||||
return {
|
||||
provider,
|
||||
accountHome: {
|
||||
variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
|
||||
path: accountHome
|
||||
}
|
||||
} as AgentSessionRecord
|
||||
}
|
||||
|
||||
describe('structured TUI transcript import roots', () => {
|
||||
it('uses the managed Claude account home when no live transcript path remains', () => {
|
||||
expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual(
|
||||
{
|
||||
claudeProjectsDir: join('/managed/claude', 'projects')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the managed Codex account home when no live transcript path remains', () => {
|
||||
expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({
|
||||
codexSessionsDirs: [join('/managed/codex', 'sessions')]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from 'node:path'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import'
|
||||
import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
|
||||
import { journalIdentityFor } from './structured-agent-session-attach'
|
||||
import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart'
|
||||
@@ -94,14 +95,13 @@ async function importTuiHistory(
|
||||
if (!record || head?.handle.provider !== 'codex') {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath)
|
||||
const imported = await importLegacyTranscriptIntoJournal({
|
||||
journal: session.journal,
|
||||
agent: 'codex',
|
||||
sessionId: head.handle.threadId,
|
||||
fence: input.fence,
|
||||
options: input.transcriptPath
|
||||
? { filePath: input.transcriptPath }
|
||||
: { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] }
|
||||
options
|
||||
})
|
||||
if (!imported.ok) {
|
||||
throw new Error(imported.error)
|
||||
@@ -109,6 +109,18 @@ async function importTuiHistory(
|
||||
host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence)
|
||||
}
|
||||
|
||||
export function structuredTuiTranscriptImportOptions(
|
||||
record: AgentSessionRecord,
|
||||
transcriptPath?: string
|
||||
): LegacyImportOptions {
|
||||
if (transcriptPath) {
|
||||
return { filePath: transcriptPath }
|
||||
}
|
||||
return record.provider === 'claude'
|
||||
? { claudeProjectsDir: join(record.accountHome.path, 'projects') }
|
||||
: { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] }
|
||||
}
|
||||
|
||||
async function acquireNativeHandoffOwner(
|
||||
deps: StructuredAgentSessionHostDeps,
|
||||
host: HostHandoffAccess,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 }
|
||||
|
||||
@@ -8,3 +13,18 @@ export type StructuredAgentSessionHostSession = {
|
||||
params: AgentSessionAttachParams
|
||||
fence: number
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionHostDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
journalRoot: string
|
||||
claimKeyId: string
|
||||
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
mintSpawnToken?: () => string
|
||||
resolveLaunchEnv?: (
|
||||
provider: AgentSessionRecord['provider']
|
||||
) => Record<string, string> | undefined
|
||||
now?: () => number
|
||||
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
|
||||
handoffTransport?: StructuredAgentSessionHandoffTransport
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentJournalMessageItem } 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,
|
||||
@@ -24,9 +20,7 @@ import type {
|
||||
AgentSessionSendResult,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-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 {
|
||||
@@ -47,7 +41,6 @@ import {
|
||||
type AgentSessionSubscribeInput
|
||||
} from './structured-agent-session-subscribers'
|
||||
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
|
||||
import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types'
|
||||
import {
|
||||
createStructuredAgentSessionHostHandoff,
|
||||
refreshRecoverableStructuredHandoffStatus,
|
||||
@@ -58,20 +51,10 @@ import { listStructuredAgentSessionTabs } from './structured-agent-session-host-
|
||||
import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
|
||||
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
|
||||
}
|
||||
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
|
||||
export class StructuredAgentSessionHost {
|
||||
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
|
||||
@@ -169,7 +152,10 @@ export class StructuredAgentSessionHost {
|
||||
spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(),
|
||||
claimKeyId: this.deps.claimKeyId,
|
||||
handoffOperationId: params.envelope.clientOperationId,
|
||||
probe: await this.runtimeState.probeOwner(sessionId)
|
||||
probe: await this.runtimeState.probeOwner(sessionId),
|
||||
...(this.deps.resolveLaunchEnv
|
||||
? { launchEnv: this.deps.resolveLaunchEnv(params.provider) }
|
||||
: {})
|
||||
},
|
||||
callerKey: caller.callerKey,
|
||||
params,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionHandoffRequest,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
|
||||
import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status'
|
||||
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
|
||||
|
||||
export function structuredManualRecoveryIsAdmissible(
|
||||
record: AgentSessionRecord,
|
||||
status: AgentSessionHandoffStatus | undefined
|
||||
): boolean {
|
||||
return (
|
||||
record.lease.handoffStage === 'manual-recovery' &&
|
||||
record.lease.runtimeKind === 'tui' &&
|
||||
record.lease.ownerProcess !== null &&
|
||||
status?.error?.canRetryProof === true
|
||||
)
|
||||
}
|
||||
|
||||
export function beginStructuredManualRecovery(input: {
|
||||
deps: StructuredAgentSessionHandoffDeps
|
||||
operationGuard: StructuredAgentSessionHandoffOperationGuard
|
||||
callerKey: string
|
||||
params: AgentSessionHandoffRequest
|
||||
fingerprint: string
|
||||
requireRecord: (sessionId: string) => AgentSessionRecord
|
||||
restore: (sessionId: string) => Promise<void>
|
||||
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
|
||||
}): Promise<void> {
|
||||
const {
|
||||
callerKey,
|
||||
deps,
|
||||
fingerprint,
|
||||
operationGuard,
|
||||
params,
|
||||
requireRecord,
|
||||
restore,
|
||||
setStatus
|
||||
} = input
|
||||
const sessionId = params.envelope.sessionId
|
||||
operationGuard.start(sessionId, {
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
fingerprint
|
||||
})
|
||||
setStatus(sessionId, {
|
||||
owner: 'none',
|
||||
direction: params.direction,
|
||||
phase: 'switching',
|
||||
stage: 'recovering',
|
||||
operationId: params.envelope.clientOperationId,
|
||||
hostLabel: deps.transport?.hostLabel
|
||||
})
|
||||
return deps
|
||||
.schedule(sessionId, async () => {
|
||||
let record = requireRecord(sessionId)
|
||||
if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) {
|
||||
record = await setStoredAgentSessionHandoffStage(deps.store, {
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
stage: 'new-owner-proving',
|
||||
handoffOperationId: record.lease.handoffOperationId,
|
||||
now: deps.now()
|
||||
})
|
||||
}
|
||||
await restore(record.sessionId)
|
||||
if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') {
|
||||
throw new Error('The TUI owner proof is still unavailable.')
|
||||
}
|
||||
})
|
||||
.then(() =>
|
||||
deps.store.recordOperationOutcome({
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
outcome: { status: 'succeeded', sessionId }
|
||||
})
|
||||
)
|
||||
.catch(async (error) => {
|
||||
await deps.store.recordOperationOutcome({
|
||||
callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
|
||||
})
|
||||
const status = idleStructuredHandoffStatus(requireRecord(sessionId))
|
||||
setStatus(sessionId, {
|
||||
...status,
|
||||
...(status.error
|
||||
? {
|
||||
error: {
|
||||
...status.error,
|
||||
details: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
})
|
||||
.finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
agentSessionExecutionLocationsEqual,
|
||||
type AgentSessionAccountHome,
|
||||
type AgentSessionExecutionLocation,
|
||||
type AgentSessionLaunchEnv,
|
||||
type AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle'
|
||||
@@ -32,6 +33,7 @@ export type AgentSessionReserveRequest = {
|
||||
location: AgentSessionExecutionLocation
|
||||
provider: AgentSessionHandleProvider
|
||||
accountHome: AgentSessionAccountHome
|
||||
launchEnv?: AgentSessionLaunchEnv
|
||||
runtimeKind: AgentSessionReservation['runtimeKind']
|
||||
/** Null when the session does not exist yet; otherwise the fence the caller last observed. */
|
||||
expectedFence: number | null
|
||||
@@ -142,6 +144,7 @@ function createAgentSessionRecord(
|
||||
provider: request.provider,
|
||||
providerHandleChain: [],
|
||||
accountHome: request.accountHome,
|
||||
...(request.launchEnv ? { launchEnv: { ...request.launchEnv } } : {}),
|
||||
createdAt: request.now,
|
||||
updatedAt: request.now,
|
||||
lease: {
|
||||
|
||||
@@ -9361,6 +9361,38 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private recoverClaudeStructuredTuiProof(input: {
|
||||
candidate: RuntimePtyWorktreeRecord
|
||||
sessionId: string
|
||||
spawnToken: string
|
||||
}): { transcriptPath: string } {
|
||||
const paneKey = input.candidate.paneKey
|
||||
if (!paneKey) {
|
||||
throw new Error('The owning Claude terminal did not retain its pane identity.')
|
||||
}
|
||||
const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({
|
||||
paneKey,
|
||||
launchTokenHash: createHash('sha256').update(input.spawnToken).digest('hex'),
|
||||
connectionId: input.candidate.connectionId,
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
if (attestation?.paneKey !== paneKey) {
|
||||
throw new Error('The owning Claude terminal did not retain launch-token authority.')
|
||||
}
|
||||
const matches = (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []).filter(
|
||||
(row) =>
|
||||
row.paneKey === paneKey &&
|
||||
row.connectionId === input.candidate.connectionId &&
|
||||
row.providerSession?.key === 'session_id' &&
|
||||
row.providerSession.id === input.sessionId &&
|
||||
Boolean(row.providerSession.transcriptPath)
|
||||
)
|
||||
if (matches.length !== 1) {
|
||||
throw new Error('The owning Claude terminal did not retain a unique resume proof.')
|
||||
}
|
||||
return { transcriptPath: matches[0]!.providerSession!.transcriptPath! }
|
||||
}
|
||||
|
||||
private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner {
|
||||
const pty = this.ptysById.get(owner.terminal.ptyId)
|
||||
if (!pty?.connected) {
|
||||
|
||||
@@ -102,6 +102,12 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
|
||||
adapter,
|
||||
journalRoot: deps.stateDirectory,
|
||||
claimKeyId: deps.claimKeyId,
|
||||
...(deps.resolveClaudeLaunchEnv
|
||||
? {
|
||||
resolveLaunchEnv: (provider) =>
|
||||
provider === 'claude' ? deps.resolveClaudeLaunchEnv?.() : undefined
|
||||
}
|
||||
: {}),
|
||||
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
|
||||
onEventSinkError: ({ sessionId, error }) =>
|
||||
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }),
|
||||
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
onRequest: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry'
|
||||
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -191,7 +191,16 @@ export function StructuredAgentSessionHandoffChrome({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{status.error.message}</span>
|
||||
{status.direction && status.error.recoverableOwner !== 'none' ? (
|
||||
{status.direction && status.error.canRetryProof ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest(status.direction!, 'now', 'recover')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.retryProof', 'Retry proof')}
|
||||
</Button>
|
||||
) : status.direction && status.error.recoverableOwner !== 'none' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -249,7 +249,7 @@ export function useStructuredAgentSession(args: {
|
||||
requestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action: 'start' | 'cancel-queued' | 'retry' = 'start'
|
||||
action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start'
|
||||
) =>
|
||||
mutate<AgentSessionHandoffResult>(
|
||||
'agentSession.requestHandoff',
|
||||
|
||||
@@ -37,6 +37,9 @@ export type AgentSessionAccountHome = {
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Provider launch environment captured by the host when the session is created. */
|
||||
export type AgentSessionLaunchEnv = Record<string, string>
|
||||
|
||||
export type AgentSessionOwnerRuntimeKind = 'native' | 'tui'
|
||||
|
||||
export type AgentSessionHandoffStage =
|
||||
@@ -108,6 +111,7 @@ export type AgentSessionRecord = {
|
||||
accountHome: AgentSessionAccountHome
|
||||
/** Provider options acknowledged for the next turn, restored across owner replacement. */
|
||||
options?: Record<string, string>
|
||||
launchEnv?: AgentSessionLaunchEnv
|
||||
lease: AgentSessionLease
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
@@ -122,6 +126,8 @@ export type AgentSessionOptionsReplacement = {
|
||||
|
||||
const MAX_ID_LENGTH = 512
|
||||
const MAX_PATH_LENGTH = 4096
|
||||
const MAX_LAUNCH_ENV_ENTRIES = 256
|
||||
const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/
|
||||
|
||||
function isBoundedString(value: unknown, max: number): value is string {
|
||||
@@ -213,6 +219,22 @@ function isAgentSessionOptions(value: unknown): value is Record<string, string>
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentSessionLaunchEnv(value: unknown): value is AgentSessionLaunchEnv {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
return (
|
||||
entries.length <= MAX_LAUNCH_ENV_ENTRIES &&
|
||||
entries.every(
|
||||
([key, entry]) =>
|
||||
isBoundedString(key, MAX_ID_LENGTH) &&
|
||||
typeof entry === 'string' &&
|
||||
entry.length <= MAX_LAUNCH_ENV_VALUE_LENGTH
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentSessionJournalCheckpoint(value: unknown): value is AgentSessionJournalCheckpoint {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
@@ -293,6 +315,7 @@ export function isAgentSessionRecord(value: unknown): value is AgentSessionRecor
|
||||
isAgentSessionProviderHandleChain(record.providerHandleChain) &&
|
||||
isAgentSessionAccountHome(record.accountHome) &&
|
||||
(record.options === undefined || isAgentSessionOptions(record.options)) &&
|
||||
(record.launchEnv === undefined || isAgentSessionLaunchEnv(record.launchEnv)) &&
|
||||
isAgentSessionLease(record.lease) &&
|
||||
record.lease.sessionId === record.sessionId &&
|
||||
Number.isSafeInteger(record.createdAt) &&
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './a
|
||||
|
||||
export type AgentSessionHandoffDirection = 'to-tui' | 'to-native'
|
||||
export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn'
|
||||
export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry'
|
||||
export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
|
||||
export type AgentSessionHandoffStatus = {
|
||||
owner: AgentSessionOwnerRuntimeKind | 'none'
|
||||
@@ -34,6 +34,7 @@ export type AgentSessionHandoffStatus = {
|
||||
message: string
|
||||
details?: string
|
||||
recoverableOwner: AgentSessionOwnerRuntimeKind | 'none'
|
||||
canRetryProof?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user