feat(session-history): Resume in New CLI forks a native chat's conversation into the terminal (#26336)

* feat(session-history): resume a native chat's conversation in a new CLI session

Agent Session History offered "Resume in New Chat" to move a terminal session
into native chat, but nothing went the other way: once a native chat owned a
conversation, its row only reopened the chat, and Orca refused any terminal
resume of it so two programs never write one conversation.

Rows a native chat owns (Claude, Codex) now offer "Resume in New CLI". It forks:
the terminal runs `claude --resume <id> --fork-session` or `codex fork <id>`,
so the CLI gets the full history under a new id and the chat is unchanged. The
host's resume guard lets a Claude fork through, and the fork tab records no
provider session, so a restart never re-enters the chat's conversation.
"Resume in New Chat" is renamed "Resume in New Native Chat".

* fix(session-history): size the Resume in New CLI tooltip to its text

The tooltip balanced one wrapped string inside a box sized before balancing,
leaving a gap beside its lines. It is now two block lines with no fixed width.

* fix(session-history): run a CLI fork under the selected Codex account and tighten the fork guard

- Resume in New CLI now asks the host for the same Codex account home plain
  Resume uses, so a fork no longer starts under whichever account's copy of the
  rollout sorted first. The preparation call carries an optional `fork` flag
  that skips only the owned-conversation refusal; plain resume still refuses.
- An older paired host that refuses the fork now shows "Update Orca on the host
  that runs this chat to resume it in a new CLI." instead of a raw error code.
- The host guard admits `--fork-session` only before a `--` terminator and
  never alongside `--session-id`, since either shape lets Claude write the
  owned conversation.
- Tests for the click path, the menu item's eligibility, the fork home
  preparation, and the tightened guard.

* fix(session-history): read a Codex rollout's identity from its first session record

A fork of an older (non-paginated) Codex thread writes its own session record
first and then copies the parent's history, the parent's session record
included. The history scanner kept the last one it saw, so the fork's row took
the parent's id, title, folder and branch: on a native chat's fork it looked
like the chat itself, and Resume on it opened the chat instead of the fork.
Codex itself treats the first session record as the thread's own and ignores
later ones; the scanner now does the same, as Orca's duplicate-rollout check
already did.

* fix(session-history): set up status hooks in the Codex home a CLI fork keeps

When Resume in New CLI can't move a Codex fork to the selected account (that
account has no copy of the conversation yet, or the system default is
selected), the fork runs in the account home the history row came from. A new
Codex tab only gets Orca's status hooks in the selected account's home, and the
fork carries no provider session, so nothing set up the hooks in that row home.
In a home only native chat had used, the fork tab could show no working or
idle status.

The fork's home preparation now readies that home the same way a resumed
session's home is readied (hook install or refresh, plus the daemon-socket
guard). That code is moved into prepareCodexPinnedLaunchHome and shared by both
paths. The fork tab still carries no provider session, so it never reopens the
chat's conversation on restart.

* fix(session-history): ready only a host-verified Codex home for a CLI fork

The fork's hook setup ran in whatever Codex home the request named, so a paired
client could have Orca write hook and config files into any directory. Plain
Resume only readies a home it has verified: one this host lists for Codex
sessions that actually holds the rollout. The fork now uses that same check,
through a shared trustedCodexResumeHomes list, and readies the home only when
the verified home is the one the fork will run in. With no Codex runtime home
service it does nothing, as plain Resume does. A failed check or setup is
logged and never stops the fork from launching.

Tests cover the untrusted home, a missing runtime home service, a rollout on
another host, a WSL home, a fork moved into the real home, and a failed setup.

* fix(session-history): show the host-update message when the desktop wraps the fork refusal

