mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user