fix(agent-resume): keep a background agent launch's flags when it is resumed (#33776)

* fix(agent-resume): record a background agent launch's recipe so resume keeps its flags

* test: narrow the reveal's notifier so the test needs no cast

* refactor(agent-resume): one recipe registration path; skip adopted panes

* test: add the launch-recipe listener to the remaining window mocks
This commit is contained in:
Jinwoo Hong
2026-10-26 01:49:54 -04:00
committed by GitHub
parent b0a33e03ea
commit a66f6bf011
22 changed files with 139 additions and 10 deletions
@@ -4,6 +4,7 @@ import type {
} from '../../shared/runtime-file-contracts'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result'
import type {
AgentLaunchTabPublished,
AgentLaunchTabViewerRule,
@@ -102,6 +103,8 @@ export type RuntimeNotifier = {
): Promise<AgentLaunchTabPublished>
/** A launch pane's fate, for the window to keep on its tab or act on. */
agentLaunchPaneVerdict?(event: AgentLaunchPaneVerdictEvent): void
/** A launch's recipe when no reveal carried it, so resume can replay it. */
rememberLaunchRecipe?(recipe: PaneLaunchRecipe): void
resolveLegacyWorkerTerminalRecovery?(
paneKey: string,
resolution: 'adopted' | 'exited' | 'rolled_back',
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { revealCreatedTerminal } from './terminal-create-reveal'
const launchConfig = { agentArgs: '--dangerously-skip-permissions', agentEnv: {} }
const recipe = {
tabId: 'tab',
leafId: 'leaf',
launchConfig,
launchToken: 'token',
launchAgent: 'claude'
}
async function reveal(args: {
presentation: 'background' | 'focused'
revealFails?: boolean
adoptedStablePane?: boolean
}) {
const notifier = {
revealTerminalSession: vi.fn(async () => {
if (args.revealFails) {
throw new Error('Terminal reveal timed out')
}
return { tabId: 'tab' }
}),
rememberLaunchRecipe: vi.fn()
}
const result = await revealCreatedTerminal({
notifier,
workspace: { id: 'wt', path: '/repo' },
ptyId: 'pty',
handle: 'term_1',
cwd: '/repo',
launchOpts: { launchAgent: 'claude' },
effectiveLaunchConfig: launchConfig,
launchToken: 'token',
adoptedStablePane: args.adoptedStablePane ?? false,
presentation: args.presentation,
opts: {},
tabId: 'tab',
leafId: 'leaf'
})
return { notifier, surface: result.surface }
}
describe('revealCreatedTerminal launch recipe', () => {
it.each([
{ name: 'a background launch', presentation: 'background' as const },
{ name: 'a launch whose reveal failed', presentation: 'focused' as const, revealFails: true }
])('tells the window the recipe of $name', async (args) => {
const { notifier, surface } = await reveal(args)
expect(surface).toBe('background')
expect(notifier.rememberLaunchRecipe).toHaveBeenCalledWith(recipe)
})
it('never re-registers an adopted pane, whose launch token is already spent', async () => {
const { notifier } = await reveal({ presentation: 'background', adoptedStablePane: true })
expect(notifier.rememberLaunchRecipe).not.toHaveBeenCalled()
})
})
@@ -4,7 +4,7 @@ import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-t
import type { RuntimeTerminalPresentation } from '../../../../shared/runtime-types'
export function revealCreatedTerminal(args: {
notifier: RuntimeNotifier | null
notifier: Pick<RuntimeNotifier, 'revealTerminalSession' | 'rememberLaunchRecipe'> | null
workspace: Pick<TerminalWorkspaceLaunchScope, 'id' | 'path'>
ptyId: string
handle: string
@@ -12,6 +12,8 @@ export function revealCreatedTerminal(args: {
launchOpts: dependencies.TerminalCreateOptions
effectiveLaunchConfig: dependencies.TerminalCreateOptions['launchConfig']
launchToken: string | undefined
/** An adopted pane already ran; its recipe and spent launch token must not be re-registered. */
adoptedStablePane: boolean
presentation: RuntimeTerminalPresentation | undefined
opts: dependencies.TerminalCreateOptions
tabId: string
@@ -28,12 +30,24 @@ export function revealCreatedTerminal(args: {
launchOpts,
effectiveLaunchConfig,
launchToken,
adoptedStablePane,
presentation,
opts,
tabId,
leafId
} = args
const reveal = notifier?.revealTerminalSession?.bind(notifier)
const rememberLaunchRecipe = (): void => {
if (effectiveLaunchConfig && !adoptedStablePane) {
notifier?.rememberLaunchRecipe?.({
tabId,
leafId,
launchConfig: effectiveLaunchConfig,
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {})
})
}
}
if (presentation !== 'background' && reveal) {
return (async () => {
try {
@@ -54,6 +68,7 @@ export function revealCreatedTerminal(args: {
return { surface: 'visible' as const }
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${ptyId}:`, err)
rememberLaunchRecipe()
return {
surface: 'background' as const,
warning: dependencies.createTerminalRevealWarning(handle, err)
@@ -61,6 +76,7 @@ export function revealCreatedTerminal(args: {
}
})()
}
rememberLaunchRecipe()
return {
surface: 'background',
warning:
@@ -279,6 +279,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
},
effectiveLaunchConfig: pty?.launchConfig ?? undefined,
launchToken: pty?.launchToken ?? undefined,
adoptedStablePane: launch.adoptedStablePane,
presentation,
opts,
tabId,
@@ -153,6 +153,7 @@ export function registerRuntimeWindowLifecycle(
publishAgentLaunchTab: (request) =>
requestAgentLaunchTabPublishFromRenderer(mainWindow, request),
agentLaunchPaneVerdict: (event) => send('ui:agentLaunchPaneVerdict', event),
rememberLaunchRecipe: (recipe) => send('ui:launchRecipe', recipe),
resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) =>
send('agentStatus:legacyWorkerTerminalRecovery', {
paneKey,
@@ -11,6 +11,7 @@ import type {
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result'
import type {
TerminalPaneRevealReply,
TerminalPaneRevealRequest
@@ -142,6 +143,11 @@ export const uiTerminalAndSessionTabsApi = {
ipcRenderer.on('ui:agentLaunchPaneVerdict', listener)
return () => ipcRenderer.removeListener('ui:agentLaunchPaneVerdict', listener)
},
onLaunchRecipe: (callback: (data: PaneLaunchRecipe) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: PaneLaunchRecipe) => callback(data)
ipcRenderer.on('ui:launchRecipe', listener)
return () => ipcRenderer.removeListener('ui:launchRecipe', listener)
},
onSplitTerminal: (
callback: (data: {
tabId: string
+2
View File
@@ -16,6 +16,7 @@ import type {
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result'
import type {
TerminalPaneRevealReply,
TerminalPaneRevealRequest
@@ -214,6 +215,7 @@ export type UiCommandEventApi = {
onPublishAgentLaunchTab: (callback: (data: AgentLaunchTabPublishRequest) => void) => () => void
replyAgentLaunchTabPublish: (reply: AgentLaunchTabPublishReply) => void
onAgentLaunchPaneVerdict: (callback: (data: AgentLaunchPaneVerdictEvent) => void) => () => void
onLaunchRecipe: (callback: (data: PaneLaunchRecipe) => void) => () => void
onRevealTerminalPane: (callback: (data: TerminalPaneRevealRequest) => void) => () => void
replyTerminalPaneReveal: (reply: TerminalPaneRevealReply) => void
onSplitTerminal: (
@@ -50,6 +50,7 @@ export function buildWindowApi(args: {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -180,6 +180,7 @@ export async function useIpcEventsForCloseRouting({
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -98,6 +98,7 @@ export function buildTerminalCreateWindow(args: {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
onFocusTerminal: (
@@ -162,6 +162,7 @@ export async function loadIpcEventsHarness(
createTerminalListener = listener
return () => {}
},
onLaunchRecipe: () => () => {},
onRequestTerminalCreate: (listener: (request: RequestTerminalCreateRequest) => void) => {
requestTerminalCreateListener = listener
return () => {}
@@ -0,0 +1,16 @@
import { useAppStore } from '../../store'
import type { PaneLaunchRecipe } from '../../../../shared/terminal-attachment-launch-result'
import { tryMakePaneKey } from './agent-status-routing'
/** Record a pane's launch recipe so a later resume replays its flags. */
export function registerLaunchRecipe(recipe: PaneLaunchRecipe): void {
const paneKey = tryMakePaneKey(recipe.tabId, recipe.leafId)
if (paneKey) {
useAppStore.getState().registerAgentLaunchConfig(paneKey, recipe.launchConfig, {
...(recipe.launchAgent ? { agentType: recipe.launchAgent } : {}),
...(recipe.launchToken ? { launchToken: recipe.launchToken } : {}),
tabId: recipe.tabId,
leafId: recipe.leafId
})
}
}
@@ -9,6 +9,7 @@ import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity'
import { insertUnifiedTabAfterAnchor } from '@/lib/unified-tab-anchor-insertion'
import { tryMakePaneKey } from './agent-status-routing'
import { registerLaunchRecipe } from './launch-recipe-registration'
import { wasAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes'
import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal'
import { useAppStore } from '../../store'
@@ -22,6 +23,7 @@ import {
export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): void {
unsubs.push(
window.api.ui.onLaunchRecipe(registerLaunchRecipe),
window.api.ui.onCreateTerminal(
({
requestId,
@@ -175,14 +177,13 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
if (leafId && ptyId) {
const launchPaneKey = tryMakePaneKey(tab.id, leafId)
if (launchConfig) {
if (launchPaneKey) {
store.registerAgentLaunchConfig(launchPaneKey, launchConfig, {
...(launchAgent ? { agentType: launchAgent } : {}),
...(launchToken ? { launchToken } : {}),
tabId: tab.id,
leafId
})
}
registerLaunchRecipe({
tabId: tab.id,
leafId,
launchConfig,
...(launchToken ? { launchToken } : {}),
...(launchAgent ? { launchAgent } : {})
})
} else if (!splitFromLeafId && launchPaneKey) {
store.clearAgentLaunchConfig(launchPaneKey)
}
@@ -168,6 +168,7 @@ describe('useIpcEvents browser tab create routing', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -139,6 +139,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -399,6 +400,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -142,6 +142,7 @@ describe('useIpcEvents browser tab close routing', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -370,6 +371,7 @@ describe('useIpcEvents browser tab close routing', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -593,6 +595,7 @@ describe('useIpcEvents browser tab close routing', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -151,6 +151,7 @@ describe('useIpcEvents updater integration', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -118,6 +118,7 @@ describe('useIpcEvents updater integration', () => {
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
onSplitTerminal: () => () => {},
@@ -145,6 +145,7 @@ function stubHost(
listener = next
return () => {}
},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: vi.fn()
}
@@ -59,6 +59,7 @@ beforeEach(() => {
revealTerminal = listener
return () => {}
},
onLaunchRecipe: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: vi.fn()
}
@@ -263,6 +263,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
replyTerminalCreate: () => {},
onPublishAgentLaunchTab: () => noopUnsubscribe,
onAgentLaunchPaneVerdict: () => noopUnsubscribe,
onLaunchRecipe: () => noopUnsubscribe,
replyAgentLaunchTabPublish: () => {},
onRevealTerminalPane: () => noopUnsubscribe,
replyTerminalPaneReveal: () => {},
@@ -1,10 +1,17 @@
import { z } from 'zod'
import type { TerminalLaunchResult } from './agent-session-resume'
import type { SleepingAgentLaunchConfig, TerminalLaunchResult } from './agent-session-resume'
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
import { ResumeProviderSessionParam } from './rpc-contract/terminal-unary-params'
import { sleepingAgentLaunchConfigBaseSchema } from './workspace-session-sleeping-agents'
/** A launch recipe for a pane the window was not shown; the reveal otherwise carries it. */
export type PaneLaunchRecipe = Pick<TerminalLaunchResult, 'launchToken' | 'launchAgent'> & {
tabId: string
leafId: string
launchConfig: SleepingAgentLaunchConfig
}
export type TerminalAttachmentLaunchResult = TerminalLaunchResult & {
ptyId: string
incarnationId: string