When the local preparation step for Resume in New CLI is refused, Electron
hands the renderer the error as "Error invoking remote method '…': Error:
agent_session_conflict". The fork's failure text matched only the bare code,
so the toast showed that raw line instead of asking the user to update Orca on
the host. It now unwraps the message with the shared IPC error helper before
matching.
This commit is contained in:
Brennan Benson
2026-10-08 03:29:36 -07:00
committed by GitHub
parent e428179270
commit 496ee9bd7e
43 changed files with 1260 additions and 175 deletions
@@ -108,6 +108,78 @@ describe('parseCodexSessionFile', () => {
})
})
// A fork of a legacy-history thread copies the parent's session_meta in after its own.
it("keeps a fork's own session_meta when its parent's follows it", async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-fork-'))
tempRoots.push(root)
const sessionPath = join(root, 'sessions', '2026', '10', '07', 'rollout-fork.jsonl')
await mkdir(dirname(sessionPath), { recursive: true })
await writeFile(
sessionPath,
jsonLines([
{
timestamp: '2026-10-07T10:00:00.000Z',
type: 'session_meta',
payload: {
id: 'fork-session',
forked_from_id: 'parent-session',
cwd: '/repo/fork',
git: { branch: 'fork-branch' }
}
},
{
timestamp: '2026-10-07T09:00:00.000Z',
type: 'session_meta',
payload: {
id: 'parent-session',
title: 'Parent chat',
history_mode: 'paginated',
cwd: '/repo/parent',
git: { branch: 'parent-branch' }
}
},
{
timestamp: '2026-10-07T09:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Copied prompt' }]
}
},
{
timestamp: '2026-10-07T09:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Copied reply' }]
}
}
])
)
const sessionStat = await stat(sessionPath)
const session = await parseCodexSessionFile(
{
path: sessionPath,
mtimeMs: sessionStat.mtimeMs,
modifiedAt: sessionStat.mtime.toISOString()
},
'darwin',
root
)
expect(session).toMatchObject({
sessionId: 'fork-session',
cwd: '/repo/fork',
branch: 'fork-branch',
title: 'Copied prompt',
messageCount: 2
})
})
it('uses user-message events instead of later injected user-role records', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-last-prompt-'))
tempRoots.push(root)
@@ -142,7 +142,9 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
updateTimeline(accumulator, extractString(record.timestamp))
const payload = asRecord(record.payload)
if (record.type === 'session_meta' && payload) {
// Codex keeps the first session_meta canonical; a fork copies its parent's in after it, and
// such a later one is ignored below like any other record type this parser does not read.
if (record.type === 'session_meta' && payload && !state.sawSessionMeta) {
state.nonUserOrigin = readCodexNonUserOrigin(payload)
if (state.nonUserOrigin) {
return
@@ -165,6 +165,50 @@ describe('structured AI Vault ownership', () => {
)
).resolves.toBeUndefined()
})
// A fork writes a new conversation, so "Resume in New CLI" on a chat-owned row must pass.
it.each([
{
provider: 'claude' as const,
command: `claude '--resume' '${PROVIDER_SESSION}' '--fork-session'`
},
{ provider: 'claude' as const, command: `claude --continue --fork-session` },
{ provider: 'codex' as const, command: `CODEX_HOME=/h codex 'fork' '${PROVIDER_SESSION}'` }
])('allows a fork of the owned session: $command', async ({ provider, command }) => {
installOwnership({ provider })
await expect(
assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined)
).resolves.toBeUndefined()
})
it.each([
// After `--` the flag is prompt text, so Claude resumes the owned session as a writer.
`claude --resume ${PROVIDER_SESSION} -- --fork-session`,
// `--session-id` makes the fork write under the id it names.
`claude --resume ${PROVIDER_SESSION} --fork-session --session-id ${PROVIDER_SESSION}`,
`claude --resume ${PROVIDER_SESSION} --fork-session --session-id=${PROVIDER_SESSION}`
])('refuses a fork flag that does not make a fork: %s', async (command) => {
installOwnership({ provider: 'claude' })
await expect(
assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined)
).rejects.toThrow('agent_session_conflict')
})
it('prepares a fork of the owned session but still refuses a resume of it', () => {
installOwnership()
const args = {
agent: 'codex' as const,
sessionId: PROVIDER_SESSION,
filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`,
codexHome: null,
executionHostId: 'local' as const
}
expect(() => assertLegacyAiVaultResumeAllowed({ ...args, fork: true })).not.toThrow()
expect(() => assertLegacyAiVaultResumeAllowed(args)).toThrow('agent_session_conflict')
})
})
function installOwnership(overrides: Partial<StructuredProviderSessionOwnership> = {}): void {
@@ -99,7 +99,8 @@ function ownershipLookup():
}
export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void {
const ownership = findResumeOwnership(args)
// A fork writes a new conversation, and preparing its home never writes this one.
const ownership = args.fork ? null : findResumeOwnership(args)
if (ownership) {
refuseLegacyWriter(ownership)
}
@@ -202,6 +203,10 @@ function parseResumeInvocation(command: string): ResumeInvocation | null {
}
const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude'
const args = normalized.slice(executableIndex + 1)
// `codex fork` already falls through below: it carries no `resume` marker.
if (provider === 'claude' && isClaudeForkInvocation(args)) {
return null
}
// `--continue`/`-c` resume the most recent session and never take an id, so a
// following token is a prompt, not a target — they are always target-less.
const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c']
@@ -230,6 +235,19 @@ function parseResumeInvocation(command: string): ResumeInvocation | null {
}
}
/** A fork reads the conversation and writes a new one, so it is no second writer. Not when
* `--session-id` names the id it writes under, and nothing after `--` is an option. */
function isClaudeForkInvocation(args: readonly string[]): boolean {
const terminatorIndex = args.indexOf('--')
const options = (terminatorIndex === -1 ? args : args.slice(0, terminatorIndex)).map((token) =>
token.toLowerCase()
)
return (
options.includes('--fork-session') &&
!options.some((token) => token === '--session-id' || token.startsWith('--session-id='))
)
}
function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never {
throw new Error(
agentSessionLeaseAdmitsWriter(ownership.lease)
@@ -4,6 +4,15 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume'
import { getOrcaManagedCodexHomePath } from './codex-home-paths'
const ROLLOUT_RELATIVE_PATH = join(
'sessions',
'2026',
'08',
'17',
'rollout-2026-08-17T10-00-00-session.jsonl'
)
// Why: session backfill resolves Orca's managed Codex home from userData, which otherwise resolves to the live one.
let userDataRoot: string
@@ -12,6 +21,7 @@ beforeEach(() => {
vi.stubEnv('ORCA_USER_DATA_PATH', userDataRoot)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
rmSync(userDataRoot, { recursive: true, force: true })
})
@@ -21,20 +31,15 @@ describe('prepareCodexAiVaultSessionResume', () => {
let peerHome: string
let selectedHome: string
let peerRolloutPath: string
let preparePinnedLaunchHome: ReturnType<typeof vi.fn<(home: string) => Promise<void>>>
beforeEach(() => {
preparePinnedLaunchHome = vi.fn(async () => undefined)
root = mkdtempSync(join(tmpdir(), 'orca-codex-ai-vault-resume-'))
peerHome = join(root, 'codex-accounts', 'account-a', 'home')
selectedHome = join(root, 'codex-accounts', 'account-b', 'home')
const relativeRolloutPath = join(
'sessions',
'2026',
'08',
'17',
'rollout-2026-08-17T10-00-00-session.jsonl'
)
peerRolloutPath = join(peerHome, relativeRolloutPath)
const selectedRolloutPath = join(selectedHome, relativeRolloutPath)
peerRolloutPath = join(peerHome, ROLLOUT_RELATIVE_PATH)
const selectedRolloutPath = join(selectedHome, ROLLOUT_RELATIVE_PATH)
mkdirSync(dirname(peerRolloutPath), { recursive: true })
mkdirSync(dirname(selectedRolloutPath), { recursive: true })
writeFileSync(peerRolloutPath, '{"type":"session_meta"}\n', 'utf-8')
@@ -67,8 +72,109 @@ describe('prepareCodexAiVaultSessionResume', () => {
it('preserves deliberate no-selection behavior for a proven-untrusted home', async () => {
await expect(prepare(() => null)).resolves.toEqual({ useRealCodexHome: false })
expect(preparePinnedLaunchHome).not.toHaveBeenCalled()
})
// A fork tab has no provider session, so no pane spawn readies the home it keeps.
it('readies the row home for a fork that keeps it', async () => {
await expect(prepareFork()).resolves.toEqual({ useRealCodexHome: false })
expect(preparePinnedLaunchHome).toHaveBeenCalledExactlyOnceWith(peerHome)
})
it('leaves a repinned fork to the pane spawn, which readies the selected home', async () => {
await expect(prepareFork({ resolveSelectedHome: () => selectedHome })).resolves.toEqual({
useRealCodexHome: false,
substituteCodexHome: selectedHome
})
expect(preparePinnedLaunchHome).not.toHaveBeenCalled()
})
it('readies no home for a fork from a home this host does not list', async () => {
await expect(prepareFork({ discoveryHomes: [selectedHome] })).resolves.toEqual({
useRealCodexHome: false
})
expect(preparePinnedLaunchHome).not.toHaveBeenCalled()
})
it.each([
['no runtime home service', { runtimeHome: null }],
['a rollout on another host', { executionHostId: 'runtime:paired' }],
['a WSL home', { codexHome: String.raw`\\wsl.localhost\Ubuntu\home\u\.codex` }]
] as const)('never looks for a fork home with %s', async (_name, overrides) => {
const discovery = vi.fn(() => [peerHome, selectedHome])
await expect(prepareFork({ ...overrides, discovery })).resolves.toEqual({
useRealCodexHome: false
})
expect(discovery).not.toHaveBeenCalled()
expect(preparePinnedLaunchHome).not.toHaveBeenCalled()
})
it('leaves a fork moved into the real home to the pane spawn', async () => {
const legacyHome = getOrcaManagedCodexHomePath()
const legacyRolloutPath = join(legacyHome, ROLLOUT_RELATIVE_PATH)
mkdirSync(dirname(legacyRolloutPath), { recursive: true })
writeFileSync(legacyRolloutPath, '{"type":"session_meta"}\n', 'utf-8')
await expect(
prepareFork({
codexHome: legacyHome,
filePath: legacyRolloutPath,
isSystemDefaultRealHome: () => true,
discoveryHomes: [legacyHome]
})
).resolves.toEqual({ useRealCodexHome: true })
expect(preparePinnedLaunchHome).not.toHaveBeenCalled()
})
it('still answers a fork when readying its home fails', async () => {
preparePinnedLaunchHome.mockRejectedValue(new Error('hooks file unreadable'))
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(prepareFork()).resolves.toEqual({ useRealCodexHome: false })
expect(preparePinnedLaunchHome).toHaveBeenCalledOnce()
})
function prepareFork(
overrides: {
resolveSelectedHome?: () => string | null
isSystemDefaultRealHome?: () => boolean
discoveryHomes?: readonly string[]
discovery?: () => string[]
runtimeHome?: null
executionHostId?: string
codexHome?: string
filePath?: string
} = {}
) {
const runtimeHome =
overrides.runtimeHome === null
? null
: {
isHostSystemDefaultRealHomeSelected: overrides.isSystemDefaultRealHome ?? (() => false),
resolveSelectedHostAccountCodexHomePathForResume:
overrides.resolveSelectedHome ?? (() => null),
getHostCodexHomePathsForSessionDiscovery:
overrides.discovery ??
(() => [...(overrides.discoveryHomes ?? [peerHome, selectedHome])])
}
return prepareCodexAiVaultSessionResume(
{
agent: 'codex',
filePath: overrides.filePath ?? peerRolloutPath,
codexHome: overrides.codexHome ?? peerHome,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test feeds host ids the RPC may receive; the function only compares them.
executionHostId: (overrides.executionHostId ?? 'local') as 'local',
fork: true
},
{
runtimeHome,
systemCodexHomePath: join(root, 'system-codex-home'),
preparePinnedLaunchHome
}
)
}
function prepare(
resolveSelectedHome: () => string | null,
isSystemDefaultRealHome: () => boolean = () => false
@@ -83,9 +189,11 @@ describe('prepareCodexAiVaultSessionResume', () => {
{
runtimeHome: {
isHostSystemDefaultRealHomeSelected: isSystemDefaultRealHome,
resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome
resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome,
getHostCodexHomePathsForSessionDiscovery: () => [peerHome, selectedHome]
},
systemCodexHomePath: join(root, 'system-codex-home')
systemCodexHomePath: join(root, 'system-codex-home'),
preparePinnedLaunchHome
}
)
}
@@ -2,27 +2,77 @@ import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { parseWslUncPath } from '../../shared/wsl-paths'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import { getSystemCodexHomePath } from './codex-home-paths'
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
import {
resolveTrustedCodexSessionResumeHome,
trustedCodexResumeHomes
} from './codex-session-resume-home'
type CodexAiVaultRuntimeHome = Pick<
CodexRuntimeHomeService,
'isHostSystemDefaultRealHomeSelected' | 'resolveSelectedHostAccountCodexHomePathForResume'
| 'isHostSystemDefaultRealHomeSelected'
| 'resolveSelectedHostAccountCodexHomePathForResume'
| 'getHostCodexHomePathsForSessionDiscovery'
>
type CodexAiVaultResumeOptions = {
runtimeHome: CodexAiVaultRuntimeHome | null
systemCodexHomePath: string | undefined
/** Readies a home a pane will pin as CODEX_HOME instead of the selected account's. */
preparePinnedLaunchHome: (home: string) => Promise<void>
}
/** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */
export function prepareCodexAiVaultSessionResume(
export async function prepareCodexAiVaultSessionResume(
args: AiVaultPrepareSessionResumeArgs,
options: {
runtimeHome: CodexAiVaultRuntimeHome | null
systemCodexHomePath: string | undefined
}
options: CodexAiVaultResumeOptions
): Promise<AiVaultPrepareSessionResumeResult> {
return prepareLegacySharedCodexSessionResume(args, {
const result = await prepareLegacySharedCodexSessionResume(args, {
isHostSystemDefaultRealHomeSelected: () =>
options.runtimeHome?.isHostSystemDefaultRealHomeSelected() === true,
getSelectedHostAccountCodexHomePath: () =>
options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null,
systemCodexHomePath: options.systemCodexHomePath
})
// Why: a fork carries no provider session, so the pane spawn never readies the home a resume
// would; when it keeps the row's own home, that home needs the same hooks here.
if (args.fork && !result.useRealCodexHome && !result.substituteCodexHome) {
try {
await readyForkLaunchHome(args, options)
} catch (error) {
// Why: hook setup is best-effort; the fork still launches without it.
console.warn('[codex-ai-vault-resume] could not ready the fork launch home:', error)
}
}
return result
}
async function readyForkLaunchHome(
args: AiVaultPrepareSessionResumeArgs,
options: CodexAiVaultResumeOptions
): Promise<void> {
if (
!options.runtimeHome ||
!args.codexHome ||
args.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
parseWslUncPath(args.codexHome) !== null
) {
return
}
// Only a home this host lists that holds this rollout: the proof a resume needs before it pins one.
const home = resolveTrustedCodexSessionResumeHome({
transcriptPath: args.filePath,
trustedCodexHomes: trustedCodexResumeHomes(options.runtimeHome, getSystemCodexHomePath())
})
if (
home &&
normalizeRuntimePathForComparison(home) === normalizeRuntimePathForComparison(args.codexHome)
) {
await options.preparePinnedLaunchHome(home)
}
}
@@ -100,6 +100,15 @@ function resolveTrustedCodexSessionResume(args: {
return null
}
/** The homes this host itself lists for Codex sessions: the only ones a pane may pin as CODEX_HOME. */
export function trustedCodexResumeHomes(
runtimeHome: { getHostCodexHomePathsForSessionDiscovery(): string[] },
systemHomePath: string
): string[] {
// Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth.
return [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()]
}
export function resolveTrustedCodexSessionResumeHome(args: {
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
@@ -226,6 +226,30 @@ describe('aiVault.prepareSessionResume', () => {
executionHostId: 'local'
})
})
it('carries a fork request through to the host preparation', async () => {
const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: false })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only these runtime members.
const runtime = {
getRuntimeId: () => 'test-runtime',
ensureStructuredAgentSessionHost: vi.fn(async () => undefined),
prepareAiVaultSessionResume
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
await dispatcher.dispatch(
makeRequest('aiVault.prepareSessionResume', {
agent: 'codex',
filePath: '/managed/sessions/rollout-a.jsonl',
codexHome: '/managed',
fork: true
})
)
expect(prepareAiVaultSessionResume).toHaveBeenCalledWith(
expect.objectContaining({ fork: true, executionHostId: 'local' })
)
})
})
// Session history and terminal resume are not chats: a process whose chats are refused still
+1
View File
@@ -112,6 +112,7 @@ export const AI_VAULT_METHODS = [
const args: AiVaultPrepareSessionResumeArgs = {
agent: params.agent,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.fork ? { fork: true } : {}),
filePath: params.filePath,
codexHome: params.codexHome,
// Why: the RPC executes on the transcript-owning host; never let a
+35 -26
View File
@@ -1,6 +1,9 @@
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
import {
trustedCodexResumeHomes,
type CodexSessionResumePreparation
} from '../codex/codex-session-resume-home'
import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation'
import {
prepareCodexAccountRestartResume,
@@ -27,8 +30,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
return null
}
const systemHomePath = getSystemCodexHomePath()
// Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth.
const trustedHomes = [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()]
const trustedHomes = trustedCodexResumeHomes(runtimeHome, systemHomePath)
// Why: resolved eagerly, once, before any ranking or provenance match. The
// marker read used to be deferred into the ranking thunk so a
// provenance-present resume never paid for it, but that optimisation let an
@@ -87,29 +89,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
: migrated.useRealCodexHome
? systemHomePath
: sessionSource.homePath
const isSystemHome =
normalizeRuntimePathForComparison(resumeHome) ===
normalizeRuntimePathForComparison(systemHomePath)
const isHooksEnabled = (): boolean =>
isAgentStatusHooksEnabledForAgent(store.getSettings(), 'codex')
const hooksEnabled = isHooksEnabled()
try {
if (isSystemHome) {
// Why bounded: the resume waits only briefly for a reconcile; it runs on ~/.codex whatever the selection.
await reconcileCodexHooksForLaunch()
} else if (hooksEnabled) {
await codexHookService.installForLaunchPrep(resumeHome, true, isHooksEnabled)
} else {
await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome)
}
} catch (error) {
// Why: hook repair is best-effort; session provenance must still win over the currently selected home.
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
}
if (!isSystemHome) {
// Why: this pins the resumed pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard.
ensureCodexDaemonSocketGuard(resumeHome)
}
await prepareCodexPinnedLaunchHome(resumeHome, systemHomePath)
return resumeHome
}
})
@@ -122,3 +102,32 @@ export async function prepareCodexSessionResumeForLaunch(args: {
}
: preparation
}
/** Hook repair and the daemon-socket guard for a pane that pins `home` as its CODEX_HOME rather
* than launching in the selected account's home, which the pane spawn prepares itself. */
export async function prepareCodexPinnedLaunchHome(
home: string,
systemHomePath: string = getSystemCodexHomePath()
): Promise<void> {
const isSystemHome =
normalizeRuntimePathForComparison(home) === normalizeRuntimePathForComparison(systemHomePath)
const isHooksEnabled = (): boolean =>
isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex')
try {
if (isSystemHome) {
// Why bounded: the resume waits only briefly for a reconcile; it runs on ~/.codex whatever the selection.
await reconcileCodexHooksForLaunch()
} else if (isHooksEnabled()) {
await codexHookService.installForLaunchPrep(home, true, isHooksEnabled)
} else {
await codexHookService.refreshRuntimeUserHooksForLaunchPrep(home)
}
} catch (error) {
// Why: hook repair is best-effort; it must not stop the pane from launching in this home.
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
}
if (!isSystemHome) {
// Why: this pins the pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard.
ensureCodexDaemonSocketGuard(home)
}
}
@@ -13,6 +13,7 @@ import { browserManager } from '../browser/browser-manager'
import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume'
import { prepareCodexPinnedLaunchHome } from './codex-session-resume-launch'
import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { getDaemonProvider } from '../daemon/daemon-init'
@@ -139,7 +140,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
prepareAiVaultSessionResume: (args) =>
prepareCodexAiVaultSessionResume(args, {
runtimeHome: state.codexRuntimeHome,
systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings())
systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()),
preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home)
}),
prepareCodexStructuredLaunch: ({ launchEnv }) =>
prepareCodexRuntimeHomeForLaunch(undefined, launchEnv),
@@ -13,7 +13,10 @@ import {
handlePtyExit
} from './main-process-pty-startup'
import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation'
import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch'
import {
prepareCodexPinnedLaunchHome,
prepareCodexSessionResumeForLaunch
} from './codex-session-resume-launch'
import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags'
import { RELAY_HOST_CLOSE_REASON } from '../../shared/relay-host-close-reason'
@@ -82,7 +85,8 @@ export function attachMainWindowCoreServices(
prepareAiVaultSessionResume: (args) =>
prepareCodexAiVaultSessionResume(args, {
runtimeHome: codexRuntimeHome,
systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings())
systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()),
preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home)
}),
onBeforeRelaunch: async () => {
state.isQuitting = true
@@ -376,6 +376,7 @@ export default function AiVaultPanel(): React.JSX.Element {
getSessionResumeInChat={getSessionResumeInChat}
onContinueInNewSession={launchActions.handleContinueInNewSession}
onResumeInNewChat={launchActions.handleResumeInNewChat}
onResumeInNewCli={launchActions.handleResumeInNewCli}
onCopyResume={(session, worktreeId) =>
void launchActions.copyResumeCommand(session, worktreeId)
}
@@ -7,12 +7,14 @@ import {
MessagesSquare,
PanelTopOpen,
Play,
SquareTerminal,
Trash2
} from 'lucide-react'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy'
export function SessionActionMenuItems({
menuKind = 'dropdown',
@@ -22,6 +24,7 @@ export function SessionActionMenuItems({
onResume,
onContinueInNewSession,
onResumeInNewChat,
onResumeInNewCli,
onJumpToOriginalPane,
showJumpToWorktree,
onJumpToWorktree,
@@ -41,6 +44,8 @@ export function SessionActionMenuItems({
onResume: () => void
onContinueInNewSession?: () => void
onResumeInNewChat?: () => void
// Offered only on rows native chat owns, where it forks: see ai-vault-session-cli-fork.
onResumeInNewCli?: () => void
onJumpToOriginalPane?: () => void
showJumpToWorktree: boolean
onJumpToWorktree?: () => void
@@ -104,11 +109,27 @@ export function SessionActionMenuItems({
<Item onSelect={onResumeInNewChat}>
<MessagesSquare className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat',
'Resume in New Chat'
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat',
'Resume in New Native Chat'
)}
</Item>
) : null}
{onResumeInNewCli ? (
<Tooltip>
<TooltipTrigger asChild>
<Item onSelect={onResumeInNewCli}>
<SquareTerminal className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli',
'Resume in New CLI'
)}
</Item>
</TooltipTrigger>
<TooltipContent side={menuKind === 'context' ? 'right' : 'left'} sideOffset={8}>
<ResumeInNewCliTooltipText />
</TooltipContent>
</Tooltip>
) : null}
{onContinueInNewSession ? (
<Item onSelect={onContinueInNewSession}>
<MessageSquarePlus className="size-3.5" />
@@ -7,7 +7,8 @@ import {
MessageSquare,
MessageSquarePlus,
MessagesSquare,
Play
Play,
SquareTerminal
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -19,6 +20,7 @@ import {
} from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { FirstPromptCard } from './ai-vault-first-prompt-card'
import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy'
import { sessionDetailConversationTurns, sessionPromptPreview } from './ai-vault-session-display'
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice'
@@ -41,6 +43,7 @@ export function SessionInlineDetails({
subagentResume,
onContinueInNewSession,
onResumeInNewChat,
onResumeInNewCli,
onOpenLog
}: {
id: string
@@ -56,6 +59,7 @@ export function SessionInlineDetails({
subagentResume?: AiVaultSubagentResumeActions
onContinueInNewSession?: () => void
onResumeInNewChat?: () => void
onResumeInNewCli?: () => void
onOpenLog?: () => void
}): React.JSX.Element {
// A zero-turn transcript would resume into an empty conversation, so the plain
@@ -86,6 +90,7 @@ export function SessionInlineDetails({
showResumeInNewTab ||
onContinueInNewSession ||
onResumeInNewChat ||
onResumeInNewCli ||
onOpenLog ? (
<div className="flex flex-wrap items-center gap-1.5 border-b border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
{showResumeInWorktree ? (
@@ -146,11 +151,37 @@ export function SessionInlineDetails({
>
<MessagesSquare className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat',
'Resume in New Chat'
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat',
'Resume in New Native Chat'
)}
</Button>
) : null}
{onResumeInNewCli ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResumeInNewCli()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<SquareTerminal className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli',
'Resume in New CLI'
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<ResumeInNewCliTooltipText />
</TooltipContent>
</Tooltip>
) : null}
{onContinueInNewSession ? (
<Button
type="button"
@@ -46,6 +46,7 @@ export function VaultSessionRow({
onResume,
onContinueInNewSession,
onResumeInNewChat,
onResumeInNewCli,
resumeLabel: defaultResumeLabel,
resumeActions,
onResumeInWorktree,
@@ -76,6 +77,7 @@ export function VaultSessionRow({
onResume: () => void
onContinueInNewSession?: () => void
onResumeInNewChat?: () => void
onResumeInNewCli?: () => void
resumeLabel: string
resumeActions: AiVaultSessionResumeActions
onResumeInWorktree: () => void
@@ -199,6 +201,7 @@ export function VaultSessionRow({
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onResumeInNewCli={onResumeInNewCli}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -246,6 +249,7 @@ export function VaultSessionRow({
subagentResume={subagentResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onResumeInNewCli={onResumeInNewCli}
onOpenLog={onOpenLog}
/>
) : null}
@@ -263,6 +267,7 @@ export function VaultSessionRow({
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onResumeInNewCli={onResumeInNewCli}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -50,6 +50,7 @@ export function AiVaultSessionVirtualList({
onResume,
onContinueInNewSession,
onResumeInNewChat,
onResumeInNewCli,
onCopyResume,
onCopyId,
onCopyPath,
@@ -81,6 +82,7 @@ export function AiVaultSessionVirtualList({
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewCli: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
@@ -227,6 +229,7 @@ export function AiVaultSessionVirtualList({
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onResumeInNewCli={onResumeInNewCli}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -39,6 +39,7 @@ function renderSession(session: AiVaultSession, blocked = false, searchHit?: AiV
const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand }))
const onCopyResume = vi.fn()
const onResume = vi.fn()
const onResumeInNewCli = vi.fn()
render(
<TooltipProvider>
<AiVaultVirtualRow
@@ -73,6 +74,7 @@ function renderSession(session: AiVaultSession, blocked = false, searchHit?: AiV
onResume={onResume}
onContinueInNewSession={vi.fn()}
onResumeInNewChat={vi.fn()}
onResumeInNewCli={onResumeInNewCli}
onCopyResume={onCopyResume}
onCopyId={vi.fn()}
onCopyPath={vi.fn()}
@@ -83,7 +85,7 @@ function renderSession(session: AiVaultSession, blocked = false, searchHit?: AiV
/>
</TooltipProvider>
)
return { buildResumeStartup, onCopyResume, onResume }
return { buildResumeStartup, onCopyResume, onResume, onResumeInNewCli }
}
describe('AiVaultVirtualRow resume command actions', () => {
@@ -169,3 +171,40 @@ describe('AiVaultVirtualRow resume command actions', () => {
expect(onResume).toHaveBeenCalledExactlyOnceWith(session, 'worktree-1')
})
})
describe('AiVaultVirtualRow Resume in New CLI', () => {
const owner = { sessionId: 'chat-1', workspaceId: 'worktree-1' }
it.each(['claude', 'codex'] as const)(
'offers the fork on a chat-owned %s row from both menus',
async (agent) => {
const session: AiVaultSession = { ...cliSession, agent, structuredSession: owner }
const { onResumeInNewCli, onResume } = renderSession(session)
const user = userEvent.setup()
await user.click(screen.getByTestId('ai-vault-session-more-actions'))
await user.click(await screen.findByRole('menuitem', { name: 'Resume in New CLI' }))
expect(onResumeInNewCli).toHaveBeenCalledExactlyOnceWith(session, 'worktree-1')
fireEvent.contextMenu(screen.getByText('CLI session'))
await user.click(await screen.findByRole('menuitem', { name: 'Resume in New CLI' }))
expect(onResumeInNewCli).toHaveBeenCalledTimes(2)
expect(onResume).not.toHaveBeenCalled()
}
)
it.each([
{ name: 'a terminal row', session: cliSession },
{
name: 'a chat-owned row of an agent that cannot fork',
session: { ...cliSession, agent: 'gemini' as const, structuredSession: owner }
}
])('does not offer the fork on $name', async ({ session }) => {
renderSession(session)
const user = userEvent.setup()
await user.click(screen.getByTestId('ai-vault-session-more-actions'))
await screen.findByRole('menu')
expect(screen.queryByRole('menuitem', { name: 'Resume in New CLI' })).toBeNull()
})
})
@@ -23,6 +23,7 @@ import {
canUseLocalAiVaultSessionPathActions
} from './ai-vault-session-path-actions'
import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation'
import { aiVaultSessionCliForkWorktreeId } from './ai-vault-session-cli-fork'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
import { canResumeAiVaultSearchHit, hasAiVaultSearchHitPath } from './ai-vault-search-session'
@@ -55,6 +56,7 @@ export function AiVaultVirtualRow({
onResume,
onContinueInNewSession,
onResumeInNewChat,
onResumeInNewCli,
onCopyResume,
onCopyId,
onCopyPath,
@@ -87,6 +89,7 @@ export function AiVaultVirtualRow({
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewCli: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
@@ -126,6 +129,13 @@ export function AiVaultVirtualRow({
? aiVaultSessionRowResumeGating(row.session, resumeState)
: { resumeDisabled: true, canCopyResumeCommand: false }
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
const cliForkWorktreeId =
row.type === 'session'
? aiVaultSessionCliForkWorktreeId(row.session, {
worktreeId: resumeState?.worktreeId,
disabled: resumeGating.resumeDisabled
})
: null
const canOpenLocalSessionPaths =
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
// Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode)
@@ -210,6 +220,11 @@ export function AiVaultVirtualRow({
? () => onResumeInNewChat(row.session, resumeInChat.workspaceId)
: undefined
}
onResumeInNewCli={
searchResumeAllowed && cliForkWorktreeId
? () => onResumeInNewCli(row.session, cliForkWorktreeId)
: undefined
}
onResumeInWorktree={() => {
if (searchResumeAllowed && resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
@@ -55,6 +55,7 @@ export function SessionRowTrailingActions({
onResume,
onContinueInNewSession,
onResumeInNewChat,
onResumeInNewCli,
onCopyResume,
onCopyId,
onCopyPath,
@@ -80,6 +81,7 @@ export function SessionRowTrailingActions({
onContinueInNewSession?: () => void
/** Passed through to the overflow menu only; the resting row keeps its two-icon budget. */
onResumeInNewChat?: () => void
onResumeInNewCli?: () => void
onCopyResume?: () => void
onCopyId: () => void
onCopyPath?: () => void
@@ -265,6 +267,7 @@ export function SessionRowTrailingActions({
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onResumeInNewCli={onResumeInNewCli}
onJumpToOriginalPane={onJumpToOriginalPane}
showJumpToWorktree={showJumpToWorktree}
onJumpToWorktree={onJumpToWorktree}
@@ -0,0 +1,24 @@
import type React from 'react'
import { translate } from '@/i18n/i18n'
/** Shared by the row menus and the details button, which offer the same fork. Two block lines, not
* one wrapped string: the tooltip balances wrapped text inside a box sized before balancing, which
* leaves a gap beside the shorter lines. */
export function ResumeInNewCliTooltipText(): React.JSX.Element {
return (
<>
<span className="block">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCliTooltipFork',
'Forks this conversation into a new CLI session.'
)}
</span>
<span className="block">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCliTooltipChat',
'The native chat stays as it is.'
)}
</span>
</>
)
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
aiVaultSessionCliForkWorktreeId,
describeAiVaultCliForkFailure
} from './ai-vault-session-cli-fork'
const OWNED = { sessionId: 'chat-1', workspaceId: 'repo-1::worktree-1' }
const RESUMABLE = { worktreeId: 'repo-1::worktree-2', disabled: false }
describe('Resume in New CLI eligibility', () => {
it('offers the fork for Claude and Codex rows native chat owns', () => {
expect(
aiVaultSessionCliForkWorktreeId({ agent: 'claude', structuredSession: OWNED }, RESUMABLE)
).toBe('repo-1::worktree-2')
expect(
aiVaultSessionCliForkWorktreeId({ agent: 'codex', structuredSession: OWNED }, RESUMABLE)
).toBe('repo-1::worktree-2')
})
it('leaves rows no chat owns to plain Resume', () => {
expect(aiVaultSessionCliForkWorktreeId({ agent: 'claude' }, RESUMABLE)).toBeNull()
})
it('withholds the fork when resume is blocked or has no target', () => {
const session = { agent: 'claude' as const, structuredSession: OWNED }
expect(aiVaultSessionCliForkWorktreeId(session, { worktreeId: 'w', disabled: true })).toBeNull()
expect(
aiVaultSessionCliForkWorktreeId(session, { worktreeId: null, disabled: false })
).toBeNull()
})
})
describe('Resume in New CLI failure text', () => {
const UPDATE = 'Update Orca on the host that runs this chat to resume it in a new CLI.'
it.each([
'agent_session_conflict',
'agent_session_ownership_unknown',
// Electron wraps an error thrown by a main-process handler.
"Error invoking remote method 'aiVault:prepareSessionResume': Error: agent_session_conflict"
])('asks for a host update when an older host refuses the fork: %s', (message) => {
expect(describeAiVaultCliForkFailure(message)).toBe(UPDATE)
})
it('passes any other failure through unchanged', () => {
const message = 'The session host is unavailable. Reconnect it and retry resume.'
expect(describeAiVaultCliForkFailure(message)).toBe(message)
})
})
@@ -0,0 +1,31 @@
import { translate } from '@/i18n/i18n'
import { compactIpcErrorMessage } from '@/lib/ipc-error'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
/**
* Where "Resume in New CLI" opens its copy of a conversation native chat owns, or null when the row
* does not offer it. A row no chat owns already resumes in the CLI through plain Resume, and only
* Claude and Codex can fork.
*/
export function aiVaultSessionCliForkWorktreeId(
session: Pick<AiVaultSession, 'agent' | 'structuredSession'>,
resume: { worktreeId: string | null | undefined; disabled: boolean }
): string | null {
if (!session.structuredSession || resume.disabled || !resume.worktreeId) {
return null
}
return session.agent === 'claude' || session.agent === 'codex' ? resume.worktreeId : null
}
/** The toast for a failed "Resume in New CLI". Only a host whose guard predates the fork refuses
* the fork as a second writer, so that refusal asks for the update instead of naming a code. */
export function describeAiVaultCliForkFailure(message: string): string {
// The desktop preparation's refusal arrives inside Electron's IPC wrapper.
const code = compactIpcErrorMessage(message)
return code === 'agent_session_conflict' || code === 'agent_session_ownership_unknown'
? translate(
'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliHostTooOld',
'Update Orca on the host that runs this chat to resume it in a new CLI.'
)
: message
}
@@ -0,0 +1,156 @@
// @vitest-environment happy-dom
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-session'
const mocks = vi.hoisted(() => ({
buildAiVaultForkStartupForWorktree: vi.fn(),
buildAiVaultResumeStartupForWorktree: vi.fn(),
prepareAiVaultSessionForFork: vi.fn(),
prepareAiVaultSessionForResume: vi.fn(),
launchAiVaultSessionInNewTab: vi.fn(),
activateAiVaultResumeWorkspace: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn()
}))
vi.mock('@/lib/ai-vault-session-fork-startup', () => ({
buildAiVaultForkStartupForWorktree: mocks.buildAiVaultForkStartupForWorktree
}))
vi.mock('@/lib/ai-vault-resume-command', () => ({
buildAiVaultResumeStartupForWorktree: mocks.buildAiVaultResumeStartupForWorktree,
buildAiVaultResumeCopyCommandForWorktree: vi.fn()
}))
vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({
prepareAiVaultSessionForFork: mocks.prepareAiVaultSessionForFork,
prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume,
dropDeletedSshResumeCwd: async (session: unknown) => session
}))
vi.mock('@/lib/launch-ai-vault-session', () => ({
launchAiVaultSessionInNewTab: mocks.launchAiVaultSessionInNewTab
}))
vi.mock('./ai-vault-session-launch-target', () => ({
resolveAiVaultSessionLaunchTarget: (args: { targetWorktreeId?: string }) => ({
status: 'ready',
worktreeId: args.targetWorktreeId
}),
resolveAiVaultTargetWorkspacePath: vi.fn(),
aiVaultResumeUnsupportedMessage: vi.fn()
}))
vi.mock('./ai-vault-session-resume-in-chat-launch', () => ({
activateAiVaultResumeWorkspace: mocks.activateAiVaultResumeWorkspace,
resumeAiVaultSessionInNewChat: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } }))
vi.mock('@/store', () => ({
useAppStore: { getState: () => ({ activeWorktreeId: 'worktree-1', settings: {} }) }
}))
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
const owned: AiVaultSession = {
id: 'local:claude:provider-1:/p.jsonl',
executionHostId: 'local',
agent: 'claude',
sessionId: 'provider-1',
title: 'Claude Chat',
cwd: '/repo',
branch: null,
model: null,
filePath: '/p.jsonl',
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-10-07T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: 'claude --resume provider-1',
subagent: null,
structuredSession: { sessionId: 'chat-1', workspaceId: 'worktree-1' }
}
const FORK_STARTUP = { command: "claude --resume 'provider-1' --fork-session", cwd: '/repo' }
function renderActions() {
return renderHook(() =>
useAiVaultSessionLaunchActions({
activeWorktree: null,
activeWorktreeId: 'worktree-1',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked launch target never reads it.
targetState: {} as never
})
).result.current
}
describe('Resume in New CLI launch', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.prepareAiVaultSessionForFork.mockImplementation(async (session) => session)
mocks.buildAiVaultForkStartupForWorktree.mockReturnValue(FORK_STARTUP)
mocks.launchAiVaultSessionInNewTab.mockReturnValue({ tabId: 'tab-1' })
})
it('opens the fork plan in the chosen workspace, never the plain resume plan', async () => {
renderActions().handleResumeInNewCli(owned, 'worktree-2')
await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1))
expect(mocks.prepareAiVaultSessionForFork).toHaveBeenCalledWith(owned)
expect(mocks.buildAiVaultForkStartupForWorktree).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'worktree-2', session: owned })
)
expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({
agent: 'claude',
worktreeId: 'worktree-2',
...FORK_STARTUP
})
expect(mocks.activateAiVaultResumeWorkspace).toHaveBeenCalledWith('worktree-2')
expect(mocks.prepareAiVaultSessionForResume).not.toHaveBeenCalled()
expect(mocks.buildAiVaultResumeStartupForWorktree).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('shows one toast and opens nothing when the agent cannot fork', async () => {
mocks.buildAiVaultForkStartupForWorktree.mockReturnValue(null)
renderActions().handleResumeInNewCli(owned, 'worktree-2')
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(mocks.toastError).toHaveBeenCalledWith('This session cannot be opened in the CLI.')
expect(mocks.launchAiVaultSessionInNewTab).not.toHaveBeenCalled()
})
// A host whose guard predates the fork refuses it as a second writer.
it('asks for a host update, once, when an older paired host refuses the fork', async () => {
mocks.launchAiVaultSessionInNewTab.mockReturnValue({
tabId: null,
runtimeLaunch: Promise.resolve<WebRuntimeTerminalCreateOutcome>({
status: 'failed',
message: 'agent_session_conflict'
})
})
renderActions().handleResumeInNewCli(owned, 'worktree-2')
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(mocks.toastError).toHaveBeenCalledWith(
'Update Orca on the host that runs this chat to resume it in a new CLI.'
)
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
it('asks for a host update when an older paired host refuses the fork preparation', async () => {
mocks.prepareAiVaultSessionForFork.mockRejectedValue(new Error('agent_session_conflict'))
renderActions().handleResumeInNewCli(owned, 'worktree-2')
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(mocks.toastError).toHaveBeenCalledWith(
'Update Orca on the host that runs this chat to resume it in a new CLI.'
)
expect(mocks.launchAiVaultSessionInNewTab).not.toHaveBeenCalled()
})
})
@@ -3,18 +3,22 @@ import { toast } from 'sonner'
import {
buildAiVaultResumeCopyCommandForWorktree,
buildAiVaultResumeStartupForWorktree,
type AiVaultResumeCommandSession
type AiVaultResumeCommandSession,
type AiVaultResumeStartup
} from '@/lib/ai-vault-resume-command'
import { buildAiVaultForkStartupForWorktree } from '@/lib/ai-vault-session-fork-startup'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import {
dropDeletedSshResumeCwd,
prepareAiVaultSessionForFork,
prepareAiVaultSessionForResume
} 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'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
@@ -88,12 +92,13 @@ export function useAiVaultSessionLaunchActions({
[buildResumeCommand]
)
const handleResume = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
if (session.structuredSession) {
void activateAiVaultStructuredSession(session)
return
}
const launchInTerminal = useCallback(
(
session: AiVaultSession,
targetWorktreeId: string | undefined,
prepareStartup: (worktreeId: string) => Promise<AiVaultResumeStartup>,
describeFailure: (message: string) => string = (message) => message
): void => {
const targetId = resolveAiVaultSessionLaunchTargetOrNotify({
sessionFilePath: session.filePath,
sessionExecutionHostId: session.executionHostId,
@@ -104,7 +109,6 @@ export function useAiVaultSessionLaunchActions({
if (!targetId) {
return
}
const showQueuedToast = (): void => {
toast.success(
translate(
@@ -114,19 +118,18 @@ export function useAiVaultSessionLaunchActions({
)
)
}
void prepareAiVaultSessionForResume(session)
.then(dropDeletedSshResumeCwd)
.then((preparedSession) => {
void prepareStartup(targetId.worktreeId)
.then((startup) => {
const launchResult = launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: targetId.worktreeId,
...buildResumeStartup(preparedSession, targetId.worktreeId)
...startup
})
if (launchResult.tabId === null) {
void launchResult.runtimeLaunch.then((outcome) => {
if (outcome.status === 'failed') {
toast.error(
outcome.message ||
describeFailure(outcome.message) ||
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
@@ -147,9 +150,49 @@ export function useAiVaultSessionLaunchActions({
}
showQueuedToast()
})
.catch(notifyAiVaultSessionPreparationFailure)
.catch((error: unknown) => notifyAiVaultSessionPreparationFailure(error, describeFailure))
},
[activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState]
[activeWorktree?.id, activeWorktreeId, targetState]
)
const handleResume = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
if (session.structuredSession) {
void activateAiVaultStructuredSession(session)
return
}
launchInTerminal(session, targetWorktreeId, (worktreeId) =>
prepareAiVaultSessionForResume(session)
.then(dropDeletedSshResumeCwd)
.then((preparedSession) => buildResumeStartup(preparedSession, worktreeId))
)
},
[buildResumeStartup, launchInTerminal]
)
// Native chat keeps the conversation it owns; the terminal gets a copy (see the fork builder).
const handleResumeInNewCli = useCallback(
(session: AiVaultSession, targetWorktreeId: string): void => {
const prepareStartup = async (worktreeId: string): Promise<AiVaultResumeStartup> => {
const startup = buildAiVaultForkStartupForWorktree({
state: useAppStore.getState(),
worktreeId,
session: await prepareAiVaultSessionForFork(session).then(dropDeletedSshResumeCwd),
commandOverride: agentCmdOverrides?.[session.agent]
})
if (!startup) {
throw new Error(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliUnavailable',
'This session cannot be opened in the CLI.'
)
)
}
return startup
}
launchInTerminal(session, targetWorktreeId, prepareStartup, describeAiVaultCliForkFailure)
},
[agentCmdOverrides, launchInTerminal]
)
const handleResumeInNewChat = useCallback(
@@ -224,6 +267,7 @@ export function useAiVaultSessionLaunchActions({
buildResumeStartup,
copyResumeCommand,
handleResume,
handleResumeInNewCli,
handleResumeInNewChat,
handleContinueInNewSession,
continuationRequest,
@@ -231,10 +275,13 @@ export function useAiVaultSessionLaunchActions({
}
}
function notifyAiVaultSessionPreparationFailure(error: unknown): void {
function notifyAiVaultSessionPreparationFailure(
error: unknown,
describeFailure: (message: string) => string = (message) => message
): void {
toast.error(
error instanceof Error
? error.message
? describeFailure(error.message)
: translate(
'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed',
'Could not prepare this session for resume.'
+7 -2
View File
@@ -13887,7 +13887,9 @@
"sessionDeleteFailed": "Couldn't delete the session",
"resumeInChatConflict": "Another chat is already holding this conversation.",
"resumeInChatTranscriptMissing": "This conversation's history could not be loaded, so it cannot be resumed in chat.",
"resumeInChatFailed": "Could not resume this session in a new chat."
"resumeInChatFailed": "Could not resume this session in a new chat.",
"resumeInNewCliUnavailable": "This session cannot be opened in the CLI.",
"resumeInNewCliHostTooOld": "Update Orca on the host that runs this chat to resume it in a new CLI."
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -14023,7 +14025,10 @@
"deleteReasonNonLocalHost": "Only sessions on this device can be deleted.",
"deleteReasonSyntheticPath": "This session can't be deleted from Orca.",
"deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.",
"resumeInNewChat": "Resume in New Chat"
"resumeInNewNativeChat": "Resume in New Native Chat",
"resumeInNewCli": "Resume in New CLI",
"resumeInNewCliTooltipFork": "Forks this conversation into a new CLI session.",
"resumeInNewCliTooltipChat": "The native chat stays as it is."
},
"AiVaultSessionDeleteDialog": {
"title": "Delete this session?",
+7 -2
View File
@@ -13653,7 +13653,9 @@
"sessionDeleteFailed": "No se pudo eliminar la sesión",
"resumeInChatConflict": "Otro chat ya está manteniendo esta conversación.",
"resumeInChatTranscriptMissing": "El historial de esta conversación no se pudo cargar, por lo que no se puede reanudar en el chat.",
"resumeInChatFailed": "No se pudo reanudar esta sesión en un chat nuevo."
"resumeInChatFailed": "No se pudo reanudar esta sesión en un chat nuevo.",
"resumeInNewCliUnavailable": "Esta sesión no se puede abrir en la CLI.",
"resumeInNewCliHostTooOld": "Actualiza Orca en el host que ejecuta este chat para reanudarlo en una nueva CLI."
},
"AiVaultPanelControls": {
"scanningSessions": "Buscando sesiones",
@@ -13789,7 +13791,10 @@
"deleteReasonNonLocalHost": "Solo se pueden eliminar las sesiones de este dispositivo.",
"deleteReasonSyntheticPath": "Esta sesión no se puede eliminar desde Orca.",
"deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca.",
"resumeInNewChat": "Reanudar en nuevo chat"
"resumeInNewNativeChat": "Reanudar en un nuevo chat nativo",
"resumeInNewCli": "Reanudar en una nueva CLI",
"resumeInNewCliTooltipFork": "Hace un fork de esta conversación en una nueva sesión de CLI.",
"resumeInNewCliTooltipChat": "El chat nativo queda como está."
},
"AiVaultSessionDeleteDialog": {
"title": "¿Eliminar esta sesión?",
+7 -2
View File
@@ -13758,7 +13758,9 @@
"sessionDeleteFailed": "Impossible de supprimer la session",
"resumeInChatConflict": "Un autre chat tient déjà cette conversation.",
"resumeInChatTranscriptMissing": "L'historique de cette conversation n'a pas pu être chargé, elle ne peut donc pas être reprise dans le chat.",
"resumeInChatFailed": "Impossible de reprendre cette session dans un nouveau chat."
"resumeInChatFailed": "Impossible de reprendre cette session dans un nouveau chat.",
"resumeInNewCliUnavailable": "Impossible d'ouvrir cette session dans la CLI.",
"resumeInNewCliHostTooOld": "Mettez à jour Orca sur l'hôte qui exécute ce chat pour le reprendre dans une nouvelle CLI."
},
"AiVaultPanelControls": {
"scanningSessions": "Analyse des sessions",
@@ -13894,7 +13896,10 @@
"deleteReasonNonLocalHost": "Seules les sessions de cet appareil peuvent être supprimées.",
"deleteReasonSyntheticPath": "Cette session ne peut pas être supprimée depuis Orca.",
"deleteReasonUnsupportedAgent": "{{value0}} sessions ne peuvent pas être supprimées depuis Orca.",
"resumeInNewChat": "Reprendre dans une nouvelle discussion"
"resumeInNewNativeChat": "Reprendre dans un nouveau chat natif",
"resumeInNewCli": "Reprendre dans une nouvelle CLI",
"resumeInNewCliTooltipFork": "Forke cette conversation dans une nouvelle session CLI.",
"resumeInNewCliTooltipChat": "Le chat natif reste tel quel."
},
"AiVaultSessionDeleteDialog": {
"title": "Supprimer cette session ?",
+7 -2
View File
@@ -13653,7 +13653,9 @@
"sessionDeleteFailed": "セッションを削除できませんでした",
"resumeInChatConflict": "別のチャットですでにこの会話が行われています。",
"resumeInChatTranscriptMissing": "この会話の履歴を読み込めなかったため、チャットで再開できません。",
"resumeInChatFailed": "新規チャットでこのセッションを再開できませんでした。"
"resumeInChatFailed": "新規チャットでこのセッションを再開できませんでした。",
"resumeInNewCliUnavailable": "このセッションは CLI で開けません。",
"resumeInNewCliHostTooOld": "このチャットを新しい CLI で再開するには、チャットを実行しているホストの Orca を更新してください。"
},
"AiVaultPanelControls": {
"scanningSessions": "セッションをスキャンしています",
@@ -13789,7 +13791,10 @@
"deleteReasonNonLocalHost": "このデバイス上のセッションのみ削除できます。",
"deleteReasonSyntheticPath": "このセッションは Orca では削除できません。",
"deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。",
"resumeInNewChat": "新規チャットで再開"
"resumeInNewNativeChat": "新規ネイティブチャットで再開",
"resumeInNewCli": "新規 CLI で再開",
"resumeInNewCliTooltipFork": "この会話を新しい CLI セッションにフォークします。",
"resumeInNewCliTooltipChat": "ネイティブチャットはそのまま残ります。"
},
"AiVaultSessionDeleteDialog": {
"title": "このセッションを削除しますか?",
+7 -2
View File
@@ -13653,7 +13653,9 @@
"sessionDeleteFailed": "세션을 삭제하지 못했습니다",
"resumeInChatConflict": "다른 채팅에서 이미 이 대화를 진행 중입니다.",
"resumeInChatTranscriptMissing": "이 대화 기록을 로드할 수 없어 채팅에서 재개할 수 없습니다.",
"resumeInChatFailed": "새 채팅에서 이 세션을 재개할 수 없습니다."
"resumeInChatFailed": "새 채팅에서 이 세션을 재개할 수 없습니다.",
"resumeInNewCliUnavailable": "이 세션은 CLI에서 열 수 없습니다.",
"resumeInNewCliHostTooOld": "이 채팅을 새 CLI에서 재개하려면 채팅을 실행하는 호스트의 Orca를 업데이트하세요."
},
"AiVaultPanelControls": {
"scanningSessions": "세션 스캔 중",
@@ -13789,7 +13791,10 @@
"deleteReasonNonLocalHost": "이 기기에 있는 세션만 삭제할 수 있습니다.",
"deleteReasonSyntheticPath": "이 세션은 Orca에서 삭제할 수 없습니다.",
"deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다.",
"resumeInNewChat": "새 채팅에서 재개"
"resumeInNewNativeChat": "새 기본 채팅에서 재개",
"resumeInNewCli": "새 CLI에서 재개",
"resumeInNewCliTooltipFork": "이 대화를 새 CLI 세션으로 포크합니다.",
"resumeInNewCliTooltipChat": "기본 채팅은 그대로 유지됩니다."
},
"AiVaultSessionDeleteDialog": {
"title": "이 세션을 삭제할까요?",
+7 -2
View File
@@ -13653,7 +13653,9 @@
"sessionDeleteFailed": "无法删除会话",
"resumeInChatConflict": "另一个聊天室已经在进行此对话。",
"resumeInChatTranscriptMissing": "无法加载此对话的历史记录,因此无法在聊天中恢复。",
"resumeInChatFailed": "无法在新聊天中恢复此会话。"
"resumeInChatFailed": "无法在新聊天中恢复此会话。",
"resumeInNewCliUnavailable": "无法在 CLI 中打开此会话。",
"resumeInNewCliHostTooOld": "请更新运行此聊天的主机上的 Orca,才能在新的 CLI 中恢复它。"
},
"AiVaultPanelControls": {
"scanningSessions": "正在扫描会话",
@@ -13789,7 +13791,10 @@
"deleteReasonNonLocalHost": "只能删除此设备上的会话。",
"deleteReasonSyntheticPath": "此会话无法在 Orca 中删除。",
"deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。",
"resumeInNewChat": "在新聊天中继续"
"resumeInNewNativeChat": "在新的本机聊天中恢复",
"resumeInNewCli": "在新的 CLI 中恢复",
"resumeInNewCliTooltipFork": "将此对话派生到新的 CLI 会话中。",
"resumeInNewCliTooltipChat": "本机聊天保持不变。"
},
"AiVaultSessionDeleteDialog": {
"title": "要删除此会话吗?",
+32 -83
View File
@@ -19,19 +19,13 @@ import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import { parseWslUncPath } from '../../../shared/wsl-paths'
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
import type { AppState } from '@/store/types'
import type { AiVaultSessionDragPayload } from '@/lib/ai-vault-session-drag'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host'
import {
getAiVaultResumeWorkspacePath,
resolveAiVaultResumeStartupShell
} from '@/lib/ai-vault-resume-shell'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { resolveAiVaultResumeStartupShell } from '@/lib/ai-vault-resume-shell'
import { getAiVaultResumeCodexHome, getAiVaultResumePlatform } from '@/lib/ai-vault-resume-platform'
export type AiVaultResumeCommandSession = Pick<
AiVaultSession,
@@ -50,7 +44,7 @@ export type AiVaultResumeStartup = {
providerSession?: AgentProviderSessionMetadata
}
type AiVaultResumeWorktreeArgs = {
export type AiVaultResumeWorktreeArgs = {
state: Pick<
AppState,
| 'activeRepoId'
@@ -147,24 +141,8 @@ function buildAiVaultResumeForWorktree(
...(providerSession ? { providerSession } : {})
}
}
const platform =
args.session.executionHostId &&
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
args.session.executionHostPlatform
? args.session.executionHostPlatform
: getAiVaultResumePlatform(args.state, args.worktreeId)
const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform)
const isLocalSession =
!args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID
const { platform, codexHome, liveShell } = resolveAiVaultResumeHost(args)
const resumeFilePath = normalizeAiVaultResumeFilePath(args.session.filePath, platform)
// Why: local shell settings do not describe a remote Windows host, whose
// queued resume command uses the remote default PowerShell syntax.
const liveShell: AgentStartupShell | undefined =
platform === 'win32'
? isLocalSession
? resolveAiVaultResumeShell(args)
: 'powershell'
: undefined
const cwd = embedCwd ? args.session.cwd : null
const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {}
if (isAntigravityReferenceSession(args.session)) {
@@ -254,21 +232,36 @@ function buildAiVaultResumeForWorktree(
}
}
function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartupShell {
const platform =
args.session.executionHostId &&
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
args.session.executionHostPlatform
? args.session.executionHostPlatform
: getAiVaultResumePlatform(args.state, args.worktreeId)
/** The platform and live shell a resume line is written for, and the Codex home it pins. */
export function resolveAiVaultResumeHost(args: AiVaultResumeWorktreeArgs): {
platform: NodeJS.Platform
codexHome: string | null
liveShell: AgentStartupShell | undefined
} {
const isLocalSession =
!args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID
return resolveAiVaultResumeStartupShell({
state: args.state,
worktreeId: args.worktreeId,
const platform =
!isLocalSession && args.session.executionHostPlatform
? args.session.executionHostPlatform
: getAiVaultResumePlatform(args.state, args.worktreeId)
// Why: local shell settings do not describe a remote Windows host, whose
// queued resume command uses the remote default PowerShell syntax.
const liveShell: AgentStartupShell | undefined =
platform === 'win32'
? isLocalSession
? resolveAiVaultResumeStartupShell({
state: args.state,
worktreeId: args.worktreeId,
platform,
isLocalSession
})
: 'powershell'
: undefined
return {
platform,
isLocalSession
})
codexHome: getAiVaultResumeCodexHome(args.session.codexHome, platform),
liveShell
}
}
export function getAiVaultAgentProviderSession(
@@ -290,47 +283,3 @@ export function getAiVaultAgentProviderSession(
}
return { key: 'session_id', id: session.sessionId }
}
function getAiVaultResumeCodexHome(
codexHome: string | null,
platform: NodeJS.Platform
): string | null {
// Why: WSL UNC Codex homes must be POSIX when invoking Linux commands.
// Keep original paths unchanged for non-Linux targets.
if (!codexHome || platform !== 'linux') {
return codexHome
}
return parseWslUncPath(codexHome)?.linuxPath ?? codexHome
}
function getAiVaultResumePlatform(
state: Pick<
AppState,
| 'activeRepoId'
| 'activeWorktreeId'
| 'folderWorkspaces'
| 'projectGroups'
| 'projects'
| 'repos'
| 'settings'
| 'worktreesByRepo'
>,
worktreeId?: string | null
): NodeJS.Platform {
const targetWorktreeId = worktreeId ?? state.activeWorktreeId
const executionHost = parseExecutionHostId(getExecutionHostIdForWorktree(state, targetWorktreeId))
if (executionHost?.kind === 'ssh' || executionHost?.kind === 'runtime') {
return 'linux'
}
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM)
if (projectRuntime?.status === 'repair-required') {
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
}
if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') {
return 'linux'
}
const workspacePath = getAiVaultResumeWorkspacePath(state, targetWorktreeId)
return workspacePath && parseWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM
}
@@ -0,0 +1,54 @@
import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getAiVaultResumeWorkspacePath } from '@/lib/ai-vault-resume-shell'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { parseWslUncPath } from '../../../shared/wsl-paths'
// Which platform's grammar an Agent Session History resume is written in: the target
// workspace's host, or Linux when the agent runs over SSH, a paired runtime, or WSL.
export function getAiVaultResumeCodexHome(
codexHome: string | null,
platform: NodeJS.Platform
): string | null {
// Why: WSL UNC Codex homes must be POSIX when invoking Linux commands.
// Keep original paths unchanged for non-Linux targets.
if (!codexHome || platform !== 'linux') {
return codexHome
}
return parseWslUncPath(codexHome)?.linuxPath ?? codexHome
}
export function getAiVaultResumePlatform(
state: Pick<
AppState,
| 'activeRepoId'
| 'activeWorktreeId'
| 'folderWorkspaces'
| 'projectGroups'
| 'projects'
| 'repos'
| 'settings'
| 'worktreesByRepo'
>,
worktreeId?: string | null
): NodeJS.Platform {
const targetWorktreeId = worktreeId ?? state.activeWorktreeId
const executionHost = parseExecutionHostId(getExecutionHostIdForWorktree(state, targetWorktreeId))
if (executionHost?.kind === 'ssh' || executionHost?.kind === 'runtime') {
return 'linux'
}
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM)
if (projectRuntime?.status === 'repair-required') {
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
}
if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') {
return 'linux'
}
const workspacePath = getAiVaultResumeWorkspacePath(state, targetWorktreeId)
return workspacePath && parseWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import { buildAiVaultForkStartupForWorktree } from './ai-vault-session-fork-startup'
vi.mock('@/lib/new-workspace', () => ({
CLIENT_PLATFORM: 'darwin'
}))
type ForkState = Pick<
AppState,
| 'activeRepoId'
| 'activeWorktreeId'
| 'folderWorkspaces'
| 'projectGroups'
| 'projects'
| 'repos'
| 'settings'
| 'worktreesByRepo'
>
function makeState(): ForkState {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fork builder reads only these slices; the rest of AppState is never reached.
return {
activeRepoId: 'repo-1',
activeWorktreeId: 'repo-1::worktree-1',
folderWorkspaces: [],
projectGroups: [],
repos: [{ id: 'repo-1', path: '/Users/ada/repo' }],
projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }],
settings: {
agentDefaultArgs: { claude: '', codex: '' },
agentDefaultEnv: { claude: {}, codex: {} }
},
worktreesByRepo: {
'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }]
}
} as unknown as ForkState
}
const SESSION_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
describe('AI Vault fork into the CLI', () => {
it('forks Claude in the recorded folder without claiming the chat-owned session', () => {
const startup = buildAiVaultForkStartupForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session: { agent: 'claude', sessionId: SESSION_ID, cwd: '/Users/ada/repo', codexHome: null }
})
expect(startup).toMatchObject({
command: `claude '--resume' '${SESSION_ID}' '--fork-session'`,
cwd: '/Users/ada/repo'
})
// A cold restore of the tab must never re-enter the conversation the chat still owns.
expect(startup).not.toHaveProperty('providerSession')
})
it('forks Codex under the home the transcript was found in', () => {
const startup = buildAiVaultForkStartupForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'codex',
sessionId: SESSION_ID,
cwd: '/Users/ada/repo',
codexHome: '/Users/ada/.orca/codex-accounts/a1/home'
}
})
expect(startup?.command).toBe(
`CODEX_HOME='/Users/ada/.orca/codex-accounts/a1/home' codex '-c' 'tui.resume_cwd=current' 'fork' '${SESSION_ID}'`
)
expect(startup).not.toHaveProperty('providerSession')
})
it('returns null for an agent that cannot fork instead of a plain resume', () => {
expect(
buildAiVaultForkStartupForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session: { agent: 'gemini', sessionId: SESSION_ID, cwd: '/Users/ada/repo', codexHome: null }
})
).toBeNull()
})
})
@@ -0,0 +1,64 @@
import { isResumableTuiAgent } from '../../../shared/agent-session-resume'
import {
buildAiVaultResumeShellCommand,
realHomeCodexResumeEnvDeletion
} from '../../../shared/ai-vault-resume-command'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
import {
getAiVaultAgentProviderSession,
resolveAiVaultResumeHost,
type AiVaultResumeStartup,
type AiVaultResumeWorktreeArgs
} from '@/lib/ai-vault-resume-command'
/**
* A terminal session that starts from a copy of a conversation a native chat owns.
*
* Null when the agent cannot fork: a plain resume in its place would make the terminal a second
* writer on the chat's conversation. No `providerSession` either: the copy gets its own id, and a
* cold restore of this tab must never re-enter the chat's conversation.
*/
export function buildAiVaultForkStartupForWorktree(
args: AiVaultResumeWorktreeArgs
): AiVaultResumeStartup | null {
const { session, state } = args
const providerSession = getAiVaultAgentProviderSession(session)
if (!providerSession || !isResumableTuiAgent(session.agent)) {
return null
}
const { platform, codexHome, liveShell } = resolveAiVaultResumeHost(args)
const plan = buildAgentResumeStartupPlan({
agent: session.agent,
providerSession,
cmdOverrides: {
...state.settings?.agentCmdOverrides,
...(args.commandOverride?.trim() ? { [session.agent]: args.commandOverride } : {})
},
platform,
shell: liveShell,
agentArgs: resolveTuiAgentLaunchArgs(session.agent, state.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(session.agent, state.settings?.agentDefaultEnv),
resumeInLaunchCwd: true,
fork: true
})
if (!plan) {
return null
}
return {
command: buildAiVaultResumeShellCommand({
resumeCommand: plan.launchCommand,
cwd: null,
platform,
codexHome,
shell: liveShell
}),
...(plan.env ? { env: plan.env } : {}),
...realHomeCodexResumeEnvDeletion(session),
...(session.cwd ? { cwd: session.cwd } : {}),
launchConfig: plan.launchConfig
}
}
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import {
dropDeletedSshResumeCwd,
prepareAiVaultSessionForFork,
prepareAiVaultSessionForResume
} from './ai-vault-session-resume-preparation'
@@ -98,6 +99,46 @@ describe('prepareAiVaultSessionForResume', () => {
})
})
describe('prepareAiVaultSessionForFork', () => {
// A fork of a chat-owned conversation must run under the selected account, as a resume does.
it('repins a chat-owned per-account session, asking the host as a fork', async () => {
const prepareSessionResume = vi.fn().mockResolvedValue({
useRealCodexHome: false,
substituteCodexHome: '/tmp/orca/codex-accounts/account-2/home'
})
stubPreparation(prepareSessionResume)
const owned = session({
codexHome: '/tmp/orca/codex-accounts/account-1/home',
structuredSession: { sessionId: 'session-1', workspaceId: 'worktree-1' }
})
const prepared = await prepareAiVaultSessionForFork(owned)
expect(prepared.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home')
expect(prepareSessionResume).toHaveBeenCalledWith({
agent: 'codex',
sessionId: owned.sessionId,
filePath: owned.filePath,
codexHome: owned.codexHome,
executionHostId: 'local',
fork: true
})
})
it('does not ask the host for a home that needs no preparation', async () => {
const prepareSessionResume = vi.fn()
stubPreparation(prepareSessionResume)
const owned = session({
agent: 'claude',
codexHome: null,
structuredSession: { sessionId: 'session-1', workspaceId: 'worktree-1' }
})
await expect(prepareAiVaultSessionForFork(owned)).resolves.toBe(owned)
expect(prepareSessionResume).not.toHaveBeenCalled()
})
})
describe('dropDeletedSshResumeCwd', () => {
it('leaves a live SSH session alone, scanner command and all', async () => {
const pathExists = vi.fn().mockResolvedValue(true)
@@ -9,10 +9,22 @@ import {
} from '../../../shared/execution-host'
import type { AiVaultResumeCommandSession } from './ai-vault-resume-command'
export async function prepareAiVaultSessionForResume(
session: AiVaultSession
export function prepareAiVaultSessionForResume(session: AiVaultSession): Promise<AiVaultSession> {
return session.structuredSession
? Promise.resolve(session)
: prepareAiVaultSessionHome(session, false)
}
/** Picks the Codex home a fork of `session` runs under, the same way a resume picks it. */
export function prepareAiVaultSessionForFork(session: AiVaultSession): Promise<AiVaultSession> {
return prepareAiVaultSessionHome(session, true)
}
async function prepareAiVaultSessionHome(
session: AiVaultSession,
fork: boolean
): Promise<AiVaultSession> {
if (session.structuredSession || !aiVaultSessionNeedsResumePreparation(session)) {
if (!aiVaultSessionNeedsResumePreparation(session)) {
return session
}
const result = await window.api.aiVault.prepareSessionResume({
@@ -20,7 +32,8 @@ export async function prepareAiVaultSessionForResume(
sessionId: session.sessionId,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
executionHostId: session.executionHostId,
...(fork ? { fork: true } : {})
})
if (result.useRealCodexHome) {
return { ...session, codexHome: null }
+15
View File
@@ -78,3 +78,18 @@ export function getAgentResumeArgv(
return providerSession.key === 'session_id' ? ['jcode', '--resume', id] : null
}
}
/** Opens a copy of the conversation under a new id, leaving the original untouched. Only the two
* agents native chat can own a conversation for, which is the only case that needs a fork. */
export function getAgentForkArgv(
agent: ResumableTuiAgent,
providerSession: AgentProviderSessionMetadata
): string[] | null {
if (providerSession.key !== 'session_id') {
return null
}
if (agent === 'claude') {
return ['claude', '--resume', providerSession.id, '--fork-session']
}
return agent === 'codex' ? ['codex', 'fork', providerSession.id] : null
}
+1 -1
View File
@@ -286,4 +286,4 @@ export function extractAgentProviderSession(
}
// Re-exported so the 18 existing call sites keep one import path.
export { getAgentResumeArgv } from './agent-resume-argv'
export { getAgentForkArgv, getAgentResumeArgv } from './agent-resume-argv'
+5 -1
View File
@@ -4,7 +4,11 @@ export type AiVaultPrepareSessionResumeArgs = Pick<
AiVaultSession,
'agent' | 'filePath' | 'codexHome' | 'executionHostId'
> &
Partial<Pick<AiVaultSession, 'sessionId'>>
Partial<Pick<AiVaultSession, 'sessionId'>> & {
/** A fork only reads the conversation, so a native chat that owns it does not refuse it.
* Older hosts drop the field and refuse as for a resume. */
fork?: boolean
}
export type AiVaultPrepareSessionResumeResult = {
useRealCodexHome: boolean
+2 -1
View File
@@ -58,7 +58,8 @@ export const AiVaultPrepareSessionResumeParams = z.object({
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()
executionHostId: z.string().optional(),
fork: z.boolean().optional()
})
export const AiVaultSessionTitlesParams = z.object({
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { getAgentForkArgv } from './agent-resume-argv'
import { buildAgentResumeStartupPlan } from './tui-agent-startup'
const SESSION = { key: 'session_id', id: 's1' } as const
describe('agent fork startup', () => {
it('forks Claude and Codex and nothing else', () => {
expect(getAgentForkArgv('claude', SESSION)).toEqual([
'claude',
'--resume',
's1',
'--fork-session'
])
expect(getAgentForkArgv('codex', SESSION)).toEqual(['codex', 'fork', 's1'])
expect(getAgentForkArgv('gemini', SESSION)).toBeNull()
expect(getAgentForkArgv('claude', { key: 'conversation_id', id: 's1' })).toBeNull()
})
it('builds a Claude fork plan that replaces a stored resume selector', () => {
const plan = buildAgentResumeStartupPlan({
agent: 'claude',
providerSession: SESSION,
cmdOverrides: { claude: 'claude --continue' },
platform: 'darwin',
fork: true
})
expect(plan?.launchCommand).toBe("claude '--resume' 's1' '--fork-session'")
})
it('builds a Codex fork plan that opens in the launch folder', () => {
const plan = buildAgentResumeStartupPlan({
agent: 'codex',
providerSession: SESSION,
cmdOverrides: {},
platform: 'darwin',
resumeInLaunchCwd: true,
fork: true
})
expect(plan?.launchCommand).toBe("codex '-c' 'tui.resume_cwd=current' 'fork' 's1'")
})
it('refuses a fork for an agent that cannot fork rather than resuming it', () => {
expect(
buildAgentResumeStartupPlan({
agent: 'gemini',
providerSession: SESSION,
cmdOverrides: {},
platform: 'darwin',
fork: true
})
).toBeNull()
})
})
+6 -1
View File
@@ -1,4 +1,5 @@
import {
getAgentForkArgv,
getAgentResumeArgv,
type AgentProviderSessionMetadata,
type ResumableTuiAgent
@@ -27,8 +28,12 @@ export function buildAgentResumeStartupPlan(args: {
isRemote?: boolean
/** The caller chose the terminal folder on purpose, so Codex must not ask for the recorded one. */
resumeInLaunchCwd?: boolean
/** Open a copy of the conversation instead of re-entering it; null for agents that cannot. */
fork?: boolean
}): AgentStartupPlan | null {
const resumeArgv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
const resumeArgv = args.fork
? getAgentForkArgv(args.agent, args.providerSession)
: getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
if (!resumeArgv) {
return null
}