fix(agent-resume): resume Claude sessions at their own effort level (#33765)

* fix(agent-resume): resume Claude sessions at their own effort level

* fix(agent-resume): keep effort through persistence and /clear; simplify ensure shaping
This commit is contained in:
Jinwoo Hong
2026-10-26 00:30:08 -04:00
committed by GitHub
parent 327c152cca
commit a1da3c0a3b
15 changed files with 252 additions and 33 deletions
@@ -1,13 +1,11 @@
import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result'
import {
terminalLaunchFacts,
agentProviderSessionForEnsure
} from '../../../../shared/agent-session-resume'
import { terminalLaunchFacts } from '../../../../shared/agent-session-resume'
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client'
import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission'
import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent'
import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability'
import { agentProviderSessionForHostEnsure } from '@/runtime/agent-session-ensure-provider-session'
import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace'
import {
createRemoteRuntimeRecoveryInputHold,
@@ -3518,10 +3516,16 @@ export function createRemoteRuntimePtyTransport(
)
const hostAuthorityCreate = async (epoch = connectLifecycleEpoch) => {
const keyboardOptions = await agentKeyboardOptions(createEnvironmentId)
const ensureProviderSession = resumeProviderSessionToSend
? await agentProviderSessionForHostEnsure(
createEnvironmentId,
resumeProviderSessionToSend
)
: undefined
return createWithUnknownOutcomeRecovery(
'agent-session',
(timeoutMs) =>
resumeProviderSessionToSend
ensureProviderSession
? callRuntimeForEnvironment<RuntimeEnsureAgentSessionResult>(
createEnvironmentId,
'terminal.ensureAgentSession',
@@ -3531,7 +3535,7 @@ export function createRemoteRuntimePtyTransport(
...originOptions,
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
agent: launchAgentToSend!,
providerSession: agentProviderSessionForEnsure(resumeProviderSessionToSend),
providerSession: ensureProviderSession,
...(launchConfigToSend?.ompResumeFilePath
? { ompResumeFilePath: launchConfigToSend.ompResumeFilePath }
: {}),
@@ -0,0 +1,29 @@
import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume'
import { AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities'
import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
import { isRuntimeCompatBlockError } from './runtime-protocol-compat'
/** The provider session an ensureAgentSession call may send to this host; older hosts' strict schema rejects `cwd` and `effort`. */
export async function agentProviderSessionForHostEnsure(
environmentId: string,
providerSession: AgentProviderSessionMetadata
): Promise<AgentProviderSessionMetadata> {
const { cwd: _cwd, effort, ...identity } = providerSession
return effort && (await hostAcceptsResumeEffort(environmentId))
? { ...identity, effort }
: identity
}
async function hostAcceptsResumeEffort(environmentId: string): Promise<boolean> {
try {
return await runtimeEnvironmentSupportsCapability(
environmentId,
AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY
)
} catch (error) {
if (isRuntimeCompatBlockError(error)) {
throw error
}
return false
}
}
@@ -3,7 +3,7 @@ import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capa
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { agentProviderSessionForEnsure } from '../../../shared/agent-session-resume'
import { agentProviderSessionForHostEnsure } from './agent-session-ensure-provider-session'
import { translate } from '../i18n/i18n'
import { useAppStore } from '../store'
import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability'
@@ -110,7 +110,10 @@ export async function createWebRuntimeSessionTerminalResult(
kind: 'explicit',
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent,
providerSession: agentProviderSessionForEnsure(args.providerSession!),
providerSession: await agentProviderSessionForHostEnsure(
environmentId,
args.providerSession!
),
...(args.launchConfig?.ompResumeFilePath
? { ompResumeFilePath: args.launchConfig.ompResumeFilePath }
: {}),
+6
View File
@@ -0,0 +1,6 @@
// Why a shape, not Claude's level list: a level a newer Claude adds must still round-trip into argv.
export const AGENT_EFFORT_LEVEL = /^[a-z][a-z0-9-]{0,31}$/
export function readAgentEffortLevel(value: unknown): string | undefined {
return typeof value === 'string' && AGENT_EFFORT_LEVEL.test(value) ? value : undefined
}
@@ -131,6 +131,51 @@ describe('shared agent-hook-listener', () => {
expect(resumedElsewhere?.providerSession?.cwd).toBe('/elsewhere')
})
it("records a Claude session's live effort and keeps it across hooks that omit it", () => {
const sessionId = '88888888-8888-4888-8888-888888888888'
const prompt = normalizeAndAccept(state, 'claude', {
hook_event_name: 'UserPromptSubmit',
session_id: sessionId,
prompt: 'go'
})
normalizeAndAccept(state, 'claude', {
hook_event_name: 'PreToolUse',
session_id: sessionId,
tool_name: 'Bash',
effort: { level: 'max' }
})
// Subagent hooks name the parent session but report the subagent's own effort.
const subagent = normalizeAndAccept(state, 'claude', {
hook_event_name: 'PostToolUse',
session_id: sessionId,
agent_id: 'agent-1',
tool_name: 'Read',
effort: { level: 'low' }
})
const nextPrompt = normalizeAndAccept(state, 'claude', {
hook_event_name: 'UserPromptSubmit',
session_id: sessionId,
prompt: 'again'
})
// /clear starts a new session id in the same process, which keeps its effort.
const cleared = normalizeAndAccept(state, 'claude', {
hook_event_name: 'SessionStart',
source: 'clear',
session_id: '99999999-9999-4999-8999-999999999999'
})
const otherSession = normalizeAndAccept(state, 'claude', {
hook_event_name: 'SessionStart',
source: 'startup',
session_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
})
expect(prompt?.providerSession?.effort).toBeUndefined()
expect(subagent?.providerSession?.effort).toBe('max')
expect(nextPrompt?.providerSession?.effort).toBe('max')
expect(cleared?.providerSession?.effort).toBe('max')
expect(otherSession?.providerSession?.effort).toBeUndefined()
})
it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => {
normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' })
normalizeAndAccept(state, 'claude', {
@@ -1,4 +1,5 @@
import type { AgentHookSource } from '../agent-hook-relay'
import { readAgentEffortLevel } from '../agent-effort-level'
import {
extractAgentProviderSession,
readPathFromKeys,
@@ -25,28 +26,69 @@ export function readPaneProviderSession(
if (!providerSession) {
return null
}
const cwd = readAgentCwd(state, source, paneKey, providerSession.id, hookPayload)
return cwd ? { ...providerSession, cwd } : providerSession
const continued = continuedProviderSession(
state,
source,
paneKey,
providerSession.id,
hookPayload
)
const cwd = readAgentCwd(source, hookPayload, continued)
const effort = readClaudeEffort(source, hookPayload, continued)
return {
...providerSession,
...(cwd ? { cwd } : {}),
...(effort ? { effort } : {})
}
}
/** The folder the pane's agent started in. Only Claude/Codex hooks are known to report it (Cursor's cwd is a shell command's). */
function readAgentCwd(
/** The pane's previous session when this hook continues it; /clear and compact restart Claude in the same process. */
function continuedProviderSession(
state: HookListenerState,
source: AgentHookSource,
paneKey: string,
sessionId: string,
hookPayload: Record<string, unknown>
): string | undefined {
if (source !== 'claude' && source !== 'codex') {
return undefined
}
): AgentProviderSessionMetadata | undefined {
const previous = state.lastStatusByPaneKey.get(paneKey)
const previousCwd = previous?.source === source ? previous.providerSession?.cwd : undefined
// Claude's hook cwd follows its Bash tool's `cd`; /clear and compact restart in the same process.
const keepsPreviousFolder =
const continues =
previous?.providerSession?.id === sessionId ||
(source === 'claude' &&
hookPayload.hook_event_name === 'SessionStart' &&
(hookPayload.source === 'clear' || hookPayload.source === 'compact'))
return previousCwd && keepsPreviousFolder ? previousCwd : readPathFromKeys(hookPayload, ['cwd'])
return previous?.source === source && continues ? previous.providerSession : undefined
}
/** The folder the pane's agent started in. Only Claude/Codex hooks are known to report it (Cursor's cwd is a shell command's). */
function readAgentCwd(
source: AgentHookSource,
hookPayload: Record<string, unknown>,
continued: AgentProviderSessionMetadata | undefined
): string | undefined {
if (source !== 'claude' && source !== 'codex') {
return undefined
}
// Claude's hook cwd follows its Bash tool's `cd`.
return continued?.cwd ?? readPathFromKeys(hookPayload, ['cwd'])
}
/** Claude 2.1.133+ reports `effort.level` on tool and Stop hooks only, so other events keep the last one. */
function readClaudeEffort(
source: AgentHookSource,
hookPayload: Record<string, unknown>,
continued: AgentProviderSessionMetadata | undefined
): string | undefined {
if (source !== 'claude') {
return undefined
}
const effort = hookPayload.effort
// Why skip subagents: their hooks name the parent session but report the subagent's own effort.
const reported =
typeof effort === 'object' &&
effort !== null &&
'level' in effort &&
!readString(hookPayload, 'agent_id')
? readAgentEffortLevel(effort.level)
: undefined
return reported ?? continued?.effort
}
+8 -1
View File
@@ -11,7 +11,14 @@ export function getAgentResumeArgv(
case 'codebuddy':
return providerSession.key === 'session_id' ? ['codebuddy', '--resume', id] : null
case 'claude':
return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null
return providerSession.key === 'session_id'
? [
'claude',
...(providerSession.effort ? ['--effort', providerSession.effort] : []),
'--resume',
id
]
: null
case 'cursor':
return providerSession.key === 'conversation_id' ? ['cursor-agent', '--resume', id] : null
case 'codex':
@@ -565,3 +565,32 @@ describe('buildAgentResumeStartupPlan claude selector guard', () => {
expect(restored?.launchConfig.agentCommand).toBe("claude '--resume'")
})
})
describe('buildAgentResumeStartupPlan claude effort', () => {
it.each(SHELLS)(
"resumes at the session's effort after a stale launch flag ($platform/$shell)",
({ platform, shell }) => {
const session = { ...providerSession, effort: 'max' }
const restored = buildAgentResumeStartupPlan({
agent: 'claude',
providerSession: session,
cmdOverrides: {},
agentArgs: '--effort low',
platform,
shell
})
const command = restored?.launchCommand ?? ''
const tokens = tokenizeStartupCommand(command, shell)
// Claude keeps the last --effort it parses.
expect(tokens.ok && tokens.tokens.slice(-6)).toEqual([
'--effort',
'low',
'--effort',
'max',
...RESUME
])
// The saved launch recipe stays the user's own.
expect(restored?.launchConfig.agentArgs).toBe('--effort low')
}
)
})
@@ -61,6 +61,8 @@ export function recoveryRecordMatches(
existing.state === next.state &&
agentMainAgentVerdict(existing) === agentMainAgentVerdict(next) &&
agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) &&
// Why: effort is not session identity, but a stale level would resume at the wrong effort.
existing.providerSession.effort === next.providerSession.effort &&
launchConfigsEqual(existing.launchConfig, next.launchConfig)
)
}
@@ -18,6 +18,10 @@ export const AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY = 'agent-session.accou
// Send is strict: advertise skills before sending selected paths; name-based agents still use the $name text.
export const AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY = 'agent-session.send-skills.v1' as const
// Ensure is strict: send a resumed Claude session's effort only to hosts advertising support.
export const AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY =
'agent-session.resume-effort.v1' as const
export const AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY =
'agent-session.send-usage-limit-continuation.v1' as const
@@ -28,5 +32,6 @@ export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [
AGENT_SESSION_CREATE_TAB_PLACEMENT_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY,
AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY
AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY,
AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY
] as const
+17
View File
@@ -65,6 +65,11 @@ describe('agent session resume metadata', () => {
it.each([
['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']],
[
'claude',
{ key: 'session_id', id: 's1', effort: 'max' },
['claude', '--effort', 'max', '--resume', 's1']
],
['codebuddy', { key: 'session_id', id: 's1' }, ['codebuddy', '--resume', 's1']],
['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']],
['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']],
@@ -168,6 +173,18 @@ describe('agent session resume metadata', () => {
})
).toEqual({ key: 'session_id', id: 'ok' })
})
it('round-trips a Claude effort level and drops values unsafe for argv', () => {
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok', effort: 'xhigh' })).toEqual(
{ key: 'session_id', id: 'ok', effort: 'xhigh' }
)
for (const effort of ['--model', 'high; rm -rf /', 'HIGH', '', 3]) {
expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok', effort })).toEqual({
key: 'session_id',
id: 'ok'
})
}
})
})
describe('OMP recorded resume locators', () => {
+6 -9
View File
@@ -1,3 +1,4 @@
import { readAgentEffortLevel } from './agent-effort-level'
import type { AgentHookSource } from './agent-hook-relay'
import type { AgentStatusCacheIdentity, RefusedPaneResume } from './agent-status-ipc-payload'
import type { AgentStatusState } from './agent-status-types'
@@ -44,6 +45,8 @@ export type AgentProviderSessionMetadata = {
transcriptPath?: string
/** Folder the agent ran in; resume runs there, not the pane's start folder (#16960). */
cwd?: string
/** Claude's live effort level; resume replays it because Claude restores only its saved per-model default. */
effort?: string
}
export type SleepingAgentLaunchConfig = {
@@ -181,22 +184,16 @@ export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessio
// Persisted/relay paths cross trust boundaries too; reject controls as for hook paths.
const transcriptPath = readPathFromKeys(record, ['transcriptPath'])
const cwd = readPathFromKeys(record, ['cwd'])
const effort = readAgentEffortLevel(record.effort)
return {
key,
id,
...(transcriptPath ? { transcriptPath } : {}),
...(cwd ? { cwd } : {})
...(cwd ? { cwd } : {}),
...(effort ? { effort } : {})
}
}
/** Older hosts' strict ensureAgentSession schema rejects the newer `cwd` field. */
export function agentProviderSessionForEnsure(
providerSession: AgentProviderSessionMetadata
): AgentProviderSessionMetadata {
const { cwd: _cwd, ...identity } = providerSession
return identity
}
/** Compare the provider-owned values that identify the CLI resume target.
* Pi-family transcript resumes use file identity; other agents use provider ids. */
export function agentProviderSessionsEqual(
@@ -5,6 +5,7 @@ import {
getAgentResumeArgv,
hasUnsafeProviderSessionIdChars
} from '../agent-session-resume'
import { AGENT_EFFORT_LEVEL } from '../agent-effort-level'
import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority'
import type {
RuntimeCreateAgentSessionRequest,
@@ -110,7 +111,8 @@ export const ProviderSession = z
Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES,
'Invalid transcript path'
)
.optional()
.optional(),
effort: z.string().regex(AGENT_EFFORT_LEVEL, 'Invalid effort level').optional()
})
.strict()
@@ -148,6 +148,35 @@ describe('parseWorkspaceSession sleeping agents', () => {
}
})
it("preserves a Claude session's effort level through hydration", () => {
const providerSession = { key: 'session_id', id: 'claude-session', effort: 'max' }
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab1:pane-1': {
paneKey: 'tab1:pane-1',
tabId: 'tab1',
worktreeId: 'wt',
agent: 'claude',
providerSession,
prompt: '',
state: 'done',
capturedAt: 10,
updatedAt: 10,
origin: 'live'
}
}
})
expect(
result.ok && result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession
).toEqual(providerSession)
})
it('preserves the AI Vault OMP resume file through hydration', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
@@ -4,6 +4,7 @@ import {
normalizeAgentProviderSession,
RESUMABLE_TUI_AGENTS
} from './agent-session-resume'
import { AGENT_EFFORT_LEVEL } from './agent-effort-level'
import { isValidTerminalTabId } from './terminal-tab-id'
import { normalizeMainAgentStatusField } from './agent-status-types'
import { salvagingRecord } from './zod-salvage'
@@ -20,7 +21,8 @@ const agentProviderSessionSchema = z.preprocess(
id: z.string().min(1).max(512),
// Pi resume requires its persisted authoritative transcript path.
transcriptPath: z.string().min(1).optional(),
cwd: z.string().min(1).optional()
cwd: z.string().min(1).optional(),
effort: z.string().regex(AGENT_EFFORT_LEVEL).optional()
})
)