Apply agent defaults to AI Vault resume (#6299)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-24 17:41:09 -07:00 committed by GitHub
parent 7436a4cfc1
commit 19b0174dca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 286 additions and 40 deletions

View File

@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { buildAiVaultResumeCommandForWorktree } from '@/lib/ai-vault-resume-command'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree
} from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import {
@ -46,7 +49,8 @@ export default function AiVaultPanel(): React.JSX.Element {
const repos = useRepos()
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {})
const settings = useAppStore((s) => s.settings)
const agentCmdOverrides = settings?.agentCmdOverrides ?? {}
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const [sort, setSort] = useState<AiVaultSort>('updated')
@ -207,7 +211,18 @@ export default function AiVaultPanel(): React.JSX.Element {
session,
commandOverride: agentCmdOverrides[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides]
[activeWorktree?.id, agentCmdOverrides, settings]
)
const buildResumeStartup = useCallback(
(session: AiVaultSession) =>
buildAiVaultResumeStartupForWorktree({
state: useAppStore.getState(),
worktreeId: activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides, settings]
)
const copyResumeCommand = useCallback(
@ -255,7 +270,7 @@ export default function AiVaultPanel(): React.JSX.Element {
launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: activeWorktree.id,
command: buildResumeCommand(session)
...buildResumeStartup(session)
})
toast.success(
translate(
@ -265,7 +280,7 @@ export default function AiVaultPanel(): React.JSX.Element {
)
)
},
[activeWorktree, buildResumeCommand, isRemoteWorktree]
[activeWorktree, buildResumeStartup, isRemoteWorktree]
)
const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => {
@ -363,7 +378,7 @@ export default function AiVaultPanel(): React.JSX.Element {
filteredSessionsCount={filteredSessions.length}
error={error}
resumeDisabled={!activeWorktree || isRemoteWorktree}
buildResumeCommand={buildResumeCommand}
buildResumeStartup={buildResumeStartup}
onToggleGroup={toggleGroup}
onResume={handleResume}
onCopyResume={(session) => void copyResumeCommand(session)}

View File

@ -16,6 +16,7 @@ import {
writeAiVaultSessionDragData
} from '@/lib/ai-vault-session-drag'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails'
@ -24,7 +25,7 @@ import { SessionRowTrailingActions } from './SessionRowTrailingActions'
export function VaultSessionRow({
session,
resumeCommand,
resumeStartup,
detailsExpanded,
resumeDisabled,
onToggleDetails,
@ -37,7 +38,7 @@ export function VaultSessionRow({
onOpenCwd
}: {
session: AiVaultSession
resumeCommand: string
resumeStartup: AiVaultResumeStartup
detailsExpanded: boolean
resumeDisabled: boolean
onToggleDetails: () => void
@ -66,11 +67,13 @@ export function VaultSessionRow({
agent: session.agent,
sessionId: session.sessionId,
title: session.title,
command: resumeCommand
command: resumeStartup.command,
...(resumeStartup.env ? { env: resumeStartup.env } : {}),
...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {})
})
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT))
},
[resumeDisabled, session.agent, session.sessionId, session.title, resumeCommand]
[resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup]
)
return (

View File

@ -1,6 +1,7 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list-virtual-rows'
@ -29,7 +30,7 @@ export function AiVaultSessionVirtualList({
filteredSessionsCount,
error,
resumeDisabled,
buildResumeCommand,
buildResumeStartup,
onToggleGroup,
onResume,
onCopyResume,
@ -46,7 +47,7 @@ export function AiVaultSessionVirtualList({
filteredSessionsCount: number
error: string | null
resumeDisabled: boolean
buildResumeCommand: (session: AiVaultSession) => string
buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup
onToggleGroup: (key: string) => void
onResume: (session: AiVaultSession) => void
onCopyResume: (session: AiVaultSession) => void
@ -163,7 +164,7 @@ export function AiVaultSessionVirtualList({
collapsedGroups={collapsedGroups}
expandedSessionIds={expandedSessionIds}
resumeDisabled={resumeDisabled}
buildResumeCommand={buildResumeCommand}
buildResumeStartup={buildResumeStartup}
onToggleGroup={onToggleGroup}
onToggleSessionDetails={toggleSessionDetails}
onResume={onResume}
@ -190,7 +191,7 @@ function AiVaultVirtualRow({
collapsedGroups,
expandedSessionIds,
resumeDisabled,
buildResumeCommand,
buildResumeStartup,
onToggleGroup,
onToggleSessionDetails,
onResume,
@ -209,7 +210,7 @@ function AiVaultVirtualRow({
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
resumeDisabled: boolean
buildResumeCommand: (session: AiVaultSession) => string
buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onResume: (session: AiVaultSession) => void
@ -245,7 +246,7 @@ function AiVaultVirtualRow({
) : (
<VaultSessionRow
session={row.session}
resumeCommand={buildResumeCommand(row.session)}
resumeStartup={buildResumeStartup(row.session)}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeDisabled}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}

View File

@ -189,6 +189,8 @@ export default function AiVaultSessionDropLayer({
agent: payload.agent,
worktreeId,
command: payload.command,
...(payload.env ? { env: payload.env } : {}),
...(payload.launchConfig ? { launchConfig: payload.launchConfig } : {}),
targetGroupId: dropTarget.groupId,
splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone
})

View File

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree,
getAiVaultResumePlatform
} from './ai-vault-resume-command'
@ -31,7 +32,11 @@ function makeState(args: {
: {})
}
],
settings: { localWindowsRuntimeDefault: { kind: 'windows-host' } },
settings: {
localWindowsRuntimeDefault: { kind: 'windows-host' },
agentDefaultArgs: { claude: '', codex: '' },
agentDefaultEnv: { claude: {}, codex: {} }
},
worktreesByRepo: {
'repo-1': [
{
@ -62,7 +67,41 @@ describe('ai vault resume command runtime', () => {
codexHome: null
}
})
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""')
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude ""--resume"" ""session one"""')
})
it('uses configured agent defaults for resumable session history entries', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
state.settings = {
...state.settings,
agentDefaultArgs: { claude: '--dangerously-skip-permissions --effort max' },
agentDefaultEnv: { claude: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } }
} as never
expect(
buildAiVaultResumeStartupForWorktree({
state,
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'claude',
sessionId: 'session-1',
cwd: '/home/alice/repo',
codexHome: null
}
})
).toEqual({
command:
"cd '/home/alice/repo' && claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
launchConfig: {
agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'",
agentArgs: '--dangerously-skip-permissions --effort max',
agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }
}
})
})
it('uses POSIX command wrapping for Windows-path projects forced to WSL', () => {
@ -83,7 +122,7 @@ describe('ai vault resume command runtime', () => {
codexHome: null
}
})
).toBe("cd '/home/alice/repo' && claude --resume 'session one'")
).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'")
})
it('keeps WSL UNC worktrees on POSIX command wrapping without an explicit override', () => {
@ -110,6 +149,6 @@ describe('ai vault resume command runtime', () => {
codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
}
})
).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex resume 'session one'")
).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'")
})
})

View File

@ -1,11 +1,30 @@
import { buildAiVaultResumeCommand, type AiVaultSession } from '../../../shared/ai-vault-types'
import {
buildAiVaultResumeCommand,
buildAiVaultResumeShellCommand,
type AiVaultSession
} from '../../../shared/ai-vault-types'
import {
isResumableTuiAgent,
type SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import { parseWslUncPath } from '../../../shared/wsl-paths'
import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
type AiVaultResumeCommandSession = Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'>
export type AiVaultResumeStartup = {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
}
export function buildAiVaultResumeCommandForWorktree(args: {
state: Pick<
AppState,
@ -15,16 +34,62 @@ export function buildAiVaultResumeCommandForWorktree(args: {
session: AiVaultResumeCommandSession
commandOverride?: string | null
}): string {
return buildAiVaultResumeStartupForWorktree(args).command
}
export function buildAiVaultResumeStartupForWorktree(args: {
state: Pick<
AppState,
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
>
worktreeId?: string | null
session: AiVaultResumeCommandSession
commandOverride?: string | null
}): AiVaultResumeStartup {
const platform = getAiVaultResumePlatform(args.state, args.worktreeId)
const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform)
return buildAiVaultResumeCommand({
agent: args.session.agent,
sessionId: args.session.sessionId,
cwd: args.session.cwd,
platform,
commandOverride: args.commandOverride,
codexHome
})
if (isResumableTuiAgent(args.session.agent)) {
const startupPlan = buildAgentResumeStartupPlan({
agent: args.session.agent,
providerSession: { key: 'session_id', id: args.session.sessionId },
cmdOverrides: {
...args.state.settings?.agentCmdOverrides,
...(args.commandOverride?.trim() ? { [args.session.agent]: args.commandOverride } : {})
},
platform,
// Why: copied AI Vault commands are shell-wrapped for portability; the
// same inner command must be queued so drag/click resume match copy.
shell: platform === 'win32' ? 'cmd' : undefined,
agentArgs: resolveTuiAgentLaunchArgs(
args.session.agent,
args.state.settings?.agentDefaultArgs
),
agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.state.settings?.agentDefaultEnv)
})
if (startupPlan) {
return {
command: buildAiVaultResumeShellCommand({
resumeCommand: startupPlan.launchCommand,
cwd: args.session.cwd,
platform,
codexHome
}),
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig
}
}
}
return {
command: buildAiVaultResumeCommand({
agent: args.session.agent,
sessionId: args.session.sessionId,
cwd: args.session.cwd,
platform,
commandOverride: args.commandOverride,
codexHome
})
}
}
function getAiVaultResumeCodexHome(

View File

@ -47,7 +47,13 @@ describe('Session History session drag data', () => {
agent: 'claude',
sessionId: 'session-1',
title: 'Fix terminal split',
command: "cd '/repo' && claude --resume session-1"
command: "cd '/repo' && claude --resume session-1",
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
launchConfig: {
agentCommand: 'claude --dangerously-skip-permissions',
agentArgs: '--dangerously-skip-permissions',
agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }
}
}
writeAiVaultSessionDragData(transfer, payload)
@ -67,6 +73,45 @@ describe('Session History session drag data', () => {
expect(readAiVaultSessionDragData(transfer)).toBeNull()
})
it('rejects array-shaped env records', () => {
const transfer = createTransfer()
transfer.setData(
AI_VAULT_SESSION_DRAG_TYPE,
JSON.stringify({
kind: 'ai-vault-session',
version: 1,
agent: 'claude',
sessionId: 'session-1',
title: 'Malformed env',
command: 'claude --resume session-1',
env: ['ANTHROPIC_BASE_URL=https://claude.example.test']
})
)
expect(readAiVaultSessionDragData(transfer)).toBeNull()
})
it('rejects array-shaped launch config env records', () => {
const transfer = createTransfer()
transfer.setData(
AI_VAULT_SESSION_DRAG_TYPE,
JSON.stringify({
kind: 'ai-vault-session',
version: 1,
agent: 'claude',
sessionId: 'session-1',
title: 'Malformed launch config env',
command: 'claude --resume session-1',
launchConfig: {
agentArgs: '',
agentEnv: ['ANTHROPIC_BASE_URL=https://claude.example.test']
}
})
)
expect(readAiVaultSessionDragData(transfer)).toBeNull()
})
it('rejects oversized serialized payloads before parsing', () => {
const transfer = createTransfer()
const secret = 'ai-vault-drag-secret'

View File

@ -1,4 +1,5 @@
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session'
@ -11,6 +12,9 @@ export type AiVaultSessionDragPayload = {
sessionId: string
title: string
command: string
// Why: drag/drop resume must preserve planned env/default args, not just the shell command.
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
}
let activeAiVaultSessionDragPayload: AiVaultSessionDragPayload | null = null
@ -28,6 +32,25 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isStringRecord(value: unknown): value is Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
return Object.values(value).every((entry) => typeof entry === 'string')
}
function isLaunchConfig(value: unknown): value is SleepingAgentLaunchConfig {
if (!value || typeof value !== 'object') {
return false
}
const config = value as Partial<SleepingAgentLaunchConfig>
return (
(config.agentCommand === undefined || typeof config.agentCommand === 'string') &&
typeof config.agentArgs === 'string' &&
isStringRecord(config.agentEnv)
)
}
function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionDragPayload {
if (!value || typeof value !== 'object') {
return false
@ -39,7 +62,9 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD
isAiVaultAgent(payload.agent) &&
isNonEmptyString(payload.sessionId) &&
isNonEmptyString(payload.title) &&
isNonEmptyString(payload.command)
isNonEmptyString(payload.command) &&
(payload.env === undefined || isStringRecord(payload.env)) &&
(payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig))
)
}
@ -85,8 +110,15 @@ export function readAiVaultSessionDragData(
if (!isSerializedPayload(parsed)) {
return null
}
const { agent, sessionId, title, command } = parsed
return { agent, sessionId, title, command }
const { agent, sessionId, title, command, env, launchConfig } = parsed
return {
agent,
sessionId,
title,
command,
...(env ? { env } : {}),
...(launchConfig ? { launchConfig } : {})
}
} catch {
return null
}

View File

@ -76,6 +76,36 @@ describe('launchAiVaultSessionInNewTab', () => {
expect(result).toEqual({ tabId: 'tab-1', groupId: 'group-1' })
})
it('queues configured resume startup details for agent history resumes', () => {
launchAiVaultSessionInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
launchConfig: {
agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'",
agentArgs: '--dangerously-skip-permissions --effort max',
agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }
}
})
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'",
env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' },
launchConfig: {
agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'",
agentArgs: '--dangerously-skip-permissions --effort max',
agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }
},
launchAgent: 'claude',
telemetry: {
agent_kind: 'claude',
launch_source: 'sidebar',
request_kind: 'resume'
}
})
})
it('creates a split group before launching when a split direction is provided', () => {
launchAiVaultSessionInNewTab({
agent: 'codex',

View File

@ -2,12 +2,15 @@ import { useAppStore } from '@/store'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import type { AiVaultAgent } from '../../../shared/ai-vault-types'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { TabSplitDirection } from '@/store/slices/tabs'
export function launchAiVaultSessionInNewTab(args: {
agent: AiVaultAgent
worktreeId: string
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
targetGroupId?: string
splitDirection?: TabSplitDirection
}): { tabId: string; groupId?: string } {
@ -22,6 +25,8 @@ export function launchAiVaultSessionInNewTab(args: {
const tab = store.createTab(args.worktreeId, targetGroupId)
store.queueTabStartupCommand(tab.id, {
command: args.command,
...(args.env ? { env: args.env } : {}),
...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}),
telemetry: {
agent_kind: tuiAgentToAgentKind(args.agent),
launch_source: 'sidebar',

View File

@ -93,11 +93,21 @@ export function buildAiVaultResumeCommand(args: {
const { agent, sessionId, cwd, platform, commandOverride, codexHome } = args
const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent)
const sessionArg = quoteShellArg(sessionId, platform)
const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg, {
codexHome: codexHome?.trim() || null,
platform
})
const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg)
return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome })
}
export function buildAiVaultResumeShellCommand(args: {
resumeCommand: string
cwd: string | null
platform: NodeJS.Platform
codexHome?: string | null
}): string {
const { cwd, platform, codexHome } = args
const resumeCommand = `${codexHomeEnvPrefix(codexHome?.trim() || null, platform)}${
args.resumeCommand
}`
if (!cwd) {
return resumeCommand
}
@ -130,12 +140,11 @@ function defaultAiVaultResumeCommandBase(agent: AiVaultAgent): string {
function buildAgentResumeInvocation(
agent: AiVaultAgent,
baseCommand: string,
sessionArg: string,
options: { codexHome: string | null; platform: NodeJS.Platform }
sessionArg: string
): string {
switch (agent) {
case 'codex':
return `${codexHomeEnvPrefix(options.codexHome, options.platform)}${baseCommand} resume ${sessionArg}`
return `${baseCommand} resume ${sessionArg}`
case 'rovo':
return `${baseCommand} rovodev run --restore ${sessionArg}`
case 'opencode':