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).
This commit is contained in:
Jinwoo-H
2026-11-12 19:35:22 -05:00
parent 4fb159a95f
commit 6cf91108fc
3 changed files with 199 additions and 20 deletions
@@ -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<string, SessionOptionValue>
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'"
)
})
@@ -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`
)
@@ -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<Record<TuiAgent, readonly string[]>> = {
claude: ['model', 'effort']
}
function sessionOwnedLaunchChoices(agent: TuiAgent, applied: Record<string, SessionOptionValue>) {
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[],