From e050c8a9badebfc5f79a97c122b6ae0325972fc3 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 8 Sep 2026 19:26:47 -0700 Subject: [PATCH] refactor(native-chat): run quick create through the shared settle loop Quick create was the last entrypoint driving the launch handle itself, because its cancel lifecycle is real: when the creation is abandoned the structured launch must be cancelled immediately so a staged prompt never reaches the provider. The shared loop now takes a cancellation hook with an eager subscription plus a post-await check; it cancels the launch once, unsubscribes on settle, and reports cancelled without running the fallback. Quick create keeps its two-branch legacy fallback and retire-on-late-cancel. Also updates the surface-caller census for the onboarding launch module that step 2 introduced. --- ...structured-agent-launch-settlement.test.ts | 98 +++++- .../lib/structured-agent-launch-settlement.ts | 39 ++- ...e-activation-surface-caller-wiring.test.ts | 3 +- ...rktree-creation-structured-session.test.ts | 307 +++++++++++++++++- .../worktree-creation-structured-session.ts | 239 +++++++------- 5 files changed, 549 insertions(+), 137 deletions(-) diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.test.ts b/src/renderer/src/lib/structured-agent-launch-settlement.test.ts index f137e59f172..a543354273c 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.test.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - startStructuredAgentLaunch: vi.fn() + startStructuredAgentLaunch: vi.fn(), + cancelStructuredAgentLaunch: vi.fn() })) vi.mock('@/lib/structured-agent-session-launch', () => ({ - startStructuredAgentLaunch: mocks.startStructuredAgentLaunch + startStructuredAgentLaunch: mocks.startStructuredAgentLaunch, + cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch })) vi.mock('@/lib/launch-structured-agent-session', () => ({ @@ -52,6 +54,29 @@ const fallbackResult = { primaryTabId: 'fallback-tab' } +/** A caller-side cancel signal: `fire` is what the caller's store subscription would call. */ +function fakeCancellation(initiallyCancelled = false) { + let cancelled = initiallyCancelled + const unsubscribe = vi.fn() + const listeners: (() => void)[] = [] + return { + unsubscribe, + fire: () => { + cancelled = true + for (const listener of listeners) { + listener() + } + }, + hook: { + isCancelled: () => cancelled, + subscribe: vi.fn((onCancel: () => void) => { + listeners.push(onCancel) + return unsubscribe + }) + } + } +} + describe('settleStructuredAgentLaunch', () => { beforeEach(() => vi.clearAllMocks()) @@ -173,10 +198,10 @@ describe('settleStructuredAgentLaunch', () => { {}, { onStructuredReady, - isCancelled: () => true + cancellation: fakeCancellation(true).hook } ) - ).resolves.toEqual({ kind: 'cancelled' }) + ).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) expect(onStructuredReady).not.toHaveBeenCalled() }) @@ -193,10 +218,71 @@ describe('settleStructuredAgentLaunch', () => { {}, { legacyFallback, - isCancelled: () => true + cancellation: fakeCancellation(true).hook } ) - ).resolves.toEqual({ kind: 'cancelled' }) + ).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) expect(legacyFallback).not.toHaveBeenCalled() }) + + it('cancels the launch eagerly, once, before the launch settles', async () => { + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + fakeLaunch({ + launchResult: new Promise((resolve) => { + resolveLaunch = resolve + }) + }) + const cancellation = fakeCancellation() + const onStructuredReady = vi.fn() + + const settlement = settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { onStructuredReady, cancellation: cancellation.hook } + ) + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + cancellation.fire() + cancellation.fire() + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + expect(cancellation.unsubscribe).not.toHaveBeenCalled() + + resolveLaunch({ sessionId: 'session-1', fence: 1 }) + await expect(settlement).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) + expect(onStructuredReady).not.toHaveBeenCalled() + expect(cancellation.unsubscribe).toHaveBeenCalledOnce() + }) + + it('honours a cancellation that fired before the loop subscribed', async () => { + fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) }) + const cancellation = fakeCancellation(true) + + const settlement = settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { cancellation: cancellation.hook } + ) + expect(cancellation.hook.subscribe).toHaveBeenCalledOnce() + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + await expect(settlement).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) + expect(cancellation.unsubscribe).toHaveBeenCalledOnce() + }) + + it('unsubscribes from the cancel signal once a launch settles without cancelling', async () => { + fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) }) + const cancellation = fakeCancellation() + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { cancellation: cancellation.hook }) + ).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' }) + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + expect(cancellation.unsubscribe).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index fd26619d438..434f588b2a9 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -1,6 +1,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { + cancelStructuredAgentLaunch, startStructuredAgentLaunch, type StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' @@ -21,17 +22,24 @@ export type StructuredAgentLaunchSettlement = promptDeliveryResult?: Promise } | ({ kind: 'refused-then-legacy' } & StructuredAgentLegacyFallbackResult) - | { kind: 'cancelled' } + | { kind: 'cancelled'; sessionId: string } | { kind: 'visibility-unknown'; sessionId: string } | { kind: 'failed'; error: unknown } +export type StructuredAgentLaunchCancellation = { + isCancelled: () => boolean + /** Fires the moment the caller abandons the launch. The loop cancels eagerly on it so a staged + * prompt is discarded before it can reach the provider; a token read only after awaits is late. */ + subscribe: (onCancel: () => void) => () => void +} + export type StructuredAgentLaunchHooks = { /** What this flow did before structured chat existed: activate with a startup payload, set the * first-message rename flag, run trust preflight. Runs at most once, only on definitive refusal. * Resume has no legacy equivalent, so a refusal without this hook settles as `failed`. */ legacyFallback?: () => Promise onStructuredReady?: (sessionId: string) => void - isCancelled?: () => boolean + cancellation?: StructuredAgentLaunchCancellation } /** @@ -45,8 +53,21 @@ export async function settleStructuredAgentLaunch( options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): Promise { - const isCancelled = (): boolean => hooks.isCancelled?.() === true const launch = startStructuredAgentLaunch(worktreeId, agent, options) + let cancelRequested = false + const isCancelled = (): boolean => cancelRequested || hooks.cancellation?.isCancelled() === true + const cancelLaunch = (): void => { + if (cancelRequested) { + return + } + cancelRequested = true + cancelStructuredAgentLaunch(worktreeId, launch.sessionId) + } + const unsubscribe = hooks.cancellation?.subscribe(cancelLaunch) + // Why: the caller may have been abandoned between its own check and this subscription. + if (isCancelled()) { + cancelLaunch() + } // Why: a holder, not a `let`: TS narrows a closure-assigned local to its initial null. const fallback: { result: StructuredAgentLegacyFallbackResult | null } = { result: null } // Why: the claim resolves after the callback settles, so awaiting it below is what serialises @@ -58,10 +79,14 @@ export async function settleStructuredAgentLaunch( } fallback.result = await hooks.legacyFallback() }) + const cancelled = (): StructuredAgentLaunchSettlement => ({ + kind: 'cancelled', + sessionId: launch.sessionId + }) try { const receipt = await launch.launchResult if (isCancelled()) { - return { kind: 'cancelled' } + return cancelled() } hooks.onStructuredReady?.(receipt.sessionId) return { @@ -71,7 +96,7 @@ export async function settleStructuredAgentLaunch( } } catch (error) { if (isCancelled()) { - return { kind: 'cancelled' } + return cancelled() } if (error instanceof StructuredAgentSessionCreateRefusalError) { const ran = await refusalFallback.then( @@ -79,7 +104,7 @@ export async function settleStructuredAgentLaunch( (fallbackError: unknown) => ({ fallbackError }) ) if (isCancelled()) { - return { kind: 'cancelled' } + return cancelled() } if (typeof ran !== 'boolean') { return { kind: 'failed', error: ran.fallbackError } @@ -96,5 +121,7 @@ export async function settleStructuredAgentLaunch( return { kind: 'visibility-unknown', sessionId: launch.sessionId } } return { kind: 'failed', error } + } finally { + unsubscribe?.() } } diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts index 419e642adcb..e23b159e982 100644 --- a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts +++ b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts @@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest' const SURFACE_PROVIDING_CALLERS = [ 'src/renderer/src/components/editor/check-annotation-open.ts', 'src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx', - 'src/renderer/src/components/sidebar/NonGitFolderDialog.tsx', 'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts', 'src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts', 'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts', @@ -19,7 +18,7 @@ const SURFACE_PROVIDING_CALLERS = [ 'src/renderer/src/lib/launch-work-item-direct.ts', 'src/renderer/src/lib/worktree-creation-flow-execute.ts', 'src/renderer/src/lib/workspace-port-actions.ts', - 'src/renderer/src/store/repos/repo-add-actions.ts' + 'src/renderer/src/lib/onboarding-folder-agent-launch.ts' ] // The activation seam itself: declares the option and forwards it into the tombstone gate. diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts index c58fcd40dce..ca51c2a052f 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -10,7 +10,11 @@ const mocks = vi.hoisted(() => ({ cancelStructuredAgentLaunch: vi.fn(), closeStructuredAgentSession: vi.fn(), callRuntimeRpc: vi.fn(), - activateStructuredAgentSessionById: vi.fn() + activateStructuredAgentSessionById: vi.fn(), + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + preflightAgentTrust: vi.fn(), + updateWorktreeMeta: vi.fn() })) vi.mock('@/store', () => ({ @@ -47,23 +51,65 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ })) vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ - ensureWorktreeHasInitialTerminal: vi.fn() + ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal })) vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() + activateAndRevealWorktree: mocks.activateAndRevealWorktree })) vi.mock('@/lib/agent-trust-preflight', () => ({ - preflightAgentTrust: vi.fn() + preflightAgentTrust: mocks.preflightAgentTrust })) vi.mock('@/lib/launch-structured-agent-session', () => ({ StructuredAgentSessionCreateRefusalError: class extends Error {} })) +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { launchStructuredWorktreeSession } from './worktree-creation-structured-session' +const request = { + repoId: 'repo-1', + name: 'routing-recovery', + setupDecision: 'run' as const, + agent: 'codex' as const, + pendingFirstAgentMessageRename: true, + note: '', + startupPlan: null, + quickPrompt: 'Fix the route', + quickTelemetry: null +} + +const idle = { accepted: true, cancelled: false, visibilityUnknown: false } + +/** Mirrors the callers layer: a refusal runs the claimed fallback once and resolves true. */ +function refusedLaunch(sessionId = 'session-refused') { + const launchResult = Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId, + launchResult, + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn((fallback: () => Promise) => + launchResult.catch(() => + Promise.resolve() + .then(fallback) + .then(() => true) + ) + ) + }) +} + +function storeWithWorktree() { + mocks.state = { + pendingWorktreeCreations: { 'creation-1': {} }, + allWorktrees: () => [{ id: 'worktree-1', path: '/tmp/worktree-1' }], + repos: [{ id: 'repo-1', connectionId: 'ssh-1' }], + updateWorktreeMeta: mocks.updateWorktreeMeta + } as unknown as typeof mocks.state +} + describe('launchStructuredWorktreeSession', () => { beforeEach(() => { vi.clearAllMocks() @@ -71,6 +117,259 @@ describe('launchStructuredWorktreeSession', () => { mocks.listener = null mocks.closeStructuredAgentSession.mockResolvedValue('closed') mocks.callRuntimeRpc.mockResolvedValue(undefined) + mocks.updateWorktreeMeta.mockResolvedValue(undefined) + mocks.preflightAgentTrust.mockResolvedValue(undefined) + }) + + it('activates the structured session once it is published', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ ...idle, activation: false, primaryTabId: null }) + expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', { + prompt: 'Fix the route' + }) + expect(mocks.activateStructuredAgentSessionById).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'worktree-1', + sessionId: 'session-1' + }) + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + }) + + it('does not activate a published session when the user has moved on', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: false, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: 'tab-existing' + }) + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + }) + + it('retries an unknown launch with no prompt so the outbox is not re-staged', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null, + recoverUnknownLaunch: true + }) + expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', {}) + }) + + it('returns cancelled without starting a launch when the creation is already gone', async () => { + mocks.state = { pendingWorktreeCreations: {} } + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ ...idle, cancelled: true, activation: false, primaryTabId: null }) + expect(mocks.startStructuredAgentLaunch).not.toHaveBeenCalled() + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + }) + + it('discards the launch eagerly when the creation is dismissed while it is pending', async () => { + const launchResult = new Promise(() => {}) + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult, + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => launchResult) + }) + + void launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + await Promise.resolve() + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + + mocks.state = { pendingWorktreeCreations: {} } + mocks.listener?.(mocks.state) + mocks.listener?.(mocks.state) + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + }) + + it('falls back to an activated terminal after a definitive refusal', async () => { + storeWithWorktree() + refusedLaunch() + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'terminal-tab' }) + const startup = { kind: 'agent', agent: 'codex', prompt: 'Fix the route' } + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: startup as never, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + activation: { primaryTabId: 'terminal-tab' }, + primaryTabId: 'terminal-tab' + }) + expect(mocks.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pendingFirstAgentMessageRename: true + }) + expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({ + agent: 'codex', + workspacePath: '/tmp/worktree-1', + connectionId: 'ssh-1' + }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + startup + }) + expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + }) + + it('seeds a background terminal after a refusal when the user has moved on', async () => { + storeWithWorktree() + refusedLaunch() + mocks.ensureWorktreeHasInitialTerminal.mockReturnValue('background-tab') + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: false, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + activation: false, + primaryTabId: 'background-tab' + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + mocks.state, + 'worktree-1', + undefined, + undefined, + undefined, + undefined, + { activateCreatedTabs: false, createNewTerminalForStartup: true } + ) + }) + + it('stops the fallback mid-way when the creation is dismissed and retires nothing', async () => { + storeWithWorktree() + refusedLaunch() + mocks.preflightAgentTrust.mockImplementation(async () => { + mocks.state = { ...mocks.state, pendingWorktreeCreations: {} } + mocks.listener?.(mocks.state) + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + cancelled: true, + activation: false, + primaryTabId: null + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('reports a known failure as accepted with the caller surface untouched', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.reject(new Error('boom')), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: { primaryTabId: 'tab-1' } as never, + primaryTabId: 'tab-1' + }) + ).resolves.toEqual({ ...idle, activation: { primaryTabId: 'tab-1' }, primaryTabId: 'tab-1' }) + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) it('cancels and retires a session when its pending creation is dismissed', async () => { diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index d3f0ea16686..56fd2ae68f2 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -2,10 +2,9 @@ import { useAppStore } from '@/store' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' import { - cancelStructuredAgentLaunch, - startStructuredAgentLaunch -} from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' + settleStructuredAgentLaunch, + type StructuredAgentLegacyFallbackResult +} from '@/lib/structured-agent-launch-settlement' import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' import { preflightAgentTrust } from '@/lib/agent-trust-preflight' @@ -23,6 +22,17 @@ export type WorktreeCreationStructuredSessionResult = { primaryTabId: string | null } +type LaunchStructuredWorktreeSessionArgs = { + creationId: string + request: WorktreeCreationRequest + worktreeId: string + shouldActivateOnCompletion: boolean + fallbackStartupOpt: WorktreeStartupPayload | undefined + activation: ActivateAndRevealResult | false + primaryTabId: string | null + recoverUnknownLaunch?: boolean +} + async function retireCancelledStructuredSession( worktreeId: string, sessionId: string @@ -36,95 +46,48 @@ async function retireCancelledStructuredSession( }).catch(() => undefined) } -export async function launchStructuredWorktreeSession(args: { - creationId: string - request: WorktreeCreationRequest - worktreeId: string - shouldActivateOnCompletion: boolean - fallbackStartupOpt: WorktreeStartupPayload | undefined - activation: ActivateAndRevealResult | false - primaryTabId: string | null - recoverUnknownLaunch?: boolean -}): Promise { - let { activation, primaryTabId } = args - let accepted = true - let visibilityUnknown = false - const agent = args.request.agent - if (!isAgentSessionHandleProvider(agent)) { - return { accepted, cancelled: false, visibilityUnknown, activation, primaryTabId } - } - if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) { - return { accepted, cancelled: true, visibilityUnknown, activation, primaryTabId } - } - - const launch = startStructuredAgentLaunch( - args.worktreeId, - agent, - args.recoverUnknownLaunch - ? {} - : { - prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt, - ...(args.request.launchDraftPrompt ? { promptDelivery: 'draft' as const } : {}) - } - ) - let cancelled = false - const cancelLaunch = (): void => { - if (cancelled) { - return - } - cancelled = true - cancelStructuredAgentLaunch(args.worktreeId, launch.sessionId) - } - const unsubscribe = useAppStore.subscribe((state) => { - if (!state.pendingWorktreeCreations[args.creationId]) { - cancelLaunch() - } - }) - if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) { - cancelLaunch() - } - const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { - accepted = false - if (cancelled) { - return - } - if (args.request.pendingFirstAgentMessageRename) { - await useAppStore - .getState() - .updateWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true }) - .catch(() => undefined) - } - if (cancelled) { - return - } - const worktree = useAppStore +/** What quick create did before structured chat: rename flag, trust preflight, then a terminal. */ +async function openLegacyWorktreeSurface( + args: LaunchStructuredWorktreeSessionArgs, + isCancelled: () => boolean +): Promise { + const unchanged = { activation: args.activation, primaryTabId: args.primaryTabId } + if (args.request.pendingFirstAgentMessageRename) { + await useAppStore .getState() - .allWorktrees?.() - .find((candidate) => candidate.id === args.worktreeId) - if (args.request.agent && worktree?.path) { - const repoConnectionId = useAppStore - .getState() - .repos.find((repo) => repo.id === args.request.repoId)?.connectionId - await preflightAgentTrust({ - agent: args.request.agent, - workspacePath: worktree.path, - connectionId: repoConnectionId - }) - } - if (cancelled) { - return - } - if (args.shouldActivateOnCompletion) { - const fallbackActivation = activateAndRevealWorktree(args.worktreeId, { - sidebarRevealBehavior: 'auto', - createNewTerminalForStartup: true, - ...(args.fallbackStartupOpt ? { startup: args.fallbackStartupOpt } : {}) - }) - activation = fallbackActivation - primaryTabId = fallbackActivation === false ? null : fallbackActivation.primaryTabId - return - } - primaryTabId = ensureWorktreeHasInitialTerminal( + .updateWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true }) + .catch(() => undefined) + } + if (isCancelled()) { + return unchanged + } + const worktree = useAppStore + .getState() + .allWorktrees?.() + .find((candidate) => candidate.id === args.worktreeId) + if (args.request.agent && worktree?.path) { + const repoConnectionId = useAppStore + .getState() + .repos.find((repo) => repo.id === args.request.repoId)?.connectionId + await preflightAgentTrust({ + agent: args.request.agent, + workspacePath: worktree.path, + connectionId: repoConnectionId + }) + } + if (isCancelled()) { + return unchanged + } + if (args.shouldActivateOnCompletion) { + const activation = activateAndRevealWorktree(args.worktreeId, { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + ...(args.fallbackStartupOpt ? { startup: args.fallbackStartupOpt } : {}) + }) + return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } + } + return { + primaryTabId: ensureWorktreeHasInitialTerminal( useAppStore.getState(), args.worktreeId, args.fallbackStartupOpt, @@ -133,35 +96,73 @@ export async function launchStructuredWorktreeSession(args: { undefined, { activateCreatedTabs: false, createNewTerminalForStartup: true } ) - }) + } +} - try { - const receipt = await launch.launchResult - if (cancelled) { - await retireCancelledStructuredSession(args.worktreeId, launch.sessionId) - return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } - } - if (args.shouldActivateOnCompletion) { - activateStructuredAgentSessionById({ - worktreeId: args.worktreeId, - sessionId: receipt.sessionId - }) - } - } catch (error) { - if (cancelled) { - await retireCancelledStructuredSession(args.worktreeId, launch.sessionId) - return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } - } - if (error instanceof StructuredAgentSessionCreateRefusalError) { - await refusalFallback - } else { - visibilityUnknown = launch.isVisibilityUnknown() - if (visibilityUnknown) { - launch.releaseCallerAfterUnknownOutcome() +export async function launchStructuredWorktreeSession( + args: LaunchStructuredWorktreeSessionArgs +): Promise { + const { activation, primaryTabId } = args + const settled = { accepted: true, cancelled: false, visibilityUnknown: false } + const agent = args.request.agent + if (!isAgentSessionHandleProvider(agent)) { + return { ...settled, activation, primaryTabId } + } + const isCancelled = (): boolean => + !useAppStore.getState().pendingWorktreeCreations[args.creationId] + if (isCancelled()) { + return { ...settled, cancelled: true, activation, primaryTabId } + } + let refused = false + const settlement = await settleStructuredAgentLaunch( + args.worktreeId, + agent, + args.recoverUnknownLaunch + ? {} + : { + prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt, + ...(args.request.launchDraftPrompt ? { promptDelivery: 'draft' as const } : {}) + }, + { + cancellation: { + isCancelled, + subscribe: (onCancel) => + useAppStore.subscribe((state) => { + if (!state.pendingWorktreeCreations[args.creationId]) { + onCancel() + } + }) + }, + legacyFallback: () => { + refused = true + return openLegacyWorktreeSurface(args, isCancelled) + }, + onStructuredReady: (sessionId) => { + if (args.shouldActivateOnCompletion) { + activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId }) + } } } - } finally { - unsubscribe() + ) + switch (settlement.kind) { + case 'cancelled': + // Why: a refusal means no session exists on the host, so there is nothing to retire. + if (!refused) { + await retireCancelledStructuredSession(args.worktreeId, settlement.sessionId) + } + return { ...settled, accepted: !refused, cancelled: true, activation, primaryTabId } + case 'refused-then-legacy': + return { + ...settled, + accepted: false, + activation: settlement.activation ?? activation, + primaryTabId: settlement.primaryTabId + } + case 'visibility-unknown': + return { ...settled, visibilityUnknown: true, activation, primaryTabId } + case 'structured': + case 'failed': + // Why: a failed launch has always reported as accepted here; the launch layer toasts it. + return { ...settled, activation, primaryTabId } } - return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } }