fix: fence structured AI Vault resumes

This commit is contained in:
Brennan Benson
2026-08-11 00:54:48 -07:00
parent 7887382cc5
commit 03d5373071
30 changed files with 680 additions and 33 deletions
@@ -9,6 +9,7 @@ import type { RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { getWorktreeLabel } from '../session/worktree-label'
import {
activateStructuredAiVaultSession,
buildMobileAiVaultResumeLaunch,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
@@ -154,16 +155,19 @@ export function MobileAgentSessionHistoryPanel({
triggerError()
return
}
if (!session.sessionId) {
setResumeMessage('This session is missing a resume id.')
triggerError()
return
}
resumeLaunchInFlightRef.current = true
setResumingSessionId(session.id)
setResumeMessage(null)
try {
const structuredWorkspaceId = await activateStructuredAiVaultSession(client, session)
if (structuredWorkspaceId) {
router.push(
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(structuredWorkspaceId)}` as Parameters<
typeof router.push
>[0]
)
return
}
const {
repos,
folderWorkspaces,
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
import {
activateStructuredAiVaultSession,
buildMobileAiVaultResumeLaunch,
buildMobileAiVaultResumeCommand,
createMobileAiVaultResumeMutationRegistry,
@@ -12,6 +13,34 @@ import {
} from './ai-vault-resume-launch'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
describe('activateStructuredAiVaultSession', () => {
it('activates the native structured tab instead of creating a legacy terminal', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: {} })
await expect(
activateStructuredAiVaultSession(
{ sendRequest },
{
structuredSession: {
sessionId: 'native-session-1',
workspaceId: 'worktree-1'
}
}
)
).resolves.toBe('worktree-1')
expect(sendRequest).toHaveBeenCalledOnce()
expect(sendRequest).toHaveBeenCalledWith(
'session.tabs.activate',
{
worktree: 'id:worktree-1',
tabId: 'agent-session:native-session-1',
navigation: 'caller'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
})
})
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
id: 'claude:1',
@@ -79,6 +79,29 @@ export type MobileAiVaultResumeLaunch = {
launchAgent?: TuiAgent
}
export async function activateStructuredAiVaultSession(
client: Pick<RpcClient, 'sendRequest'>,
session: Pick<AiVaultSession, 'structuredSession'>
): Promise<string | null> {
const structured = session.structuredSession
if (!structured) {
return null
}
const response = await client.sendRequest(
'session.tabs.activate',
{
worktree: `id:${structured.workspaceId}`,
tabId: `agent-session:${structured.sessionId}`,
navigation: 'caller'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to open structured agent session')
}
return structured.workspaceId
}
export function buildMobileAiVaultResumeLaunch(args: {
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
Partial<Pick<AiVaultSession, 'filePath'>>
@@ -91,6 +91,7 @@ describe('prepareMobileAiVaultSessionResume', () => {
'aiVault.prepareSessionResume',
{
agent: 'codex',
sessionId: legacy.sessionId,
filePath: legacy.filePath,
codexHome: LEGACY_CODEX_HOME,
executionHostId: 'local'
@@ -151,6 +152,7 @@ describe('prepareMobileAiVaultSessionResume', () => {
'aiVault.prepareSessionResume',
{
agent: 'codex',
sessionId: current.sessionId,
filePath: current.filePath,
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'local'
@@ -30,6 +30,7 @@ export async function prepareMobileAiVaultSessionResume(
'aiVault.prepareSessionResume',
{
agent: session.agent,
sessionId: session.sessionId,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
@@ -67,6 +67,12 @@ const aiVaultSessionSchema = z.object({
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string(),
structuredSession: z
.object({
sessionId: z.string().min(1).max(512),
workspaceId: z.string().min(1).max(512)
})
.optional(),
subagent: z
.object({
parentSessionId: z.string(),
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it } from 'vitest'
import { agentSessionLeaseFixture } 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,
assertLegacyAiVaultResumeCommandAllowed,
projectStructuredAiVaultSessions
} from './structured-session-ownership'
const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
describe('structured AI Vault ownership', () => {
afterEach(() => setStructuredAgentSessionHost(null))
it('hides owned rows from legacy clients and annotates them for capable clients', () => {
installOwnership()
const result = listResult()
expect(projectStructuredAiVaultSessions(result, false).sessions).toEqual([])
expect(projectStructuredAiVaultSessions(result, true).sessions[0]).toMatchObject({
structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' }
})
})
it('derives typed refusals from the single writer predicate for live and proving leases', async () => {
installOwnership()
expect(() =>
assertLegacyAiVaultResumeAllowed({
agent: 'codex',
filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`,
codexHome: null,
executionHostId: 'local'
})
).toThrow('agent_session_conflict')
installOwnership({
lease: agentSessionLeaseFixture({
handoffStage: 'new-owner-proving',
claimStatus: 'reserved',
ownerProcess: null
})
})
await expect(
assertLegacyAiVaultResumeCommandAllowed(
`codex resume '${PROVIDER_SESSION}'`,
async () => undefined
)
).rejects.toThrow('agent_session_ownership_unknown')
})
})
function installOwnership(overrides: Partial<StructuredProviderSessionOwnership> = {}): void {
const ownership: StructuredProviderSessionOwnership = {
sessionId: 'session-alpha',
workspaceId: 'workspace-1',
provider: 'codex',
providerSessionId: PROVIDER_SESSION,
lease: agentSessionLeaseFixture(),
...overrides
}
const record = agentSessionRecordFixture(ownership.lease)
setStructuredAgentSessionHost({
deps: {
store: {
listRecords: () => [
{
...record,
sessionId: ownership.sessionId,
location: { ...record.location, workspaceId: ownership.workspaceId },
provider: ownership.provider,
providerHandleChain: [
{
...record.providerHandleChain[0]!,
handle: { provider: 'codex', threadId: ownership.providerSessionId }
}
],
lease: { ...ownership.lease, sessionId: ownership.sessionId }
}
]
}
}
} as never)
}
function listResult(): AiVaultListResult {
const session: AiVaultSession = {
id: `local:codex:${PROVIDER_SESSION}`,
executionHostId: 'local',
agent: 'codex',
sessionId: PROVIDER_SESSION,
title: 'Owned',
cwd: '/repo',
branch: null,
model: null,
filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-08-11T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume '${PROVIDER_SESSION}'`,
subagent: null
}
return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' }
}
@@ -0,0 +1,140 @@
import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
listStructuredProviderSessionOwnership,
type StructuredProviderSessionOwnership
} from '../native-chat/agent-session-wire/structured-provider-session-ownership'
export function projectStructuredAiVaultSessions(
result: AiVaultListResult,
structuredSupported: boolean
): AiVaultListResult {
const host = getStructuredAgentSessionHost()
if (!host) {
return result
}
const sessions = result.sessions.flatMap((session) => {
const ownership = findSessionOwnership(session)
if (!ownership) {
return [session]
}
if (!structuredSupported) {
return []
}
return [
{
...session,
structuredSession: {
sessionId: ownership.sessionId,
workspaceId: ownership.workspaceId
}
}
]
})
return sessions.length === result.sessions.length &&
sessions.every((row, index) => row === result.sessions[index])
? result
: { ...result, sessions }
}
export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void {
const ownership = findResumeOwnership(args)
if (ownership) {
refuseLegacyWriter(ownership)
}
}
export async function assertLegacyAiVaultResumeCommandAllowed(
command: string,
ensureHost: () => Promise<void>
): Promise<void> {
if (!isPotentialStructuredResumeCommand(command)) {
return
}
await ensureHost()
const host = getStructuredAgentSessionHost()
if (!host) {
return
}
for (const ownership of listOwnership()) {
if (isResumeCommandFor(command, ownership)) {
refuseLegacyWriter(ownership)
}
}
}
function isPotentialStructuredResumeCommand(command: string): boolean {
return (
/\bcodex(?:\.exe)?\b[\s\S]*\bresume\b/i.test(command) ||
/\bclaude(?:\.exe)?\b[\s\S]*--resume\b/i.test(command)
)
}
function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null {
if (session.agent !== 'codex' && session.agent !== 'claude') {
return null
}
return findOwnership(session.agent, session.sessionId)
}
function findResumeOwnership(
args: AiVaultPrepareSessionResumeArgs
): StructuredProviderSessionOwnership | null {
if (args.agent !== 'codex' && args.agent !== 'claude') {
return null
}
const host = getStructuredAgentSessionHost()
if (!host) {
return null
}
const exact = args.sessionId ? findOwnership(args.agent, args.sessionId) : null
if (exact) {
return exact
}
const fileName = args.filePath.split(/[\\/]/).at(-1) ?? ''
return (
listOwnership().find(
(ownership) =>
ownership.provider === args.agent && fileName.includes(ownership.providerSessionId)
) ?? null
)
}
function findOwnership(
provider: 'claude' | 'codex',
providerSessionId: string
): StructuredProviderSessionOwnership | null {
return (
listOwnership().find(
(ownership) =>
ownership.provider === provider && ownership.providerSessionId === providerSessionId
) ?? null
)
}
function listOwnership(): StructuredProviderSessionOwnership[] {
const host = getStructuredAgentSessionHost()
return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : []
}
function isResumeCommandFor(
command: string,
ownership: StructuredProviderSessionOwnership
): boolean {
if (!command.includes(ownership.providerSessionId)) {
return false
}
return ownership.provider === 'codex'
? /\bcodex(?:\.exe)?\b[\s\S]*\bresume\b/i.test(command)
: /\bclaude(?:\.exe)?\b[\s\S]*--resume\b/i.test(command)
}
function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never {
throw new Error(
agentSessionLeaseAdmitsWriter(ownership.lease)
? 'agent_session_conflict'
: 'agent_session_ownership_unknown'
)
}
+4
View File
@@ -5,8 +5,10 @@ import type {
AiVaultSessionResumePreparation
} from '../../shared/ai-vault-resume-preparation'
import { parseExecutionHostId } from '../../shared/execution-host'
import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership'
export type AiVaultResumeHandlerOptions = {
ensureStructuredSessionOwnership?: () => Promise<void>
prepareSessionResume?: AiVaultSessionResumePreparation
prepareRuntimeSessionResume?: (
environmentId: string,
@@ -24,6 +26,8 @@ export async function prepareAiVaultSessionResume(
args: AiVaultPrepareSessionResumeArgs,
options: AiVaultResumeHandlerOptions
): Promise<AiVaultPrepareSessionResumeResult> {
await options.ensureStructuredSessionOwnership?.()
assertLegacyAiVaultResumeAllowed(args)
const executionHost = parseExecutionHostId(args.executionHostId)
if (executionHost?.kind === 'runtime') {
if (!options.prepareRuntimeSessionResume) {
+5 -3
View File
@@ -56,6 +56,7 @@ import {
resolveAiVaultSessionTitlesByHost,
type RuntimeAiVaultSessionTitleResolver
} from './ai-vault-session-title-routing'
import { projectStructuredAiVaultSessions } from '../ai-vault/structured-session-ownership'
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
// Why: a remote home with many agent roots routinely needs seconds to walk,
@@ -279,7 +280,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
: undefined
const controller = listCancellations.begin(event, requestToken)
try {
return await listAiVaultSessions(args, { signal: controller?.signal })
await handlerOptions.ensureStructuredSessionOwnership?.()
const result = await listAiVaultSessions(args, { signal: controller?.signal })
return projectStructuredAiVaultSessions(result, true)
} catch (error) {
// Why: superseding a scan is normal control flow, but Electron logs every
// rejected handler — report it as a result so the log stays truthful.
@@ -314,8 +317,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
handleAiVaultGetFirstUserPrompt(args)
)
registerAiVaultDeleteHandler(aiVaultDeleteDeps)
// DOM focus/visibility events don't fire in the renderer on macOS app
// activation, so refresh-on-refocus needs this main-process signal.
// macOS app activation skips DOM focus events, so emit the refresh signal here.
app.on('browser-window-focus', (_event, window) => {
if (!window.isDestroyed()) {
window.webContents.send('aiVault:windowFocused')
+1
View File
@@ -214,6 +214,7 @@ export function registerCoreHandlers(
registerRuntimeEnvironmentHandlers(store)
registerEphemeralVmHandlers(store, pluginService)
registerAiVaultHandlers({
ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(),
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume,
getActiveRuntimeAiVaultHostInfos: () =>
@@ -94,7 +94,7 @@ export class StructuredAgentSessionHost {
private readonly eventSinks = new Map<string, DeferredStructuredAgentSessionEventSink>()
private readonly reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
constructor(private readonly deps: StructuredAgentSessionHostDeps) {
constructor(readonly deps: StructuredAgentSessionHostDeps) {
this.reconcileLeases = createRestartReconciler({
store: deps.store,
probe: (record) => this.probeRecord(record),
@@ -0,0 +1,24 @@
import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record'
export type StructuredProviderSessionOwnership = {
sessionId: string
workspaceId: string
provider: 'claude' | 'codex'
providerSessionId: string
lease: AgentSessionLease
}
export function listStructuredProviderSessionOwnership(
records: readonly AgentSessionRecord[]
): StructuredProviderSessionOwnership[] {
return records.flatMap((record) =>
record.providerHandleChain.map((link) => ({
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
provider: record.provider,
providerSessionId:
link.handle.provider === 'codex' ? link.handle.threadId : link.handle.sessionId,
lease: record.lease
}))
)
}
@@ -71,6 +71,7 @@ function makeDispatcher(): RpcDispatcher {
// which delegates to the shared cache module the IPC handler also uses.
const runtime = {
getRuntimeId: () => 'test-runtime',
ensureStructuredAgentSessionHost: vi.fn(async () => undefined),
listAiVaultSessions: (args?: Parameters<typeof listAiVaultSessions>[0]) =>
listAiVaultSessions(args),
resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) =>
@@ -187,6 +188,7 @@ describe('aiVault.prepareSessionResume', () => {
const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true })
const runtime = {
getRuntimeId: () => 'test-runtime',
ensureStructuredAgentSessionHost: vi.fn(async () => undefined),
prepareAiVaultSessionResume
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
+23 -5
View File
@@ -4,7 +4,13 @@ import { OptionalBoolean } from '../schemas'
import { restampAiVaultListResult } from '../../../ai-vault/session-list-results'
import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types'
import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title'
import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
assertLegacyAiVaultResumeAllowed,
projectStructuredAiVaultSessions
} from '../../../ai-vault/structured-session-ownership'
// Why: bound limit + scopePaths so a client cannot force an unbounded scan.
// Each scopePath is a host-local match prefix (validated/capped, never used for
@@ -55,6 +61,7 @@ export const AiVaultListSessionsParams = z
export const AiVaultPrepareSessionResumeParams = z.object({
agent: z.enum(AI_VAULT_AGENTS),
sessionId: z.string().min(1).max(512).optional(),
filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH),
codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(),
executionHostId: z.string().optional()
@@ -82,7 +89,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [
defineMethod({
name: 'aiVault.listSessions',
params: AiVaultListSessionsParams,
handler: async (params, { runtime }) => {
handler: async (params, { runtime, clientKind, clientCapabilities }) => {
await runtime.ensureStructuredAgentSessionHost()
const result = await runtime.listAiVaultSessions({
limit: params.unlimited ? undefined : params.limit,
unlimited: params.unlimited,
@@ -91,22 +99,32 @@ export const AI_VAULT_METHODS: RpcMethod[] = [
})
// Why: web clients consume this response directly (no parent-side retag),
// so sessions must come back stamped as the runtime host they addressed.
return params.executionHostId
const stamped = params.executionHostId
? restampAiVaultListResult(result, params.executionHostId)
: result
return projectStructuredAiVaultSessions(
stamped,
clientKind === undefined ||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
)
}
}),
defineMethod({
name: 'aiVault.prepareSessionResume',
params: AiVaultPrepareSessionResumeParams,
handler: (params, { runtime }) =>
runtime.prepareAiVaultSessionResume({
handler: async (params, { runtime }) => {
const args: AiVaultPrepareSessionResumeArgs = {
agent: params.agent,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
filePath: params.filePath,
codexHome: params.codexHome,
// Why: the RPC executes on the transcript-owning host; never let a
// client-provided runtime/SSH stamp escape that host boundary.
executionHostId: LOCAL_EXECUTION_HOST_ID
})
}
await runtime.ensureStructuredAgentSessionHost()
assertLegacyAiVaultResumeAllowed(args)
return runtime.prepareAiVaultSessionResume(args)
}
})
]
+9 -2
View File
@@ -14,6 +14,7 @@ import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { MOBILE_MARKDOWN_TAB_METHODS } from './mobile-markdown-tab-methods'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
@@ -59,8 +60,13 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.createTerminal',
params: CreateTerminalTab,
handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) =>
runtime.createMobileSessionTerminal(params.worktree, {
handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => {
if (params.command) {
await assertLegacyAiVaultResumeCommandAllowed(params.command, () =>
runtime.ensureStructuredAgentSessionHost()
)
}
return runtime.createMobileSessionTerminal(params.worktree, {
afterTabId: params.afterTabId,
targetGroupId: params.targetGroupId,
command: params.command,
@@ -86,6 +92,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
// of running down the timeout and rolling back a live tab (#7718).
signal
})
}
}),
defineMethod({
name: 'session.tabs.move',
+11
View File
@@ -38,6 +38,7 @@ import {
} from '../terminal-stream-byte-length'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
import {
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
scanTerminalReplyQuerySequences,
@@ -1221,6 +1222,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime, clientId }) => {
await assertTerminalSendTextWithinLimit(params.text)
await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text)
if (params.text) {
await assertLegacyAiVaultResumeCommandAllowed(params.text, () =>
runtime.ensureStructuredAgentSessionHost()
)
}
if (params.resolvedLaunchDraft?.text) {
await assertLegacyAiVaultResumeCommandAllowed(params.resolvedLaunchDraft.text, () =>
runtime.ensureStructuredAgentSessionHost()
)
}
const queryReplyClientId = clientId ?? params.client?.id
if (
params.inputKind === 'query-reply' &&
@@ -96,6 +96,7 @@ export function VaultSessionRow({
writeAiVaultSessionDragData(event.dataTransfer, {
agent: session.agent,
sessionId: session.sessionId,
...(session.structuredSession ? { structuredSession: session.structuredSession } : {}),
title: session.title,
command: resumeStartup.command,
sessionFilePath: session.filePath,
@@ -2,8 +2,7 @@ import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import {
buildAiVaultResumeCopyCommandForWorktree,
buildAiVaultResumeStartupForWorktree,
type AiVaultResumeStartup
buildAiVaultResumeStartupForWorktree
} from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import {
@@ -29,6 +28,7 @@ import {
import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session'
export function useAiVaultSessionLaunchActions({
activeWorktree,
@@ -40,14 +40,7 @@ export function useAiVaultSessionLaunchActions({
activeWorktreeId: string | null
targetState: AiVaultSessionResumeTargetState
agentCmdOverrides?: Partial<Record<AiVaultAgent, string | null>>
}): {
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise<void>
handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void
handleContinueInNewSession: (session: AiVaultSession, targetWorktreeId: string) => void
continuationRequest: AgentSessionContinuationRequest | null
handleContinuationDialogOpenChange: (open: boolean) => void
} {
}) {
const [continuationRequest, setContinuationRequest] =
useState<AgentSessionContinuationRequest | null>(null)
@@ -93,6 +86,9 @@ export function useAiVaultSessionLaunchActions({
const handleResume = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
if (activateAiVaultStructuredSession(session)) {
return
}
const targetId = resolveAiVaultSessionLaunchTargetOrNotify({
sessionFilePath: session.filePath,
sessionExecutionHostId: session.executionHostId,
@@ -23,6 +23,7 @@ import { resolveDropZone } from './tab-drop-zone'
import type { TabDropZone } from './useTabDragSplit'
import { translate } from '@/i18n/i18n'
import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault-resume-preparation'
import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation'
type PaneDropTarget = {
groupId: string
@@ -175,6 +176,13 @@ export default function AiVaultSessionDropLayer({
)
return true
}
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.')
}
return true
}
const state = useAppStore.getState()
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId)
@@ -224,6 +232,7 @@ export default function AiVaultSessionDropLayer({
})
? window.api.aiVault.prepareSessionResume({
agent: payload.agent,
sessionId: payload.sessionId,
filePath: payload.sessionFilePath,
executionHostId: payload.sessionExecutionHostId,
codexHome: payload.codexHome
@@ -0,0 +1,25 @@
import { toast } from 'sonner'
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import { activateAndRevealWorktree } from './worktree-activation'
import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation'
import { useAppStore } from '@/store'
export function activateAiVaultStructuredSession(session: AiVaultSession): boolean {
const structured = session.structuredSession
if (!structured) {
return false
}
if (
!activateStructuredAgentSessionById({
worktreeId: structured.workspaceId,
sessionId: structured.sessionId
})
) {
toast.error('The structured agent session is not available yet. Retry in a moment.')
return true
}
if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) {
activateAndRevealWorktree(structured.workspaceId)
}
return true
}
@@ -11,6 +11,7 @@ export const AI_VAULT_SESSION_DRAG_PAYLOAD_MAX_BYTES = 16 * 1024
export type AiVaultSessionDragPayload = {
agent: AiVaultAgent
sessionId: string
structuredSession?: { sessionId: string; workspaceId: string }
title: string
command: string
// Why: drop targets must know where the session file lives (host vs local
@@ -49,6 +50,16 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isStructuredSession(
value: unknown
): value is NonNullable<AiVaultSessionDragPayload['structuredSession']> {
if (!value || typeof value !== 'object') {
return false
}
const structured = value as Record<string, unknown>
return isNonEmptyString(structured.sessionId) && isNonEmptyString(structured.workspaceId)
}
function isStringRecord(value: unknown): value is Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
@@ -87,6 +98,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD
payload.version === 1 &&
isAiVaultAgent(payload.agent) &&
isNonEmptyString(payload.sessionId) &&
(payload.structuredSession === undefined || isStructuredSession(payload.structuredSession)) &&
isNonEmptyString(payload.title) &&
isNonEmptyString(payload.command) &&
(payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) &&
@@ -165,6 +177,7 @@ export function readAiVaultSessionDragData(
const {
agent,
sessionId,
structuredSession,
title,
command,
sessionFilePath,
@@ -179,6 +192,7 @@ export function readAiVaultSessionDragData(
return {
agent,
sessionId,
...(structuredSession ? { structuredSession } : {}),
title,
command,
...(sessionFilePath ? { sessionFilePath } : {}),
@@ -19,6 +19,7 @@ describe('prepareAiVaultSessionForResume', () => {
expect(prepared.codexHome).toBeNull()
expect(prepareSessionResume).toHaveBeenCalledWith({
agent: 'codex',
sessionId: legacy.sessionId,
filePath: legacy.filePath,
codexHome: legacy.codexHome,
executionHostId: 'local'
@@ -55,6 +56,7 @@ describe('prepareAiVaultSessionForResume', () => {
expect(prepared.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home')
expect(prepareSessionResume).toHaveBeenCalledWith({
agent: 'codex',
sessionId: current.sessionId,
filePath: current.filePath,
codexHome: current.codexHome,
executionHostId: 'local'
@@ -8,11 +8,12 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
export async function prepareAiVaultSessionForResume(
session: AiVaultSession
): Promise<AiVaultSession> {
if (!aiVaultSessionNeedsResumePreparation(session)) {
if (!session.structuredSession && !aiVaultSessionNeedsResumePreparation(session)) {
return session
}
const result = await window.api.aiVault.prepareSessionResume({
agent: session.agent,
sessionId: session.sessionId,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
@@ -30,7 +30,10 @@ vi.mock('@/runtime/runtime-worktree-selector', () => ({
toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}`
}))
import { activateStructuredAgentSessionTab } from './structured-agent-session-tab-activation'
import {
activateStructuredAgentSessionById,
activateStructuredAgentSessionTab
} from './structured-agent-session-tab-activation'
describe('activateStructuredAgentSessionTab', () => {
beforeEach(() => {
@@ -71,4 +74,16 @@ describe('activateStructuredAgentSessionTab', () => {
{ worktree: 'id:wt-1', tabId: 'agent-session:session-1' }
)
})
it('routes a provider-owned vault row through its structured session id', () => {
expect(activateStructuredAgentSessionById({ worktreeId: 'wt-1', sessionId: 'session-1' })).toBe(
true
)
expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' })
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'session.tabs.activate',
{ worktree: 'id:wt-1', tabId: 'agent-session:session-1' }
)
})
})
@@ -28,3 +28,16 @@ export function activateStructuredAgentSessionTab(args: {
)
return true
}
export function activateStructuredAgentSessionById(args: {
worktreeId: string
sessionId: string
}): boolean {
const tab = (useAppStore.getState().unifiedTabsByWorktree[args.worktreeId] ?? []).find(
(candidate) =>
candidate.contentType === 'agent-session' && candidate.entityId === args.sessionId
)
return tab
? activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id })
: false
}
+2 -1
View File
@@ -3,7 +3,8 @@ import type { AiVaultSession } from './ai-vault-types'
export type AiVaultPrepareSessionResumeArgs = Pick<
AiVaultSession,
'agent' | 'filePath' | 'codexHome' | 'executionHostId'
>
> &
Partial<Pick<AiVaultSession, 'sessionId'>>
export type AiVaultPrepareSessionResumeResult = {
useRealCodexHome: boolean
+5
View File
@@ -118,6 +118,11 @@ export type AiVaultSession = {
subagentTranscriptCount: number
resumeCommand: string
subagent: AiVaultSessionSubagentInfo | null
/** Present only when the negotiated client can open the native structured owner. */
structuredSession?: {
sessionId: string
workspaceId: string
}
}
export type AiVaultSubagentListArgs = {
@@ -162,6 +162,7 @@ function runtimeStub(): unknown {
const cleanups = new Map<string, () => void>()
return {
getRuntimeId: () => 'runtime-1',
ensureStructuredAgentSessionHost: async () => undefined,
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async () => {
const {
@@ -321,6 +322,176 @@ describe('cross-version structured agent sessions', () => {
})
})
describe('an old client against a structured-owned AI Vault row', () => {
let root: string
let store: AgentSessionRecordStore
let runtime: ReturnType<typeof runtimeStub> & Record<string, unknown>
let createMobileSessionTerminal: ReturnType<typeof vi.fn>
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-'))
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const host = new StructuredAgentSessionHost({
store,
adapter: {
acquire: async ({ fence }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: NOW,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault'
},
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
origin: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}),
dispatch: async () => ({ state: 'accepted' }),
cancelTurn: async () => ({ cancelled: true }),
answerPrompt: async () => undefined,
setOption: async () => undefined
},
journalRoot: root,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-vault',
now: () => NOW
})
setStructuredAgentSessionHost(host)
const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never)
expect(attached.ok).toBe(true)
createMobileSessionTerminal = vi.fn()
runtime = {
...(runtimeStub() as Record<string, unknown>),
listAiVaultSessions: vi.fn(async () => ({
sessions: [
{
id: `local:codex:${THREAD}:/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
executionHostId: 'local',
agent: 'codex',
sessionId: THREAD,
title: 'Owned thread',
cwd: '/repo',
branch: null,
model: null,
filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
codexHome: '/home/dev/.codex',
createdAt: null,
updatedAt: null,
modifiedAt: '2026-08-11T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume '${THREAD}'`,
subagent: null
}
],
issues: [],
scannedAt: '2026-08-11T00:00:00.000Z'
})),
prepareAiVaultSessionResume: vi.fn(),
createMobileSessionTerminal
}
})
afterEach(async () => {
setStructuredAgentSessionHost(null)
await rm(root, { recursive: true, force: true })
})
it('hides the row from the old client and annotates it for a capable client', async () => {
const oldReply = (
await callBuild(
current,
'aiVault.listSessions',
{},
{
clientKind: 'mobile',
clientCapabilities: baseline.capabilities
},
runtime
)
)[0]
expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } })
const capableReply = (
await callBuild(
current,
'aiVault.listSessions',
{},
{
clientKind: 'mobile',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
},
runtime
)
)[0]
expect(capableReply).toMatchObject({
ok: true,
result: {
sessions: [
{
structuredSession: { sessionId: SESSION, workspaceId: WORKSPACE }
}
]
}
})
})
it('refuses cached prepare and both legacy launch deliveries before a second writer starts', async () => {
const params = {
agent: 'codex',
filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
codexHome: '/home/dev/.codex'
}
expect(
(
await callBuild(
current,
'aiVault.prepareSessionResume',
params,
{
clientKind: 'mobile',
clientCapabilities: baseline.capabilities
},
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(
(
await callBuild(
current,
'session.tabs.createTerminal',
{ worktree: `id:${WORKSPACE}`, command: `codex resume '${THREAD}'` },
{ clientKind: 'mobile', clientCapabilities: baseline.capabilities },
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(
(
await callBuild(
current,
'terminal.send',
{ terminal: 'terminal-1', text: `codex resume '${THREAD}'`, enter: true },
{ clientKind: 'mobile', clientCapabilities: baseline.capabilities },
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(createMobileSessionTerminal).not.toHaveBeenCalled()
})
})
describe('a cursor across a host restart', () => {
let root: string
let store: AgentSessionRecordStore
@@ -89,10 +89,13 @@ function capabilityStrings(module: Record<string, unknown>): readonly string[] {
}
async function loadWorkingTreeBuild(): Promise<AgentSessionWireBuild> {
const [protocol, dispatcher, structured] = await Promise.all([
const [protocol, dispatcher, structured, aiVault, sessionTabs, terminal] = await Promise.all([
import('../../../src/shared/protocol-version'),
import('../../../src/main/runtime/rpc/dispatcher'),
import('../../../src/main/runtime/rpc/methods/structured-agent-session')
import('../../../src/main/runtime/rpc/methods/structured-agent-session'),
import('../../../src/main/runtime/rpc/methods/ai-vault'),
import('../../../src/main/runtime/rpc/methods/session-tabs'),
import('../../../src/main/runtime/rpc/methods/terminal')
])
const module = dispatcher as unknown as DispatcherModule
return {
@@ -104,7 +107,12 @@ async function loadWorkingTreeBuild(): Promise<AgentSessionWireBuild> {
createDispatcher: (runtime) =>
new module.RpcDispatcher({
runtime,
methods: structured.STRUCTURED_AGENT_SESSION_METHODS as unknown[]
methods: [
...(structured.STRUCTURED_AGENT_SESSION_METHODS as unknown[]),
...(aiVault.AI_VAULT_METHODS as unknown[]),
...(sessionTabs.SESSION_TAB_METHODS as unknown[]),
...(terminal.TERMINAL_METHODS as unknown[])
]
})
}
}