Open a native chat copy in the CLI without waiting for History (#29126)

* Prepare native chat CLI copies from the host record

* Apply staged-file lint and formatting checks

* Cover pairing replacement before queued preparation dispatch

* Reuse CLI failure feedback and complete the pairing test response

* Isolate Codex materialization test user data

* Preserve native chat CLI copy completion and preparation ownership

* test: consolidate native conversation fork contracts

* fix(native-chat): refuse a Codex chat fork whose saved account home has no CODEX_HOME path
This commit is contained in:
Brennan Benson
2026-10-15 14:59:26 -07:00
committed by GitHub
parent cc5cbc014a
commit 1b1d09cfb5
30 changed files with 1491 additions and 126 deletions
+4 -2
View File
@@ -1,3 +1,4 @@
import { AiVaultChatForkSourceSchema } from '../../shared/ai-vault-chat-fork-contract'
import { z } from 'zod'
import {
AI_VAULT_AGENTS,
@@ -10,7 +11,7 @@ import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeRequest,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type {
@@ -34,6 +35,7 @@ export type RuntimeAiVaultScanOptions = {
// parent would silently drop it and resume under the wrong account's home.
const aiVaultPrepareSessionResumeResultSchema = z.object({
useRealCodexHome: z.boolean(),
cliForkSource: AiVaultChatForkSourceSchema.optional(),
substituteCodexHome: z.string().optional(),
forkedSession: z
.object({
@@ -148,7 +150,7 @@ export async function resolveRuntimeAiVaultSessionTitles(
export async function prepareRuntimeAiVaultSessionResume(
userDataPath: string,
environmentId: string,
args: AiVaultPrepareSessionResumeArgs
args: AiVaultPrepareSessionResumeRequest
): Promise<AiVaultPrepareSessionResumeResult> {
const response = await callRuntimeEnvironment(
userDataPath,
@@ -0,0 +1,210 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, expect, it, vi } from 'vitest'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentJournalSnapshot } from '../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { prepareStructuredChatCliFork } from './structured-chat-cli-fork'
import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume'
import { AiVaultPrepareSessionResumeParams } from '../../shared/rpc-contract/ai-vault-params'
let root: string | undefined
const request = {
sourceChat: { sessionId: 'session-alpha-1', workspaceId: 'workspace-1' },
fork: true
} as const
const prepare = vi.fn(async () => ({ useRealCodexHome: false }))
function savedJournal(): AgentJournalSnapshot {
return {
sessionId: 'session-alpha-1',
cursor: { epoch: 'e', sequence: 1 },
submissions: [],
items: [
{
itemId: 'legacy:pi:source:user-1',
revision: 0,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Saved prompt' }] }
}
]
}
}
function install(record: AgentSessionRecord, copy = vi.fn(), journal = savedJournal()) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preparation reads only these host dependencies.
setStructuredAgentSessionHost({
journalSnapshot: async () => journal,
deps: {
store: { getRecord: () => record, listRecords: () => [record] },
adapter: { copyConversation: copy },
resolveWorkspacePath: async () => root,
resolveLaunchEnv: () => ({})
}
} as never)
}
afterEach(async () => {
setStructuredAgentSessionHost(null)
prepare.mockClear()
vi.unstubAllEnvs()
if (root) {
await rm(root, { recursive: true, force: true })
}
root = undefined
})
it('locates the current Codex rollout in its recorded home and materializes the selected home', async () => {
root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-'))
vi.stubEnv('ORCA_USER_DATA_PATH', root)
const home = join(root, 'codex-runtime-home', 'home')
const system = join(root, 'system')
const record = agentSessionRecordFixture()
record.provider = 'codex'
record.accountHome = { variable: 'CODEX_HOME', path: home }
record.providerHandleChain = [
{
...record.providerHandleChain[0]!,
handle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'obsolete' }
},
{
...record.providerHandleChain[0]!,
linkId: 'latest',
handle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'current-conversation' }
}
]
const relative = join('2026', '10', '15', 'rollout-2026-10-15-current-conversation.jsonl')
const source = join(home, 'sessions', relative)
await mkdir(join(home, 'sessions', '2026', '10', '15'), { recursive: true })
await writeFile(source, '{"saved":true}\n')
install(record)
const result = await prepareStructuredChatCliFork(request, async (args) => {
expect(args.sessionId).toBe('current-conversation')
expect(args.filePath).toBe(source)
const prepared = await prepareLegacySharedCodexSessionResume(args, {
isHostSystemDefaultRealHomeSelected: () => true,
legacyCodexHomePath: home,
systemCodexHomePath: system
})
// Production uses its system home; this fixture selects the same materialized home explicitly.
return { ...prepared, useRealCodexHome: false, substituteCodexHome: system }
})
expect(result.cliForkSource).toMatchObject({
sessionId: 'current-conversation',
codexHome: system,
filePath: join(system, 'sessions', relative),
cwd: root
})
})
it('copies OpenCode directly from the selected record and preserves its typed account and copy id', async () => {
root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-'))
const record = agentSessionRecordFixture()
record.provider = 'opencode'
record.accountHome = { kind: 'opencode', locator: { kind: 'unmanaged' } }
record.providerHandleChain = [
{
...record.providerHandleChain[0]!,
handle: { agent: 'opencode', transport: 'acp', nativeId: 'ses_source' }
}
]
const copy = vi.fn(async () => ({ sessionId: 'ses_copy' }))
install(record, copy)
const result = await prepareStructuredChatCliFork(request, prepare)
expect(copy).toHaveBeenCalledWith(
expect.objectContaining({
accountHome: record.accountHome,
providerSessionId: 'ses_source',
identity: expect.objectContaining({ sessionId: record.sessionId })
})
)
expect(result.cliForkSource).toMatchObject({
agent: 'opencode',
sourceAgent: 'opencode2',
sessionId: 'ses_copy'
})
expect(prepare).not.toHaveBeenCalled()
})
it('uses the Pi file handle and refuses a mismatched saved header or missing handle', async () => {
root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-'))
const filePath = join(root, '2026-10-15_12345678-abcd-1234-abcd-123456789abc.jsonl')
const record = agentSessionRecordFixture()
record.provider = 'pi'
record.providerHandleChain = [
{
...record.providerHandleChain[0]!,
handle: { agent: 'pi', transport: 'jsonl-rpc', nativeId: filePath }
}
]
await writeFile(
filePath,
`${JSON.stringify({ type: 'session', id: '12345678-abcd-1234-abcd-123456789abc', cwd: root })}\n`
)
const journal = savedJournal()
const savedMessage = journal.items.splice(0)
install(record, vi.fn(), journal)
await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow(
'no saved conversation'
)
expect(prepare).not.toHaveBeenCalled()
await writeFile(
filePath,
`${JSON.stringify({ type: 'session', id: '12345678-abcd-1234-abcd-123456789abc', cwd: root })}\n${JSON.stringify({ type: 'message', id: 'user-1', parentId: null, message: { role: 'user', content: [{ type: 'text', text: 'Saved prompt' }], timestamp: 1 } })}\n`
)
journal.items.push(...savedMessage)
await expect(prepareStructuredChatCliFork(request, prepare)).resolves.toMatchObject({
cliForkSource: { filePath, sessionId: '12345678-abcd-1234-abcd-123456789abc' }
})
journal.items[0]!.itemId = agentJournalSubmissionKey('send-1')
for (const dispatchState of ['pending', 'rejected', 'unknown', 'accepted'] as const) {
journal.submissions = [
{
clientMessageId: 'send-1',
fence: 1,
payloadFingerprint: 'p',
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null,
handoverRecorded: true
}
]
const preparation = prepareStructuredChatCliFork(request, prepare)
await (dispatchState === 'accepted'
? expect(preparation).resolves.toHaveProperty('cliForkSource')
: expect(preparation).rejects.toThrow(
dispatchState === 'unknown' ? 'Could not confirm' : 'no saved conversation'
))
}
await writeFile(filePath, `${JSON.stringify({ type: 'session', id: 'wrong', cwd: root })}\n`)
await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow('does not match')
record.providerHandleChain = []
await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow(
'no saved conversation'
)
})
it('refuses a foreign workspace or WSL record and rejects mixed chat/row requests', async () => {
root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-'))
const record = agentSessionRecordFixture()
install(record)
await expect(
prepareStructuredChatCliFork(
{ ...request, sourceChat: { ...request.sourceChat, workspaceId: 'other' } },
prepare
)
).rejects.toThrow('on this host')
record.location.wslDistro = 'Ubuntu'
await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow('on this host')
expect(
AiVaultPrepareSessionResumeParams.safeParse({
...request,
agent: 'claude',
filePath: '/foreign'
}).success
).toBe(false)
expect(prepare).not.toHaveBeenCalled()
})
@@ -0,0 +1,179 @@
import { stat } from 'node:fs/promises'
import { isAbsolute, join, relative } from 'node:path'
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import { agentForksConversations, agentForksThroughChat } from '../../shared/agent-resume-argv'
import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import type {
AiVaultPrepareChatForkArgs,
AiVaultPrepareSessionResumeResult,
AiVaultSessionResumePreparation
} from '../../shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { isWslUncPath } from '../../shared/wsl-paths'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { structuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations'
import { readPiSessionHeader } from '../pi/rpc-launch-resolution'
import { copyStructuredChatRecordConversation } from './structured-chat-conversation-copy'
import { isAiVaultAgent } from './session-list-result-validation'
import type { AgentJournalSnapshot } from '../../shared/agent-session-journal-types'
import {
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../shared/agent-session-journal-item-key'
function assertNativeConversationContent(snapshot: AgentJournalSnapshot): void {
const submissions = new Map(
snapshot.submissions.map((submission) => [
agentJournalSubmissionKey(submission.clientMessageId),
submission
])
)
let uncertain = false
const hasContent = snapshot.items.some(({ itemId, body }) => {
if (body.kind !== 'message' || (body.role !== 'user' && body.role !== 'assistant')) {
return false
}
const identity = parseAgentJournalItemKey(itemId)
const submission = submissions.get(itemId)
if (submission?.dispatchState === 'accepted' || (identity && identity.provider !== 'orca')) {
return true
}
// Write-ahead messages alone prove neither provider content nor failed delivery.
uncertain ||=
!submission ||
submission.dispatchState === 'unknown' ||
(submission.dispatchState === 'pending' &&
(!submission.handoverRecorded || submission.handedOverAt !== undefined))
return false
})
if (!hasContent) {
throw new Error(
uncertain
? 'Could not confirm this conversation was saved. Try again after the agent responds.'
: 'This chat has no saved conversation to copy.'
)
}
}
/** Prepares the record's current conversation on its execution host, without a History inventory. */
export async function prepareStructuredChatCliFork(
args: AiVaultPrepareChatForkArgs,
prepare: AiVaultSessionResumePreparation
): Promise<AiVaultPrepareSessionResumeResult> {
const host = getStructuredAgentSessionHost()
const record = host?.deps.store.getRecord(args.sourceChat.sessionId)
const registration = record && structuredAgentRuntimeRegistration(record.provider)
const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain)
if (
!host ||
!record ||
!registration ||
!head ||
record.location.workspaceId !== args.sourceChat.workspaceId ||
record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
record.location.wslDistro !== null ||
// A typed locator has no CODEX_HOME path; forking would silently use the default account.
(record.provider === 'codex' && !isLegacyAgentSessionAccountHome(record.accountHome)) ||
!isAiVaultAgent(record.provider) ||
!agentForksConversations(record.provider)
) {
throw new Error('This chat has no saved conversation available on this host.')
}
const agent = record.provider
const cwd =
(await host.deps.resolveWorkspacePath?.(record.location.workspaceId)) || record.launchDirectory
if (!cwd || !isAbsolute(cwd) || isWslUncPath(cwd)) {
throw new Error('The chat workspace is unavailable on this host.')
}
const providerSessionId = registration.sessionHistory.rowSessionId(head)
if (!providerSessionId.trim()) {
throw new Error('This chat has no saved conversation to copy.')
}
assertNativeConversationContent(await host.journalSnapshot(record.sessionId))
if (agentForksThroughChat(agent)) {
const result = await copyStructuredChatRecordConversation(record, providerSessionId)
const copy = result.forkedSession
if (!copy || (agent === 'omp' && !copy.filePath)) {
throw new Error('The agent did not return a saved conversation copy.')
}
return {
...result,
cliForkSource: {
agent: copy.agent,
sourceAgent: registration.sessionHistory.rowAgents.at(-1) ?? agent,
sessionId: copy.sessionId,
filePath: copy.filePath ?? '',
codexHome: null,
cwd,
executionHostPlatform: process.platform
}
}
}
const importer = registration.transcriptImport
if (!importer) {
throw new Error('This chat cannot be opened in the CLI.')
}
const launchEnv = { ...process.env, ...(await host.deps.resolveLaunchEnv?.(agent)) }
let filePath: string | null
if (importer.handleNamesTranscript) {
filePath = head.handle.nativeId
if (head.handle.transport !== 'jsonl-rpc' || !isAbsolute(filePath) || isWslUncPath(filePath)) {
throw new Error('The chat has no valid saved session file.')
}
const header = await readPiSessionHeader(filePath)
if (!header || header.id !== providerSessionId) {
throw new Error('The saved conversation does not match this chat.')
}
} else {
filePath = await importer.findTranscript({
providerSessionId,
accountHome: record.accountHome,
launchEnv,
workspaceCwd: cwd
})
}
if (!filePath || !isAbsolute(filePath) || isWslUncPath(filePath)) {
throw new Error('This chat has no saved conversation to copy.')
}
const saved = await stat(filePath)
if (!saved.isFile() || saved.size === 0) {
throw new Error('This chat has no saved conversation to copy.')
}
const codexHome =
agent === 'codex' && isLegacyAgentSessionAccountHome(record.accountHome)
? record.accountHome.path
: null
const result = await prepare({
agent,
sessionId: providerSessionId,
filePath,
codexHome,
executionHostId: LOCAL_EXECUTION_HOST_ID,
fork: true
})
const preparedHome = result.useRealCodexHome ? null : (result.substituteCodexHome ?? codexHome)
if (codexHome && (result.useRealCodexHome || result.substituteCodexHome)) {
filePath = join(
preparedHome ?? getSystemCodexHomePath(),
'sessions',
relative(join(codexHome, 'sessions'), filePath)
)
const preparedFile = await stat(filePath)
if (!preparedFile.isFile() || preparedFile.size === 0) {
throw new Error('The prepared conversation is unavailable in the selected account.')
}
}
return {
...result,
cliForkSource: {
agent,
sourceAgent: agent,
sessionId: providerSessionId,
filePath,
cwd,
codexHome: preparedHome,
executionHostPlatform: process.platform
}
}
}
@@ -1,3 +1,4 @@
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { agentForksThroughChat } from '../../shared/agent-resume-argv'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import type {
@@ -26,6 +27,18 @@ export async function copyStructuredChatConversation(
if (!host || !ownership || !record || !copy) {
throw new Error('This chat is no longer in Orca, so it cannot be copied to a new CLI.')
}
return copyStructuredChatRecordConversation(record, ownership.providerSessionId)
}
export async function copyStructuredChatRecordConversation(
record: AgentSessionRecord,
providerSessionId: string
): Promise<AiVaultPrepareSessionResumeResult> {
const host = getStructuredAgentSessionHost()
const copy = host?.deps.adapter.copyConversation
if (!host || !copy || !isAiVaultAgent(record.provider)) {
throw new Error('This chat cannot be copied to a new CLI.')
}
const forkedSession = await copy.call(host.deps.adapter, {
identity: {
sessionId: record.sessionId,
@@ -35,12 +48,17 @@ export async function copyStructuredChatConversation(
providerHandle:
agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null
},
providerSessionId: ownership.providerSessionId,
providerSessionId,
accountHome: record.accountHome
})
if (
forkedSession.sessionId === ownership.providerSessionId ||
findResumeOwnership({ agent: args.agent, sessionId: forkedSession.sessionId, filePath: '' })
!forkedSession.sessionId.trim() ||
forkedSession.sessionId === providerSessionId ||
findResumeOwnership({
agent: record.provider,
sessionId: forkedSession.sessionId,
filePath: ''
})
) {
throw new Error('The agent returned a conversation already owned by a chat instead of a copy.')
}
@@ -49,7 +67,7 @@ export async function copyStructuredChatConversation(
forkedSession: {
...forkedSession,
// The chat's own agent, not the row's: an OpenCode 2 row's chat runs `opencode`.
agent: isAiVaultAgent(record.provider) ? record.provider : args.agent
agent: record.provider
}
}
}
+22 -5
View File
@@ -1,6 +1,8 @@
import { AiVaultPrepareSessionResumeParams } from '../../shared/rpc-contract/ai-vault-params'
import { prepareStructuredChatCliFork } from '../ai-vault/structured-chat-cli-fork'
import { ipcMain } from 'electron'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeRequest,
AiVaultPrepareSessionResumeResult,
AiVaultSessionResumePreparation
} from '../../shared/ai-vault-resume-preparation'
@@ -15,18 +17,19 @@ export type AiVaultResumeHandlerOptions = {
prepareSessionResume?: AiVaultSessionResumePreparation
prepareRuntimeSessionResume?: (
environmentId: string,
args: AiVaultPrepareSessionResumeArgs
args: AiVaultPrepareSessionResumeRequest
) => Promise<AiVaultPrepareSessionResumeResult>
}
export function registerAiVaultResumeHandler(options: AiVaultResumeHandlerOptions): void {
ipcMain.handle('aiVault:prepareSessionResume', (_event, args: AiVaultPrepareSessionResumeArgs) =>
prepareAiVaultSessionResume(args, options)
ipcMain.handle(
'aiVault:prepareSessionResume',
(_event, args: AiVaultPrepareSessionResumeRequest) => prepareAiVaultSessionResume(args, options)
)
}
export async function prepareAiVaultSessionResume(
args: AiVaultPrepareSessionResumeArgs,
args: AiVaultPrepareSessionResumeRequest,
options: AiVaultResumeHandlerOptions
): Promise<AiVaultPrepareSessionResumeResult> {
const executionHost = parseExecutionHostId(args.executionHostId)
@@ -36,6 +39,20 @@ export async function prepareAiVaultSessionResume(
}
return options.prepareRuntimeSessionResume(executionHost.environmentId, args)
}
if ('sourceChat' in args) {
const parsed = AiVaultPrepareSessionResumeParams.parse(args)
if (!('sourceChat' in parsed)) {
throw new Error('Invalid chat source.')
}
if (executionHost?.kind === 'ssh') {
throw new Error('This chat cannot be copied on this host.')
}
await options.ensureStructuredSessionOwnership?.()
return prepareStructuredChatCliFork(
parsed,
options.prepareSessionResume ?? (async () => ({ useRealCodexHome: false }))
)
}
// Why: the desktop process must never materialize transcript paths owned by an SSH host.
if (executionHost?.kind === 'ssh') {
return { useRealCodexHome: false }
+7
View File
@@ -1,3 +1,4 @@
import { prepareStructuredChatCliFork } from '../../../ai-vault/structured-chat-cli-fork'
import {
AiVaultSearchRequestSchema,
AiVaultSearchStatusRequestSchema,
@@ -128,6 +129,12 @@ export const AI_VAULT_METHODS = [
permission: 'workspace',
params: AiVaultPrepareSessionResumeParams,
handler: async (params, { runtime }) => {
if ('sourceChat' in params) {
await runtime.ensureStructuredAgentSessionHost()
return prepareStructuredChatCliFork(params, (args) =>
runtime.prepareAiVaultSessionResume(args)
)
}
const args: AiVaultPrepareSessionResumeArgs = {
agent: params.agent,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
+2 -2
View File
@@ -20,7 +20,7 @@ import type {
AiVaultSessionTitlesResult
} from '../../shared/ai-vault-session-title'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeRequest,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type { ExecutionHostId, ExecutionHostScope } from '../../shared/execution-host'
@@ -52,7 +52,7 @@ export type AiVaultApi = {
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise<AiVaultSessionTitlesResult>
cancelListSessions: (args: { requestToken: string }) => Promise<void>
prepareSessionResume: (
args: AiVaultPrepareSessionResumeArgs
args: AiVaultPrepareSessionResumeRequest
) => Promise<AiVaultPrepareSessionResumeResult>
/** Lists the Task subagent transcripts of one session, on demand. */
listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise<AiVaultSubagentListResult>
+2 -2
View File
@@ -12,7 +12,7 @@ import type {
AiVaultSubagentListArgs
} from '../../shared/ai-vault-types'
import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title'
import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation'
import type { AiVaultPrepareSessionResumeRequest } from '../../shared/ai-vault-resume-preparation'
import type { PreloadApi } from '../api-types'
import {
AI_VAULT_SCAN_PROGRESS_CHANNEL,
@@ -49,7 +49,7 @@ export const aiVaultApi = {
ipcRenderer.invoke('aiVault:resolveSessionTitles', args),
cancelListSessions: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('aiVault:cancelListSessions', args),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) =>
ipcRenderer.invoke('aiVault:prepareSessionResume', args),
listSubagentSessions: (args: AiVaultSubagentListArgs) =>
ipcRenderer.invoke('aiVault:listSubagentSessions', args),
@@ -3,6 +3,7 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { ContextMenuItem } from '@/components/ui/context-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy'
/** "Resume in New Native Chat" / "Resume in New CLI", shared by the session-history row menus and
@@ -11,12 +12,14 @@ export function AiVaultSessionSurfaceSwitchMenuItems({
menuKind,
tooltipSide,
onResumeInNewChat,
onResumeInNewCli
onResumeInNewCli,
cliPending = false
}: {
menuKind: 'dropdown' | 'context'
tooltipSide: 'left' | 'right'
onResumeInNewChat?: () => void
onResumeInNewCli?: () => void
onResumeInNewCli?: (event: Event) => void
cliPending?: boolean
}): React.JSX.Element {
const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem
return (
@@ -33,8 +36,12 @@ export function AiVaultSessionSurfaceSwitchMenuItems({
{onResumeInNewCli ? (
<Tooltip>
<TooltipTrigger asChild>
<Item onSelect={onResumeInNewCli}>
<SquareTerminal className="size-3.5" />
<Item onSelect={onResumeInNewCli} disabled={cliPending} aria-busy={cliPending}>
{cliPending ? (
<LoadingSpinner className="size-3.5" />
) : (
<SquareTerminal className="size-3.5" />
)}
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli',
'Resume in New CLI'
@@ -1,6 +1,5 @@
import {
notifyAiVaultSessionPreparationFailure,
notifyAiVaultSessionQueued,
resolveAiVaultSessionLaunchTargetOrNotify
} from './ai-vault-session-launch-feedback'
import { useCallback, useState } from 'react'
@@ -16,7 +15,7 @@ import {
buildAiVaultCopiedSessionStartupForWorktree,
buildAiVaultForkStartupForWorktree
} from '@/lib/ai-vault-session-fork-startup'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal'
import { useAppStore } from '@/store'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import {
@@ -27,7 +26,6 @@ import {
} from '@/lib/ai-vault-session-resume-preparation'
import type { Worktree } from '../../../../shared/worktree/types'
import { translate } from '@/i18n/i18n'
import { agentLabel } from './ai-vault-session-filters'
import { describeAiVaultCliForkFailure } from './ai-vault-session-cli-fork'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation'
@@ -36,10 +34,7 @@ import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-struct
import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal'
import {
activateAiVaultResumeWorkspace,
resumeAiVaultSessionInNewChat
} from './ai-vault-session-resume-in-chat-launch'
import { resumeAiVaultSessionInNewChat } from './ai-vault-session-resume-in-chat-launch'
import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target'
export function useAiVaultSessionLaunchActions({
@@ -117,37 +112,12 @@ export function useAiVaultSessionLaunchActions({
return
}
void prepareStartup(targetId.worktreeId)
.then((startup) => {
const launchResult = launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: targetId.worktreeId,
...startup
})
if (launchResult.tabId === null) {
void launchResult.runtimeLaunch.then((outcome) => {
if (outcome.status === 'failed') {
toast.error(
describeFailure(outcome.message) ||
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: agentLabel(session.agent) }
)
)
return
}
if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) {
activateAiVaultResumeWorkspace(targetId.worktreeId)
}
notifyAiVaultSessionQueued(session.agent)
})
return
}
if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) {
activateAiVaultResumeWorkspace(targetId.worktreeId)
}
notifyAiVaultSessionQueued(session.agent)
})
.then((startup) =>
launchAiVaultSessionAndReveal(
{ agent: session.agent, worktreeId: targetId.worktreeId, ...startup },
describeFailure
)
)
.catch((error: unknown) =>
notifyAiVaultSessionPreparationFailure(error, session.agent, describeFailure)
)
@@ -0,0 +1,47 @@
import { useState } from 'react'
import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types'
import { AiVaultSessionSurfaceSwitchMenuItems } from '../right-sidebar/AiVaultSessionSurfaceSwitchMenuItems'
import {
resolveTabSessionHistorySubject,
type NativeChatTabSession
} from './tab-session-history-switch'
import { runNativeChatCliFork, useNativeChatCliForkPending } from './native-chat-cli-fork-run'
export function NativeChatCliForkMenuItems({
tab,
nativeChat,
leadingSeparator,
onComplete
}: {
tab: { id: string; worktreeId: string }
nativeChat: NativeChatTabSession
leadingSeparator: boolean
onComplete?: () => void
}): React.JSX.Element | null {
const [subject] = useState(() =>
resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat })
)
const chat = subject?.kind === 'chat' ? subject : null
const pending = useNativeChatCliForkPending(chat)
const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides)
const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent)
if (!chat || !agent) {
return null
}
return (
<>
{leadingSeparator ? <DropdownMenuSeparator /> : null}
<AiVaultSessionSurfaceSwitchMenuItems
menuKind="dropdown"
tooltipSide="right"
cliPending={pending}
onResumeInNewCli={(event) => {
event.preventDefault()
void runNativeChatCliFork({ subject: chat, tab, agent, commandOverrides, onComplete })
}}
/>
</>
)
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { X, Minimize2, Pin } from 'lucide-react'
import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration'
@@ -139,7 +139,13 @@ export default function SortableTab({
})
// Why: no transform/transition/opacity so tabs stay anchored during drag, only the insertion bar moves (see TabBar.tsx).
const [menuOpen, setMenuOpen] = useState(false)
const [menuOpening, setMenuOpening] = useState<number | null>(null)
const menuGeneration = useRef(0)
const setMenuOpen = useCallback((open: boolean) => {
// Every gesture retires the old opening, even when React batches close and reopen.
const generation = ++menuGeneration.current
setMenuOpening(open ? generation : null)
}, [])
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const {
isEditing,
@@ -163,17 +169,17 @@ export default function SortableTab({
const closeMenu = (): void => setMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
}, [setMenuOpen])
// Why: webview clicks miss the renderer pointerdown Radix uses for outside-click, so dismiss on window blur instead.
useEffect(() => {
if (!menuOpen) {
if (menuOpening === null) {
return
}
const dismiss = (): void => setMenuOpen(false)
window.addEventListener('blur', dismiss)
return () => window.removeEventListener('blur', dismiss)
}, [menuOpen])
}, [menuOpening, setMenuOpen])
// Why: while editing, drop drag listeners so typing can't start a drag; attributes stay spread to keep dnd-kit a11y.
const dragListeners = isEditing ? undefined : listeners
@@ -368,7 +374,7 @@ export default function SortableTab({
setMenuOpen(true)
}}
>
{isEditing || menuOpen ? (
{isEditing || menuOpening !== null ? (
tabRoot
) : (
<TabHoverCard
@@ -396,11 +402,17 @@ export default function SortableTab({
</div>
<SortableTabContextMenu
sessionSwitchOpening={menuOpening}
onSessionSwitchComplete={() => {
if (menuGeneration.current === menuOpening) {
setMenuOpen(false)
}
}}
tab={tab}
unifiedTabId={unifiedTabId}
groupId={groupId}
isActive={isActive}
open={menuOpen}
open={menuOpening !== null}
point={menuPoint}
tabCount={tabCount}
hasTabsToRight={hasTabsToRight}
@@ -12,7 +12,7 @@
*/
import type { ReactElement, ReactNode } from 'react'
import { cloneElement, isValidElement, StrictMode, useEffect, useState } from 'react'
import { act, render, cleanup } from '@testing-library/react'
import { act, render, cleanup, fireEvent } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
@@ -106,6 +106,22 @@ vi.mock('./TerminalTabLeadingIcon', () => ({
}
}))
const menuCompletions: (() => void)[] = []
vi.mock('./SortableTabContextMenu', () => ({
SortableTabContextMenu: ({
open,
onSessionSwitchComplete
}: {
open: boolean
onSessionSwitchComplete: () => void
}) => {
if (open) {
menuCompletions.push(onSessionSwitchComplete)
}
return open ? <div data-testid="open-menu" /> : null
}
}))
vi.mock('./shell-icons', () => ({ ShellIcon: () => <span /> }))
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => <span />,
@@ -247,3 +263,22 @@ describe('SortableTab update-depth probe', () => {
expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull()
})
})
it('retires completion ownership for repeated gestures and batched blur/reopen', () => {
const { container } = render(<Harness tab={makeTab()} />)
const slot = container.querySelector('[data-tab-strip-slot]')!
act(() => fireEvent.contextMenu(slot))
const first = menuCompletions.at(-1)!
act(() => fireEvent.contextMenu(slot))
act(first)
expect(container.querySelector('[data-testid="open-menu"]')).not.toBeNull()
const second = menuCompletions.at(-1)!
act(() => {
window.dispatchEvent(new Event('blur'))
fireEvent.contextMenu(slot)
})
act(second)
expect(container.querySelector('[data-testid="open-menu"]')).not.toBeNull()
act(menuCompletions.at(-1)!)
expect(container.querySelector('[data-testid="open-menu"]')).toBeNull()
})
@@ -101,6 +101,8 @@ type SortableTabContextMenuProps = {
hasTabsToLeft: boolean
isPinned: boolean
onOpenChange: (open: boolean) => void
onSessionSwitchComplete?: () => void
sessionSwitchOpening?: number | null
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onCloseOthers: (tabId: string) => void
@@ -135,6 +137,8 @@ export function SortableTabContextMenu({
hasTabsToLeft,
isPinned,
onOpenChange,
onSessionSwitchComplete,
sessionSwitchOpening,
onActivate,
onClose,
onCloseOthers,
@@ -193,6 +197,8 @@ export function SortableTabContextMenu({
</>
) : null}
<TabSessionSurfaceSwitchMenuItems
key={sessionSwitchOpening}
onComplete={onSessionSwitchComplete}
tab={tab}
nativeChat={nativeChat}
leadingSeparator={!showTerminalViewSwitch}
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import { useSyncExternalStore } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -20,14 +21,29 @@ const mocks = vi.hoisted(() => {
} = { subject: null, move: null, launchActionArgs: [] }
return {
...state,
pending: false,
pendingListeners: new Set<() => void>(),
handleResumeInNewChat: vi.fn(),
handleResumeInNewCli: vi.fn(),
resolveSwitch: vi.fn(),
runNativeChatCliFork: vi.fn(),
listSessions: vi.fn(),
cancelListSessions: vi.fn(async () => {})
}
})
vi.mock('./native-chat-cli-fork-run', () => ({
runNativeChatCliFork: mocks.runNativeChatCliFork,
useNativeChatCliForkPending: () =>
useSyncExternalStore(
(listener) => {
mocks.pendingListeners.add(listener)
return () => mocks.pendingListeners.delete(listener)
},
() => mocks.pending
)
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector({ settings: null }),
@@ -103,6 +119,8 @@ function listResult(sessions: AiVaultSession[]) {
beforeEach(() => {
resetAiVaultForcedRescanThrottleForTest()
mocks.subject = null
mocks.pending = false
mocks.runNativeChatCliFork.mockReset()
mocks.move = null
mocks.launchActionArgs = []
mocks.handleResumeInNewChat.mockReset()
@@ -159,26 +177,19 @@ describe('TabSessionSurfaceSwitchMenuItems', () => {
expect(screen.queryByRole('separator')).toBeNull()
})
it('hides the move when the Session History gate withholds it', async () => {
it('offers the native chat action immediately while History never answers', async () => {
mocks.subject = CHAT_SUBJECT
await renderItems('orca-chat-1')
expect(mocks.listSessions).toHaveBeenCalled()
expect(screen.queryByRole('menuitem')).toBeNull()
})
it('runs the Session History "Resume in New CLI" handler on the row, in the tab workspace', async () => {
mocks.subject = CHAT_SUBJECT
mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' }
await renderItems('orca-chat-1')
expect(screen.getByRole('separator')).toBeTruthy()
expect(screen.queryByText('Resume in New Native Chat')).toBeNull()
mocks.listSessions.mockReturnValue(new Promise(() => {}))
renderItemsNow('orca-chat-1')
expect(mocks.listSessions).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New CLI' }))
expect(mocks.handleResumeInNewCli).toHaveBeenCalledWith(CHAT_ROW, 'wt-1')
expect(mocks.launchActionArgs.at(-1)).toMatchObject({
activeWorktree: null,
activeWorktreeId: 'wt-1'
})
expect(mocks.runNativeChatCliFork).toHaveBeenCalledWith(
expect.objectContaining({
subject: CHAT_SUBJECT,
tab: { id: 'tab-1', worktreeId: 'wt-1' },
agent: 'claude'
})
)
})
it('runs the Session History "Resume in New Native Chat" handler for a CLI tab', async () => {
@@ -218,13 +229,13 @@ describe('TabSessionSurfaceSwitchMenuItems', () => {
})
it('offers the move once the fresh answer has it', async () => {
mocks.subject = CHAT_SUBJECT
mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' }
renderItemsNow('orca-chat-1')
mocks.subject = CLI_SUBJECT
mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' }
renderItemsNow()
expect(screen.queryByRole('menuitem')).toBeNull()
await act(async () => {})
expect(screen.getByRole('menuitem', { name: 'Resume in New CLI' })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: 'Resume in New Native Chat' })).toBeTruthy()
})
it('ignores a lookup that settles after the menu closed', async () => {
@@ -234,9 +245,9 @@ describe('TabSessionSurfaceSwitchMenuItems', () => {
settle = resolve
})
)
mocks.subject = CHAT_SUBJECT
mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' }
await renderItems('orca-chat-1')
mocks.subject = CLI_SUBJECT
mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' }
await renderItems()
cleanup()
await act(async () => settle(listResult([CHAT_ROW])))
@@ -245,20 +256,34 @@ describe('TabSessionSurfaceSwitchMenuItems', () => {
it('shows nothing when the lookup fails', async () => {
mocks.listSessions.mockRejectedValue(new Error('scanner crashed'))
mocks.subject = CHAT_SUBJECT
mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' }
await renderItems('orca-chat-1')
mocks.subject = CLI_SUBJECT
mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' }
await renderItems()
expect(screen.queryByRole('menuitem')).toBeNull()
})
it('cancels its lookup when the menu closes', async () => {
mocks.subject = CHAT_SUBJECT
mocks.subject = CLI_SUBJECT
mocks.listSessions.mockReturnValue(new Promise(() => {}))
await renderItems('orca-chat-1')
await renderItems()
cleanup()
expect(mocks.cancelListSessions).toHaveBeenCalledWith({
requestToken: expect.any(String)
})
})
})
it('keeps an accepted native action visible with shared disabled spinner feedback', async () => {
mocks.subject = CHAT_SUBJECT
mocks.runNativeChatCliFork.mockImplementation(() => {
mocks.pending = true
mocks.pendingListeners.forEach((listener) => listener())
})
await renderItems('orca-chat-1')
fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New CLI' }))
const item = screen.getByRole('menuitem', { name: 'Resume in New CLI' })
expect(item.getAttribute('aria-busy')).toBe('true')
expect(item.getAttribute('data-disabled')).not.toBeNull()
expect(item.querySelector('.animate-spin')).not.toBeNull()
})
@@ -1,3 +1,4 @@
import { NativeChatCliForkMenuItems } from './NativeChatCliForkMenuItems'
import { useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
@@ -69,7 +70,7 @@ function useTabSessionSwitch(
/** The Session History row's "Resume in New Native Chat" / "Resume in New CLI", offered on the tab
* that shows that session and hidden wherever the row would not offer it. */
export function TabSessionSurfaceSwitchMenuItems({
function HistoryTabSessionSurfaceSwitchMenuItems({
tab,
nativeChat,
leadingSeparator
@@ -119,3 +120,16 @@ export function TabSessionSurfaceSwitchMenuItems({
</>
)
}
export function TabSessionSurfaceSwitchMenuItems(props: {
onComplete?: () => void
tab: Pick<TerminalTab, 'id' | 'worktreeId'>
nativeChat?: NativeChatTabSession
leadingSeparator: boolean
}): React.JSX.Element | null {
return props.nativeChat ? (
<NativeChatCliForkMenuItems {...props} nativeChat={props.nativeChat} />
) : (
<HistoryTabSessionSurfaceSwitchMenuItems {...props} />
)
}
@@ -0,0 +1,151 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { runNativeChatCliFork } from './native-chat-cli-fork-run'
import type {
NativeChatCliForkPreparation,
NativeChatCliForkSubject
} from '@/lib/native-chat-cli-fork-preparation'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
const mocks = vi.hoisted(() => ({
prepare: vi.fn(),
launch: vi.fn(),
notify: vi.fn(),
activate: vi.fn(),
activeWorktreeId: 'workspace-1',
error: vi.fn(),
open: true,
host: 'runtime:peer',
listeners: new Set<() => void>()
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
activeWorktreeId: mocks.activeWorktreeId,
unifiedTabsByWorktree: {
'workspace-1': mocks.open
? [{ id: 'tab-1', contentType: 'agent-session', entityId: 'chat-1' }]
: []
}
}),
subscribe: (listener: () => void) => {
mocks.listeners.add(listener)
return () => mocks.listeners.delete(listener)
}
}
}))
vi.mock('./tab-session-history-switch', () => ({
resolveTabSessionHistorySubject: () => ({
kind: 'chat',
request: { executionHostScope: mocks.host }
})
}))
vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({
prepareNativeChatCliFork: mocks.prepare
}))
vi.mock('@/lib/ai-vault-session-fork-startup', () => ({
assertAiVaultCopiedSessionLaunchAllowed: () => {},
buildAiVaultForkStartupForWorktree: () => ({ command: 'claude --resume source --fork-session' }),
buildAiVaultCopiedSessionStartupForWorktree: () => ({ command: 'omp --resume copy' })
}))
vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-launch', () => ({
activateAiVaultResumeWorkspace: mocks.activate
}))
vi.mock('sonner', () => ({ toast: { error: mocks.error } }))
vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launch }))
vi.mock('../right-sidebar/ai-vault-session-launch-feedback', () => ({
notifyAiVaultSessionQueued: mocks.notify,
notifyAiVaultSessionPreparationFailure: mocks.error
}))
const subject: NativeChatCliForkSubject = {
kind: 'chat',
sessionId: 'chat-1',
workspaceId: 'workspace-1',
request: { executionHostScope: 'runtime:peer', scopePaths: ['/host/repo'], sessionLimit: 250 }
}
const args = { subject, tab: { id: 'tab-1', worktreeId: 'workspace-1' }, agent: 'claude' } as const
const prepared: NativeChatCliForkPreparation = {
session: { agent: 'claude', sessionId: 'source', cwd: '/host/repo', codexHome: null },
copied: false
}
beforeEach(() => {
mocks.open = true
mocks.activeWorktreeId = 'workspace-1'
mocks.activate.mockReset()
mocks.prepare.mockReset()
mocks.launch.mockReset().mockReturnValue({ tabId: 'destination' })
mocks.notify.mockReset()
mocks.error.mockReset()
replaceRuntimeEnvironmentRevisions([{ id: 'peer', createdAt: 1, pairingRevision: 7 }])
})
function closeSource() {
mocks.open = false
mocks.listeners.forEach((listener) => listener())
}
it('shares pending across menu openings and suppresses dispatch after source retirement', async () => {
let settle = (_value: NativeChatCliForkPreparation) => {}
mocks.prepare.mockReturnValue(
new Promise((resolve) => {
settle = resolve
})
)
const first = runNativeChatCliFork(args)
await runNativeChatCliFork(args)
expect(mocks.prepare).toHaveBeenCalledTimes(1)
closeSource()
settle(prepared)
await first
expect(mocks.launch).not.toHaveBeenCalled()
expect(mocks.error).not.toHaveBeenCalled()
})
it('retires preparation when the initiating pairing changes', async () => {
let settle = (_value: NativeChatCliForkPreparation) => {}
mocks.prepare.mockReturnValue(
new Promise((resolve) => {
settle = resolve
})
)
const running = runNativeChatCliFork(args)
replaceRuntimeEnvironmentRevisions([{ id: 'peer', createdAt: 1, pairingRevision: 8 }])
settle(prepared)
await running
expect(mocks.launch).not.toHaveBeenCalled()
expect(mocks.error).not.toHaveBeenCalled()
})
it('lets the destination finish after dispatch and passes no source resume identity', async () => {
mocks.prepare.mockResolvedValue(prepared)
mocks.launch.mockImplementation(() => {
closeSource()
return { tabId: null, runtimeLaunch: Promise.resolve({ status: 'created' }) }
})
await runNativeChatCliFork(args)
expect(mocks.launch).toHaveBeenCalledWith({
agent: 'claude',
worktreeId: 'workspace-1',
command: 'claude --resume source --fork-session'
})
expect(mocks.notify).toHaveBeenCalledWith('claude')
expect(mocks.error).not.toHaveBeenCalled()
})
it('reveals the successful source workspace after another workspace was selected', async () => {
mocks.prepare.mockImplementation(async () => {
mocks.activeWorktreeId = 'workspace-2'
return prepared
})
await runNativeChatCliFork(args)
expect(mocks.activate).toHaveBeenCalledExactlyOnceWith('workspace-1')
mocks.activate.mockClear()
mocks.notify.mockClear()
mocks.launch.mockReturnValue({
tabId: null,
runtimeLaunch: Promise.resolve({ status: 'failed', message: 'refused' })
})
await runNativeChatCliFork(args)
expect(mocks.activate).not.toHaveBeenCalled()
expect(mocks.notify).not.toHaveBeenCalled()
expect(mocks.error).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,156 @@
import { useSyncExternalStore } from 'react'
import { useAppStore } from '@/store'
import { readIpcErrorMessage } from '@/lib/ipc-error'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import {
prepareNativeChatCliFork,
type NativeChatCliForkSubject
} from '@/lib/native-chat-cli-fork-preparation'
import {
assertAiVaultCopiedSessionLaunchAllowed,
buildAiVaultCopiedSessionStartupForWorktree,
buildAiVaultForkStartupForWorktree
} from '@/lib/ai-vault-session-fork-startup'
import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal'
import { notifyAiVaultSessionPreparationFailure } from '../right-sidebar/ai-vault-session-launch-feedback'
import { describeAiVaultCliForkFailure } from '../right-sidebar/ai-vault-session-cli-fork'
import {
captureWebSessionIntentOwner,
matchesWebSessionIntentOwner
} from '@/runtime/web-runtime-session-environment'
import {
onRuntimeEnvironmentsRetired,
onRuntimeEnvironmentRevisionsChanged
} from '@/runtime/runtime-environment-revision'
import type { AiVaultAgent } from '../../../../shared/ai-vault-types'
import { resolveTabSessionHistorySubject } from './tab-session-history-switch'
const pending = new Map<string, AbortController>()
const listeners = new Set<() => void>()
const changed = () => listeners.forEach((listener) => listener())
const keyFor = (subject: NativeChatCliForkSubject) =>
`${subject.request.executionHostScope}\0${subject.sessionId}`
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function useNativeChatCliForkPending(subject: NativeChatCliForkSubject | null): boolean {
return useSyncExternalStore(
subscribe,
() => !!subject && pending.has(keyFor(subject)),
() => false
)
}
export async function runNativeChatCliFork(args: {
subject: NativeChatCliForkSubject
tab: { id: string; worktreeId: string }
agent: AiVaultAgent
onComplete?: () => void
commandOverrides?: Partial<Record<AiVaultAgent, string | null>>
}): Promise<void> {
const { subject, tab } = args
const key = keyFor(subject)
if (pending.has(key)) {
return
}
const host = parseExecutionHostId(subject.request.executionHostScope)
const owner = host?.kind === 'runtime' ? captureWebSessionIntentOwner(host.environmentId) : null
const controller = new AbortController()
const isCurrent = () => {
if (controller.signal.aborted || (owner && !matchesWebSessionIntentOwner(owner))) {
return false
}
const state = useAppStore.getState()
const source = state.unifiedTabsByWorktree[tab.worktreeId]?.find((item) => item.id === tab.id)
const current =
source?.contentType === 'agent-session' && source.entityId === subject.sessionId
? resolveTabSessionHistorySubject(state, {
tab,
nativeChat: { sessionId: subject.sessionId, agent: args.agent }
})
: null
return (
current?.kind === 'chat' &&
current.request.executionHostScope === subject.request.executionHostScope
)
}
if (!isCurrent()) {
return
}
const release = () => {
if (pending.get(key) === controller) {
pending.delete(key)
changed()
}
}
const abandon = () => {
controller.abort()
release()
}
pending.set(key, controller)
changed()
const stopSource = useAppStore.subscribe(() => {
if (!isCurrent()) {
abandon()
}
})
const retire = (ids: readonly string[]) => {
if (owner && ids.includes(owner.environmentId)) {
abandon()
}
}
const stopRetirement = onRuntimeEnvironmentsRetired(retire)
const stopRevision = onRuntimeEnvironmentRevisionsChanged(retire)
const stop = () => {
stopSource()
stopRetirement()
stopRevision()
}
try {
const prepared = await prepareNativeChatCliFork(subject, {
pairingRevision: owner?.pairingRevision,
isCurrent,
signal: controller.signal
})
if (!isCurrent()) {
return
}
const target = prepared.session
const startupArgs = {
state: useAppStore.getState(),
worktreeId: subject.workspaceId,
session: target,
commandOverride: args.commandOverrides?.[target.agent]
}
assertAiVaultCopiedSessionLaunchAllowed(startupArgs)
const startup = prepared.copied
? buildAiVaultCopiedSessionStartupForWorktree(startupArgs, prepared.sourceAgent ?? args.agent)
: buildAiVaultForkStartupForWorktree(startupArgs)
if (!startup) {
throw new Error('This session cannot be opened in the CLI.')
}
// The destination takes over synchronously; closing the source afterward must not cancel it.
if (!isCurrent()) {
return
}
stop()
await launchAiVaultSessionAndReveal(
{ agent: target.agent, worktreeId: subject.workspaceId, ...startup },
describeAiVaultCliForkFailure
)
} catch (error) {
if (pending.get(key) === controller) {
notifyAiVaultSessionPreparationFailure(
new Error(readIpcErrorMessage(error)),
args.agent,
describeAiVaultCliForkFailure
)
}
} finally {
stop()
release()
args.onComplete?.()
}
}
@@ -1,6 +1,9 @@
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import { AiVaultChatForkSourceSchema } from '../../../shared/ai-vault-chat-fork-contract'
import type { AiVaultAgent, AiVaultSession } from '../../../shared/ai-vault-types'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareChatForkArgs,
AiVaultPrepareSessionResumeRequest,
AiVaultPrepareSessionResumeResult
} from '../../../shared/ai-vault-resume-preparation'
import {
@@ -86,13 +89,42 @@ function isMissingPairedResumePreparation(error: unknown): boolean {
* makes the copy (`agentForksThroughChat`) has the chat's host make it here; the result is that
* copy, which a plain resume opens and no chat owns (`copied: true`).
*/
type ForkPreparationRequest = (
args: AiVaultPrepareSessionResumeRequest
) => Promise<AiVaultPrepareSessionResumeResult>
export function prepareAiVaultSessionForFork(
session: AiVaultSession,
request?: ForkPreparationRequest
): Promise<{ session: AiVaultSession; copied: boolean }>
export function prepareAiVaultSessionForFork(
session: AiVaultPrepareChatForkArgs,
request?: ForkPreparationRequest
): Promise<{ session: AiVaultResumeCommandSession; copied: boolean; sourceAgent?: AiVaultAgent }>
export async function prepareAiVaultSessionForFork(
session: AiVaultSession
): Promise<{ session: AiVaultSession; copied: boolean }> {
if (!agentForksThroughChat(session.agent)) {
return { session: await prepareAiVaultSessionHome(session, true), copied: false }
session: AiVaultSession | AiVaultPrepareChatForkArgs,
request: ForkPreparationRequest = (args) => window.api.aiVault.prepareSessionResume(args)
): Promise<{ session: AiVaultResumeCommandSession; copied: boolean; sourceAgent?: AiVaultAgent }> {
if ('sourceChat' in session) {
const result = await request(session)
const source = AiVaultChatForkSourceSchema.parse(result.cliForkSource)
return {
session: {
...source,
executionHostId:
parseExecutionHostId(session.executionHostId)?.id ?? LOCAL_EXECUTION_HOST_ID
},
sourceAgent: source.sourceAgent,
copied: result.forkedSession !== undefined
}
}
const result = await window.api.aiVault.prepareSessionResume({
if (!agentForksThroughChat(session.agent)) {
return {
session: await prepareAiVaultSessionHome(session, true, false, request),
copied: false
}
}
const result = await request({
agent: session.agent,
sessionId: session.sessionId,
filePath: session.filePath,
@@ -123,7 +155,8 @@ export function aiVaultCliForkHostTooOldMessage(): string {
async function prepareAiVaultSessionHome(
session: AiVaultSession,
fork: boolean,
terminalResume = false
terminalResume = false,
request: ForkPreparationRequest = (args) => window.api.aiVault.prepareSessionResume(args)
): Promise<AiVaultSession> {
if (fork && !aiVaultSessionNeedsResumePreparation(session)) {
return session
@@ -136,9 +169,7 @@ async function prepareAiVaultSessionHome(
executionHostId: session.executionHostId,
...(fork ? { fork: true } : {})
}
const result = await (terminalResume
? prepareAiVaultTerminalResumeRequest(args)
: prepareAiVaultSessionResumeRequest(args))
const result = await (terminalResume ? prepareAiVaultTerminalResumeRequest(args) : request(args))
if (result.useRealCodexHome) {
return { ...session, codexHome: null }
}
@@ -0,0 +1,32 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session'
import { activateAiVaultResumeWorkspace } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch'
import { notifyAiVaultSessionQueued } from '@/components/right-sidebar/ai-vault-session-launch-feedback'
import { agentLabel } from '@/components/right-sidebar/ai-vault-session-filters'
export async function launchAiVaultSessionAndReveal(
args: Parameters<typeof launchAiVaultSessionInNewTab>[0],
describeFailure: (message: string) => string = (message) => message
): Promise<void> {
const launch = launchAiVaultSessionInNewTab(args)
if (launch.tabId === null) {
const outcome = await launch.runtimeLaunch
if (outcome.status === 'failed') {
toast.error(
describeFailure(outcome.message) ||
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: agentLabel(args.agent) }
)
)
return
}
}
if (useAppStore.getState().activeWorktreeId !== args.worktreeId) {
activateAiVaultResumeWorkspace(args.worktreeId)
}
notifyAiVaultSessionQueued(args.agent)
}
@@ -0,0 +1,138 @@
import { afterEach, expect, it, vi } from 'vitest'
import {
prepareNativeChatCliFork,
type NativeChatCliForkSubject
} from './native-chat-cli-fork-preparation'
import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from '../../../shared/ai-vault-resume-preparation'
const { call } = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: call }))
afterEach(() => {
call.mockReset()
vi.useRealTimers()
vi.unstubAllGlobals()
})
const subject: NativeChatCliForkSubject = {
kind: 'chat',
sessionId: 'chat-1',
workspaceId: 'workspace-1',
request: { executionHostScope: 'runtime:peer', scopePaths: ['/host/repo'], sessionLimit: 250 }
}
const source = {
agent: 'claude',
sourceAgent: 'claude',
sessionId: 'provider-1',
filePath: '/host/saved.jsonl',
cwd: '/host/repo',
codexHome: null,
executionHostPlatform: 'linux'
}
const options = { pairingRevision: 7, isCurrent: () => true, signal: new AbortController().signal }
const delayed = <T>(value: T, ms: number) =>
new Promise<T>((resolve) => setTimeout(() => resolve(value), ms))
it('negotiates then prepares on the same paired host without requesting History', async () => {
call
.mockResolvedValueOnce({ capabilities: [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] })
.mockResolvedValueOnce({ useRealCodexHome: false, cliForkSource: source })
await expect(prepareNativeChatCliFork(subject, options)).resolves.toMatchObject({
session: { sessionId: 'provider-1', executionHostId: 'runtime:peer' },
copied: false
})
expect(call.mock.calls.map((args) => args[1])).toEqual([
'status.get',
'aiVault.prepareSessionResume'
])
expect(call.mock.calls[1]).toEqual([
{ kind: 'environment', environmentId: 'peer' },
'aiVault.prepareSessionResume',
{
sourceChat: { sessionId: 'chat-1', workspaceId: 'workspace-1' },
executionHostId: 'runtime:peer',
fork: true
},
expect.objectContaining({ expectedEnvironmentPairingRevision: 7 })
])
})
it('does not retry History after a capable host refuses or returns a malformed source', async () => {
for (const result of [
() => Promise.reject(new Error('Cannot read saved conversation')),
() => Promise.resolve({ useRealCodexHome: false })
]) {
call
.mockResolvedValueOnce({ capabilities: [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] })
.mockImplementationOnce(result)
await expect(prepareNativeChatCliFork(subject, options)).rejects.toThrow()
}
expect(call.mock.calls.some((args) => args[1] === 'aiVault.listSessions')).toBe(false)
})
it('asks an old peer for one bounded same-host list and does not scan twice when absent', async () => {
call
.mockResolvedValueOnce({ capabilities: [] })
.mockResolvedValueOnce({ sessions: [], issues: [], scannedAt: 'now' })
await expect(prepareNativeChatCliFork(subject, options)).rejects.toThrow('no saved conversation')
expect(call.mock.calls.map((args) => args[1])).toEqual(['status.get', 'aiVault.listSessions'])
expect(call.mock.calls[1]?.[2]).toMatchObject({
executionHostId: 'runtime:peer',
force: undefined
})
expect(call.mock.calls[1]?.[3]).toMatchObject({
expectedEnvironmentPairingRevision: 7,
timeoutMs: 150_000
})
})
it('does not prepare when the source retires during capability admission', async () => {
let current = true
call.mockImplementationOnce(async () => {
current = false
return { capabilities: [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] }
})
await expect(
prepareNativeChatCliFork(subject, { ...options, isCurrent: () => current })
).rejects.toThrow('source chat changed')
expect(call).toHaveBeenCalledTimes(1)
})
it.each([true, false])(
'preserves delayed modern=%s preparation within its phase budgets',
async (modern) => {
vi.useFakeTimers()
call.mockResolvedValueOnce({
capabilities: modern ? [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] : []
})
if (!modern) {
const row = {
...source,
agent: 'codex',
codexHome: '/host/codex-runtime-home/home',
executionHostId: 'runtime:peer',
messageCount: 1,
queuedMessageCount: 0,
structuredSession: { sessionId: 'chat-1', workspaceId: 'workspace-1' }
}
call.mockImplementationOnce(() => delayed({ sessions: [row] }, 45_000))
}
call.mockImplementationOnce(() =>
delayed({ useRealCodexHome: false, cliForkSource: source }, 40_000)
)
const result = prepareNativeChatCliFork(subject, options)
await vi.advanceTimersByTimeAsync(modern ? 40_000 : 85_000)
await expect(result).resolves.toMatchObject({ session: { sessionId: 'provider-1' } })
expect(vi.getTimerCount()).toBe(0)
}
)
it('bounds legacy inventory observation at 150 seconds and revokes its queued signal', async () => {
vi.useFakeTimers()
call
.mockResolvedValueOnce({ capabilities: [] })
.mockImplementationOnce(() => new Promise(() => {}))
const failed = expect(prepareNativeChatCliFork(subject, options)).rejects.toThrow('timed out')
await vi.advanceTimersByTimeAsync(150_000)
await failed
expect(call.mock.calls[1]?.[3].signal.aborted).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
@@ -0,0 +1,115 @@
import { createBrowserUuid } from './browser-uuid'
import type { AiVaultAgent, AiVaultListResult } from '../../../shared/ai-vault-types'
import { isAiVaultSessionResumableContent } from '../../../shared/ai-vault-types'
import {
AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY,
type AiVaultPrepareSessionResumeRequest,
type AiVaultPrepareSessionResumeResult
} from '../../../shared/ai-vault-resume-preparation'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { prepareAiVaultSessionForFork } from './ai-vault-session-resume-preparation'
import { aiVaultSessionListArgs } from '@/components/right-sidebar/ai-vault-session-list-request'
import {
findTabSessionHistoryRow,
type TabSessionHistorySubject
} from '@/components/tab-bar/tab-session-history-switch'
import type { AiVaultResumeCommandSession } from './ai-vault-resume-command'
import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
export type NativeChatCliForkSubject = Extract<TabSessionHistorySubject, { kind: 'chat' }>
export type NativeChatCliForkPreparation = {
session: AiVaultResumeCommandSession
copied: boolean
sourceAgent?: AiVaultAgent
}
export async function prepareNativeChatCliFork(
subject: NativeChatCliForkSubject,
options: { pairingRevision?: number; isCurrent: () => boolean; signal: AbortSignal }
): Promise<NativeChatCliForkPreparation> {
const hostId = subject.request.executionHostScope
const host = parseExecutionHostId(hostId)
if (!host || (host.kind !== 'local' && host.kind !== 'runtime')) {
throw new Error('The conversation host is unavailable.')
}
const target =
host.kind === 'runtime'
? { kind: 'environment' as const, environmentId: host.environmentId }
: { kind: 'local' as const }
const observe = async <T>(timeoutMs: number, run: (signal: AbortSignal) => Promise<T>) => {
const deadline = new AbortController()
const timer = setTimeout(
() => deadline.abort(new Error('Conversation preparation timed out.')),
timeoutMs
)
const signal = AbortSignal.any([options.signal, deadline.signal])
try {
signal.throwIfAborted()
if (!options.isCurrent()) {
throw new Error('The source chat changed before preparation.')
}
return await waitForPromiseWithSignal(run(signal), signal)
} finally {
clearTimeout(timer)
}
}
const callOptions = (timeoutMs: number, signal: AbortSignal) => ({
timeoutMs,
signal,
expectedEnvironmentPairingRevision: options.pairingRevision
})
const request = (args: AiVaultPrepareSessionResumeRequest) =>
observe(90_000, (signal) =>
host.kind === 'local'
? window.api.aiVault.prepareSessionResume(args)
: callRuntimeRpc<AiVaultPrepareSessionResumeResult>(
target,
'aiVault.prepareSessionResume',
args,
callOptions(90_000, signal)
)
)
// Local desktop and host ship together; only a paired peer needs negotiation.
const modern =
host.kind === 'local' ||
(
await observe(30_000, (signal) =>
callRuntimeRpc<RuntimeStatus>(target, 'status.get', undefined, callOptions(30_000, signal))
)
).capabilities?.includes(AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY) === true
if (!options.isCurrent()) {
throw new Error('The source chat changed before preparation.')
}
if (modern) {
return prepareAiVaultSessionForFork(
{
sourceChat: { sessionId: subject.sessionId, workspaceId: subject.workspaceId },
executionHostId: host.id,
fork: true
},
request
)
}
// Old peers alone need one same-host inventory; never force a rescan or retry a modern error.
const listed = await observe(150_000, (signal) =>
callRuntimeRpc<AiVaultListResult>(
target,
'aiVault.listSessions',
{
...aiVaultSessionListArgs(subject.request, { requestToken: createBrowserUuid() }),
executionHostId: host.id
},
callOptions(150_000, signal)
)
)
if (!options.isCurrent()) {
throw new Error('The source chat changed before preparation.')
}
const row = findTabSessionHistoryRow(listed.sessions, subject)
if (!row || row.executionHostId !== host.id || !isAiVaultSessionResumableContent(row)) {
throw new Error('This chat has no saved conversation to copy.')
}
return { ...(await prepareAiVaultSessionForFork(row, request)), sourceAgent: row.agent }
}
@@ -4,7 +4,7 @@ import {
} from '../../../../shared/ai-vault-search-client'
import type { PreloadApi } from '../../../../preload/api-types'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeRequest,
AiVaultPrepareSessionResumeResult
} from '../../../../shared/ai-vault-resume-preparation'
import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault-session-deletion'
@@ -82,7 +82,7 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
// settles on its own timeout. The renderer's refreshId guard already drops
// the late result; this only means web pays for a scan nobody reads.
cancelListSessions: () => Promise.resolve(),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) =>
callRuntimeResult<AiVaultPrepareSessionResumeResult>('aiVault.prepareSessionResume', args),
// Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result.
listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }),
@@ -48,17 +48,31 @@ export async function callRuntimeEnvelope<TResult = unknown>(
return response as RuntimeRpcResponse<TResult>
}
export function assertEnvironmentPairing(selector: string, expectedRevision?: number): void {
const current = resolveEnvironment(selector)
if (
expectedRevision !== undefined &&
(current.pairingRevision ?? current.createdAt) !== expectedRevision
) {
throw new Error('The paired Orca server changed while the request was in progress.')
}
}
export async function callEnvironmentEnvelope<TResult = unknown>(
selector: string,
method: string,
params?: unknown,
timeoutMs?: number
timeoutMs?: number,
expectedEnvironmentPairingRevision?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = resolveEnvironment(selector)
const assertPairing = () => assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision)
assertPairing()
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => {
assertPairing()
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return Promise.resolve(manuallyDisconnectedResponse(environment))
}
@@ -67,6 +81,7 @@ export async function callEnvironmentEnvelope<TResult = unknown>(
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
assertPairing()
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
@@ -15,7 +15,7 @@ import {
} from '../web-runtime-environment'
import { translate } from '@/i18n/i18n'
import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy'
import { callEnvironmentEnvelope } from './web-runtime-calls'
import { assertEnvironmentPairing, callEnvironmentEnvelope } from './web-runtime-calls'
import {
closeActiveRuntimeClients,
subscribeWebRuntimeStatus,
@@ -27,7 +27,10 @@ import {
removeActiveRuntimeEnvironment,
requireActiveEnvironmentOrNull,
resolveEnvironment,
webRuntimeState
webRuntimeState,
runtimeCallQueuePool,
updateEnvironmentFromResponse,
manuallyDisconnectedResponse
} from './web-runtime-session'
export function createRuntimeEnvironmentsApi(): NonNullable<
@@ -200,13 +203,23 @@ export function createRuntimeEnvironmentsApi(): NonNullable<
: callEnvironmentEnvelope<RuntimeStatus>(selector, 'status.get', undefined, timeoutMs),
retryControlConnection: () => Promise.resolve(),
prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }),
call: ({ selector, method, params, timeoutMs }) =>
callEnvironmentEnvelope(selector, method, params, timeoutMs),
call: ({ selector, method, params, timeoutMs, expectedEnvironmentPairingRevision }) =>
callEnvironmentEnvelope(
selector,
method,
params,
timeoutMs,
expectedEnvironmentPairingRevision
),
cancelSubscription: async ({ subscriptionId }) => {
pendingSubscriptions.get(subscriptionId)?.abort()
},
subscribe: async ({ selector, method, params, timeoutMs, subscriptionId }, callbacks) => {
subscribe: async (
{ selector, method, params, timeoutMs, subscriptionId, expectedEnvironmentPairingRevision },
callbacks
) => {
const environment = resolveEnvironment(selector)
assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision)
const client = getClientForEnvironment(environment)
const controller = new AbortController()
if (subscriptionId) {
@@ -217,10 +230,44 @@ export function createRuntimeEnvironmentsApi(): NonNullable<
}
let subscription
try {
subscription = await client.subscribe(method, params, callbacks, {
timeoutMs,
signal: controller.signal
})
subscription = await runtimeCallQueuePool.enqueue(
environment.id,
method,
() => {
assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision)
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
throw new Error('runtime_manually_disconnected')
}
return client.subscribe(
method,
params,
{
...callbacks,
onResponse: (response) => {
try {
assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision)
} catch (error) {
callbacks.onError?.({
code: 'runtime_environment_changed',
message: String(error)
})
callbacks.onClose?.()
controller.abort()
return
}
const result = manuallyDisconnectedEnvironmentIds.has(environment.id)
? manuallyDisconnectedResponse(environment)
: response
updateEnvironmentFromResponse(environment, result)
callbacks.onResponse(result)
}
},
{ timeoutMs, signal: controller.signal }
)
},
0,
controller.signal
)
} finally {
if (subscriptionId && pendingSubscriptions.get(subscriptionId) === controller) {
pendingSubscriptions.delete(subscriptionId)
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { WebRuntimeSubscriptionCallbacks } from './web-runtime-subscription-contract'
import {
installBrowserGlobals,
writeStoredRuntimeEnvironment
@@ -145,6 +146,74 @@ describe('web preload runtime calls', () => {
await expect(Promise.all(accepted)).resolves.toHaveLength(264)
})
it('revokes unsent work and refuses queued or delivered work after pairing replacement', async () => {
let release!: () => void
const blocked = new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
release = () =>
resolve({ id: 'blocked', ok: true, result: null, _meta: { runtimeId: 'runtime-a' } })
})
let response = (_value: RuntimeRpcResponse<unknown>) => {}
const call = vi.fn(() => blocked)
const subscribe = vi.fn(
async (_method: string, _params: unknown, callbacks: WebRuntimeSubscriptionCallbacks) => {
response = callbacks.onResponse
return { unsubscribe: vi.fn(), sendBinary: vi.fn() }
}
)
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call = call
subscribe = subscribe
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
const { callAbortableRuntimeEnvironment } =
await import('@/runtime/abortable-runtime-environment-call')
const { webRuntimeState } = await import('./preload-api/web-runtime-session')
installWebPreloadApi()
const environment = webRuntimeState.activeEnvironment!
const revision = environment.pairingRevision ?? environment.createdAt
const blockers = Array.from({ length: 8 }, () =>
globals.window.api.runtime.call({ method: 'blocker' })
)
const prepare = (signal: AbortSignal) =>
callAbortableRuntimeEnvironment(
'web-server-a',
'aiVault.prepareSessionResume',
{},
90_000,
signal,
revision
)
const source = new AbortController()
const cancelled = expect(prepare(source.signal)).rejects.toThrow('aborted')
source.abort()
await cancelled
const queued = globals.window.api.runtimeEnvironments.call({
selector: 'web-server-a',
method: 'aiVault.prepareSessionResume',
expectedEnvironmentPairingRevision: revision
})
const refused = expect(queued).rejects.toThrow('paired Orca server changed')
webRuntimeState.activeEnvironment = { ...environment, pairingRevision: revision + 1 }
release()
await Promise.all(blockers)
await refused
expect(call).toHaveBeenCalledTimes(8)
expect(subscribe).not.toHaveBeenCalled()
webRuntimeState.activeEnvironment = environment
const staleResponse = expect(prepare(new AbortController().signal)).rejects.toThrow(
'paired Orca server changed'
)
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1))
webRuntimeState.activeEnvironment = { ...environment, pairingRevision: revision + 1 }
response({ id: 'prepare', ok: true, result: {}, _meta: { runtimeId: 'old' } })
await staleResponse
})
it('does not spend a selected call timeout while queued and forwards it unchanged', async () => {
vi.useFakeTimers()
let release!: () => void
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
import { AI_VAULT_AGENTS } from './ai-vault-types'
export const AiVaultChatForkSourceSchema = z.object({
agent: z.enum(AI_VAULT_AGENTS),
sourceAgent: z.enum(AI_VAULT_AGENTS),
sessionId: z.string().min(1),
filePath: z.string(),
cwd: z.string().min(1).nullable(),
codexHome: z.string().min(1).nullable(),
executionHostPlatform: z.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
])
})
+18
View File
@@ -15,8 +15,26 @@ export type AiVaultPrepareSessionResumeArgs = Pick<
fork?: boolean
}
export const AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY = 'ai-vault.chat-fork-source.v1' as const
export type AiVaultChatForkSource = Pick<
AiVaultSession,
'agent' | 'sessionId' | 'filePath' | 'cwd' | 'codexHome'
> & { executionHostPlatform: NodeJS.Platform; sourceAgent: AiVaultAgent }
export type AiVaultPrepareChatForkArgs = {
sourceChat: { sessionId: string; workspaceId: string }
executionHostId?: string
fork: true
}
export type AiVaultPrepareSessionResumeRequest =
| AiVaultPrepareSessionResumeArgs
| AiVaultPrepareChatForkArgs
export type AiVaultPrepareSessionResumeResult = {
useRealCodexHome: boolean
cliForkSource?: AiVaultChatForkSource
// Why: cross-account hardlinking lists one rollout under several per-account
// homes, so the owning host repins resume to the selected account's home.
// Absent (older hosts included) means resume keeps the session's own home.
+2
View File
@@ -1,3 +1,4 @@
import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation'
import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability'
import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters'
import { JIRA_SAVED_FILTER_RUNTIME_CAPABILITY } from './jira-saved-filter-capability'
@@ -168,6 +169,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
] as const
export const RUNTIME_CAPABILITIES = [
AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY,
jiraDetailCapabilities.JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY,
JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY,
jiraDetailCapabilities.JIRA_CURRENT_SPRINT_RUNTIME_CAPABILITY,
+26 -8
View File
@@ -58,14 +58,32 @@ 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(),
fork: z.boolean().optional()
})
const AiVaultRowResumeParams = 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(),
fork: z.boolean().optional()
})
.strict()
export const AiVaultPrepareSessionResumeParams = z.union([
AiVaultRowResumeParams,
z
.object({
sourceChat: z
.object({
sessionId: z.string().min(1).max(512),
workspaceId: z.string().min(1).max(512)
})
.strict(),
executionHostId: z.string().optional(),
fork: z.literal(true)
})
.strict()
])
export const AiVaultSessionTitlesParams = z.object({
requests: z