diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index abad3a84780..2b0b6cdf560 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -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, diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 9efc0e50f1a..274c75f0778 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -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 { return { id: 'claude:1', diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index aa8c1bf98a6..b1bc6c8e4d6 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -79,6 +79,29 @@ export type MobileAiVaultResumeLaunch = { launchAgent?: TuiAgent } +export async function activateStructuredAiVaultSession( + client: Pick, + session: Pick +): Promise { + 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 & Partial> diff --git a/mobile/src/session/ai-vault-resume-preparation.test.ts b/mobile/src/session/ai-vault-resume-preparation.test.ts index a0eed5515c6..d5db8c03ceb 100644 --- a/mobile/src/session/ai-vault-resume-preparation.test.ts +++ b/mobile/src/session/ai-vault-resume-preparation.test.ts @@ -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' diff --git a/mobile/src/session/ai-vault-resume-preparation.ts b/mobile/src/session/ai-vault-resume-preparation.ts index 4265442ff66..cd015240f46 100644 --- a/mobile/src/session/ai-vault-resume-preparation.ts +++ b/mobile/src/session/ai-vault-resume-preparation.ts @@ -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 diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index abcdd1612b3..fbd184a5493 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -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(), diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts new file mode 100644 index 00000000000..27ef4fc61c8 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -0,0 +1,133 @@ +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('keeps Claude rows hidden until provider support is negotiated', () => { + installOwnership({ provider: 'claude', providerSessionId: 'claude-owned-session' }) + const result = listResult({ + agent: 'claude', + sessionId: 'claude-owned-session', + filePath: '/sessions/claude-owned-session.jsonl', + resumeCommand: 'claude --resume claude-owned-session' + }) + + expect( + projectStructuredAiVaultSessions(result, true, (provider) => provider === 'codex').sessions + ).toEqual([]) + expect(projectStructuredAiVaultSessions(result, true, () => 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 = {}): 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: + ownership.provider === 'codex' + ? { provider: 'codex', threadId: ownership.providerSessionId } + : { provider: 'claude', sessionId: ownership.providerSessionId } + } + ], + lease: { ...ownership.lease, sessionId: ownership.sessionId } + } + ] + } + } + } as never) +} + +function listResult(overrides: Partial = {}): 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, + ...overrides + } + return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' } +} diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts new file mode 100644 index 00000000000..6fe9995c46f --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -0,0 +1,142 @@ +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, + providerSupported: (provider: StructuredProviderSessionOwnership['provider']) => boolean = () => + true +): AiVaultListResult { + const host = getStructuredAgentSessionHost() + if (!host) { + return result + } + const sessions = result.sessions.flatMap((session) => { + const ownership = findSessionOwnership(session) + if (!ownership) { + return [session] + } + if (!structuredSupported || !providerSupported(ownership.provider)) { + 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 +): Promise { + 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' + ) +} diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index a5618c0041d..e8f56516b08 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -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 prepareSessionResume?: AiVaultSessionResumePreparation prepareRuntimeSessionResume?: ( environmentId: string, @@ -24,6 +26,8 @@ export async function prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, options: AiVaultResumeHandlerOptions ): Promise { + await options.ensureStructuredSessionOwnership?.() + assertLegacyAiVaultResumeAllowed(args) const executionHost = parseExecutionHostId(args.executionHostId) if (executionHost?.kind === 'runtime') { if (!options.prepareRuntimeSessionResume) { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5701b97f230..face1485517 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -48,6 +48,7 @@ import { scanHostLegWithCache } from './ai-vault-host-leg-cache' import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth' +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, @@ -267,7 +268,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. @@ -297,8 +300,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') diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 90f1a0e647d..0bde2f17b13 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -216,6 +216,7 @@ export function registerCoreHandlers( registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ + ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 6975c8d6902..d4fb7f6409c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -95,7 +95,7 @@ export class StructuredAgentSessionHost { private readonly eventSinks = new Map() private readonly reconcileLeases: (sessionId: string) => Promise - constructor(private readonly deps: StructuredAgentSessionHostDeps) { + constructor(readonly deps: StructuredAgentSessionHostDeps) { this.reconcileLeases = createRestartReconciler({ store: deps.store, probe: (record) => this.probeRecord(record), diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts new file mode 100644 index 00000000000..ad211902433 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -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 + })) + ) +} diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 7e053daa613..a036b1df428 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -64,6 +64,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[0]) => listAiVaultSessions(args) } as unknown as OrcaRuntimeService @@ -127,6 +128,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 }) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 70acf5dab67..6ebe68662c0 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -3,7 +3,16 @@ import { defineMethod, type RpcMethod } from '../core' 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 type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + 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 @@ -54,6 +63,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() @@ -63,7 +73,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, @@ -72,22 +83,37 @@ 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), + (provider) => + provider !== 'claude' || + clientKind === undefined || + (clientCapabilities?.includes(CLAUDE_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) + } }) ] diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index ba5dd29ecde..57e5d9ba0e6 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 8d45ad5c0bc..578b375686b 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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' && diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 9e2f1702f7c..cc79cbb8933 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -100,6 +100,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, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 43504842aaa..c4a0329a3e2 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -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> -}): { - buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup - copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise - handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void - handleContinueInNewSession: (session: AiVaultSession, targetWorktreeId: string) => void - continuationRequest: AgentSessionContinuationRequest | null - handleContinuationDialogOpenChange: (open: boolean) => void -} { +}) { const [continuationRequest, setContinuationRequest] = useState(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, diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 3c9ddd26369..8504d255813 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -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 diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts new file mode 100644 index 00000000000..bffcee51b8b --- /dev/null +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -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 +} diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 69c17c5caa6..536616fdf81 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -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 { + if (!value || typeof value !== 'object') { + return false + } + const structured = value as Record + return isNonEmptyString(structured.sessionId) && isNonEmptyString(structured.workspaceId) +} + function isStringRecord(value: unknown): value is Record { 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 } : {}), diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index 2ff2d5be6ab..1a26b8b80b7 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -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' diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts index fc78dbed0c1..9c7fa8740ff 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -8,11 +8,12 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' export async function prepareAiVaultSessionForResume( session: AiVaultSession ): Promise { - 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 diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts new file mode 100644 index 00000000000..dda79aeede1 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/types' + +const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), + callRuntimeRpc: vi.fn(async () => ({ ok: true })), + focusGroup: vi.fn(), + setActiveTabType: vi.fn(), + state: { unifiedTabsByWorktree: {} } as Record +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mocks.state } +})) + +vi.mock('./worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => 'env-1' +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string + }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) +})) + +vi.mock('@/runtime/runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` +})) + +import { + activateStructuredAgentSessionById, + activateStructuredAgentSessionTab +} from './structured-agent-session-tab-activation' + +describe('activateStructuredAgentSessionTab', () => { + beforeEach(() => { + vi.clearAllMocks() + const tab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + mocks.state = { + unifiedTabsByWorktree: { 'wt-1': [tab] }, + focusGroup: mocks.focusGroup, + activateTab: mocks.activateTab, + setActiveTabType: mocks.setActiveTabType + } + }) + + it('selects the unified tab and synchronizes host focus', () => { + expect( + activateStructuredAgentSessionTab({ worktreeId: 'wt-1', tabId: 'structured-tab-1' }) + ).toBe(true) + + expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') + expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) + expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session') + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'session.tabs.activate', + { 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' } + ) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts new file mode 100644 index 00000000000..e4d6eed92bc --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -0,0 +1,43 @@ +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' + +export function activateStructuredAgentSessionTab(args: { + worktreeId: string + tabId: string +}): boolean { + const state = useAppStore.getState() + const tab = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find( + (candidate) => candidate.id === args.tabId && candidate.contentType === 'agent-session' + ) + if (!tab) { + return false + } + state.focusGroup(args.worktreeId, tab.groupId) + state.activateTab(tab.id, { worktreeId: args.worktreeId }) + state.setActiveTabType('agent-session') + const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) + void callRuntimeRpc( + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + 'session.tabs.activate', + { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + tabId: `agent-session:${tab.entityId}` + } + ) + 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 +} diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 39edd01038a..64797710537 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -3,7 +3,8 @@ import type { AiVaultSession } from './ai-vault-types' export type AiVaultPrepareSessionResumeArgs = Pick< AiVaultSession, 'agent' | 'filePath' | 'codexHome' | 'executionHostId' -> +> & + Partial> export type AiVaultPrepareSessionResumeResult = { useRealCodexHome: boolean diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 6e132fce990..a30761538a0 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -116,6 +116,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 = { diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 90aa844efa2..13e486f0b1c 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -18,7 +18,10 @@ import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/age import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' import { resolveBaselineReleaseRef } from './release-checkout' import { loadAgentSessionWireBuild, @@ -34,6 +37,8 @@ const SUITE_TIMEOUT_MS = 180_000 const SESSION = 'session-alpha' const WORKSPACE = 'workspace-1' const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const CLAUDE_SESSION = 'session-claude' +const CLAUDE_PROVIDER_SESSION = '6de39f04-fdec-4f96-9d38-d0c1169db382' const NOW = 1_800_000_000_000 /** Every method the structured surface publishes, paired with the host method it @@ -116,6 +121,38 @@ function attachParams(fence: number | null): Record { } } +function claudeAttachParams(): Record { + const params = { + envelope: { + sessionId: CLAUDE_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: null + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native', + providerHandle: { kind: 'claude', sessionId: CLAUDE_PROVIDER_SESSION, leafUuid: null } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: CLAUDE_SESSION, + fields: attachFingerprintFields(params as unknown as AgentSessionAttachParams) + }) + } + } +} + function createIntentParams(): Record { const worktree = `id:${WORKSPACE}` const fields = { worktree, agent: 'codex' } @@ -162,6 +199,7 @@ function runtimeStub(): unknown { const cleanups = new Map void>() return { getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => undefined, getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async () => { const { @@ -321,6 +359,263 @@ 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 & Record + let createMobileSessionTerminal: ReturnType + + 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, identity }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken: + store.getRecord(identity.sessionId)?.lease.reservedSpawnToken ?? 'spawn-vault' + }, + link: { + linkId: `link-${fence}`, + handle: + identity.agent === 'claude' + ? { provider: 'claude', sessionId: CLAUDE_PROVIDER_SESSION, leafUuid: null } + : { 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) + const claudeAttached = await host.attach({ callerKey: 'test' }, claudeAttachParams() as never) + expect(claudeAttached.ok).toBe(true) + createMobileSessionTerminal = vi.fn() + runtime = { + ...(runtimeStub() as Record), + 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 + }, + { + id: `local:claude:${CLAUDE_PROVIDER_SESSION}`, + executionHostId: 'local', + agent: 'claude', + sessionId: CLAUDE_PROVIDER_SESSION, + title: 'Owned Claude session', + cwd: '/repo', + branch: null, + model: null, + filePath: `/home/dev/.claude/projects/repo/${CLAUDE_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: `claude --resume '${CLAUDE_PROVIDER_SESSION}'`, + 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 } + } + ] + } + }) + + const claudeCapableReply = ( + await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'mobile', + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + }, + runtime + ) + )[0] + expect(claudeCapableReply).toMatchObject({ + ok: true, + result: { + sessions: [ + { structuredSession: { sessionId: SESSION, workspaceId: WORKSPACE } }, + { structuredSession: { sessionId: CLAUDE_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() + + expect( + ( + await callBuild( + current, + 'aiVault.prepareSessionResume', + { + agent: 'claude', + filePath: `/home/dev/.claude/projects/repo/${CLAUDE_PROVIDER_SESSION}.jsonl`, + codexHome: null + }, + { + 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: `claude --resume '${CLAUDE_PROVIDER_SESSION}'` + }, + { 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 diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 1b2eb3dae97..241585e141b 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -89,10 +89,13 @@ function capabilityStrings(module: Record): readonly string[] { } async function loadWorkingTreeBuild(): Promise { - 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 { 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[]) + ] }) } }