mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user