mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(agents): resume an agent from the folder it ran in, not the pane's start folder (#33527)
Resumed Claude/Codex agents come back in the folder they were started in: resume records save the agent's starting cwd from hooks (kept within a session and across Claude /clear and compact), persist it, and resume as `cd <folder> && <agent> --resume <id>` when it differs from the workspace root. A deleted folder now fails loudly at the cd instead of silently resuming in the root. Fixes #16960 Refs #15485
This commit is contained in:
@@ -91,7 +91,8 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends
|
||||
...resolveResumeLaunchRecipe(launchConfig),
|
||||
platform: launchRuntime.platform,
|
||||
shell: agentLaunchQuotingShell(launchRuntime),
|
||||
isRemote: Boolean(workspace.connectionId)
|
||||
isRemote: Boolean(workspace.connectionId),
|
||||
launchCwd: workspace.path
|
||||
})
|
||||
if (!startupPlan) {
|
||||
return null
|
||||
|
||||
@@ -123,6 +123,10 @@ export const ORCAD_MIGRATION_PATH_FIELDS: readonly {
|
||||
at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.providerSession.transcriptPath',
|
||||
kind: 'absolute'
|
||||
},
|
||||
{
|
||||
at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.providerSession.cwd',
|
||||
kind: 'absolute'
|
||||
},
|
||||
{
|
||||
at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.launchConfig.ompResumeFilePath',
|
||||
kind: 'absolute'
|
||||
|
||||
+2
-1
@@ -93,7 +93,8 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe
|
||||
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
|
||||
...resolveResumeLaunchRecipe(launchConfig),
|
||||
platform: resumeTarget.platform,
|
||||
shell: resumeTarget.shell
|
||||
shell: resumeTarget.shell,
|
||||
launchCwd: session.worktree?.path
|
||||
})
|
||||
if (!startupPlan) {
|
||||
return null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission'
|
||||
import { agentProviderSessionForEnsure } from '../../../../shared/agent-session-resume'
|
||||
import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent'
|
||||
import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability'
|
||||
import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace'
|
||||
@@ -3451,7 +3452,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
...keyboardOptions,
|
||||
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
|
||||
agent: launchAgentToSend!,
|
||||
providerSession: resumeProviderSessionToSend,
|
||||
providerSession: agentProviderSessionForEnsure(resumeProviderSessionToSend),
|
||||
...(launchConfigToSend?.ompResumeFilePath
|
||||
? { ompResumeFilePath: launchConfigToSend.ompResumeFilePath }
|
||||
: {}),
|
||||
|
||||
@@ -58,7 +58,8 @@ export function launchSleepingAgentSession(
|
||||
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
|
||||
...resolveResumeLaunchRecipe(launchConfig),
|
||||
platform: resumeTarget.platform,
|
||||
shell: resumeTarget.shell
|
||||
shell: resumeTarget.shell,
|
||||
launchCwd: state.getKnownWorktreeById(record.worktreeId)?.path
|
||||
})
|
||||
if (!startupPlan) {
|
||||
toast.error(
|
||||
|
||||
@@ -3,6 +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 { translate } from '../i18n/i18n'
|
||||
import { useAppStore } from '../store'
|
||||
import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability'
|
||||
@@ -109,7 +110,7 @@ export async function createWebRuntimeSessionTerminalResult(
|
||||
kind: 'explicit',
|
||||
worktree: toRuntimeWorktreeSelector(args.worktreeId),
|
||||
agent,
|
||||
providerSession: args.providerSession!,
|
||||
providerSession: agentProviderSessionForEnsure(args.providerSession!),
|
||||
...(args.launchConfig?.ompResumeFilePath
|
||||
? { ompResumeFilePath: args.launchConfig.ompResumeFilePath }
|
||||
: {}),
|
||||
|
||||
@@ -91,6 +91,46 @@ describe('shared agent-hook-listener', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('records the folder a Claude session started in, not the one its Bash tool moved to (#16960)', () => {
|
||||
const sessionId = '55555555-5555-4555-8555-555555555555'
|
||||
normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: sessionId,
|
||||
prompt: 'go',
|
||||
cwd: '/repo/pkg'
|
||||
})
|
||||
// Claude's hook cwd follows `cd` inside its Bash tool.
|
||||
const afterCd = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'PostToolUse',
|
||||
session_id: sessionId,
|
||||
tool_name: 'Bash',
|
||||
cwd: '/repo/pkg/sub'
|
||||
})
|
||||
const cleared = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'clear',
|
||||
session_id: '66666666-6666-4666-8666-666666666666',
|
||||
cwd: '/repo/pkg'
|
||||
})
|
||||
const clearedPrompt = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: '66666666-6666-4666-8666-666666666666',
|
||||
prompt: 'next',
|
||||
cwd: '/repo/pkg/sub'
|
||||
})
|
||||
const resumedElsewhere = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'resume',
|
||||
session_id: '77777777-7777-4777-8777-777777777777',
|
||||
cwd: '/elsewhere'
|
||||
})
|
||||
|
||||
expect(afterCd?.providerSession).toMatchObject({ id: sessionId, cwd: '/repo/pkg' })
|
||||
expect(cleared?.providerSession?.cwd).toBe('/repo/pkg')
|
||||
expect(clearedPrompt?.providerSession?.cwd).toBe('/repo/pkg')
|
||||
expect(resumedElsewhere?.providerSession?.cwd).toBe('/elsewhere')
|
||||
})
|
||||
|
||||
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', {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from './agent-hook-listener/listener-state'
|
||||
import { normalizeHookPayload } from './agent-hook-listener'
|
||||
import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths'
|
||||
import { PANE_KEY } from './agent-hook-listener-test-harness'
|
||||
import { normalizeAndAccept, PANE_KEY } from './agent-hook-listener-test-harness'
|
||||
|
||||
describe('shared agent-hook-listener', () => {
|
||||
let state: HookListenerState
|
||||
@@ -234,6 +234,23 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(working?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the new folder when Codex forks a thread elsewhere (/cd, /worktree)', () => {
|
||||
normalizeAndAccept(state, 'codex', {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'thread-a',
|
||||
prompt: 'go',
|
||||
cwd: '/repo/a'
|
||||
})
|
||||
const forked = normalizeAndAccept(state, 'codex', {
|
||||
hook_event_name: 'SessionStart',
|
||||
session_id: 'thread-b',
|
||||
source: 'startup',
|
||||
cwd: '/repo/b'
|
||||
})
|
||||
|
||||
expect(forked?.providerSession).toMatchObject({ id: 'thread-b', cwd: '/repo/b' })
|
||||
})
|
||||
|
||||
it('clears stale Droid tool input when a same-tool update has explicit unpreviewable input', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentHookSource } from '../agent-hook-relay'
|
||||
import {
|
||||
extractAgentProviderSession,
|
||||
readPathFromKeys,
|
||||
type AgentProviderSessionMetadata
|
||||
} from '../agent-session-resume'
|
||||
import type { HookListenerState } from './listener-state'
|
||||
@@ -17,8 +18,35 @@ export function readPaneProviderSession(
|
||||
if (source === 'codex' && readString(hookPayload, 'agent_id')) {
|
||||
return null
|
||||
}
|
||||
return extractAgentProviderSession(
|
||||
const providerSession = extractAgentProviderSession(
|
||||
source,
|
||||
source === 'hermes' ? withHermesPaneSessionId(state, paneKey, hookPayload) : hookPayload
|
||||
)
|
||||
if (!providerSession) {
|
||||
return null
|
||||
}
|
||||
const cwd = readAgentCwd(state, source, paneKey, providerSession.id, hookPayload)
|
||||
return cwd ? { ...providerSession, cwd } : providerSession
|
||||
}
|
||||
|
||||
/** 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(
|
||||
state: HookListenerState,
|
||||
source: AgentHookSource,
|
||||
paneKey: string,
|
||||
sessionId: string,
|
||||
hookPayload: Record<string, unknown>
|
||||
): string | undefined {
|
||||
if (source !== 'claude' && source !== 'codex') {
|
||||
return 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 =
|
||||
previous?.providerSession?.id === sessionId ||
|
||||
(source === 'claude' &&
|
||||
hookPayload.hook_event_name === 'SessionStart' &&
|
||||
(hookPayload.source === 'clear' || hookPayload.source === 'compact'))
|
||||
return previousCwd && keepsPreviousFolder ? previousCwd : readPathFromKeys(hookPayload, ['cwd'])
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export type AgentProviderSessionMetadata = {
|
||||
id: string
|
||||
/** Prefer hook-reported transcript paths: recent Claude filenames differ from session_id. Claude/Codex still resume by ID; Pi uses session_file as its --session locator. */
|
||||
transcriptPath?: string
|
||||
/** Folder the agent ran in; resume runs there, not the pane's start folder (#16960). */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type SleepingAgentLaunchConfig = {
|
||||
@@ -131,8 +133,8 @@ function readSessionId(record: Record<string, unknown>, keys: readonly string[])
|
||||
return null
|
||||
}
|
||||
|
||||
/** Read the hook-reported transcript path; recent Claude filenames cannot be reconstructed from session ID. */
|
||||
function readTranscriptPathFromKeys(
|
||||
/** Read a hook-reported path; recent Claude transcript filenames cannot be reconstructed from session ID. */
|
||||
export function readPathFromKeys(
|
||||
record: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): string | undefined {
|
||||
@@ -154,7 +156,7 @@ function withTranscriptPath(
|
||||
payload: Record<string, unknown>,
|
||||
keys: readonly string[] = ['transcript_path', 'transcriptPath']
|
||||
): AgentProviderSessionMetadata {
|
||||
const transcriptPath = readTranscriptPathFromKeys(payload, keys)
|
||||
const transcriptPath = readPathFromKeys(payload, keys)
|
||||
return transcriptPath ? { ...metadata, transcriptPath } : metadata
|
||||
}
|
||||
|
||||
@@ -176,8 +178,22 @@ export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessio
|
||||
return null
|
||||
}
|
||||
// Persisted/relay paths cross trust boundaries too; reject controls as for hook paths.
|
||||
const transcriptPath = readTranscriptPathFromKeys(record, ['transcriptPath'])
|
||||
return transcriptPath ? { key, id, transcriptPath } : { key, id }
|
||||
const transcriptPath = readPathFromKeys(record, ['transcriptPath'])
|
||||
const cwd = readPathFromKeys(record, ['cwd'])
|
||||
return {
|
||||
key,
|
||||
id,
|
||||
...(transcriptPath ? { transcriptPath } : {}),
|
||||
...(cwd ? { cwd } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -12,6 +12,9 @@ import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command'
|
||||
import { buildAiVaultResumeShellCommand } from './ai-vault-resume-command'
|
||||
import { normalizeRuntimePathForComparison } from './cross-platform-path'
|
||||
import { parseWslUncPath } from './wsl-paths'
|
||||
|
||||
export function buildAgentResumeStartupPlan(args: {
|
||||
agent: ResumableTuiAgent
|
||||
@@ -28,6 +31,8 @@ export function buildAgentResumeStartupPlan(args: {
|
||||
isRemote?: boolean
|
||||
/** The caller chose the terminal folder on purpose, so Codex must not ask for the recorded one. */
|
||||
resumeInLaunchCwd?: boolean
|
||||
/** Folder the resume shell starts in; an agent recorded elsewhere is resumed from its own folder (#16960). */
|
||||
launchCwd?: string | null
|
||||
/** Open a copy of the conversation instead of re-entering it; null for agents that cannot. */
|
||||
fork?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
@@ -68,7 +73,17 @@ export function buildAgentResumeStartupPlan(args: {
|
||||
...args,
|
||||
agentCommand: baseCommand.commandWithoutSessionOptions
|
||||
})
|
||||
const launchCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell)
|
||||
const resumeCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell)
|
||||
const agentCwd = args.providerSession.cwd
|
||||
const launchCommand =
|
||||
agentCwd && args.launchCwd && !args.resumeInLaunchCwd && !isSameFolder(agentCwd, args.launchCwd)
|
||||
? buildAiVaultResumeShellCommand({
|
||||
resumeCommand,
|
||||
cwd: agentCwd,
|
||||
platform: args.platform,
|
||||
shell
|
||||
})
|
||||
: resumeCommand
|
||||
const applied = baseCommand.appliedSessionOptions
|
||||
return {
|
||||
agent: args.agent,
|
||||
@@ -81,3 +96,11 @@ export function buildAgentResumeStartupPlan(args: {
|
||||
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** WSL panes start at a UNC path while the agent reports its Linux path. */
|
||||
function isSameFolder(agentCwd: string, launchCwd: string): boolean {
|
||||
const launchPath = parseWslUncPath(launchCwd)?.linuxPath ?? launchCwd
|
||||
return (
|
||||
normalizeRuntimePathForComparison(agentCwd) === normalizeRuntimePathForComparison(launchPath)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -455,6 +455,23 @@ describe('tui agent startup plans', () => {
|
||||
expect(plan?.startupCommandDelivery).toBe('shell-ready')
|
||||
})
|
||||
|
||||
it('resumes an agent from the folder it ran in when the pane starts elsewhere (#16960)', () => {
|
||||
const resume = (cwd: string, launchCwd: string) =>
|
||||
buildAgentResumeStartupPlan({
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 's1', cwd },
|
||||
cmdOverrides: {},
|
||||
platform: 'darwin',
|
||||
launchCwd
|
||||
})?.launchCommand
|
||||
|
||||
expect(resume('/repo/pkg', '/repo')).toBe("cd '/repo/pkg' && claude '--resume' 's1'")
|
||||
expect(resume('/repo/', '/repo')).toBe("claude '--resume' 's1'")
|
||||
expect(resume('/home/me/repo', '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).toBe(
|
||||
"claude '--resume' 's1'"
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes Windows resume argv for cmd.exe when shell is cmd', () => {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'grok',
|
||||
|
||||
@@ -19,7 +19,8 @@ const agentProviderSessionSchema = z.preprocess(
|
||||
key: z.enum(['session_id', 'conversation_id']),
|
||||
id: z.string().min(1).max(512),
|
||||
// Pi resume requires its persisted authoritative transcript path.
|
||||
transcriptPath: z.string().min(1).optional()
|
||||
transcriptPath: z.string().min(1).optional(),
|
||||
cwd: z.string().min(1).optional()
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user