diff --git a/src/main/runtime/session-construction/agent-session/launch-recipe-round-trip.test.ts b/src/main/runtime/session-construction/agent-session/launch-recipe-round-trip.test.ts new file mode 100644 index 00000000000..97e8e27dff2 --- /dev/null +++ b/src/main/runtime/session-construction/agent-session/launch-recipe-round-trip.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' +import { resolveResumeLaunchRecipe } from '../../../../shared/agent-launch/choices/resume-launch-recipe' +import { buildAgentResumeStartupPlan } from '../../../../shared/agent-launch/startup/tui-agent-resume-startup' +import { buildAgentStartupPlan } from '../../../../shared/agent-launch/startup/tui-agent-startup' +import type { + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import type { SessionOptionValue } from '../../../../shared/native-chat/input/native-chat-session-options' +import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session/workspace-session-sleeping-agents' +import { parseDaemonLaunchNoteChange } from '../../../daemon/protocol/daemon-launch-note-protocol' +import { resolveSavedRestoreLaunchRecipe } from './saved-restore-launch-recipe' + +type RoundTripCase = { + agent: ResumableTuiAgent + agentArgs: string + sessionOptions: Record + launch: string + resume: string +} + +// Claude's model and effort come back from its own session, so its resume carries neither flag. +const CASES: RoundTripCase[] = [ + { + agent: 'claude', + agentArgs: '--verbose', + sessionOptions: { model: 'opus', effort: 'max', fastMode: true, permissionMode: 'full-access' }, + launch: `claude '--verbose' '--model' 'opus' '--effort' 'max' '--settings' '{"fastMode":true}' '--dangerously-skip-permissions'`, + resume: `claude '--verbose' '--settings' '{"fastMode":true}' '--dangerously-skip-permissions' '--resume' 'S1'` + }, + { + agent: 'codex', + agentArgs: '--search', + sessionOptions: { + model: 'gpt-5.6-terra', + effort: 'xhigh', + serviceTier: 'fast', + permissionMode: 'full-access' + }, + launch: `codex '-c' 'disable_paste_burst=true' '--search' '-m' 'gpt-5.6-terra' '-c' 'model_reasoning_effort=xhigh' '-c' 'service_tier=fast' '--dangerously-bypass-approvals-and-sandbox'`, + resume: `codex '--search' '-m' 'gpt-5.6-terra' '-c' 'model_reasoning_effort=xhigh' '-c' 'service_tier=fast' '--dangerously-bypass-approvals-and-sandbox' '-c' 'disable_paste_burst=true' 'resume' 'S1'` + }, + { + agent: 'gemini', + agentArgs: '--debug', + sessionOptions: { model: 'gemini-2.5-pro', permissionMode: 'full-access' }, + launch: `gemini '--debug' '-m' 'gemini-2.5-pro' '--yolo'`, + resume: `gemini '--debug' '-m' 'gemini-2.5-pro' '--yolo' '--resume' 'S1'` + }, + { + agent: 'cursor', + agentArgs: '--debug', + sessionOptions: { model: 'gpt-5', permissionMode: 'full-access' }, + launch: `cursor-agent '--debug' '--model' 'gpt-5' '--yolo'`, + resume: `cursor-agent '--debug' '--model' 'gpt-5' '--yolo' '--resume' 'S1'` + }, + { + agent: 'grok', + agentArgs: '--debug', + sessionOptions: { model: 'grok-4', effort: 'high', permissionMode: 'full-access' }, + launch: `grok '--debug' '-m' 'grok-4' '--reasoning-effort' 'high' '--permission-mode' 'bypassPermissions'`, + resume: `grok '--debug' '-m' 'grok-4' '--reasoning-effort' 'high' '--permission-mode' 'bypassPermissions' '--resume' 'S1'` + } +] + +const ENV = { ACCOUNT_REGION: 'eu' } + +function providerSession(agent: TuiAgent): AgentProviderSessionMetadata { + return { key: agent === 'cursor' ? 'conversation_id' : 'session_id', id: 'S1' } +} + +function resume(agent: ResumableTuiAgent, recipe: SleepingAgentLaunchConfig) { + return buildAgentResumeStartupPlan({ + agent, + providerSession: providerSession(agent), + cmdOverrides: {}, + platform: 'linux', + shell: 'posix', + ...recipe + }) +} + +/** Every way a saved note reaches a resume: the workspace session, the daemon, and a host's saved restore. */ +function restorePaths( + agent: ResumableTuiAgent, + saved: SleepingAgentLaunchConfig +): [string, SleepingAgentLaunchConfig][] { + const persisted = sleepingAgentLaunchConfigSchema.parse(JSON.parse(JSON.stringify(saved)))! + const daemon = parseDaemonLaunchNoteChange({ arm: { launchToken: 't', launchConfig: saved } }) + const session = providerSession(agent) + return [ + [ + 'window restore, sleep/wake, mobile, worker reopen', + resolveResumeLaunchRecipe(persisted, agent, session) + ], + [ + 'daemon launch note', + resolveResumeLaunchRecipe( + daemon && 'arm' in daemon ? daemon.arm.launchConfig : undefined, + agent, + session + ) + ], + [ + 'host saved restore', + resolveSavedRestoreLaunchRecipe({ + agent, + providerSession: session, + record: { + paneKey: 'tab:1', + worktreeId: 'w', + agent, + providerSession: session, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + launchConfig: persisted + }, + liveRow: undefined + }) + ] + ] +} + +describe.each(CASES)( + '$agent launch recipe', + ({ agent, agentArgs, sessionOptions, launch, resume: expected }) => { + const plan = buildAgentStartupPlan({ + agent, + prompt: '', + allowEmptyPromptLaunch: true, + cmdOverrides: {}, + platform: 'linux', + shell: 'posix', + agentArgs, + agentEnv: ENV, + sessionOptions, + sessionOptionsOverrideAgentArgs: true + })! + + it('launches with every choice', () => { + expect(plan.launchCommand).toBe(launch) + }) + + it.each(restorePaths(agent, plan.launchConfig))('%s rebuilds it, twice', (_path, recipe) => { + const first = resume(agent, recipe)! + expect(first.launchCommand).toBe(expected) + expect(first.env).toEqual(ENV) + expect( + resume(agent, resolveResumeLaunchRecipe(first.launchConfig, agent, null))!.launchCommand + ).toBe(expected) + }) + } +) + +it('replays a note saved before launch choices were recorded exactly as before', () => { + const old = { agentCommand: "codex '--search'", agentArgs: "'--search'", agentEnv: {} } + expect(resume('codex', resolveResumeLaunchRecipe(old, 'codex', null))!.launchCommand).toBe( + "codex '--search' '-c' 'disable_paste_burst=true' 'resume' 'S1'" + ) +}) diff --git a/src/renderer/src/lib/source-control/ai/source-control-agent-launch-picker.source.test.ts b/src/renderer/src/lib/source-control/ai/source-control-agent-launch-picker.source.test.ts index 2eb39b9ac5b..ee9c1b99f37 100644 --- a/src/renderer/src/lib/source-control/ai/source-control-agent-launch-picker.source.test.ts +++ b/src/renderer/src/lib/source-control/ai/source-control-agent-launch-picker.source.test.ts @@ -312,7 +312,10 @@ it('retains a genuinely admitted saved model node through real effort editing, S if (!compiled.ok) { throw new Error(compiled.error) } - expect(compiled.resumeLaunch).toEqual({ agentCommand: "codex '--search'" }) + expect(compiled.resumeLaunch).toEqual({ + agentCommand: "codex '--search' '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high'", + agentArgs: "'--search' '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high'" + }) }) it.each([ @@ -420,14 +423,10 @@ it.each([ throw new Error(resumeTokens.error) } expect(resumeTokens.tokens).toContain('provider-recorded-session') - expect(resumeTokens.tokens).not.toContain('model_reasoning_effort=future_effort') - expect(resumeTokens.tokens).not.toContain('model_reasoning_effort=high') - if (agent === 'codex') { - expect(resumeTokens.tokens.filter((token) => token === '-m')).toHaveLength(1) - expect(resumeTokens.tokens).toContain(raw.split(' ')[1]) - } else { - expect(resumeTokens.tokens.filter((token) => token === '--model')).toHaveLength(0) - } + // The resume keeps every launch choice, each flag once. + expect(resumeTokens.tokens).toEqual(expect.arrayContaining([...expected])) + const modelFlag = agent === 'codex' ? '-m' : '--model' + expect(resumeTokens.tokens.filter((token) => token === modelFlag)).toHaveLength(1) process.stdout.write( `${JSON.stringify({ metric: 'dependent-initial-provider-resume', agent, raw, options, projection, compiled, resumed })}\n` ) diff --git a/src/shared/agent-launch/startup/tui-agent-launch-command.ts b/src/shared/agent-launch/startup/tui-agent-launch-command.ts index 341ff11b19c..f3ae48fe23b 100644 --- a/src/shared/agent-launch/startup/tui-agent-launch-command.ts +++ b/src/shared/agent-launch/startup/tui-agent-launch-command.ts @@ -175,27 +175,33 @@ export function resolveAgentLaunchCommand(args: { const render = (tokens: readonly string[]): string => tokens.map((token) => quoteStartupArg(token, args.shell)).join(' ') const withArgs = (suffix: string, head = command): string => (suffix ? `${head} ${suffix}` : head) - const initialTokens = args.sessionOptionsOverrideAgentArgs - ? insertBeforeTerminator(args.agent, keptTokens, resolvedOptions.args) - : [...resolvedOptions.args, ...keptTokens] + const withOptionArgs = (optionArgs: readonly string[]): string[] => + args.sessionOptionsOverrideAgentArgs + ? insertBeforeTerminator(args.agent, keptTokens, optionArgs) + : [...optionArgs, ...keptTokens] + const initialTokens = withOptionArgs(resolvedOptions.args) // Aider's last boolean flag wins, even over configured --check-update. const launchTokens = args.agent === 'aider' ? insertBeforeTerminator(args.agent, initialTokens, suppressionArgs) : initialTokens - // Restart retains explicit policy and surviving raw pins, while providers restore model seeds. - const resumeArgs = render( - insertBeforeTerminator(args.agent, keptTokens, resolvedOptions.permissionArgs ?? []) + // The saved recipe is this launch minus choices the agent's own session restores on resume. + const recipeTokens = withOptionArgs( + removeOverriddenAgentSessionArgs( + args.agent, + sessionOwnedLaunchChoices(args.agent, resolvedOptions.appliedValues), + resolvedOptions.args + ) ) + const recipeArgs = render(recipeTokens) return { ok: true, command: withArgs(render(launchTokens), launchHead), resumeLaunch: { - agentCommand: withArgs(resumeArgs), - ...(keptTokens.length !== rawTokens.length || - keptTokens.some((token, index) => token !== rawTokens[index]) || - resolvedOptions.permissionArgs - ? { agentArgs: resumeArgs } + agentCommand: withArgs(recipeArgs), + ...(recipeTokens.length !== rawTokens.length || + recipeTokens.some((token, index) => token !== rawTokens[index]) + ? { agentArgs: recipeArgs } : {}) }, appliedSessionOptions: resolvedOptions.appliedValues, @@ -205,6 +211,16 @@ export function resolveAgentLaunchCommand(args: { } } +// Claude resumes on its session's model, and its resume replays the live effort; a recorded flag would undo a mid-session switch. +const SESSION_OWNED_LAUNCH_CHOICES: Partial> = { + claude: ['model', 'effort'] +} + +function sessionOwnedLaunchChoices(agent: TuiAgent, applied: Record) { + const owned = SESSION_OWNED_LAUNCH_CHOICES[agent] ?? [] + return Object.fromEntries(Object.entries(applied).filter(([id]) => owned.includes(id))) +} + function insertBeforeTerminator( agent: TuiAgent, tokens: readonly string[],