From 6cf91108fcd8e2b9cddff0ad6ab956a8ad215dfd Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 12 Nov 2026 19:35:22 -0500 Subject: [PATCH] fix(agents): keep every launch choice when a restored agent resumes The saved launch note was built from a narrower token list than the launch, so model, effort and other picker options were applied at launch and dropped on every restore. The note is now the launch itself minus the choices an agent's own session restores (Claude's model and live effort). --- .../launch-recipe-round-trip.test.ts | 164 ++++++++++++++++++ ...control-agent-launch-picker.source.test.ts | 17 +- .../startup/tui-agent-launch-command.ts | 38 ++-- 3 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 src/main/runtime/session-construction/agent-session/launch-recipe-round-trip.test.ts 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[],