mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(agent-map): resume selected retained agents
This commit is contained in:
@@ -534,7 +534,8 @@ describe('dashboard payload validation', () => {
|
||||
worktreeId: 'worktree-1',
|
||||
executionHostId: 'runtime:env-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: null
|
||||
leafId: null,
|
||||
paneKey: 'tab-1:leaf-1'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
@@ -549,5 +550,14 @@ describe('dashboard payload validation', () => {
|
||||
expect(
|
||||
isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', tabId: '' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardRevealAgentArgs({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: null,
|
||||
paneKey: ''
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,7 +78,8 @@ export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRe
|
||||
(isBoundedString(args.executionHostId, MAX_ID_LENGTH) &&
|
||||
normalizeExecutionHostId(args.executionHostId) !== null)) &&
|
||||
isBoundedString(args.tabId, MAX_ID_LENGTH) &&
|
||||
(args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH))
|
||||
(args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) &&
|
||||
(args.paneKey === undefined || isBoundedString(args.paneKey, MAX_ID_LENGTH))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ describe('AgentTerminalDialog', () => {
|
||||
worktreeId: 'w1',
|
||||
executionHostId: 'runtime:env-1',
|
||||
tabId: 'tab1',
|
||||
leafId: 'leaf1'
|
||||
leafId: 'leaf1',
|
||||
paneKey: 'tab1:leaf1'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ function AgentTerminalFrame({
|
||||
worktreeId: card.worktreeId,
|
||||
executionHostId: card.executionHostId,
|
||||
tabId: card.tabId,
|
||||
leafId: card.leafId
|
||||
leafId: card.leafId,
|
||||
paneKey: card.paneKey
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
function makeCompletedRecord(
|
||||
overrides: Partial<SleepingAgentSessionRecord> = {}
|
||||
): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: makePaneKey('closed-tab', LEAF_ID),
|
||||
tabId: 'closed-tab',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeTerminalTab(id: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('selected completed agent resume', () => {
|
||||
it('resumes the exact completed record when its pane is gone', () => {
|
||||
const selected = makeCompletedRecord()
|
||||
const other = makeCompletedRecord({
|
||||
paneKey: makePaneKey('other-closed-tab', LEAF_ID),
|
||||
tabId: 'other-closed-tab',
|
||||
providerSession: { key: 'session_id', id: 'sess-2' }
|
||||
})
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[selected.paneKey]: selected,
|
||||
[other.paneKey]: other
|
||||
}
|
||||
} as never)
|
||||
|
||||
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', {
|
||||
resumeCompletedPaneKey: selected.paneKey
|
||||
})
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(launched).toBe(1)
|
||||
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(state.tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude')
|
||||
expect(state.sleepingAgentSessionsByPaneKey[selected.paneKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a preserved selected pane for in-place cold restore', () => {
|
||||
const record = makeCompletedRecord({
|
||||
paneKey: makePaneKey('tab-1', LEAF_ID),
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'wt-1',
|
||||
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1')] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
} as never)
|
||||
|
||||
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', {
|
||||
resumeCompletedPaneKey: record.paneKey
|
||||
})
|
||||
|
||||
expect(launched).toBe(0)
|
||||
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('does not duplicate a selected provider session that is already active', () => {
|
||||
const record = makeCompletedRecord()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [makeTerminalTab('live-tab')] },
|
||||
agentStatusByPaneKey: {
|
||||
'live-tab:leaf-1': {
|
||||
paneKey: 'live-tab:leaf-1',
|
||||
tabId: 'live-tab',
|
||||
worktreeId: 'wt-1',
|
||||
agentType: 'claude',
|
||||
state: 'working',
|
||||
providerSession: record.providerSession
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
} as never)
|
||||
|
||||
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', {
|
||||
resumeCompletedPaneKey: record.paneKey
|
||||
})
|
||||
|
||||
expect(launched).toBe(0)
|
||||
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -68,6 +68,25 @@ function getNewestActiveRecordsByClaimKey(
|
||||
return newestRecords
|
||||
}
|
||||
|
||||
function getPreservedPassiveClaimOwner(
|
||||
record: SleepingAgentSessionRecord,
|
||||
records: readonly SleepingAgentSessionRecord[],
|
||||
state: ReturnType<typeof useAppStore.getState>,
|
||||
excludedPaneKey?: string
|
||||
): SleepingAgentSessionRecord | null {
|
||||
const claimKey = getProviderSessionClaimKey(record)
|
||||
return (
|
||||
records.find(
|
||||
(candidate) =>
|
||||
candidate.paneKey !== excludedPaneKey &&
|
||||
state.sleepingAgentSessionsByPaneKey[candidate.paneKey] === candidate &&
|
||||
isPassiveCompletedHibernationEvidence(candidate) &&
|
||||
getProviderSessionClaimKey(candidate) === claimKey &&
|
||||
recordPaneIsOwnedByPreservedPane(candidate, state)
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentStatusTabId(entry: {
|
||||
paneKey: string
|
||||
tabId?: string | undefined
|
||||
@@ -181,10 +200,38 @@ export function resumeSleepingAgentSessionsForWorktree(
|
||||
}
|
||||
const isPaneOwned = recordPaneIsOwnedByPreservedPane(record, currentState)
|
||||
if (isPassiveCompletedHibernationEvidence(record)) {
|
||||
// Why: completed-agent hibernation is passive history; activation should
|
||||
// only keep displayable evidence, never start new work from it.
|
||||
if (!isPaneOwned || activeClaimKeys.has(claimKey)) {
|
||||
const explicitlySelected = options?.resumeCompletedPaneKey === record.paneKey
|
||||
// Why: completed-agent hibernation stays passive unless the user opened
|
||||
// this exact card; broad workspace activation must not restart history.
|
||||
if (!explicitlySelected) {
|
||||
if (!isPaneOwned || activeClaimKeys.has(claimKey)) {
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
activeClaimKeys.has(claimKey) ||
|
||||
activeOrQueuedResumeClaimsProviderSession(record, currentState, isPaneOwned)
|
||||
) {
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
continue
|
||||
}
|
||||
const preservedOwner = getPreservedPassiveClaimOwner(
|
||||
record,
|
||||
validWorktreeRecords,
|
||||
currentState,
|
||||
options?.forceFreshSelectedCompletion ? record.paneKey : undefined
|
||||
)
|
||||
if (preservedOwner) {
|
||||
if (preservedOwner !== record) {
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (launchSleepingAgentSession(record, options)) {
|
||||
launched += 1
|
||||
freshlyLaunchedClaimKeys.add(claimKey)
|
||||
clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -53,6 +53,28 @@ describe('revealAgentPane', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requests only the selected retained card and focuses its replacement tab', () => {
|
||||
mocks.activateAndRevealWorkspace.mockReturnValue({
|
||||
primaryTabId: 'shell-tab',
|
||||
resumedAgentTabId: 'resumed-tab'
|
||||
})
|
||||
mocks.tabsByWorktree = { 'wt-1': [{ id: 'shell-tab' }, { id: 'resumed-tab' }] }
|
||||
|
||||
expect(
|
||||
revealAgentPane({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'closed-tab',
|
||||
leafId: 'closed-leaf',
|
||||
paneKey: 'closed-tab:closed-leaf'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
|
||||
resumeCompletedPaneKey: 'closed-tab:closed-leaf'
|
||||
})
|
||||
expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('resumed-tab', null, {})
|
||||
})
|
||||
|
||||
// Dashboard/agent-map cards carry `folder:<id>` in worktreeId; the dispatcher
|
||||
// inside activateAndRevealWorkspace is what routes those to the folder path.
|
||||
it('passes a folder workspace key straight through to the dispatcher', () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ export type RevealAgentPaneTarget = {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string | null
|
||||
/** Exact retained card to resume when its completed pane no longer exists. */
|
||||
paneKey?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
@@ -35,10 +37,10 @@ export function revealAgentPane(
|
||||
target: RevealAgentPaneTarget,
|
||||
options?: RevealAgentPaneOptions
|
||||
): boolean {
|
||||
const activated = activateAndRevealWorkspace(
|
||||
target.worktreeId,
|
||||
target.executionHostId ? { executionHostId: target.executionHostId } : {}
|
||||
)
|
||||
const activated = activateAndRevealWorkspace(target.worktreeId, {
|
||||
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
|
||||
...(target.paneKey ? { resumeCompletedPaneKey: target.paneKey } : {})
|
||||
})
|
||||
if (!activated) {
|
||||
options?.onTargetUnavailable?.()
|
||||
return false
|
||||
@@ -46,13 +48,15 @@ export function revealAgentPane(
|
||||
// Why: read tabs AFTER activation — waking a slept agent can add its `--resume`
|
||||
// tab, and a husk tab can be replaced rather than revived.
|
||||
const tabs = useAppStore.getState().tabsByWorktree[target.worktreeId] ?? []
|
||||
if (!tabs.some((tab) => tab.id === target.tabId)) {
|
||||
const targetTabExists = tabs.some((tab) => tab.id === target.tabId)
|
||||
const focusTabId = activated.resumedAgentTabId ?? (targetTabExists ? target.tabId : undefined)
|
||||
if (!focusTabId || !tabs.some((tab) => tab.id === focusTabId)) {
|
||||
// Why: the wake already focused the replacement tab; re-activating the dead
|
||||
// id would yank the user back to a tab that no longer renders.
|
||||
options?.onTargetUnavailable?.()
|
||||
return false
|
||||
}
|
||||
activateTabAndFocusPane(target.tabId, target.leafId, {
|
||||
activateTabAndFocusPane(focusTabId, activated.resumedAgentTabId ? null : target.leafId, {
|
||||
...(options?.ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess: options.ackPaneKeyOnSuccess } : {}),
|
||||
...(options?.flashFocusedPane ? { flashFocusedPane: true } : {}),
|
||||
...(options?.scrollToBottomIfOutputSinceLastView
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getKnownWorktreeById: vi.fn(),
|
||||
getLocalProjectExecutionRuntimeContext: vi.fn(),
|
||||
resolveAgentResumeLaunchTarget: vi.fn(() => ({ platform: 'linux', shell: undefined })),
|
||||
queueTabStartupCommand: vi.fn(),
|
||||
createTab: vi.fn(() => ({ id: 'resumed-tab' })),
|
||||
state: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
|
||||
vi.mock('@/lib/tui-agent-startup', () => ({
|
||||
buildAgentResumeStartupPlan: () => ({ launchCommand: "claude '--resume' 'session-1'" })
|
||||
}))
|
||||
vi.mock('@/lib/telemetry', () => ({ tuiAgentToAgentKind: () => 'claude' }))
|
||||
vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: () => [] }))
|
||||
vi.mock('@/lib/agent-resume-launch-target', () => ({
|
||||
resolveAgentResumeLaunchTarget: mocks.resolveAgentResumeLaunchTarget
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getExecutionHostIdForWorktree: () => 'local'
|
||||
}))
|
||||
vi.mock('@/lib/local-preflight-context', () => ({
|
||||
getLocalProjectExecutionRuntimeContext: mocks.getLocalProjectExecutionRuntimeContext
|
||||
}))
|
||||
vi.mock('../../../shared/tui-agent-launch-defaults', () => ({
|
||||
resolveTuiAgentLaunchArgs: () => '',
|
||||
resolveTuiAgentLaunchEnv: () => ({})
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch')
|
||||
|
||||
const record: SleepingAgentSessionRecord = {
|
||||
paneKey: 'closed-tab:leaf-1',
|
||||
tabId: 'closed-tab',
|
||||
worktreeId: 'colliding-worktree',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'finish',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
connectionId: 'remote-connection'
|
||||
}
|
||||
|
||||
describe('sleeping agent resume host collision', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getKnownWorktreeById.mockReturnValue({
|
||||
id: record.worktreeId,
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/worktree'
|
||||
})
|
||||
mocks.state = {
|
||||
getKnownWorktreeById: mocks.getKnownWorktreeById,
|
||||
repos: [
|
||||
{ id: 'repo-1', path: 'C:\\local', executionHostId: 'local' },
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/remote',
|
||||
connectionId: 'remote-connection',
|
||||
executionHostId: 'ssh:remote-connection'
|
||||
}
|
||||
],
|
||||
settings: { agentCmdOverrides: {} },
|
||||
tabsByWorktree: { [record.worktreeId]: [] },
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
createTab: mocks.createTab,
|
||||
queueTabStartupCommand: mocks.queueTabStartupCommand,
|
||||
claimAutomaticAgentResume: vi.fn(),
|
||||
clearSleepingAgentSession: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
setTabBarOrder: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a colliding local WSL project override an SSH resume target', () => {
|
||||
expect(launchSleepingAgentSession(record, { executionHostId: 'ssh:remote-connection' })).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith(
|
||||
record.worktreeId,
|
||||
'ssh:remote-connection'
|
||||
)
|
||||
expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled()
|
||||
expect(mocks.resolveAgentResumeLaunchTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectRuntime: undefined,
|
||||
connectionId: 'remote-connection',
|
||||
executionHostId: 'ssh:remote-connection',
|
||||
worktreePath: '/remote/worktree'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,10 +14,19 @@ import {
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type ResumeSleepingAgentSessionsOptions = {
|
||||
suppressNavigation?: boolean
|
||||
/** Exact completed pane the user explicitly asked to reopen. Generic
|
||||
* workspace activation keeps completed hibernation evidence passive. */
|
||||
resumeCompletedPaneKey?: string
|
||||
/** Folder panes do not cold-restore on activation; replace only the selected
|
||||
* completion before its preserved pane can claim the provider session. */
|
||||
forceFreshSelectedCompletion?: boolean
|
||||
/** Pins resume launch resolution when workspace ids collide across hosts. */
|
||||
executionHostId?: ExecutionHostId
|
||||
/** Provider-session claim keys already woken in place by mounted panes
|
||||
* (WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT). Their sleeping records are
|
||||
* cleared only after the in-place spawn succeeds, so the generic resume
|
||||
@@ -26,17 +35,33 @@ export type ResumeSleepingAgentSessionsOptions = {
|
||||
/** Called with the tab id of each freshly launched resume tab, so
|
||||
* navigation-suppressed callers can background-mount exactly those tabs. */
|
||||
onSessionLaunched?: (tabId: string) => void
|
||||
/** Reports only the explicitly selected completed record's replacement. */
|
||||
onSelectedCompletedSessionLaunched?: (tabId: string) => void
|
||||
}
|
||||
|
||||
function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget {
|
||||
function getResumeLaunchTarget(
|
||||
record: SleepingAgentSessionRecord,
|
||||
executionHostId?: ExecutionHostId
|
||||
): AgentResumeLaunchTarget {
|
||||
const state = useAppStore.getState()
|
||||
const worktree = state.getKnownWorktreeById(worktreeId)
|
||||
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
|
||||
const worktreeId = record.worktreeId
|
||||
const worktree = state.getKnownWorktreeById(worktreeId, executionHostId)
|
||||
const resolvedExecutionHostId =
|
||||
executionHostId ?? getExecutionHostIdForWorktree(state, worktreeId)
|
||||
const repo = worktree
|
||||
? (state.repos.find(
|
||||
(entry) =>
|
||||
entry.id === worktree.repoId && getRepoExecutionHostId(entry) === resolvedExecutionHostId
|
||||
) ?? null)
|
||||
: null
|
||||
// The resume tab is created without a shell override, so the global Windows shell wins.
|
||||
return resolveAgentResumeLaunchTarget({
|
||||
projectRuntime: getLocalProjectExecutionRuntimeContext(state, worktreeId),
|
||||
connectionId: repo?.connectionId,
|
||||
executionHostId: getExecutionHostIdForWorktree(state, worktreeId),
|
||||
projectRuntime:
|
||||
resolvedExecutionHostId === 'local'
|
||||
? getLocalProjectExecutionRuntimeContext(state, worktreeId)
|
||||
: undefined,
|
||||
connectionId: repo?.connectionId ?? record.connectionId,
|
||||
executionHostId: resolvedExecutionHostId,
|
||||
worktreePath: worktree?.path,
|
||||
terminalWindowsShell: state.settings?.terminalWindowsShell
|
||||
})
|
||||
@@ -68,7 +93,7 @@ export function launchSleepingAgentSession(
|
||||
): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const launchConfig = record.launchConfig
|
||||
const resumeTarget = getResumeLaunchTarget(record.worktreeId)
|
||||
const resumeTarget = getResumeLaunchTarget(record, options?.executionHostId)
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: record.agent,
|
||||
providerSession: record.providerSession,
|
||||
@@ -130,5 +155,8 @@ export function launchSleepingAgentSession(
|
||||
}
|
||||
appendTabToWorktreeOrder(record.worktreeId, tab.id)
|
||||
options?.onSessionLaunched?.(tab.id)
|
||||
if (options?.resumeCompletedPaneKey === record.paneKey) {
|
||||
options.onSelectedCompletedSessionLaunched?.(tab.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ProjectGroup } from '../../../shared/project-group-types'
|
||||
import type { FolderWorkspacePathStatusReason } from '../../../shared/folder-workspace-path-status'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { resolveWindowShortcutAction } from '../../../shared/window-shortcut-policy'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
|
||||
// Why: drives the real activation module against a real store, so a regression in
|
||||
// the workspace dispatch or in the path-status guard itself both surface here.
|
||||
@@ -63,7 +65,9 @@ async function seedFolderWorkspace(
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceKey: null,
|
||||
activeView: 'terminal',
|
||||
folderWorkspacePathStatuses: {}
|
||||
folderWorkspacePathStatuses: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
tabsByWorktree: {}
|
||||
})
|
||||
mocks.getPathStatus.mockResolvedValue({ path: folderWorkspace.folderPath, ...status })
|
||||
// Why the real fetch: it writes the cache under the exact key + request snapshot
|
||||
@@ -94,6 +98,32 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => {
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a selected completion retryable when folder activation is blocked', async () => {
|
||||
await seedFolderWorkspace({ exists: false, reason: 'missing' })
|
||||
const worktreeId = folderWorkspaceKey(folderWorkspace.id)
|
||||
const record = {
|
||||
paneKey: makePaneKey('completed-tab', '11111111-1111-4111-8111-111111111111'),
|
||||
tabId: 'completed-tab',
|
||||
worktreeId,
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'completed-session' },
|
||||
prompt: 'completed task',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
} satisfies SleepingAgentSessionRecord
|
||||
store.setState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } })
|
||||
|
||||
const result = activateAndRevealWorkspace(worktreeId, {
|
||||
resumeCompletedPaneKey: record.paneKey
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
expect(store.getState().tabsByWorktree[worktreeId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('blocks the number shortcut when the SSH connection owning the folder is ambiguous', async () => {
|
||||
await seedFolderWorkspace({ exists: false, reason: 'ambiguous-connection' })
|
||||
|
||||
@@ -112,6 +142,124 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => {
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attributes a mixed wake to the explicitly selected completed folder agent', async () => {
|
||||
await seedFolderWorkspace({ exists: true })
|
||||
const worktreeId = folderWorkspaceKey(folderWorkspace.id)
|
||||
const completed = {
|
||||
paneKey: makePaneKey('completed-tab', '11111111-1111-4111-8111-111111111111'),
|
||||
tabId: 'completed-tab',
|
||||
worktreeId,
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'completed-session' },
|
||||
prompt: 'completed task',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
} satisfies SleepingAgentSessionRecord
|
||||
const active = {
|
||||
...completed,
|
||||
paneKey: makePaneKey('active-tab', '22222222-2222-4222-8222-222222222222'),
|
||||
tabId: 'active-tab',
|
||||
providerSession: { key: 'session_id', id: 'active-session' },
|
||||
prompt: 'active task',
|
||||
state: 'working',
|
||||
capturedAt: 2,
|
||||
updatedAt: 2
|
||||
} satisfies SleepingAgentSessionRecord
|
||||
store.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[completed.paneKey]: completed,
|
||||
[active.paneKey]: active
|
||||
}
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorkspace(worktreeId, {
|
||||
resumeCompletedPaneKey: completed.paneKey
|
||||
})
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
if (!result) {
|
||||
throw new Error('folder workspace activation unexpectedly failed')
|
||||
}
|
||||
const state = store.getState()
|
||||
expect(state.tabsByWorktree[worktreeId]).toHaveLength(2)
|
||||
const resumedAgentTabId = result.resumedAgentTabId
|
||||
if (!resumedAgentTabId) {
|
||||
throw new Error('selected completed agent did not receive a replacement tab')
|
||||
}
|
||||
expect(state.pendingStartupByTabId[resumedAgentTabId]?.resumeProviderSession).toEqual(
|
||||
completed.providerSession
|
||||
)
|
||||
const otherTabId = state.tabsByWorktree[worktreeId]?.find(
|
||||
(tab) => tab.id !== resumedAgentTabId
|
||||
)?.id
|
||||
if (!otherTabId) {
|
||||
throw new Error('active sleeping agent did not receive a resume tab')
|
||||
}
|
||||
expect(state.pendingStartupByTabId[otherTabId]?.resumeProviderSession).toEqual(
|
||||
active.providerSession
|
||||
)
|
||||
})
|
||||
|
||||
it('fresh-resumes the selected folder agent when its preserved pane cannot cold-restore', async () => {
|
||||
await seedFolderWorkspace({ exists: true })
|
||||
const worktreeId = folderWorkspaceKey(folderWorkspace.id)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const record = {
|
||||
paneKey: makePaneKey('completed-tab', leafId),
|
||||
tabId: 'completed-tab',
|
||||
worktreeId,
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'completed-session' },
|
||||
prompt: 'completed task',
|
||||
state: 'done',
|
||||
origin: 'worktree-sleep',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
} satisfies SleepingAgentSessionRecord
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: 'completed-tab',
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'completed-tab': {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: 'dead-pty' }
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorkspace(worktreeId, {
|
||||
resumeCompletedPaneKey: record.paneKey
|
||||
})
|
||||
|
||||
if (!result || !result.resumedAgentTabId) {
|
||||
throw new Error('selected folder agent did not receive a replacement tab')
|
||||
}
|
||||
expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
|
||||
result.resumedAgentTabId
|
||||
])
|
||||
expect(
|
||||
store.getState().pendingStartupByTabId[result.resumedAgentTabId]?.resumeProviderSession
|
||||
).toEqual(record.providerSession)
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why per AGENTS.md: the gate must hold on Windows/Linux Ctrl too, not just Cmd.
|
||||
// The digit chord resolves to the same jumpToWorktreeIndex action on every platform,
|
||||
// so all three land on the guarded activator this suite pins above.
|
||||
|
||||
@@ -185,6 +185,32 @@ export type ActivateAndRevealResult = {
|
||||
/** Id of the primary terminal tab seeded with `opts.startup`, or null. Prefer this over
|
||||
* `activeTabIdByWorktree`, which may point at another tab if setup/issue scripts opened their own. */
|
||||
primaryTabId: string | null
|
||||
/** Fresh tab created for an explicitly selected completed agent. */
|
||||
resumedAgentTabId?: string
|
||||
}
|
||||
|
||||
type WorkspaceAgentResumeOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
resumeCompletedPaneKey?: string
|
||||
}
|
||||
|
||||
function resumeAgentsForWorkspace(
|
||||
workspaceId: string,
|
||||
opts?: WorkspaceAgentResumeOptions,
|
||||
forceFreshSelectedCompletion = false
|
||||
): string | null {
|
||||
let resumedTabId: string | null = null
|
||||
resumeSleepingAgentSessionsForWorktree(workspaceId, {
|
||||
...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}),
|
||||
...(opts?.resumeCompletedPaneKey
|
||||
? { resumeCompletedPaneKey: opts.resumeCompletedPaneKey }
|
||||
: {}),
|
||||
...(forceFreshSelectedCompletion ? { forceFreshSelectedCompletion: true } : {}),
|
||||
onSelectedCompletedSessionLaunched: (tabId) => {
|
||||
resumedTabId = tabId
|
||||
}
|
||||
})
|
||||
return resumedTabId
|
||||
}
|
||||
|
||||
function ensureFolderWorkspaceInitialTerminal(
|
||||
@@ -211,6 +237,7 @@ export function activateAndRevealFolderWorkspace(
|
||||
startup?: WorktreeStartupPayload
|
||||
runtimeEnvironmentId?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
resumeCompletedPaneKey?: string
|
||||
}
|
||||
): ActivateAndRevealResult | false {
|
||||
const state = useAppStore.getState()
|
||||
@@ -260,8 +287,12 @@ export function activateAndRevealFolderWorkspace(
|
||||
if (!state.isNavigatingHistory) {
|
||||
state.recordWorktreeVisit(workspaceKey)
|
||||
}
|
||||
resumeSleepingAgentSessionsForWorktree(workspaceKey)
|
||||
const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
|
||||
const resumedTabId = resumeAgentsForWorkspace(
|
||||
workspaceKey,
|
||||
opts,
|
||||
Boolean(opts?.resumeCompletedPaneKey)
|
||||
)
|
||||
const ensuredTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
|
||||
|
||||
if (opts?.sidebarRevealBehavior) {
|
||||
state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior })
|
||||
@@ -269,7 +300,10 @@ export function activateAndRevealFolderWorkspace(
|
||||
state.revealWorktreeInSidebar(workspaceKey)
|
||||
}
|
||||
|
||||
return { primaryTabId }
|
||||
return {
|
||||
primaryTabId: ensuredTabId,
|
||||
...(resumedTabId ? { resumedAgentTabId: resumedTabId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function activateAndRevealWorktree(
|
||||
@@ -284,6 +318,7 @@ export function activateAndRevealWorktree(
|
||||
notifyHostRuntime?: boolean
|
||||
revealInSidebar?: boolean
|
||||
executionHostId?: ExecutionHostId
|
||||
resumeCompletedPaneKey?: string
|
||||
backendStartupTerminalSpawned?: boolean
|
||||
}
|
||||
): ActivateAndRevealResult | false {
|
||||
@@ -336,7 +371,7 @@ export function activateAndRevealWorktree(
|
||||
}
|
||||
|
||||
// Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically.
|
||||
resumeSleepingAgentSessionsForWorktree(worktreeId)
|
||||
const resumedTabId = resumeAgentsForWorkspace(worktreeId, opts)
|
||||
|
||||
// 4. Ensure a focusable surface exists for externally-created worktrees
|
||||
const primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
@@ -382,7 +417,10 @@ export function activateAndRevealWorktree(
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId)
|
||||
}
|
||||
|
||||
return { primaryTabId }
|
||||
return {
|
||||
primaryTabId,
|
||||
...(resumedTabId ? { resumedAgentTabId: resumedTabId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void {
|
||||
@@ -743,7 +781,7 @@ function queueSetupAndIssueCommands(
|
||||
*/
|
||||
export function activateAndRevealWorkspace(
|
||||
workspaceId: string,
|
||||
opts?: { executionHostId?: ExecutionHostId }
|
||||
opts?: WorkspaceAgentResumeOptions
|
||||
): ActivateAndRevealResult | false {
|
||||
const workspaceScope = parseWorkspaceKey(workspaceId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
|
||||
@@ -212,6 +212,8 @@ export type DashboardRevealAgentArgs = {
|
||||
executionHostId?: ExecutionHostId
|
||||
tabId: string
|
||||
leafId: string | null
|
||||
/** Optional for mixed-version pop-outs; identifies retained resume evidence. */
|
||||
paneKey?: string
|
||||
}
|
||||
|
||||
export type DashboardSpawnAgentArgs = {
|
||||
|
||||
Reference in New Issue
Block a user