mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix: fence structured AI Vault resumes
This commit is contained in:
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[])
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user