mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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:
@@ -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'"
|
||||
)
|
||||
})
|
||||
+8
-9
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user