diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx index 0aef2c378865..becabddc7b83 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx @@ -151,7 +151,7 @@ describe('QuickLaunchAgentMenuItems launches', () => { it('gives each pick its own request, so a second pick opens its own chat', () => { launchMock.mockReset() launchMock.mockReturnValue({ - surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' } + surface: { kind: 'host-published' } }) render( { it("hands the launch's own delivery outcome to the notes menu", async () => { const delivery = Promise.resolve({ delivered: true, failureNotified: false }) launchMock.mockReturnValue({ - surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' }, + surface: { kind: 'host-published' }, promptDeliveryResult: delivery }) const onPromptHandedOff = vi.fn() @@ -199,7 +199,7 @@ describe('QuickLaunchAgentMenuItems launches', () => { // The notes keep their text, so they are the one place that says it did not go. it('tells the notes menu once why its notes did not reach the new agent', async () => { launchMock.mockReturnValue({ - surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' }, + surface: { kind: 'host-published' }, promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false, diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 900968b51855..ccf5a3d76295 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -197,20 +197,14 @@ describe('forkAgentSessionFromPane', () => { [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } } const result = { - surface: { - kind: 'local-agent-session', - tabId: 'structured-agent-session-session-1', - sessionId: 'session-1' - }, + surface: { kind: 'host-published' }, startupPlan: {}, pasteDraftAfterLaunch: false, structuredSettlement: new Promise(() => {}) } mockLaunchAgentInNewTab.mockImplementationOnce( - (args: { - beforeSurfaceOpen?: (surface: { kind: 'local-agent-session'; sessionId: string }) => void - }) => { - args.beforeSurfaceOpen?.({ kind: 'local-agent-session', sessionId: 'session-1' }) + (args: { beforeSurfaceOpen?: (surface: { kind: 'host-published' }) => void }) => { + args.beforeSurfaceOpen?.({ kind: 'host-published' }) return result } ) @@ -242,20 +236,14 @@ describe('forkAgentSessionFromPane', () => { [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } } const result = { - surface: { - kind: 'local-agent-session', - tabId: 'structured-agent-session-session-1', - sessionId: 'session-1' - }, + surface: { kind: 'host-published' }, startupPlan: {}, pasteDraftAfterLaunch: false, structuredSettlement: Promise.resolve(settlement) } mockLaunchAgentInNewTab.mockImplementationOnce( - (args: { - beforeSurfaceOpen?: (surface: { kind: 'local-agent-session'; sessionId: string }) => void - }) => { - args.beforeSurfaceOpen?.({ kind: 'local-agent-session', sessionId: 'session-1' }) + (args: { beforeSurfaceOpen?: (surface: { kind: 'host-published' }) => void }) => { + args.beforeSurfaceOpen?.({ kind: 'host-published' }) return result } ) diff --git a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx new file mode 100644 index 000000000000..fdc3b6222028 --- /dev/null +++ b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment happy-dom +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const answers: ((support: unknown) => void)[] = [] + return { + gate: vi.fn(), + createTab: vi.fn(), + createUnifiedTab: vi.fn(), + begin: vi.fn(), + answers + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => 'none', { + getState: () => ({ + activeWorktreeId: 'wt-1', + tabsByWorktree: {}, + closedTerminalTabTombstonesByTabId: {}, + unifiedTabsByWorktree: {}, + activeGroupIdByWorktree: {}, + createUnifiedTab: mocks.createUnifiedTab, + setActiveTabType: vi.fn() + }) + }) +})) +vi.mock('@/lib/worktree-agent-activation-gate', () => ({ gateWorktreeAgentActivation: mocks.gate })) +vi.mock('@/lib/resume-sleeping-agent-session', () => ({ + resumeSleepingAgentSessionsForWorktree: vi.fn() +})) +vi.mock('@/lib/workspace-terminal-host-authority', () => ({ + createWorkspaceTerminalHostAuthoritySelector: () => () => 'none' +})) +vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({ + pruneParkedTerminalWatchers: vi.fn(), + terminalWatcherLiveWorkspaceIds: () => new Set(), + syncParkedTerminalTabWatchersForWorkspaces: vi.fn(), + disposeAllParkedTerminalWatchers: vi.fn() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: (_target: unknown, method: string) => + method === 'agentSession.createSupport' + ? new Promise((resolve) => mocks.answers.push(resolve)) + : new Promise(() => undefined) +})) + +import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' +import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' +import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' +import type { Tab } from '../../../../shared/tab-types' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +let root: Root | undefined + +afterEach(async () => { + await act(async () => root?.unmount()) + root = undefined + mocks.answers.length = 0 + vi.clearAllMocks() +}) + +function Watcher(): null { + useTerminalWatcherEffects({ + activeWorktreeId: 'wt-1', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, + hydrationSucceeded: false, + workspaceSurfaceIds: [], + tabsByWorktree: {}, + createTab: mocks.createTab, + reconcileWorktreeTabModel: () => ({ renderableTabCount: 0, activeRenderableTabId: null }), + activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeView: 'terminal', + activityTerminalPortals: [], + anyMountedWorktreeHasLayout: false, + backgroundMountRevision: 0, + effectiveParkedTerminalWorktreeIds: new Set(), + evictionExemptTerminalTabIds: new Set(), + getEffectiveLayoutForWorktree: () => undefined, + groupsByWorktree: {}, + measurableBackgroundWorktreeIdsRef: { current: new Set() }, + mountedWorktreeIdsRef: { current: new Set() }, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: 'wt-1', + startupTerminalTabHold: null, + terminalParkingEnabled: false, + terminalProviderSnapshotCapabilityRevision: 0, + terminalSshParkingEnabled: false, + terminalTitleSnapshotAuthorityEnabled: false + }) + return null +} + +function localChatPlan(): AgentSessionLaunchPlan { + mocks.begin.mockReturnValue({ + sessionId: 'claude_1', + executionHostId: 'local', + settlement: new Promise(() => undefined), + cancel: vi.fn() + }) + mocks.createUnifiedTab.mockImplementation( + (worktreeId: string, _type: string, tab: Partial) => ({ + ...tab, + worktreeId + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: begin() is the only member this path calls. + return { + route: 'structured-native-chat', + requestId: 'request-1', + agent: 'claude', + worktreeId: 'wt-1', + executionHostId: 'local', + begin: mocks.begin, + launch: vi.fn() + } as unknown as AgentSessionLaunchPlan +} + +function gateAnsweredLater(): { + gate: Promise<'empty'> + finish: () => void +} { + let finish!: () => void + const gate = new Promise<'empty'>((resolve) => { + finish = () => resolve('empty') + }) + mocks.gate.mockReturnValue(gate) + return { gate, finish } +} + +// While this machine decides, a passive first-terminal seeder used to put "Terminal 1" beside the +// chat, since no launch record exists until the host answers. +describe('a local chat launch waiting on its host', () => { + it('keeps a watched create free of a stray shell until its chat opens', async () => { + const { finish } = gateAnsweredLater() + // A watched create reveals the workspace, then waits for this machine's answer. + beginStructuredAgentSessionProvisionalLaunch({ + plan: localChatPlan(), + hooks: {}, + beforeOpen: () => true + }) + expect(isEmptyWorkspaceDefaultSurfacePending('wt-1')).toBe(true) + + root = createRoot(document.createElement('div')) + await act(async () => root?.render(createElement(Watcher))) + await act(async () => finish()) + expect(mocks.createTab).not.toHaveBeenCalled() + + await act(async () => mocks.answers[0]?.({ supported: true })) + expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() + expect(mocks.createTab).not.toHaveBeenCalled() + }) + + it("keeps an empty workspace's default chat free of a shell seeded by the shared gate", async () => { + const { gate, finish } = gateAnsweredLater() + // The activation's reseed attaches first and opens the default agent as a chat. + void gate.then(() => + beginStructuredAgentSessionProvisionalLaunch({ plan: localChatPlan(), hooks: {} }) + ) + root = createRoot(document.createElement('div')) + // The watcher shares that in-flight gate and runs right after the reseed. + await act(async () => root?.render(createElement(Watcher))) + await act(async () => finish()) + expect(mocks.begin).not.toHaveBeenCalled() + expect(mocks.createTab).not.toHaveBeenCalled() + + await act(async () => mocks.answers[0]?.({ supported: true })) + expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() + expect(mocks.createTab).not.toHaveBeenCalled() + }) + + it('stops owning the surface once the host has answered and its surface opened', async () => { + const onHostDeclined = vi.fn(() => ({ opened: true })) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localChatPlan(), + hooks: {}, + onHostDeclined + }) + expect(isEmptyWorkspaceDefaultSurfacePending('wt-1')).toBe(true) + + await act(async () => mocks.answers[0]?.({ supported: false })) + await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' }) + expect(onHostDeclined).toHaveBeenCalledOnce() + expect(isEmptyWorkspaceDefaultSurfacePending('wt-1')).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-caller-routing.test.ts b/src/renderer/src/lib/agent-launch-caller-routing.test.ts index 553bea2aa058..fb79b9c746a1 100644 --- a/src/renderer/src/lib/agent-launch-caller-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-routing.test.ts @@ -13,12 +13,11 @@ import { } from './agent-launch-caller-profiles-test-harness' import { createLaunchFunnelStore, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' -import type * as PairedAdmissionModule from './structured-agent-session-paired-admission' +import type * as LaunchAdmissionModule from './structured-agent-session-launch-admission' const store = createLaunchFunnelStore() const mockIsWebRuntimeSessionActive = vi.fn(() => false) const mockLaunchAgentInWebHostTab = vi.fn() -const mockLaunchAgentInStructuredNewTab = vi.fn() -const mockBeginPairedStructuredLaunch = vi.fn() +const mockBeginHostAdmittedStructuredLaunch = vi.fn() const mockCreateSupport = vi.fn() const mockHostCapabilities = vi.fn<() => readonly string[] | null>(() => []) const mockExecutionHostId = vi.fn(() => 'local') @@ -60,13 +59,11 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: (_target: unknown, method: string) => method === 'agentSession.createSupport' ? mockCreateSupport() : new Promise(() => undefined) })) -vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } })) -vi.mock('@/lib/structured-agent-session-paired-admission', async (importOriginal) => ({ - ...(await importOriginal()), - beginPairedStructuredLaunch: mockBeginPairedStructuredLaunch -})) -vi.mock('@/lib/launch-agent-in-new-tab-structured', () => ({ - launchAgentInStructuredNewTab: mockLaunchAgentInStructuredNewTab +const { mockToastInfo } = vi.hoisted(() => ({ mockToastInfo: vi.fn() })) +vi.mock('sonner', () => ({ toast: { info: mockToastInfo, error: vi.fn(), success: vi.fn() } })) +vi.mock('@/lib/structured-agent-session-launch-admission', async (importOriginal) => ({ + ...(await importOriginal()), + beginHostAdmittedStructuredLaunch: mockBeginHostAdmittedStructuredLaunch })) vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => mockHostCapabilities() @@ -88,6 +85,15 @@ const CHAT_DEFAULT_SETTINGS = { const cases = callerProfileCases() +async function useActualAdmission(): Promise { + const actual = await vi.importActual( + './structured-agent-session-launch-admission' + ) + mockBeginHostAdmittedStructuredLaunch.mockImplementationOnce( + actual.beginHostAdmittedStructuredLaunch + ) +} + async function launch(profile: AgentLaunchCallerProfile) { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') return launchAgentInNewTab({ requestId: 'request-1', ...profile.args }) @@ -101,10 +107,11 @@ describe('agent launch caller routing', () => { mockExecutionHostId.mockReturnValue('local') serverReports(null) mockHostCapabilities.mockReturnValue([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) - mockLaunchAgentInStructuredNewTab.mockReturnValue({ - sessionId: 'session-1', - tabId: 'tab-structured', - structuredSettlement: Promise.resolve({ kind: 'structured' }) + mockBeginHostAdmittedStructuredLaunch.mockReturnValue({ + sessionId: null, + tab: null, + settlement: new Promise(() => undefined), + cancel: vi.fn() }) mockLaunchAgentInWebHostTab.mockResolvedValue({ delivered: true, failureNotified: false }) }) @@ -114,7 +121,7 @@ describe('agent launch caller routing', () => { expect(result?.surface.kind).toBe('local-terminal') expect(store.createTab).toHaveBeenCalledTimes(1) - expect(mockLaunchAgentInStructuredNewTab).not.toHaveBeenCalled() + expect(mockBeginHostAdmittedStructuredLaunch).not.toHaveBeenCalled() expect(mockLaunchAgentInWebHostTab).not.toHaveBeenCalled() }) @@ -147,34 +154,29 @@ describe('agent launch caller routing', () => { // Why: a caller-named cwd is a process shape only a PTY produces, so that profile is // structurally barred rather than merely unconfigured and stays terminal with the default on. const structurallyBarred = profile.id === 'session-continuation' - expect(result?.surface.kind).toBe( - structurallyBarred ? 'local-terminal' : 'local-agent-session' - ) + expect(result?.surface.kind).toBe(structurallyBarred ? 'local-terminal' : 'host-published') } ) it.each(cases)( - 'reports the structured session identity to %s when it routes structured', + 'asks this machine to admit the chat %s routes structured before any of it opens', async (_id, profile) => { store.settings = { ...store.settings, ...CHAT_DEFAULT_SETTINGS } const result = await launch(profile) - if (result?.surface.kind !== 'local-agent-session') { - expect(mockLaunchAgentInStructuredNewTab).not.toHaveBeenCalled() + if (result?.surface.kind !== 'host-published') { + expect(mockBeginHostAdmittedStructuredLaunch).not.toHaveBeenCalled() return } - expect(result.surface).toEqual({ - kind: 'local-agent-session', - tabId: 'tab-structured', - sessionId: 'session-1' - }) expect(result.pasteDraftAfterLaunch).toBe(false) expect(store.createTab).not.toHaveBeenCalled() - // Placement is the one launch input that does ride to the structured executor, as a target - // group rather than anything on a wire. - expect(mockLaunchAgentInStructuredNewTab.mock.calls[0]?.[0]?.targetGroupId).toBe( - profile.args.groupId + expect(mockBeginHostAdmittedStructuredLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: profile.args.worktreeId, + executionHostId: 'local', + target: { kind: 'local' } + }) ) } ) @@ -191,13 +193,6 @@ describe('agent launch caller routing', () => { ]) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - mockBeginPairedStructuredLaunch.mockReturnValueOnce({ - sessionId: null, - tab: null, - settlement: new Promise(() => undefined), - cancel: vi.fn() - }) - const result = launchAgentInNewTab({ requestId: 'request-2', agent: 'claude', @@ -206,8 +201,9 @@ describe('agent launch caller routing', () => { // The server admits the chat before any of it exists here, so the surface is the host's. expect(result?.surface.kind).toBe('host-published') - expect(mockBeginPairedStructuredLaunch).toHaveBeenCalledTimes(1) - expect(mockLaunchAgentInStructuredNewTab).not.toHaveBeenCalled() + expect(mockBeginHostAdmittedStructuredLaunch).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ target: { kind: 'environment', environmentId: 'web-runtime' } }) + ) expect(mockLaunchAgentInWebHostTab).not.toHaveBeenCalled() }) @@ -221,10 +217,7 @@ describe('agent launch caller routing', () => { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY ]) - const actual = await vi.importActual( - './structured-agent-session-paired-admission' - ) - mockBeginPairedStructuredLaunch.mockImplementationOnce(actual.beginPairedStructuredLaunch) + await useActualAdmission() mockCreateSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -253,7 +246,67 @@ describe('agent launch caller routing', () => { agentArgs: '--model sonnet' }) ) - expect(mockLaunchAgentInStructuredNewTab).not.toHaveBeenCalled() + expect(store.createTab).not.toHaveBeenCalled() + }) + + // Before, a local chat opened first and this machine's "no" left it failed, with a Retry that + // failed the same way and no terminal. + it("runs the caller's own launch as a local terminal when this machine declines the chat", async () => { + store.settings = { ...store.settings, ...CHAT_DEFAULT_SETTINGS } + await useActualAdmission() + mockCreateSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + requestId: 'request-10', + agent: 'claude', + worktreeId: 'wt-1', + groupId: 'group-1', + prompt: 'fix the flaky test', + promptDelivery: 'submit-after-ready', + agentArgs: '--model sonnet' + }) + + expect(result?.surface).toEqual({ kind: 'host-published' }) + expect(store.createTab).not.toHaveBeenCalled() + await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'terminal' }) + expect(store.createTab).toHaveBeenCalledOnce() + expect(store.createTab.mock.calls[0]?.slice(0, 2)).toEqual(['wt-1', 'group-1']) + expect(store.queueTabStartupCommand).toHaveBeenCalledOnce() + expect(store.queueTabStartupCommand.mock.calls[0]?.[1]?.command).toBe( + "claude '--model' 'sonnet'" + ) + // The typed prompt goes to that terminal once, and the caller hears it arrived. + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + const { pasteDraftWhenAgentReady } = await import('@/lib/agent-paste-draft') + expect(pasteDraftWhenAgentReady).toHaveBeenCalledOnce() + expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ content: 'fix the flaky test' }) + ) + // On this machine the terminal opening is the answer; no notice names a server. + expect(mockToastInfo).not.toHaveBeenCalled() + expect(mockLaunchAgentInWebHostTab).not.toHaveBeenCalled() + }) + + it("opens the caller's own fallback, not the agent's terminal, when it names one", async () => { + store.settings = { ...store.settings, ...CHAT_DEFAULT_SETTINGS } + await useActualAdmission() + mockCreateSupport.mockResolvedValue({ supported: false }) + const onStructuredHostDeclined = vi.fn(() => ({ opened: true })) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + requestId: 'request-11', + agent: 'claude', + worktreeId: 'wt-1', + onStructuredHostDeclined + }) + + await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'terminal' }) + expect(onStructuredHostDeclined).toHaveBeenCalledOnce() expect(store.createTab).not.toHaveBeenCalled() }) @@ -271,7 +324,7 @@ describe('agent launch caller routing', () => { }) expect(result?.surface).toEqual({ kind: 'host-published' }) - expect(mockLaunchAgentInStructuredNewTab).not.toHaveBeenCalled() + expect(mockBeginHostAdmittedStructuredLaunch).not.toHaveBeenCalled() }) it('aborts a terminal open when beforeSurfaceOpen refuses', async () => { @@ -308,28 +361,22 @@ describe('agent launch caller routing', () => { expect(mockLaunchAgentInWebHostTab).not.toHaveBeenCalled() }) - it('hands the structured branch a session-scoped refusal hook', async () => { + it('aborts a structured open before its host is asked when beforeSurfaceOpen refuses', async () => { store.settings = { ...store.settings, ...CHAT_DEFAULT_SETTINGS } const beforeSurfaceOpen = vi.fn(() => false) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ + const result = launchAgentInNewTab({ requestId: 'request-7', agent: 'codex', worktreeId: 'wt-1', beforeSurfaceOpen }) - // Why: the structured route cannot name its surface before the session id exists, so the - // funnel wraps the caller's hook and the structured executor decides when to ask. - const beforeOpen = mockLaunchAgentInStructuredNewTab.mock.calls[0]?.[0]?.beforeOpen - expect(typeof beforeOpen).toBe('function') - expect(beforeSurfaceOpen).not.toHaveBeenCalled() - expect(beforeOpen('session-7')).toBe(false) - expect(beforeSurfaceOpen).toHaveBeenCalledExactlyOnceWith({ - kind: 'local-agent-session', - sessionId: 'session-7' - }) + expect(result).toBeNull() + expect(beforeSurfaceOpen).toHaveBeenCalledExactlyOnceWith({ kind: 'host-published' }) + expect(mockBeginHostAdmittedStructuredLaunch).not.toHaveBeenCalled() + expect(mockCreateSupport).not.toHaveBeenCalled() }) it('opens the terminal when beforeSurfaceOpen returns undefined rather than false', async () => { diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts index e3ad93eb3db4..78ba58e4eaf8 100644 --- a/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts @@ -11,7 +11,8 @@ import { const mocks = vi.hoisted(() => ({ launchAgentInNewTab: vi.fn(), planAgentSessionLaunch: vi.fn(), - detectionTargetKey: vi.fn<() => string | undefined>() + detectionTargetKey: vi.fn<() => string | undefined>(), + seedShell: vi.fn(() => true) })) vi.mock('@/lib/launch-agent-in-new-tab', () => ({ @@ -45,7 +46,7 @@ beforeEach(() => { mocks.detectionTargetKey.mockReturnValue('local') mocks.planAgentSessionLaunch.mockReturnValue({ route: 'structured-native-chat' }) mocks.launchAgentInNewTab.mockReturnValue({ - surface: { kind: 'local-agent-session', tabId: 'chat-tab', sessionId: 's-1' } + surface: { kind: 'host-published' } }) }) @@ -59,23 +60,38 @@ describe('openDefaultAgentChatInEmptyWorkspace', () => { it('launches the default agent as a chat', () => { seedSettings({}) - expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toEqual({ primaryTabId: 'chat-tab' }) + // The chat's tab opens once its host admits it, so none is primary yet. + expect(openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell)).toEqual({ + primaryTabId: null + }) expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( expect.objectContaining({ agent: 'codex', worktreeId: 'wt-1', pendingActivationSpawn: true }) ) }) + // The function's promise: no agent nobody asked for. A "no" seeds the shell it would have. + it("seeds a plain shell, not the agent's terminal, when the host declines the chat", () => { + seedSettings({}) + + openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell) + const onDeclined = mocks.launchAgentInNewTab.mock.calls[0]?.[0]?.onStructuredHostDeclined + + expect(mocks.seedShell).not.toHaveBeenCalled() + expect(onDeclined()).toEqual({ opened: true }) + expect(mocks.seedShell).toHaveBeenCalledOnce() + }) + it('does nothing unless new agent tabs open as chat', () => { seedSettings({ openAgentTabsInChatByDefault: false }) - expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell)).toBeNull() expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() }) it('respects a Blank Terminal default agent', () => { seedSettings({ defaultTuiAgent: 'blank' }) - expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell)).toBeNull() expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() }) @@ -83,7 +99,7 @@ describe('openDefaultAgentChatInEmptyWorkspace', () => { seedSettings({}) mocks.planAgentSessionLaunch.mockReturnValue({ route: 'terminal-tui' }) - expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell)).toBeNull() expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() }) @@ -92,7 +108,7 @@ describe('openDefaultAgentChatInEmptyWorkspace', () => { mocks.detectionTargetKey.mockReturnValue('ssh:conn-1') useAppStore.setState({ remoteDetectedAgentIds: { 'conn-1': ['claude'] } }) - openDefaultAgentChatInEmptyWorkspace('wt-1') + openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell) expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( expect.objectContaining({ agent: 'claude' }) @@ -104,7 +120,7 @@ describe('openDefaultAgentChatInEmptyWorkspace', () => { mocks.detectionTargetKey.mockReturnValue(undefined) expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) - expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(openDefaultAgentChatInEmptyWorkspace('wt-1', mocks.seedShell)).toBeNull() expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts index 9bd44e4a2f26..79612829b713 100644 --- a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts @@ -74,10 +74,12 @@ export async function loadEmptyWorkspaceDefaultChatDetection(worktreeId: string) /** * When the user's new agent tabs open as chat, an empty workspace opens their default agent as a - * chat instead of a bare shell. Null means nothing opened and the caller seeds the shell. + * chat instead of a bare shell. Null means nothing opened and the caller seeds the shell; so does + * `seedShell` when the host declines the chat, since nobody asked for the agent's terminal. */ export function openDefaultAgentChatInEmptyWorkspace( - worktreeId: string + worktreeId: string, + seedShell: () => boolean ): { primaryTabId: string | null } | null { const state = useAppStore.getState() const target = defaultChatDetectionTarget(state, worktreeId) @@ -107,7 +109,8 @@ export function openDefaultAgentChatInEmptyWorkspace( worktreeId, launchSource: 'unknown', agentSessionLaunchPlan, - pendingActivationSpawn: true + pendingActivationSpawn: true, + onStructuredHostDeclined: () => ({ opened: seedShell() }) }) if (!result) { return null diff --git a/src/renderer/src/lib/empty-workspace-default-surface-claims.ts b/src/renderer/src/lib/empty-workspace-default-surface-claims.ts index 61a989e6bfc0..b01c2572c290 100644 --- a/src/renderer/src/lib/empty-workspace-default-surface-claims.ts +++ b/src/renderer/src/lib/empty-workspace-default-surface-claims.ts @@ -1,9 +1,33 @@ import type { GatedEmptyWorkspaceReseedIntent } from './worktree-initial-terminal-seeding' -// Why: while a gated reseed waits on agent detection, it owns the workspace's first surface; -// any other seeder landing in that wait would put a shell beside the chat it is about to open. -// Why the intent: the wait seeds for the latest activation, so a later one must not be dropped. +// Why: while a gated reseed waits on agent detection, or a chat launch waits on its host's answer, +// that wait owns the workspace's first surface; any other seeder landing in it would put a shell +// beside the chat about to open. +// Why the intent: the detection wait seeds for the latest activation, so a later one must not be +// dropped. A launch's wait replays nothing: its own answer opens the surface. const pendingIntentByWorkspace = new Map() +const launchWaitCountByWorkspace = new Map() + +/** Holds the first surface for a chat launch until its host answers; the release is idempotent. */ +export function holdEmptyWorkspaceDefaultSurfaceForLaunch(workspaceKey: string): () => void { + launchWaitCountByWorkspace.set( + workspaceKey, + (launchWaitCountByWorkspace.get(workspaceKey) ?? 0) + 1 + ) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (launchWaitCountByWorkspace.get(workspaceKey) ?? 1) - 1 + if (remaining > 0) { + launchWaitCountByWorkspace.set(workspaceKey, remaining) + } else { + launchWaitCountByWorkspace.delete(workspaceKey) + } + } +} /** Records this activation's intent; false when a wait already pending now carries it instead. */ export function claimEmptyWorkspaceDefaultSurface( @@ -25,5 +49,5 @@ export function releaseEmptyWorkspaceDefaultSurface( } export function isEmptyWorkspaceDefaultSurfacePending(workspaceKey: string): boolean { - return pendingIntentByWorkspace.has(workspaceKey) + return pendingIntentByWorkspace.has(workspaceKey) || launchWaitCountByWorkspace.has(workspaceKey) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index b5ca43f6f563..46d12f15db18 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -3,13 +3,10 @@ import { type AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import type { AgentLaunchSurface, LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' -import { launchAgentInStructuredNewTab } from '@/lib/launch-agent-in-new-tab-structured' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' -import { - beginStructuredAgentSessionProvisionalLaunch, - structuredLaunchPairedOwner -} from '@/lib/structured-agent-session-provisional-tab' +import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' +import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' type StructuredFromNewTab = { surface: AgentLaunchSurface @@ -19,57 +16,33 @@ type StructuredFromNewTab = { } /** - * The new-tab launcher's structured route. A local chat opens at once. A paired server admits the - * chat before any of it exists here, so its surface is the host's, as for every paired launch, and - * its "no" runs the caller's own launch as a terminal. + * The new-tab launcher's structured route. The owning host, this machine or a paired server, + * admits the chat before any of it exists here, so its surface is the host's, and its "no" runs + * the caller's own launch as a terminal unless the caller names what opens instead. */ export function launchStructuredAgentFromNewTab(args: { plan: AgentSessionLaunchPlan worktreeId: string groupId?: string beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] + onHostDeclined?: () => StructuredLaunchTerminal openTerminal: (terminalPlan: AgentSessionLaunchPlan) => { promptDeliveryResult?: Promise } | null }): StructuredFromNewTab | null { const { plan, beforeSurfaceOpen } = args - const paired = structuredLaunchPairedOwner(plan, args.worktreeId) - if (!paired) { - const structured = launchAgentInStructuredNewTab({ - plan, - ...(beforeSurfaceOpen - ? { - beforeOpen: (sessionId?: string) => - sessionId === undefined || - beforeSurfaceOpen({ kind: 'local-agent-session', sessionId }) - } - : {}), - ...(args.groupId ? { targetGroupId: args.groupId } : {}) - }) - return ( - structured && { - surface: { - kind: 'local-agent-session', - tabId: structured.tabId, - sessionId: structured.sessionId - }, - pasteDraftAfterLaunch: false, - structuredSettlement: structured.structuredSettlement, - ...(structured.promptDeliveryResult - ? { promptDeliveryResult: structured.promptDeliveryResult } - : {}) - } - ) - } if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { return null } const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, - target: { worktreeId: args.worktreeId, executionHostId: paired.executionHostId }, + target: { worktreeId: args.worktreeId }, ...(args.groupId ? { targetGroupId: args.groupId } : {}), onHostDeclined: () => { + if (args.onHostDeclined) { + return args.onHostDeclined() + } const terminal = args.openTerminal( adoptAgentSessionLaunchVerdict({ route: 'terminal-tui', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts deleted file mode 100644 index 1505ca3e99b4..000000000000 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement' - -type BeginArgs = { - plan: unknown - targetGroupId?: string - beforeOpen?: (sessionId: string) => boolean | void -} -type Launch = { - sessionId: string - tab: { id: string } - settlement: Promise - promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> -} - -const mocks = vi.hoisted(() => ({ - beginStructuredAgentSessionProvisionalLaunch: vi.fn<(args: BeginArgs) => Launch | null>() -})) - -vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({ - beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch -})) - -import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' -import { launchAgentInStructuredNewTab } from './launch-agent-in-new-tab-structured' - -type Delivery = 'auto-submit' | 'submit-after-ready' | 'draft' -const structuredPlan = (prompt: string, promptDelivery: Delivery) => - adoptAgentSessionLaunchVerdict({ - requestId: 'request-1', - route: 'structured-native-chat', - agent: 'codex', - worktreeId: 'wt-1', - prompt, - promptDelivery - }) - -describe('launchAgentInStructuredNewTab', () => { - let consoleError: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation(() => ({ - sessionId: 'session-1', - tab: { id: 'agent-session:session-1' }, - settlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }) - })) - }) - - afterEach(() => consoleError.mockRestore()) - - it('returns the usable chat surface immediately and settles in the background', async () => { - const result = launchAgentInStructuredNewTab({ - plan: structuredPlan('Fix it', 'submit-after-ready'), - targetGroupId: 'group-1' - }) - - expect(result).toMatchObject({ sessionId: 'session-1', tabId: 'agent-session:session-1' }) - expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith( - expect.objectContaining({ plan: expect.anything(), targetGroupId: 'group-1', hooks: {} }) - ) - await expect(result?.structuredSettlement).resolves.toEqual({ - kind: 'structured', - sessionId: 'session-1' - }) - expect(consoleError).not.toHaveBeenCalled() - }) - - it('reports failed settlement without opening a terminal fallback', async () => { - const error = new Error('boom') - mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({ - sessionId: 'session-1', - tab: { id: 'agent-session:session-1' }, - settlement: Promise.resolve({ kind: 'failed', error }) - }) - - const result = launchAgentInStructuredNewTab({ plan: structuredPlan('Fix it', 'auto-submit') }) - - await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'failed', error }) - expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', error) - }) - - it('returns null when the route cannot begin', () => { - mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue(null) - expect( - launchAgentInStructuredNewTab({ plan: structuredPlan('Fix it', 'auto-submit') }) - ).toBeNull() - }) - - it('does not expose a delivery promise for drafts', () => { - mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({ - sessionId: 'session-1', - tab: { id: 'agent-session:session-1' }, - settlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }), - promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) - }) - - const result = launchAgentInStructuredNewTab({ plan: structuredPlan('Fix it', 'draft') }) - expect(result?.promptDeliveryResult).toBeUndefined() - }) -}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts deleted file mode 100644 index 6dca43140bd3..000000000000 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' -import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' -import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' - -export type StructuredNewTabLaunchArgs = { - /** Planned on the structured route with an already-trimmed prompt; empty means no prompt. */ - plan: AgentSessionLaunchPlan - targetGroupId?: string - /** Lets a workspace reveal itself after ID allocation but before tab ownership. */ - beforeOpen?: (sessionId?: string) => boolean | void -} - -export type StructuredNewTabLaunch = { - sessionId: string - tabId: string - structuredSettlement: Promise - promptDeliveryResult?: Promise -} - -/** - * The new-tab launcher's structured branch. Returns synchronously so `launchAgentInNewTab` keeps - * its signature; the settlement carries what the structured launch actually did. - */ -export function launchAgentInStructuredNewTab( - args: StructuredNewTabLaunchArgs -): StructuredNewTabLaunch | null { - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: args.plan, - hooks: {}, - ...(args.beforeOpen ? { beforeOpen: args.beforeOpen } : {}), - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}) - }) - // The new-tab launcher sends a paired server's launch through admission itself. - if (!launch?.tab) { - launch?.cancel() - return null - } - const structuredSettlement = launch.settlement - void structuredSettlement.then((settlement) => { - if (settlement.kind === 'failed') { - console.error('Structured agent launch failed', settlement.error) - } - }) - return { - sessionId: launch.sessionId, - tabId: launch.tab.id, - structuredSettlement, - // Why: draft mode has no delivery event; the composer owns the text until the user sends it. - ...(launch.promptDeliveryResult && args.plan.promptDelivery !== 'draft' - ? { promptDeliveryResult: launch.promptDeliveryResult } - : {}) - } -} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d64237ed8fbf..270d7b4670b3 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -26,6 +26,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-structured-route' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { planAgentSessionLaunch, @@ -77,16 +78,15 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { pendingActivationSpawn?: boolean /** Lets a workspace reveal itself before the selected surface opens. */ beforeSurfaceOpen?: ( - surface: - | { kind: 'local-terminal' } - | { kind: 'local-agent-session'; sessionId: string } - | { kind: 'host-published' } + surface: { kind: 'local-terminal' } | { kind: 'host-published' } ) => boolean | void + /** Opens instead of this agent's terminal when the host declines its structured chat. */ + onStructuredHostDeclined?: () => StructuredLaunchTerminal } +/** `host-published`: the surface opens once its host answers, a structured chat's included. */ export type AgentLaunchSurface = | { kind: 'local-terminal'; tabId: string } - | { kind: 'local-agent-session'; tabId: string; sessionId: string } | { kind: 'host-published' } export type LaunchAgentInNewTabResult = { @@ -203,7 +203,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent worktreeId, ...(groupId ? { groupId } : {}), ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), - // A paired server's "no" opens this same launch as a terminal, with the caller's arguments. + ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), + // The host's "no" opens this same launch as a terminal, with the caller's arguments. openTerminal: (terminalPlan) => launchAgentInNewTabInternal({ ...args, diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 2c165711160e..a40a49c6b3bd 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -38,6 +38,7 @@ const mockLaunchStructuredCodexSession = vi.fn() const mockRefreshLocalStructuredSessionTabs = vi.fn() const mockToastError = vi.fn() const mockCallStructuredAgentSession = vi.fn() +const mockCreateSupport = vi.fn() const STRUCTURED_HOST_CAPABILITIES = ['agent-session.structured.v1'] let hostCapabilities: readonly string[] | null = STRUCTURED_HOST_CAPABILITIES @@ -168,7 +169,10 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mockCallStructuredAgentSession + callStructuredAgentSession: (target: unknown, method: string, params: unknown) => + method === 'agentSession.createSupport' + ? mockCreateSupport() + : mockCallStructuredAgentSession(target, method, params) })) /** Structured adoption creates the tab in terminal mode and flips it to chat once @@ -237,6 +241,7 @@ describe('structured chat adoption guard on the launch path', () => { tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] } ]) + mockCreateSupport.mockResolvedValue({ supported: true }) mockToastError.mockReset() hostCapabilities = STRUCTURED_HOST_CAPABILITIES store.settings.openAgentTabsInChatByDefault = true @@ -262,15 +267,13 @@ describe('structured chat adoption guard on the launch path', () => { worktreeId: 'wt-1' }) + // This machine admits the chat before any of it opens, so the surface is its answer. expect(result).toMatchObject({ - surface: { - kind: 'local-agent-session', - tabId: 'structured-agent-session-codex-session-1', - sessionId: 'codex-session-1' - }, + surface: { kind: 'host-published' }, pasteDraftAfterLaunch: false }) - expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false) + expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled() + expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(true) await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'structured', sessionId: 'codex-session-1' @@ -306,9 +309,8 @@ describe('structured chat adoption guard on the launch path', () => { worktreeId: 'wt-1' }) - expect(result).toMatchObject({ - surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' } - }) + expect(result).toMatchObject({ surface: { kind: 'host-published' } }) + await result?.structuredSettlement expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith( 'wt-1', 'codex', @@ -328,9 +330,8 @@ describe('structured chat adoption guard on the launch path', () => { worktreeId: 'wt-1' }) - expect(result).toMatchObject({ - surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' } - }) + expect(result).toMatchObject({ surface: { kind: 'host-published' } }) + await result?.structuredSettlement expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith( 'wt-1', 'claude', @@ -341,6 +342,40 @@ describe('structured chat adoption guard on the launch path', () => { expect(mockCreateTab).not.toHaveBeenCalled() }) + // A Claude account bound to WSL is one such "no". Before, the chat opened first and was left + // failed, with a Retry that failed the same way. + it('opens the terminal, and no chat, when this machine declines the chat', async () => { + store.unifiedTabsByWorktree = {} + mockCreateSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + requestId: 'request-declined', + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'start this task', + promptDelivery: 'submit-after-ready' + }) + + expect(mockCreateTab).not.toHaveBeenCalled() + await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'terminal' }) + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(mockCreateTab).toHaveBeenCalledOnce() + expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledOnce() + expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ content: 'start this task' }) + ) + // Nothing of a chat was committed: no launch, intent, tab, or message about it. + expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled() + expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled() + expect(mockCreateUnifiedTab).not.toHaveBeenCalled() + expect(localStorage.length).toBe(0) + expect(mockToastError).not.toHaveBeenCalled() + }) + it('keeps a native-chat agent with no structured adapter on the terminal-backed path', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -400,7 +435,7 @@ describe('structured chat adoption guard on the launch path', () => { }) expect(result).toMatchObject({ - surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' }, + surface: { kind: 'host-published' }, pasteDraftAfterLaunch: false }) await expect(result?.structuredSettlement).resolves.toEqual({ @@ -462,9 +497,12 @@ describe('structured chat adoption guard on the launch path', () => { worktreeId: 'wt-1' }) - expect(first).toMatchObject({ surface: { kind: 'local-agent-session' } }) - expect(second).toMatchObject({ surface: { kind: 'local-agent-session' } }) + expect(first).toMatchObject({ surface: { kind: 'host-published' } }) + expect(second).toMatchObject({ surface: { kind: 'host-published' } }) + await vi.waitFor(() => expect(mockLaunchStructuredCodexSession).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1) resolveLaunch({ sessionId: 'codex-session-1', fence: 1 }) }) @@ -484,6 +522,8 @@ describe('structured chat adoption guard on the launch path', () => { await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(1)) launchAgentInNewTab({ requestId: 'double-click', agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockCreateSupport).toHaveBeenCalledTimes(2)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) store.unifiedTabsByWorktree['wt-1'] = [ @@ -591,9 +631,7 @@ describe('structured chat adoption guard on the launch path', () => { prompt: 'start this task' }) - expect(result).toMatchObject({ - surface: { kind: 'local-agent-session', sessionId: 'codex-session-1' } - }) + expect(result).toMatchObject({ surface: { kind: 'host-published' } }) await expect(result?.promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 11625fd8161f..6f95d73d0ed0 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -248,7 +248,8 @@ async function requireHostCreateSupport( readAgentSessionErrorRefusal(support.error) ) } - if (support.kind === 'declined') { + // A workspace the host still cannot resolve is refused here, as before; Retry asks again. + if (support.kind === 'declined' || support.kind === 'workspace-unresolved') { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( 'structured_agent_session_unsupported', diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 20b02593adbe..b1a0ac294fc2 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -7,7 +7,7 @@ import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' -import type { DeclinedStructuredLaunchTerminalOptions } from '@/lib/structured-agent-session-paired-admission' +import type { DeclinedStructuredLaunchTerminalOptions } from '@/lib/structured-agent-session-launch-admission' export function buildDirectWorkItemStartup(args: { agent: TuiAgent | null @@ -83,7 +83,7 @@ export function beginDirectWorkItemStructuredLaunch(args: { plan: AgentSessionLaunchPlan | null primaryTabId: string | null beforeOpen: (sessionId?: string) => boolean | void - /** The terminal launch a paired server's "no" falls back to, carrying the caller's own CLI args. */ + /** The terminal launch the host's "no" falls back to, carrying the caller's own CLI args. */ declinedTerminal?: DeclinedStructuredLaunchTerminalOptions }): { completed: boolean diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts index f6f850ff0ef6..0a9056796a90 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts @@ -245,13 +245,7 @@ describe('runQuickCommandInNewTab', () => { }) it('records history while a structured agent quick command publishes asynchronously', () => { - mocks.launchAgentInNewTab.mockReturnValue({ - surface: { - kind: 'local-agent-session', - tabId: 'agent-session:codex-session-1', - sessionId: 'codex-session-1' - } - }) + mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' } }) const result = runQuickCommandInNewTab({ command: { @@ -266,7 +260,8 @@ describe('runQuickCommandInNewTab', () => { historyId: 'runtime:local\u0000agent-review' }) - expect(result).toEqual({ tabId: 'agent-session:codex-session-1' }) + // The chat opens once its host admits it, so there is no tab to hand back yet. + expect(result).toBeNull() expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith( 'group-1', 'runtime:local\u0000agent-review' @@ -274,13 +269,7 @@ describe('runQuickCommandInNewTab', () => { }) it('uses the active group for structured history when the caller has no group', () => { - mocks.launchAgentInNewTab.mockReturnValue({ - surface: { - kind: 'local-agent-session', - tabId: 'agent-session:codex-session-1', - sessionId: 'codex-session-1' - } - }) + mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' } }) mockState.activeGroupIdByWorktree['repo::worktree'] = 'active-group' runQuickCommandInNewTab({ diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.ts index 99a709c40b0b..4228060d58a8 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.ts @@ -77,10 +77,7 @@ export function runQuickCommandInNewTab({ launchSource: 'quick_command', quickCommandLabel: command.label }) - if ( - result?.surface.kind === 'local-terminal' || - result?.surface.kind === 'local-agent-session' - ) { + if (result?.surface.kind === 'local-terminal') { const launchedGroupId = resolveQuickCommandGroupId(worktreeId, result.surface.tabId, groupId) if (launchedGroupId) { useAppStore.getState().setRecentQuickCommandForGroup(launchedGroupId, historyId) diff --git a/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts index 5a494bf99c4d..fd503624ad15 100644 --- a/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts +++ b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts @@ -5,6 +5,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-ses import type { Tab, TabGroup } from '../../../shared/tab-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import type * as LaunchAdmissionModule from './structured-agent-session-launch-admission' const mocks = vi.hoisted(() => ({ createIntent: vi.fn(), @@ -58,6 +59,11 @@ vi.mock('@/lib/launch-structured-agent-session', () => { StructuredAgentSessionOwnerUnresolvedError } }) +// Reuse is decided where an admitted launch opens its chat; here this machine admits at once. +vi.mock('@/lib/structured-agent-session-launch-admission', async (importOriginal) => ({ + ...(await importOriginal()), + beginHostAdmittedStructuredLaunch: (args: { openAdmitted: () => unknown }) => args.openAdmitted() +})) vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ refreshLocalStructuredSessionTabs: mocks.refreshTabs })) diff --git a/src/renderer/src/lib/structured-agent-session-host-admission.ts b/src/renderer/src/lib/structured-agent-session-host-admission.ts index 97b1467888db..b09be516ef7e 100644 --- a/src/renderer/src/lib/structured-agent-session-host-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-host-admission.ts @@ -32,16 +32,18 @@ export type StructuredLaunchAdmission = | { kind: 'admitted'; seedOptions?: Readonly> } | { kind: 'declined' } | { kind: 'unreachable' } + /** The host still could not resolve the workspace after the retries: not yet, rather than no. */ + | { kind: 'workspace-unresolved' } export type HostCreateSupport = - | { kind: 'admitted'; seedOptions?: Readonly> } - | { kind: 'declined' } + | Exclude | { kind: 'unreachable'; code: string; message: string; error: unknown } /** * Whether the executing host supports creating this session, retrying only while the host cannot * yet resolve the worktree. "Could not answer" and "answered no" are different states and only the - * second is a verdict. Each call is bounded by the runtime RPC client's own timeout. + * second is a verdict. A paired server's call is bounded by the runtime RPC client's timeout; this + * machine's call has none. */ export async function askHostCreateSupport( target: RuntimeClientTarget, @@ -61,12 +63,11 @@ export async function askHostCreateSupport( const seedOptions = parseStructuredLaunchSeedOptions(support.seedOptions) return seedOptions ? { kind: 'admitted', seedOptions } : { kind: 'admitted' } } catch (error) { - const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt] - if (retryDelayMs === undefined) { - // A selector that never appears is a definitive refusal. - return { kind: 'declined' } - } if (hasRuntimeRpcErrorCode(error, SELECTOR_NOT_RESOLVABLE_CODE)) { + const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt] + if (retryDelayMs === undefined) { + return { kind: 'workspace-unresolved' } + } await delay(retryDelayMs) continue } @@ -80,12 +81,30 @@ export async function askHostCreateSupport( } } +/** How long a launch on this machine waits for its answer before anything shows. Past it the + * launch treats the host as unable to answer, and the chat's own create keeps asking. */ +export const LOCAL_ADMISSION_WAIT_MS = 3_000 + /** Asks a host to admit a chat before the client commits any of it. */ export async function admitStructuredLaunchOnHost( target: RuntimeClientTarget, worktree: string, agent: TuiAgent ): Promise { - const support = await askHostCreateSupport(target, worktree, agent) - return support.kind === 'unreachable' ? { kind: 'unreachable' } : support + const asked = askHostCreateSupport(target, worktree, agent).then( + (support): StructuredLaunchAdmission => + support.kind === 'unreachable' ? { kind: 'unreachable' } : support + ) + if (target.kind !== 'local') { + return asked + } + let timer: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve({ kind: 'unreachable' }), LOCAL_ADMISSION_WAIT_MS) + }) + try { + return await Promise.race([asked, timedOut]) + } finally { + clearTimeout(timer) + } } diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts similarity index 55% rename from src/renderer/src/lib/structured-agent-session-paired-admission.test.ts rename to src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index a8df010234f2..2cdb6dbe5012 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -32,11 +32,15 @@ import { resumeAiVaultSessionInNewChat } from '@/components/right-sidebar/ai-vau import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-status' import { getStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options' import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' +import { LOCAL_ADMISSION_WAIT_MS } from './structured-agent-session-host-admission' +import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' const WORKTREE = 'repo-1::/srv/app' const INITIAL_SETTINGS = useAppStore.getState().settings -function pairedPlan(overrides: { resumeFrom?: { providerSessionId: string } } = {}) { +function pairedPlan( + overrides: { resumeFrom?: { providerSessionId: string }; executionHostId?: 'local' } = {} +) { return adoptAgentSessionLaunchVerdict({ requestId: 'request-1', route: 'structured-native-chat', @@ -49,12 +53,17 @@ function pairedPlan(overrides: { resumeFrom?: { providerSessionId: string } } = }) } +const localPlan = () => pairedPlan({ executionHostId: 'local' }) + /** Nothing of a chat exists on this machine: no tab, launch, record, queued prompt or intent. */ function expectNoChatCommitted(): void { expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) expect(getStructuredAgentLaunchStatus(WORKTREE, 'claude')).toBe('idle') expect(Object.keys(localStorage)).toEqual([]) expect(peekWebSessionFocusIntent({ environmentId: 'server-1' }, WORKTREE)).toBeNull() + expect( + peekWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), WORKTREE) + ).toBeNull() } beforeEach(() => { @@ -220,3 +229,178 @@ describe('a structured chat launch on a paired server', () => { launch?.cancel() }) }) + +// The same ask, of this machine's own runtime: a Claude account bound to WSL is one "no" it gives. +describe('a structured chat launch on this machine', () => { + it('opens only the terminal when this machine declines, without a notice', async () => { + mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) + const onHostDeclined = vi.fn(() => ({ + opened: true, + promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) + })) + const reveal = vi.fn() + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + beforeOpen: reveal, + onHostDeclined + }) + + expect(launch?.tab).toBeNull() + expect(reveal).toHaveBeenCalledOnce() + await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' }) + await expect(launch?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(onHostDeclined).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }) + expect(mocks.toastInfo).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + expectNoChatCommitted() + }) + + it("opens a new agent tab's terminal with the typed prompt when the caller names none", async () => { + mocks.createSupport.mockResolvedValue({ supported: false }) + mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } }) + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + targetGroupId: 'group-1', + declinedTerminal: { agentArgs: '--model opus' } + }) + + await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' }) + expect(mocks.launchAgentInNewTab).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + agent: 'claude', + worktreeId: WORKTREE, + groupId: 'group-1', + prompt: 'fix the flaky test', + agentArgs: '--model opus', + agentSessionLaunchPlan: expect.objectContaining({ route: 'terminal-tui' }) + }) + ) + expectNoChatCommitted() + }) + + it('opens nothing when the launch is abandoned before this machine answers', async () => { + let answer!: (support: { supported: boolean }) => void + mocks.createSupport.mockReturnValue(new Promise((resolve) => (answer = resolve))) + const onHostDeclined = vi.fn() + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + onHostDeclined + }) + launch?.cancel() + answer({ supported: false }) + + await expect(launch?.settlement).resolves.toEqual({ kind: 'cancelled', sessionId: null }) + await expect(launch?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: false + }) + expect(onHostDeclined).not.toHaveBeenCalled() + expectNoChatCommitted() + }) + + it('opens the chat once this machine admits it', async () => { + mocks.createSupport.mockResolvedValue({ supported: true }) + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + onHostDeclined: vi.fn() + }) + + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) + await vi.waitFor(() => + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toEqual([ + expect.objectContaining({ contentType: 'agent-session', executionHostId: 'local' }) + ]) + ) + launch?.cancel() + }) + + // This machine has no connection to lose: the chat opens, and its own start reports a failure. + it('opens the chat without a notice when this machine cannot answer the ask', async () => { + mocks.createSupport.mockRejectedValue(new Error('runtime not ready')) + const onHostDeclined = vi.fn() + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + onHostDeclined + }) + + await vi.waitFor(() => + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1) + ) + expect(onHostDeclined).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + launch?.cancel() + }) + + // Not resolvable yet is not a "no": the chat opens and its own create asks again, as before. + it('opens the chat, not a terminal, when this machine cannot resolve a new workspace yet', async () => { + mocks.createSupport.mockRejectedValue(new Error('selector_not_found')) + const onHostDeclined = vi.fn() + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + onHostDeclined + }) + + await vi.waitFor( + () => expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1), + { timeout: 3_000 } + ) + expect(onHostDeclined).not.toHaveBeenCalled() + launch?.cancel() + }) + + // The click must never wait without end: past the bound the chat opens and reports for itself. + it('opens the chat once the wait for this machine passes its bound', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + mocks.createSupport.mockReturnValue(new Promise(() => undefined)) + const onHostDeclined = vi.fn() + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + onHostDeclined + }) + await vi.advanceTimersByTimeAsync(LOCAL_ADMISSION_WAIT_MS - 1) + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) + + await vi.advanceTimersByTimeAsync(1) + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1) + expect(onHostDeclined).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + launch?.cancel() + } finally { + vi.useRealTimers() + } + }) +}) + +describe('a paired server that cannot resolve a new workspace yet', () => { + it('keeps its terminal fallback, as before', async () => { + mocks.createSupport.mockRejectedValue(new Error('selector_not_found')) + const onHostDeclined = vi.fn(() => ({ opened: true })) + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: pairedPlan(), + hooks: {}, + onHostDeclined + }) + + await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' }) + expect(onHostDeclined).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts similarity index 62% rename from src/renderer/src/lib/structured-agent-session-paired-admission.ts rename to src/renderer/src/lib/structured-agent-session-launch-admission.ts index 5461489544fc..3d55f8ee71cc 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -8,7 +8,11 @@ import { adoptAgentSessionLaunchVerdict, type AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -import { admitStructuredLaunchOnHost } from '@/lib/structured-agent-session-host-admission' +import { + admitStructuredLaunchOnHost, + type StructuredLaunchAdmission +} from '@/lib/structured-agent-session-host-admission' +import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from '@/lib/empty-workspace-default-surface-claims' import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import type { @@ -26,8 +30,8 @@ export type StructuredLaunchTerminal = { promptDeliveryResult?: Promise } -/** A chat launch on a paired server. Nothing of it exists here until the server admits it. */ -export type PairedStructuredLaunch = { +/** A chat launch its owning host has not admitted yet. Nothing of it exists here until it does. */ +export type HostAdmittedStructuredLaunch = { sessionId: null tab: null settlement: Promise @@ -121,12 +125,13 @@ export async function openDeclinedStructuredLaunchTerminal(args: { } /** - * Asks the paired server that would run a chat before committing any of it here: no tab, launch - * record, queued prompt or focus intent exists until it answers. Admitted opens the chat as a local - * launch would; declined opens the caller's terminal with a notice (a resume, which has no terminal - * equivalent, fails); unreachable opens nothing and says so. There is nothing to undo either way. + * Asks the host that would run a chat, this machine or a paired server, before committing any of it + * here: no tab, launch record, queued prompt or focus intent exists until it answers. Admitted opens + * the chat; declined opens the caller's terminal (a resume, which has no terminal equivalent, + * fails); an unreachable server opens nothing and says so. There is nothing to undo either way. + * This machine's "can't answer" or "not resolvable yet" opens the chat, whose own create reports. */ -export function beginPairedStructuredLaunch(args: { +export function beginHostAdmittedStructuredLaunch(args: { plan: AgentSessionLaunchPlan & { agent: TuiAgent } hooks: StructuredAgentLaunchHooks worktreeId: string @@ -135,9 +140,13 @@ export function beginPairedStructuredLaunch(args: { /** Commits the admitted chat: the local launch path, told which host admitted it and the saved * selection that host said create will seed. */ openAdmitted: (seedOptions?: Readonly>) => AdmittedLaunch | null - onHostDeclined: () => Promise | StructuredLaunchTerminal -}): PairedStructuredLaunch { + /** Told which host declined, since where its terminal opens can depend on it. */ + onHostDeclined: ( + target: RuntimeClientTarget + ) => Promise | StructuredLaunchTerminal +}): HostAdmittedStructuredLaunch { const { plan } = args + const paired = args.target.kind === 'environment' let cancelled = false let admitted: AdmittedLaunch | null = null const isCancelled = (): boolean => cancelled || args.hooks.signal?.aborted === true @@ -148,52 +157,72 @@ export function beginPairedStructuredLaunch(args: { resolveDelivery = resolve }) : undefined + // Released once the surface that answers the launch has opened, or nothing will. + const releaseHold = holdEmptyWorkspaceDefaultSurfaceForLaunch(args.worktreeId) const settlement = (async (): Promise => { - const admission = await admitStructuredLaunchOnHost( - args.target, - toRuntimeWorktreeSelector(args.worktreeId), - plan.agent - ) - if (isCancelled()) { - resolveDelivery(NOT_DELIVERED) - return { kind: 'cancelled', sessionId: null } - } - if (admission.kind === 'unreachable') { - notifyHostUnreachable(plan.agent, args.executionHostId) - resolveDelivery({ delivered: false, failureNotified: true }) - return { - kind: 'failed', - error: new Error('structured chat host unreachable'), - notified: true - } - } - if (admission.kind === 'declined') { - if (plan.resumeFrom) { + try { + const asked = await admitStructuredLaunchOnHost( + args.target, + toRuntimeWorktreeSelector(args.worktreeId), + plan.agent + ) + if (isCancelled()) { resolveDelivery(NOT_DELIVERED) + return { kind: 'cancelled', sessionId: null } + } + // A server that cannot resolve the workspace yet keeps its terminal fallback; this machine has + // no connection to lose, so its "can't answer" leaves the report to the chat's own create. + const admission: StructuredLaunchAdmission = + asked.kind !== 'workspace-unresolved' + ? asked + : paired + ? { kind: 'declined' } + : { kind: 'unreachable' } + if (admission.kind === 'unreachable' && paired) { + notifyHostUnreachable(plan.agent, args.executionHostId) + resolveDelivery({ delivered: false, failureNotified: true }) return { kind: 'failed', - error: new StructuredAgentSessionCreateRefusalError( - 'structured_agent_session_unsupported' - ) + error: new Error('structured chat host unreachable'), + notified: true } } - notifyHostDeclined(plan.agent) - const terminal = await args.onHostDeclined() - void ( - terminal.promptDeliveryResult ?? - Promise.resolve(terminal.opened ? DELIVERED : NOT_DELIVERED) - ).then(resolveDelivery, () => resolveDelivery(NOT_DELIVERED)) - return terminal.opened ? { kind: 'terminal' } : { kind: 'cancelled', sessionId: null } - } - admitted = args.openAdmitted(admission.seedOptions) - if (!admitted) { - resolveDelivery(NOT_DELIVERED) - return { kind: 'cancelled', sessionId: null } + if (admission.kind === 'declined') { + if (plan.resumeFrom) { + resolveDelivery(NOT_DELIVERED) + return { + kind: 'failed', + error: new StructuredAgentSessionCreateRefusalError( + 'structured_agent_session_unsupported' + ) + } + } + // The notice explains a server's refusal; on this machine the terminal opening is the answer. + if (paired) { + notifyHostDeclined(plan.agent) + } + const terminal = await args.onHostDeclined(args.target) + void ( + terminal.promptDeliveryResult ?? + Promise.resolve(terminal.opened ? DELIVERED : NOT_DELIVERED) + ).then(resolveDelivery, () => resolveDelivery(NOT_DELIVERED)) + return terminal.opened ? { kind: 'terminal' } : { kind: 'cancelled', sessionId: null } + } + admitted = args.openAdmitted( + admission.kind === 'admitted' ? admission.seedOptions : undefined + ) + if (!admitted) { + resolveDelivery(NOT_DELIVERED) + return { kind: 'cancelled', sessionId: null } + } + void (admitted.promptDeliveryResult ?? Promise.resolve(DELIVERED)).then(resolveDelivery, () => + resolveDelivery(NOT_DELIVERED) + ) + // Not awaited: the hold ends when the chat opens, not when it settles. + return admitted.settlement + } finally { + releaseHold() } - void (admitted.promptDeliveryResult ?? Promise.resolve(DELIVERED)).then(resolveDelivery, () => - resolveDelivery(NOT_DELIVERED) - ) - return admitted.settlement })().catch((error: unknown): StructuredAgentLaunchSettlement => { resolveDelivery(NOT_DELIVERED) return { kind: 'failed', error } diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index e5a75c237438..87bbac89df74 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -4,7 +4,8 @@ import type { AgentSessionLaunchPlan } from './agent-session-launch-plan' import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement' const mocks = vi.hoisted(() => ({ - createUnifiedTab: vi.fn() + createUnifiedTab: vi.fn(), + createSupport: vi.fn() })) vi.mock('@/store', () => ({ @@ -17,6 +18,10 @@ vi.mock('@/store', () => ({ }) } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: (_target: unknown, method: string) => + method === 'agentSession.createSupport' ? mocks.createSupport() : new Promise(() => undefined) +})) import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' @@ -26,9 +31,10 @@ function planSettlingAs(settlement: StructuredAgentLaunchSettlement): AgentSessi route: 'structured-native-chat', agent: 'claude', worktreeId: 'wt-1', + executionHostId: 'local', begin: () => ({ sessionId: 'claude_1', - executionHostId: 'runtime:server-1', + executionHostId: 'local', settlement: Promise.resolve(settlement), cancel: vi.fn() }), @@ -41,19 +47,25 @@ beforeEach(() => { mocks.createUnifiedTab.mockImplementation( (worktreeId: string, _type: string, tab: Partial) => ({ ...tab, worktreeId }) ) + mocks.createSupport.mockResolvedValue({ supported: true }) }) describe('a provisional structured chat tab', () => { - it('carries the host the launch was sent to', () => { - beginStructuredAgentSessionProvisionalLaunch({ + it('opens once this machine admits the chat, in the caller group, on the host it was sent to', async () => { + const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }), - hooks: {} + hooks: {}, + targetGroupId: 'group-2' }) + expect(launch?.tab).toBeNull() + expect(mocks.createUnifiedTab).not.toHaveBeenCalled() + await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) + expect(mocks.createSupport).toHaveBeenCalledOnce() expect(mocks.createUnifiedTab).toHaveBeenCalledWith( 'wt-1', 'agent-session', - expect.objectContaining({ executionHostId: 'runtime:server-1' }) + expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) ) }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 1992833170d2..28d56571f516 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -13,12 +13,12 @@ import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' import type { TuiAgent } from '../../../shared/tui-agent' import { - beginPairedStructuredLaunch, + beginHostAdmittedStructuredLaunch, openDeclinedStructuredLaunchTerminal, type DeclinedStructuredLaunchTerminalOptions, - type PairedStructuredLaunch, + type HostAdmittedStructuredLaunch, type StructuredLaunchTerminal -} from '@/lib/structured-agent-session-paired-admission' +} from '@/lib/structured-agent-session-launch-admission' import { resolveStructuredAgentSessionOwner, structuredAgentSessionTargetForHost @@ -27,10 +27,10 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } -/** A local chat has its tab at once; a paired server's has none until the server admits it. */ +/** A chat's tab opens once its host admits it; a launch with no host to ask takes the direct path. */ export type StructuredAgentSessionProvisionalLaunch = | LocalProvisionalLaunch - | PairedStructuredLaunch + | HostAdmittedStructuredLaunch export function openStructuredAgentSessionProvisionalTab(args: { worktreeId: string @@ -72,8 +72,8 @@ export function openStructuredAgentSessionProvisionalTab(args: { return tab } -/** The paired server a structured launch would run on, which admits the chat before it exists. */ -export function structuredLaunchPairedOwner( +/** The host a structured launch would run on, which admits the chat before it exists. */ +export function structuredLaunchOwner( plan: Pick, worktreeId: string, target?: AgentSessionLaunchTarget @@ -86,9 +86,7 @@ export function structuredLaunchPairedOwner( plan.executionHostId ?? resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) const hostTarget = structuredAgentSessionTargetForHost(executionHostId) - return executionHostId && hostTarget?.kind === 'environment' - ? { executionHostId, target: hostTarget } - : null + return executionHostId && hostTarget ? { executionHostId, target: hostTarget } : null } type ProvisionalLaunchArgs = { @@ -97,40 +95,43 @@ type ProvisionalLaunchArgs = { target?: AgentSessionLaunchTarget targetGroupId?: string activate?: boolean - /** Lets workspace flows reveal before the chat's tab is owned; a paired launch reveals before its - * server is asked, when no session id exists yet. */ + /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no + * session id exists yet. */ beforeOpen?: (sessionId?: string) => boolean | void - /** The terminal a paired server's "no" opens; a caller without one gets a new agent tab's. */ - onHostDeclined?: () => Promise | StructuredLaunchTerminal + /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. */ + onHostDeclined?: ( + target: RuntimeClientTarget + ) => Promise | StructuredLaunchTerminal /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ declinedTerminal?: DeclinedStructuredLaunchTerminalOptions } -/** Binds the launch to a chat tab: at once locally, after the server admits it on a paired host. */ +/** Binds the launch to a chat tab once its host, this machine included, admits the chat. */ export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - const paired = worktreeId ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) : null - if (!paired || !worktreeId) { + const owner = worktreeId ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null + if (!owner || !worktreeId) { + // No host to ask: the launch path refuses it with its own message. return beginLocalProvisionalLaunch(args) } if (args.beforeOpen?.() === false) { return null } - return beginPairedStructuredLaunch({ + return beginHostAdmittedStructuredLaunch({ plan: args.plan, hooks: args.hooks, worktreeId, - executionHostId: paired.executionHostId, - target: paired.target, + executionHostId: owner.executionHostId, + target: owner.target, openAdmitted: (seedOptions) => beginLocalProvisionalLaunch({ ...args, target: { ...args.target, worktreeId, - executionHostId: paired.executionHostId, + executionHostId: owner.executionHostId, ...(seedOptions ? { seedOptions } : {}) }, beforeOpen: undefined diff --git a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts index ae956cabd216..22665ff65277 100644 --- a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts +++ b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts @@ -113,11 +113,34 @@ describe('empty workspace seeding with a default agent chat', () => { { seedUserDefaultSurface: true } ) - expect(defaultChat.open).toHaveBeenCalledWith('wt-1') + expect(defaultChat.open).toHaveBeenCalledWith('wt-1', expect.any(Function)) expect(primaryTabId).toBe('chat-tab') expect(createTab).not.toHaveBeenCalled() }) + // A host's "no" to the chat seeds the shell this workspace would have had, not the agent's TUI. + it('seeds the plain shell when the host declines the default chat', () => { + defaultChat.open.mockImplementation((_worktreeId: string, seedShell: () => boolean) => { + seedShell() + return { primaryTabId: null } + }) + const createTab = vi.fn(() => ({ id: 'shell-tab' })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, undefined, { + seedUserDefaultSurface: true + }) + + expect(defaultChat.open).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.not.objectContaining({ launchAgent: expect.anything() }) + ) + }) + it('falls back to a shell when no chat can open', () => { defaultChat.open.mockReturnValue(null) const createTab = vi.fn(() => ({ id: 'shell-tab' })) @@ -156,7 +179,7 @@ describe('empty workspace seeding with a default agent chat', () => { activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) - expect(defaultChat.open).toHaveBeenCalledWith(worktree.id) + expect(defaultChat.open).toHaveBeenCalledWith(worktree.id, expect.any(Function)) }) it('back/forward navigation asks for the default surface', () => { @@ -170,7 +193,7 @@ describe('empty workspace seeding with a default agent chat', () => { useAppStore.getState().goBackWorktree() - expect(defaultChat.open).toHaveBeenCalledWith(worktree.id) + expect(defaultChat.open).toHaveBeenCalledWith(worktree.id, expect.any(Function)) }) // Why: CLI/phone creates, fallbacks after a failed agent launch, and the move after a delete @@ -203,7 +226,7 @@ describe('the gated reseed that seeds an empty workspace in Electron', () => { expect(result === false ? 'failed' : result.primaryTabId).toBeNull() await gate.mock.results[0]?.value - expect(defaultChat.open).toHaveBeenCalledWith(worktree.id) + expect(defaultChat.open).toHaveBeenCalledWith(worktree.id, expect.any(Function)) expect(tabCount(worktree.id)).toBe(0) }) @@ -227,7 +250,7 @@ describe('the gated reseed that seeds an empty workspace in Electron', () => { activateAndRevealFolderWorkspace(FOLDER_ID, { ...USER_OPEN, executionHostId: 'local' }) await gate.mock.results[0]?.value - expect(defaultChat.open).toHaveBeenCalledWith(FOLDER_KEY) + expect(defaultChat.open).toHaveBeenCalledWith(FOLDER_KEY, expect.any(Function)) expect(tabCount(FOLDER_KEY)).toBe(0) }) diff --git a/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts b/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts new file mode 100644 index 000000000000..6749ba5a5ddd --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, it, vi } from 'vitest' + +const admit = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/structured-agent-session-host-admission', () => ({ + admitStructuredLaunchOnHost: admit +})) + +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from './worktree-activation' +import * as activationGate from './worktree-agent-activation-gate' +import { + makeCreatedAgentWorktree as makeWorktree, + seedEmptyActivatableWorktree +} from './worktree-activation-created-agent-test-state' +import { registerWorktreeActivationReset } from './worktree-activation-test-harness' +import { beginHostAdmittedStructuredLaunch } from './structured-agent-session-launch-admission' +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' + +const initial = useAppStore.getState() +registerWorktreeActivationReset() +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + useAppStore.setState(initial, true) +}) + +// Clicking the still-empty workspace while its chat waits on this machine used to seed +// "Terminal 1" beside the chat, through the activation's reseed rather than the watcher. +it('does not seed a shell when the workspace is re-activated while its chat waits on its host', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + vi.stubGlobal('window', { api: { runtime: { call: vi.fn() }, pty: { listSessions: vi.fn() } } }) + const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockResolvedValue('empty') + let answer!: (admission: { kind: 'admitted' }) => void + admit.mockReturnValue(new Promise((resolve) => (answer = resolve))) + const openAdmitted = vi.fn(() => ({ settlement: new Promise(() => {}), cancel: vi.fn() })) + beginHostAdmittedStructuredLaunch({ + plan: { + ...adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + requestId: 'request-1', + agent: 'claude', + worktreeId: worktree.id + }), + agent: 'claude' + }, + hooks: {}, + worktreeId: worktree.id, + executionHostId: LOCAL_EXECUTION_HOST_ID, + target: { kind: 'local' }, + openAdmitted, + onHostDeclined: () => ({ opened: true }) + }) + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[0]?.value + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toEqual([]) + + answer({ kind: 'admitted' }) + await vi.waitFor(() => expect(openAdmitted).toHaveBeenCalledOnce()) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toEqual([]) + expect(admit).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/lib/worktree-creation-structured-session-paired-decline.test.ts b/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts similarity index 63% rename from src/renderer/src/lib/worktree-creation-structured-session-paired-decline.test.ts rename to src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts index f1c903aab89d..82426c322b3a 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session-paired-decline.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts @@ -20,8 +20,12 @@ import { launchStructuredWorktreeSession } from './worktree-creation-structured- const initial = useAppStore.getState() -function worktree(name: string): Worktree { +function worktree(name: string, host: 'paired' | 'local' = 'paired'): Worktree { const worktreePath = path.join('workspace', name) + const owner: Pick = + host === 'paired' + ? { hostId: 'runtime:server-1', runtimeOwnerEnvironmentId: 'server-1' } + : { hostId: 'local' } return { id: `repo-1::${worktreePath}`, repoId: 'repo-1', @@ -41,8 +45,7 @@ function worktree(name: string): Worktree { sortOrder: 0, lastActivityAt: 0, createdWithAgent: 'claude', - hostId: 'runtime:server-1', - runtimeOwnerEnvironmentId: 'server-1' + ...owner } } @@ -123,3 +126,60 @@ describe('a background worktree create whose paired server declines the chat', ( expect(useAppStore.getState().activeWorktreeId).toBe(OTHER.id) }) }) + +const LOCAL_OTHER = worktree('local-other', 'local') +const LOCAL_CREATED = worktree('local-created', 'local') + +function seedThisMachine(): void { + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: path.join('workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [LOCAL_OTHER, LOCAL_CREATED] }, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only checks the creation is still pending. + pendingWorktreeCreations: { 'creation-1': {} as never }, + settings: getDefaultSettings(path.join('workspace', '.orca')), + // The user moved to another workspace while the create ran. + activeWorktreeId: LOCAL_OTHER.id + }) +} + +describe('a background worktree create whose own machine declines the chat', () => { + it("opens the request's agent terminal in place, with no chat, leaving the user where they are", async () => { + mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) + seedThisMachine() + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request: REQUEST, + agentLaunchRoute: 'structured-native-chat', + worktreeId: LOCAL_CREATED.id, + shouldActivateOnCompletion: false, + activation: false, + primaryTabId: null + }) + + await vi.waitFor(() => + expect(useAppStore.getState().tabsByWorktree[LOCAL_CREATED.id]).toHaveLength(1) + ) + const state = useAppStore.getState() + const tab = state.tabsByWorktree[LOCAL_CREATED.id]![0]! + expect(state.pendingStartupByTabId[tab.id]).toMatchObject({ + command: "claude --model opus 'fix the flaky test'" + }) + expect( + (state.unifiedTabsByWorktree[LOCAL_CREATED.id] ?? []).filter( + (candidate) => candidate.contentType === 'agent-session' + ) + ).toEqual([]) + expect(state.activeWorktreeId).toBe(LOCAL_OTHER.id) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 9cc64b670176..d9cb4a07d0b0 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -4,9 +4,12 @@ import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' -import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-paired-admission' +import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' +import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' +import { mountCreatedWorktreeStartupTabsInBackground } from '@/lib/worktree-creation-background-mount' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' export type WorktreeCreationStructuredSessionResult = { accepted: boolean @@ -27,19 +30,38 @@ type LaunchStructuredWorktreeSessionArgs = { } /** - * A paired server's "no" to a create the user moved away from opens the agent terminal the way a + * The host's "no" to a create the user moved away from opens the agent terminal the way a * background terminal create does: in place, without selecting the workspace (#23974). A create the * user is still watching keeps the default, which opens it as a new agent tab there. */ function openBackgroundDeclinedTerminal( - args: LaunchStructuredWorktreeSessionArgs + args: LaunchStructuredWorktreeSessionArgs, + host: RuntimeClientTarget ): StructuredLaunchTerminal { - ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, { - startup: buildWorktreeCreationStartupOpt(args.request, false), - agent: args.request.agent, - activate: false - }) - return { opened: true } + const startup = buildWorktreeCreationStartupOpt(args.request, false) + if (host.kind === 'environment') { + ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, { + startup, + agent: args.request.agent, + activate: false + }) + return { opened: true } + } + if (!startup) { + return { opened: false } + } + // This machine seeds the agent terminal itself; a background workspace mounts it without showing it. + const tabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + args.worktreeId, + startup, + undefined, + undefined, + undefined, + { activateCreatedTabs: false, createNewTerminalForStartup: true } + ) + mountCreatedWorktreeStartupTabsInBackground(args.worktreeId) + return { opened: tabId !== null } } export async function launchStructuredWorktreeSession( @@ -83,7 +105,7 @@ export async function launchStructuredWorktreeSession( activate: args.shouldActivateOnCompletion, ...(args.shouldActivateOnCompletion ? {} - : { onHostDeclined: () => openBackgroundDeclinedTerminal(args) }), + : { onHostDeclined: (host) => openBackgroundDeclinedTerminal(args, host) }), beforeOpen: () => { // Why: cancellation can arrive through the launch signal while reveal is running, before // the pending-creation store snapshot has caught up. diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index feb01ef0b9b5..3bb5a28c4200 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -231,7 +231,20 @@ export function ensureWorktreeHasInitialTerminal( !hasExplicitLaunchWork && opts.activateCreatedTabs !== false ) { - const defaultChat = openDefaultAgentChatInEmptyWorkspace(worktreeId) + // A host's "no" to the chat seeds what this same call would have without the default chat. + const defaultChat = openDefaultAgentChatInEmptyWorkspace( + worktreeId, + () => + ensureWorktreeHasInitialTerminal( + store, + worktreeId, + startup, + setup, + issueCommand, + defaultTabs, + { ...opts, seedUserDefaultSurface: false } + ) !== null + ) if (defaultChat) { return defaultChat.primaryTabId }