From 80ceb74b1a78cd9dcf103eebd5a3019c68e307fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:20:19 -0700 Subject: [PATCH 1/4] fix(native-chat): a local chat the host declines opens the agent in a terminal A local structured launch opened its chat tab before this machine said whether it could create the chat, so a "no" (a Claude account bound to WSL, for example) left a failed chat whose Retry failed the same way and no terminal. Local launches now ask first through the same admission a paired server's launch already uses: nothing of the chat exists until the host answers, and a "no" runs the caller's own launch as a terminal. --- .../QuickLaunchButton.launch-status.test.tsx | 4 +- .../components/tab-bar/QuickLaunchButton.tsx | 3 - .../terminal-agent-session-fork.test.ts | 24 +-- .../lib/agent-launch-caller-routing.test.ts | 142 +++++++++++------- ...empty-workspace-default-agent-chat.test.ts | 5 +- ...aunch-agent-in-new-tab-structured-route.ts | 42 +----- ...launch-agent-in-new-tab-structured.test.ts | 102 ------------- .../lib/launch-agent-in-new-tab-structured.ts | 54 ------- .../src/lib/launch-agent-in-new-tab.ts | 9 +- ...launch-agent-structured-chat-guard.test.ts | 76 +++++++--- .../launch-work-item-direct-agent-routing.ts | 4 +- .../lib/run-quick-command-in-new-tab.test.ts | 19 +-- .../src/lib/run-quick-command-in-new-tab.ts | 5 +- ...red-agent-session-empty-chat-reuse.test.ts | 6 + ...ed-agent-session-launch-admission.test.ts} | 125 ++++++++++++++- ...uctured-agent-session-launch-admission.ts} | 34 +++-- ...ured-agent-session-provisional-tab.test.ts | 24 ++- ...tructured-agent-session-provisional-tab.ts | 45 +++--- ...n-structured-session-host-decline.test.ts} | 66 +++++++- .../worktree-creation-structured-session.ts | 42 ++++-- 20 files changed, 455 insertions(+), 376 deletions(-) delete mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts delete mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-structured.ts rename src/renderer/src/lib/{structured-agent-session-paired-admission.test.ts => structured-agent-session-launch-admission.test.ts} (65%) rename src/renderer/src/lib/{structured-agent-session-paired-admission.ts => structured-agent-session-launch-admission.ts} (84%) rename src/renderer/src/lib/{worktree-creation-structured-session-paired-decline.test.ts => worktree-creation-structured-session-host-decline.test.ts} (63%) 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 370ca704b87b..3813ddf2ddd0 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 @@ -149,7 +149,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() diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 3f15fe434181..2ac5e3d2b13b 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -161,9 +161,6 @@ function QuickLaunchAgentMenuItemsInner({ onPromptHandedOff( newAgentPromptOutcome({ prompt: prompt ?? '', - ...(result.surface.kind === 'local-agent-session' - ? { sessionId: result.surface.sessionId } - : {}), delivery: result.promptDeliveryResult }) ) 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/lib/agent-launch-caller-routing.test.ts b/src/renderer/src/lib/agent-launch-caller-routing.test.ts index 553bea2aa058..d8e00cdfd3b8 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,10 +246,51 @@ 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('keeps the host-published terminal for a paired server without structured sessions', async () => { store.settings = { ...store.settings, ...CHAT_DEFAULT_SETTINGS } mockIsWebRuntimeSessionActive.mockReturnValue(true) @@ -271,7 +305,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 +342,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..315d483fe0f1 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 @@ -45,7 +45,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,7 +59,8 @@ 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')).toEqual({ primaryTabId: null }) expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( expect.objectContaining({ agent: 'codex', worktreeId: 'wt-1', pendingActivationSpawn: true }) ) 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..4cc86e9c5366 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,9 @@ 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 { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' type StructuredFromNewTab = { surface: AgentLaunchSurface @@ -19,9 +15,9 @@ 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. */ export function launchStructuredAgentFromNewTab(args: { plan: AgentSessionLaunchPlan @@ -33,41 +29,13 @@ export function launchStructuredAgentFromNewTab(args: { } | 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: () => { const terminal = args.openTerminal( 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 deabaa32a597..0779f58e91f2 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -75,16 +75,13 @@ 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 } +/** `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 = { @@ -200,7 +197,7 @@ 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. + // 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 64d748ec858e..b9a7dfc6de7f 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-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 004de247a849..25cf96993d49 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-paired-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts similarity index 65% 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..49bde743a521 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,14 @@ 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 { 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 +52,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 +228,118 @@ 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() + }) +}) 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 84% 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 4e52f2fe31d0..d581c3c81e94 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -26,8 +26,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 @@ -123,12 +123,12 @@ 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. */ -export function beginPairedStructuredLaunch(args: { +export function beginHostAdmittedStructuredLaunch(args: { plan: AgentSessionLaunchPlan & { agent: AgentSessionHandleProvider } hooks: StructuredAgentLaunchHooks worktreeId: string @@ -137,9 +137,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 @@ -160,7 +164,8 @@ export function beginPairedStructuredLaunch(args: { resolveDelivery(NOT_DELIVERED) return { kind: 'cancelled', sessionId: null } } - if (admission.kind === 'unreachable') { + // This machine's runtime has no connection to lose; the chat's own create reports its failure. + if (admission.kind === 'unreachable' && paired) { notifyHostUnreachable(plan.agent, args.executionHostId) resolveDelivery({ delivered: false, failureNotified: true }) return { @@ -179,15 +184,18 @@ export function beginPairedStructuredLaunch(args: { ) } } - notifyHostDeclined(plan.agent) - const terminal = await args.onHostDeclined() + // 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.seedOptions) + admitted = args.openAdmitted(admission.kind === 'admitted' ? admission.seedOptions : undefined) if (!admitted) { resolveDelivery(NOT_DELIVERED) return { kind: 'cancelled', sessionId: null } 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 0a4c1e5aaad9..f8052c1cc036 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -16,12 +16,12 @@ import { type AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' 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 @@ -30,10 +30,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 @@ -75,8 +75,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 @@ -89,9 +89,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 = { @@ -100,44 +98,47 @@ 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 agent = args.plan.agent - const paired = + const owner = worktreeId && isAgentSessionHandleProvider(agent) - ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) + ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null - if (!paired || !worktreeId || !isAgentSessionHandleProvider(agent)) { + if (!owner || !worktreeId || !isAgentSessionHandleProvider(agent)) { + // 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, agent }, 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-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..0cb27f1026ea 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 = + 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 7c3e6d940b6a..dd5d6667de8f 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -5,9 +5,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 @@ -28,19 +31,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. From 058da748798b5f09637c8c7e84dbe7c734bd9bea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:59:26 -0700 Subject: [PATCH 2/4] fix(native-chat): no stray shell while a local chat waits on its host; bound that wait Round 1 review of the ask-first local launch: - A launch waiting on its host now counts as a pending chat create, so the first-terminal watcher no longer seeds "Terminal 1" beside a local chat (watched creates and the empty-workspace default chat). - This machine's answer is waited on for at most 3 s; past it, or while a new workspace is not resolvable yet, the chat opens and its own create reports, as before. Only a real "no" opens a terminal. - The empty-workspace default chat seeds its plain shell on a "no" instead of starting the agent in a terminal nobody asked for, through a narrow launcher option. --- .../initial-terminal-admission-wait.test.tsx | 193 ++++++++++++++++++ .../lib/agent-launch-caller-routing.test.ts | 19 ++ ...empty-workspace-default-agent-chat.test.ts | 29 ++- .../lib/empty-workspace-default-agent-chat.ts | 9 +- ...aunch-agent-in-new-tab-structured-route.ts | 7 +- .../src/lib/launch-agent-in-new-tab.ts | 4 + .../lib/launch-structured-agent-session.ts | 3 +- ...structured-agent-session-host-admission.ts | 39 +++- ...red-agent-session-launch-admission.test.ts | 61 ++++++ ...ructured-agent-session-launch-admission.ts | 107 ++++++---- ...ured-agent-session-launch-awaiting-host.ts | 39 ++++ .../structured-agent-session-launch-status.ts | 7 +- ...tree-activation-default-agent-chat.test.ts | 33 ++- .../lib/worktree-initial-terminal-seeding.ts | 15 +- 14 files changed, 493 insertions(+), 72 deletions(-) create mode 100644 src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx create mode 100644 src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts 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..c9e6770233e8 --- /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 { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +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(getStructuredAgentLaunchStatus('wt-1', 'claude')).toBe('pending') + + 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(getStructuredAgentLaunchStatus('wt-1', 'claude')).toBe('pending') + + await act(async () => mocks.answers[0]?.({ supported: false })) + await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' }) + expect(onHostDeclined).toHaveBeenCalledOnce() + expect(getStructuredAgentLaunchStatus('wt-1', 'claude')).toBe('idle') + }) +}) 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 d8e00cdfd3b8..fb79b9c746a1 100644 --- a/src/renderer/src/lib/agent-launch-caller-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-routing.test.ts @@ -291,6 +291,25 @@ describe('agent launch caller routing', () => { 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() + }) + it('keeps the host-published terminal for a paired server without structured sessions', async () => { store.settings = { ...store.settings, ...CHAT_DEFAULT_SETTINGS } mockIsWebRuntimeSessionActive.mockReturnValue(true) 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 315d483fe0f1..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', () => ({ @@ -60,23 +61,37 @@ describe('openDefaultAgentChatInEmptyWorkspace', () => { seedSettings({}) // The chat's tab opens once its host admits it, so none is primary yet. - expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toEqual({ primaryTabId: null }) + 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() }) @@ -84,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() }) @@ -93,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' }) @@ -105,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/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index 4cc86e9c5366..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 @@ -5,6 +5,7 @@ import { import type { AgentLaunchSurface, LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' +import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' type StructuredFromNewTab = { @@ -17,13 +18,14 @@ type StructuredFromNewTab = { /** * 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. + * 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 @@ -38,6 +40,9 @@ export function launchStructuredAgentFromNewTab(args: { 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.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 0779f58e91f2..99d55f64f601 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,6 +78,8 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { beforeSurfaceOpen?: ( 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. */ @@ -197,6 +200,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent worktreeId, ...(groupId ? { groupId } : {}), ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), + ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), // The host's "no" opens this same launch as a terminal, with the caller's arguments. openTerminal: (terminalPlan) => launchAgentInNewTabInternal({ diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 1762d88d20d8..994ee47660fa 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/structured-agent-session-host-admission.ts b/src/renderer/src/lib/structured-agent-session-host-admission.ts index 3474ce1c45dd..256990a62846 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: AgentSessionHandleProvider ): 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-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 49bde743a521..2cdb6dbe5012 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -32,6 +32,7 @@ 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' @@ -342,4 +343,64 @@ describe('a structured chat launch on this machine', () => { 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-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index d581c3c81e94..06fc9dd1c1c8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-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 { holdStructuredLaunchAwaitingHost } from '@/lib/structured-agent-session-launch-awaiting-host' import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import type { @@ -127,6 +131,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: { * 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 beginHostAdmittedStructuredLaunch(args: { plan: AgentSessionLaunchPlan & { agent: AgentSessionHandleProvider } @@ -154,56 +159,72 @@ export function beginHostAdmittedStructuredLaunch(args: { resolveDelivery = resolve }) : undefined + // Released once the surface that answers the launch has opened, or nothing will. + const releaseHold = holdStructuredLaunchAwaitingHost(args.worktreeId, plan.agent) 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 } - } - // This machine's runtime has no connection to lose; the chat's own create reports its failure. - if (admission.kind === 'unreachable' && paired) { - 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 + } + } + 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 } } - // The notice explains a server's refusal; on this machine the terminal opening is the answer. - if (paired) { - notifyHostDeclined(plan.agent) + admitted = args.openAdmitted( + admission.kind === 'admitted' ? admission.seedOptions : undefined + ) + if (!admitted) { + resolveDelivery(NOT_DELIVERED) + return { kind: 'cancelled', sessionId: null } } - 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-launch-awaiting-host.ts b/src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts new file mode 100644 index 000000000000..f6b643023290 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts @@ -0,0 +1,39 @@ +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' + +// Why: a launch owns its workspace's first surface while its host decides, before any launch +// record exists; a first-terminal seeder landing in that wait would put a shell beside the chat. +// In memory only, and each hold ends when its ask settles. +const holdCountByPair = new Map() + +function pairKey(worktreeId: string, agent: AgentSessionHandleProvider): string { + return `${worktreeId}\u0000${agent}` +} + +/** Marks a launch as waiting on its host; the returned release is idempotent. */ +export function holdStructuredLaunchAwaitingHost( + worktreeId: string, + agent: AgentSessionHandleProvider +): () => void { + const key = pairKey(worktreeId, agent) + holdCountByPair.set(key, (holdCountByPair.get(key) ?? 0) + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (holdCountByPair.get(key) ?? 1) - 1 + if (remaining > 0) { + holdCountByPair.set(key, remaining) + } else { + holdCountByPair.delete(key) + } + } +} + +export function isStructuredLaunchAwaitingHost( + worktreeId: string, + agent: AgentSessionHandleProvider +): boolean { + return holdCountByPair.has(pairKey(worktreeId, agent)) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts index 64d42bab136a..1c1ac8df1951 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-status.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts @@ -1,15 +1,20 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders' +import { isStructuredLaunchAwaitingHost } from './structured-agent-session-launch-awaiting-host' import { structuredLaunchIdentity, type StructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' -/** Whether a chat for this pair is being created here: its own first create, or an adoption. */ +/** Whether a chat for this pair is being created here: its own first create, or an adoption, or a + * launch still waiting on its host's answer. */ export function getStructuredAgentLaunchStatus( worktreeId: string, agent: AgentSessionHandleProvider ): StructuredAgentLaunchStatus { + if (isStructuredLaunchAwaitingHost(worktreeId, agent)) { + return 'pending' + } // Any launch holding an identity for this pair, adopted conversations included, is starting here. // A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat. const identity = structuredLaunchIdentity(worktreeId, agent) 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-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 } From 8dcd576ced2c3bfdb3fe7fc4e8b1d8f88928b11b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:04:32 -0700 Subject: [PATCH 3/4] test(native-chat): type the declined-create worktree fixture's owner fields --- .../worktree-creation-structured-session-host-decline.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts b/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts index 0cb27f1026ea..82426c322b3a 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts @@ -22,7 +22,7 @@ const initial = useAppStore.getState() function worktree(name: string, host: 'paired' | 'local' = 'paired'): Worktree { const worktreePath = path.join('workspace', name) - const owner = + const owner: Pick = host === 'paired' ? { hostId: 'runtime:server-1', runtimeOwnerEnvironmentId: 'server-1' } : { hostId: 'local' } From c1bb90abf4f66c9302f854eef670959b2fbd3f2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:22:26 -0700 Subject: [PATCH 4/4] fix(native-chat): one first-surface claim for a launch waiting on its host The wait was held in a separate record only the passive watcher read, so re-activating the still-empty workspace during the wait seeded "Terminal 1" through the activation's reseed. The wait now lives in the empty-workspace default-surface claims both seeders already read, and the separate record is gone. --- .../initial-terminal-admission-wait.test.tsx | 8 +-- .../empty-workspace-default-surface-claims.ts | 32 +++++++-- ...ructured-agent-session-launch-admission.ts | 4 +- ...ured-agent-session-launch-awaiting-host.ts | 39 ----------- .../structured-agent-session-launch-status.ts | 7 +- ...ee-activation-launch-awaiting-host.test.ts | 66 +++++++++++++++++++ 6 files changed, 101 insertions(+), 55 deletions(-) delete mode 100644 src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts create mode 100644 src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts 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 index c9e6770233e8..fdc3b6222028 100644 --- a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx @@ -49,7 +49,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' -import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +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' @@ -146,7 +146,7 @@ describe('a local chat launch waiting on its host', () => { hooks: {}, beforeOpen: () => true }) - expect(getStructuredAgentLaunchStatus('wt-1', 'claude')).toBe('pending') + expect(isEmptyWorkspaceDefaultSurfacePending('wt-1')).toBe(true) root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) @@ -183,11 +183,11 @@ describe('a local chat launch waiting on its host', () => { hooks: {}, onHostDeclined }) - expect(getStructuredAgentLaunchStatus('wt-1', 'claude')).toBe('pending') + 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(getStructuredAgentLaunchStatus('wt-1', 'claude')).toBe('idle') + expect(isEmptyWorkspaceDefaultSurfacePending('wt-1')).toBe(false) }) }) 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/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index 06fc9dd1c1c8..42effd8cc61a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -12,7 +12,7 @@ import { admitStructuredLaunchOnHost, type StructuredLaunchAdmission } from '@/lib/structured-agent-session-host-admission' -import { holdStructuredLaunchAwaitingHost } from '@/lib/structured-agent-session-launch-awaiting-host' +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 { @@ -160,7 +160,7 @@ export function beginHostAdmittedStructuredLaunch(args: { }) : undefined // Released once the surface that answers the launch has opened, or nothing will. - const releaseHold = holdStructuredLaunchAwaitingHost(args.worktreeId, plan.agent) + const releaseHold = holdEmptyWorkspaceDefaultSurfaceForLaunch(args.worktreeId) const settlement = (async (): Promise => { try { const asked = await admitStructuredLaunchOnHost( diff --git a/src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts b/src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts deleted file mode 100644 index f6b643023290..000000000000 --- a/src/renderer/src/lib/structured-agent-session-launch-awaiting-host.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' - -// Why: a launch owns its workspace's first surface while its host decides, before any launch -// record exists; a first-terminal seeder landing in that wait would put a shell beside the chat. -// In memory only, and each hold ends when its ask settles. -const holdCountByPair = new Map() - -function pairKey(worktreeId: string, agent: AgentSessionHandleProvider): string { - return `${worktreeId}\u0000${agent}` -} - -/** Marks a launch as waiting on its host; the returned release is idempotent. */ -export function holdStructuredLaunchAwaitingHost( - worktreeId: string, - agent: AgentSessionHandleProvider -): () => void { - const key = pairKey(worktreeId, agent) - holdCountByPair.set(key, (holdCountByPair.get(key) ?? 0) + 1) - let released = false - return () => { - if (released) { - return - } - released = true - const remaining = (holdCountByPair.get(key) ?? 1) - 1 - if (remaining > 0) { - holdCountByPair.set(key, remaining) - } else { - holdCountByPair.delete(key) - } - } -} - -export function isStructuredLaunchAwaitingHost( - worktreeId: string, - agent: AgentSessionHandleProvider -): boolean { - return holdCountByPair.has(pairKey(worktreeId, agent)) -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts index 1c1ac8df1951..64d42bab136a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-status.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts @@ -1,20 +1,15 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders' -import { isStructuredLaunchAwaitingHost } from './structured-agent-session-launch-awaiting-host' import { structuredLaunchIdentity, type StructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' -/** Whether a chat for this pair is being created here: its own first create, or an adoption, or a - * launch still waiting on its host's answer. */ +/** Whether a chat for this pair is being created here: its own first create, or an adoption. */ export function getStructuredAgentLaunchStatus( worktreeId: string, agent: AgentSessionHandleProvider ): StructuredAgentLaunchStatus { - if (isStructuredLaunchAwaitingHost(worktreeId, agent)) { - return 'pending' - } // Any launch holding an identity for this pair, adopted conversations included, is starting here. // A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat. const identity = structuredLaunchIdentity(worktreeId, agent) 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() +})