diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 604378ec710..661bf623880 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -258,7 +258,7 @@ export async function submitFolderWorkspaceCreate({ await launch.launchResult } catch (error) { if (!(error instanceof StructuredAgentSessionCreateRefusalError)) { - return true + return !launch.isVisibilityUnknown() } await refusalFallback } diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 80844c86331..7cb5f88f17d 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -236,25 +236,32 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { ...(structuredLaunch ? { providesInitialSurface: true } : {}) }) - const { structuredLaunchAccepted, activation } = await settleFullCreationStructuredLaunch({ - structuredLaunch, - agent: tuiAgent, - worktreeId: worktree.id, - prompt: startupPlan?.draftPrompt ?? submitStartupPrompt, - initialActivation, - onDefinitiveRefusal: async () => { - if (pendingFirstAgentMessageRename) { - await applyWorktreeMeta(worktree.id, { pendingFirstAgentMessageRename: true }).catch( - () => undefined - ) + const { structuredLaunchAccepted, visibilityUnknown, activation } = + await settleFullCreationStructuredLaunch({ + structuredLaunch, + agent: tuiAgent, + worktreeId: worktree.id, + prompt: startupPlan?.draftPrompt ?? submitStartupPrompt, + initialActivation, + onDefinitiveRefusal: async () => { + if (pendingFirstAgentMessageRename) { + await applyWorktreeMeta(worktree.id, { pendingFirstAgentMessageRename: true }).catch( + () => undefined + ) + } + return activateAndRevealWorktree(worktree.id, { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + ...(startup ? { startup } : {}) + }) } - return activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', - createNewTerminalForStartup: true, - ...(startup ? { startup } : {}) - }) - } - }) + }) + + if (visibilityUnknown) { + setSidebarOpen(true) + onCreated?.() + return + } if (!structuredLaunchAccepted && startupPlan) { const optionScopeKey = diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts new file mode 100644 index 00000000000..2176a1863ed --- /dev/null +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + startStructuredCodexLaunch: vi.fn(), + activateStructuredAgentSessionById: vi.fn() +})) + +vi.mock('@/lib/structured-agent-session-launch', () => ({ + startStructuredCodexLaunch: mocks.startStructuredCodexLaunch +})) + +vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ + activateStructuredAgentSessionById: mocks.activateStructuredAgentSessionById +})) + +vi.mock('@/lib/launch-structured-codex-session', () => ({ + StructuredAgentSessionCreateRefusalError: class extends Error {} +})) + +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' + +describe('settleFullCreationStructuredLaunch', () => { + beforeEach(() => vi.clearAllMocks()) + + it('runs the legacy terminal fallback after a definitive refusal', async () => { + const fallbackActivation = { primaryTabId: 'fallback-tab' } + const onDefinitiveRefusal = vi.fn().mockResolvedValue(fallbackActivation) + mocks.startStructuredCodexLaunch.mockReturnValue({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')), + isVisibilityUnknown: () => false, + claimDefinitiveRefusalFallback: (fallback: () => Promise) => + Promise.resolve() + .then(fallback) + .then(() => true) + }) + + await expect( + settleFullCreationStructuredLaunch({ + structuredLaunch: true, + agent: 'codex', + worktreeId: 'worktree-1', + prompt: 'Fix the route', + initialActivation: false, + onDefinitiveRefusal + }) + ).resolves.toEqual({ + structuredLaunchAccepted: false, + visibilityUnknown: false, + activation: fallbackActivation + }) + expect(onDefinitiveRefusal).toHaveBeenCalledOnce() + }) + + it('reports an unknown outcome without starting a fallback terminal', async () => { + const onDefinitiveRefusal = vi.fn() + mocks.startStructuredCodexLaunch.mockReturnValue({ + launchResult: Promise.reject(new Error('connection lost')), + isVisibilityUnknown: () => true, + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await expect( + settleFullCreationStructuredLaunch({ + structuredLaunch: true, + agent: 'codex', + worktreeId: 'worktree-1', + prompt: 'Fix the route', + initialActivation: false, + onDefinitiveRefusal + }) + ).resolves.toEqual({ + structuredLaunchAccepted: true, + visibilityUnknown: true, + activation: false + }) + expect(onDefinitiveRefusal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts index dc03849cd73..f352724d841 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts @@ -13,11 +13,15 @@ export async function settleFullCreationStructuredLaunch(args: { prompt: string initialActivation: Activation onDefinitiveRefusal: () => Activation | Promise -}): Promise<{ structuredLaunchAccepted: boolean; activation: Activation }> { +}): Promise<{ + structuredLaunchAccepted: boolean + visibilityUnknown: boolean + activation: Activation +}> { let activation = args.initialActivation let structuredLaunchAccepted = args.structuredLaunch if (!args.structuredLaunch || args.agent !== 'codex') { - return { structuredLaunchAccepted, activation } + return { structuredLaunchAccepted, visibilityUnknown: false, activation } } const launch = startStructuredCodexLaunch(args.worktreeId, { prompt: args.prompt }) @@ -34,7 +38,9 @@ export async function settleFullCreationStructuredLaunch(args: { } catch (error) { if (error instanceof StructuredAgentSessionCreateRefusalError) { await refusalFallback + } else if (launch.isVisibilityUnknown()) { + return { structuredLaunchAccepted, visibilityUnknown: true, activation } } } - return { structuredLaunchAccepted, activation } + return { structuredLaunchAccepted, visibilityUnknown: false, activation } } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts new file mode 100644 index 00000000000..f0e9cf5b783 --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + startStructuredCodexLaunch: vi.fn(), + activateAndRevealWorktree: vi.fn(), + preflightAgentTrust: vi.fn() +})) + +vi.mock('@/lib/structured-agent-session-launch', () => ({ + startStructuredCodexLaunch: mocks.startStructuredCodexLaunch +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) + +vi.mock('@/lib/agent-trust-preflight', () => ({ + preflightAgentTrust: mocks.preflightAgentTrust +})) + +vi.mock('@/lib/launch-structured-codex-session', () => ({ + StructuredAgentSessionCreateRefusalError: class extends Error {} +})) + +vi.mock('@/lib/native-chat-transcript-readability', () => ({ + isNativeChatTranscriptLocalReadable: vi.fn(() => true) +})) + +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { settleDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' + +const baseArgs = { + structuredLaunch: true, + agent: 'codex' as const, + worktreeId: 'worktree-1', + workspacePath: '/repo/worktree', + connectionId: null, + draftContent: 'Fix the route', + promptDelivery: 'draft' as const, + primaryTabId: null, + startupPlan: null, + launchSource: 'task_page' as const +} + +describe('settleDirectWorkItemStructuredLaunch', () => { + beforeEach(() => vi.clearAllMocks()) + + it('runs the legacy terminal fallback after a definitive refusal', async () => { + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' }) + mocks.startStructuredCodexLaunch.mockReturnValue({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')), + isVisibilityUnknown: () => false, + claimDefinitiveRefusalFallback: (fallback: () => Promise) => + Promise.resolve() + .then(fallback) + .then(() => true) + }) + + await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({ + completed: false, + structuredLaunch: false, + visibilityUnknown: false, + primaryTabId: 'fallback-tab' + }) + }) + + it('reports an unknown outcome without starting a fallback terminal', async () => { + mocks.startStructuredCodexLaunch.mockReturnValue({ + launchResult: Promise.reject(new Error('connection lost')), + isVisibilityUnknown: () => true, + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({ + completed: false, + structuredLaunch: true, + visibilityUnknown: true, + primaryTabId: null + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) +}) 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 71e390aa3b5..43f6a6e700d 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 @@ -123,11 +123,12 @@ export async function settleDirectWorkItemStructuredLaunch(args: { }): Promise<{ completed: boolean structuredLaunch: boolean + visibilityUnknown: boolean primaryTabId: string | null }> { let { structuredLaunch, primaryTabId } = args if (!structuredLaunch || args.agent !== 'codex') { - return { completed: false, structuredLaunch, primaryTabId } + return { completed: false, structuredLaunch, visibilityUnknown: false, primaryTabId } } const launch = startStructuredCodexLaunch(args.worktreeId, { @@ -155,12 +156,18 @@ export async function settleDirectWorkItemStructuredLaunch(args: { }) try { await launch.launchResult - return { completed: true, structuredLaunch, primaryTabId } + return { completed: true, structuredLaunch, visibilityUnknown: false, primaryTabId } } catch (error) { if (!(error instanceof StructuredAgentSessionCreateRefusalError)) { - return { completed: true, structuredLaunch, primaryTabId } + const visibilityUnknown = launch.isVisibilityUnknown() + return { + completed: !visibilityUnknown, + structuredLaunch, + visibilityUnknown, + primaryTabId + } } await refusalFallback } - return { completed: false, structuredLaunch, primaryTabId } + return { completed: false, structuredLaunch, visibilityUnknown: false, primaryTabId } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 5ca87d8f9a8..9ba94388fc3 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -263,6 +263,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom startupPlan, launchSource }) + if (structuredResult.visibilityUnknown) { + return false + } if (structuredResult.completed) { return true } diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts new file mode 100644 index 00000000000..1b068ff2ce3 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -0,0 +1,235 @@ +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { + settleStructuredCodexLaunchPrompt, + type StructuredPromptDeliveryResult +} from '@/lib/structured-agent-session-launch-prompt' +import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' + +export type StructuredRefusalFallback = () => + | void + | StructuredPromptDeliveryResult + | Promise + +export type StructuredCodexLaunchOptions = { + prompt?: string + promptDelivery?: 'auto-submit' | 'submit-after-ready' + onPromptDelivered?: () => void +} + +export type StructuredLaunchCaller = { + promptDeliveryResult?: Promise + refusalFallback: { + callback: StructuredRefusalFallback | null + promise: Promise + resolve: (ran: boolean) => void + reject: (error: unknown) => void + promptDeliveryPromise: Promise + resolvePromptDelivery: (result: StructuredPromptDeliveryResult | null) => void + started: boolean + settled: boolean + ran: boolean + } +} + +export type StructuredLaunchCallerGroup = { + outcome: 'pending' | 'published' | 'failed' | 'refused' | 'unknown' | 'cancelled' + entries: Set + promptDeliveryResults: Set> + refusalSettlement: { + promise: Promise + resolve: (ran: boolean) => void + reject: (error: unknown) => void + settled: boolean + failure: { error: unknown } | null + } + onSettled: () => void +} + +export function createStructuredLaunchCallerGroup(): StructuredLaunchCallerGroup { + const refusalSettlement = Promise.withResolvers() + return { + outcome: 'pending', + entries: new Set(), + promptDeliveryResults: new Set(), + refusalSettlement: { + promise: refusalSettlement.promise, + resolve: refusalSettlement.resolve, + reject: refusalSettlement.reject, + settled: false, + failure: null + }, + onSettled: () => {} + } +} + +function settleCallerWithoutFallback(caller: StructuredLaunchCaller): void { + if (caller.refusalFallback.settled) { + return + } + caller.refusalFallback.settled = true + caller.refusalFallback.resolve(false) + caller.refusalFallback.resolvePromptDelivery(null) +} + +function finalizeRefusalSettlement(group: StructuredLaunchCallerGroup): void { + if ( + group.outcome !== 'refused' || + group.refusalSettlement.settled || + [...group.entries].some((caller) => !caller.refusalFallback.settled) + ) { + return + } + group.refusalSettlement.settled = true + if (group.refusalSettlement.failure) { + group.refusalSettlement.reject(group.refusalSettlement.failure.error) + } else { + group.refusalSettlement.resolve([...group.entries].some((caller) => caller.refusalFallback.ran)) + } + group.onSettled() +} + +function runCallerRefusalFallback( + group: StructuredLaunchCallerGroup, + caller: StructuredLaunchCaller +): void { + if (caller.refusalFallback.started || caller.refusalFallback.settled) { + return + } + caller.refusalFallback.started = true + const fallback = caller.refusalFallback.callback + if (!fallback) { + settleCallerWithoutFallback(caller) + finalizeRefusalSettlement(group) + return + } + void Promise.resolve() + .then(fallback) + .then( + (result) => { + caller.refusalFallback.ran = true + caller.refusalFallback.resolve(true) + caller.refusalFallback.resolvePromptDelivery(result ?? null) + }, + (error) => { + group.refusalSettlement.failure ??= { error } + caller.refusalFallback.reject(error) + caller.refusalFallback.resolvePromptDelivery(null) + } + ) + .finally(() => { + caller.refusalFallback.settled = true + finalizeRefusalSettlement(group) + }) +} + +function trackPromptDelivery( + group: StructuredLaunchCallerGroup, + promptDeliveryResult: Promise +): void { + group.promptDeliveryResults.add(promptDeliveryResult) + const settled = (): void => { + group.promptDeliveryResults.delete(promptDeliveryResult) + group.onSettled() + } + void promptDeliveryResult.then(settled, settled) +} + +export function addStructuredLaunchCaller(args: { + group: StructuredLaunchCallerGroup + launchResult: Promise<{ sessionId: string; fence: number }> + options: StructuredCodexLaunchOptions + stagedEntry: StructuredAgentSessionOutboxEntry | null +}): StructuredLaunchCaller { + const fallback = Promise.withResolvers() + const fallbackPromptDelivery = Promise.withResolvers() + const caller: StructuredLaunchCaller = { + refusalFallback: { + callback: null, + promise: fallback.promise, + resolve: fallback.resolve, + reject: fallback.reject, + promptDeliveryPromise: fallbackPromptDelivery.promise, + resolvePromptDelivery: fallbackPromptDelivery.resolve, + started: false, + settled: false, + ran: false + } + } + args.group.entries.add(caller) + const promptDeliveryResult = settleStructuredCodexLaunchPrompt({ + launchResult: args.launchResult, + options: args.options, + stagedEntry: args.stagedEntry + }) + caller.promptDeliveryResult = promptDeliveryResult?.catch(async (error) => { + if (error instanceof StructuredAgentSessionCreateRefusalError) { + return ( + (await caller.refusalFallback.promptDeliveryPromise) ?? { + delivered: false, + failureNotified: true + } + ) + } + return { delivered: false, failureNotified: true } + }) + if (caller.promptDeliveryResult) { + trackPromptDelivery(args.group, caller.promptDeliveryResult) + } + if (['published', 'failed', 'cancelled'].includes(args.group.outcome)) { + settleCallerWithoutFallback(caller) + } else if (args.group.outcome === 'refused') { + queueMicrotask(() => runCallerRefusalFallback(args.group, caller)) + } + return caller +} + +export function settleStructuredLaunchCallersWithoutFallback( + group: StructuredLaunchCallerGroup, + outcome: 'published' | 'failed' | 'cancelled' +): void { + group.outcome = outcome + for (const caller of group.entries) { + settleCallerWithoutFallback(caller) + } + if (!group.refusalSettlement.settled) { + group.refusalSettlement.settled = true + group.refusalSettlement.resolve(false) + } + group.onSettled() +} + +export function settleStructuredLaunchCallersWithFallback( + group: StructuredLaunchCallerGroup +): void { + if (group.outcome === 'refused') { + return + } + group.outcome = 'refused' + for (const caller of group.entries) { + runCallerRefusalFallback(group, caller) + } + finalizeRefusalSettlement(group) +} + +export function claimStructuredLaunchCallerFallback( + group: StructuredLaunchCallerGroup, + caller: StructuredLaunchCaller, + fallback: StructuredRefusalFallback +): Promise { + caller.refusalFallback.callback ??= fallback + if (group.outcome === 'refused') { + runCallerRefusalFallback(group, caller) + } + return caller.refusalFallback.promise +} + +export function structuredLaunchCallersHavePendingWork( + group: StructuredLaunchCallerGroup +): boolean { + return ( + group.outcome === 'pending' || + group.outcome === 'unknown' || + group.promptDeliveryResults.size > 0 || + (group.outcome === 'refused' && !group.refusalSettlement.settled) + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index 5b60e2ae457..ffe8ebf86a4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -196,6 +196,39 @@ describe('startStructuredCodexLaunch', () => { ) }) + it('keeps the launch reserved until every coalesced prompt delivery settles', async () => { + const worktreeId = 'wt-coalesced-prompt-reservation' + const intent = launchIntent(worktreeId) + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + let resolveDelivery!: (result: { + ok: true + value: { submission: { dispatchState: 'accepted' } } + }) => void + mocks.createIntent.mockReturnValue(intent) + mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve))) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + mocks.callStructuredAgentSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveDelivery = resolve)) + ) + + startStructuredCodexLaunch(worktreeId) + const coalesced = startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' }) + resolveLaunch({ sessionId: intent.sessionId, fence: 1 }) + await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce()) + + startStructuredCodexLaunch(worktreeId) + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + + resolveDelivery({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + await expect(coalesced.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + }) + it('keeps one launch identity per worktree while the outcome is unknown', async () => { const worktreeId = 'wt-unknown-different-prompts' const intent = launchIntent(worktreeId) @@ -315,7 +348,7 @@ describe('startStructuredCodexLaunch', () => { storageFailure.mockRestore() }) - it('discards every coalesced prompt when the launch is definitively refused', async () => { + it('runs each caller fallback and preserves its delivery result after refusal', async () => { const worktreeId = 'wt-refused-coalesced-prompts' const intent = launchIntent(worktreeId) let rejectLaunch!: (error: unknown) => void @@ -325,16 +358,36 @@ describe('startStructuredCodexLaunch', () => { ) const first = startStructuredCodexLaunch(worktreeId, { prompt: 'first prompt' }) - startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' }) - const fallback = first.claimDefinitiveRefusalFallback(vi.fn()) + const second = startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' }) + const firstFallback = vi.fn().mockResolvedValue({ + delivered: true, + failureNotified: false + }) + const secondFallback = vi.fn().mockResolvedValue({ + delivered: false, + failureNotified: true + }) + const firstFallbackResult = first.claimDefinitiveRefusalFallback(firstFallback) + const secondFallbackResult = second.claimDefinitiveRefusalFallback(secondFallback) expect(readOutbox(intent.sessionId)).toHaveLength(2) rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported')) await expect(first.launchResult).rejects.toBeInstanceOf( StructuredAgentSessionCreateRefusalError ) - await expect(fallback).resolves.toBe(true) + await expect(firstFallbackResult).resolves.toBe(true) + await expect(secondFallbackResult).resolves.toBe(true) + await expect(first.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + await expect(second.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(firstFallback).toHaveBeenCalledOnce() + expect(secondFallback).toHaveBeenCalledOnce() expect(readOutbox(intent.sessionId)).toEqual([]) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 25be7287562..f04ef9462f8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -16,47 +16,37 @@ import { type StructuredCodexLaunchReceipt, type StructuredLaunchRecoveryState } from '@/lib/structured-agent-session-launch-recovery' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import { - settleStructuredCodexLaunchPrompt, - type StructuredPromptDeliveryResult -} from '@/lib/structured-agent-session-launch-prompt' + addStructuredLaunchCaller, + claimStructuredLaunchCallerFallback, + createStructuredLaunchCallerGroup, + settleStructuredLaunchCallersWithFallback, + settleStructuredLaunchCallersWithoutFallback, + structuredLaunchCallersHavePendingWork, + type StructuredCodexLaunchOptions, + type StructuredLaunchCaller, + type StructuredLaunchCallerGroup, + type StructuredRefusalFallback +} from '@/lib/structured-agent-session-launch-callers' -export type { StructuredCodexLaunchReceipt } - -type StructuredRefusalFallback = () => - | void - | StructuredPromptDeliveryResult - | Promise +export type { StructuredCodexLaunchOptions, StructuredCodexLaunchReceipt } type StructuredLaunchState = StructuredLaunchRecoveryState & { identity: string - promptDeliveryResult?: Promise - refusalFallback: { - callback: StructuredRefusalFallback | null - promise: Promise - resolve: (ran: boolean) => void - reject: (error: unknown) => void - promptDeliveryPromise: Promise - resolvePromptDelivery: (result: StructuredPromptDeliveryResult | null) => void - started: boolean - } + callers: StructuredLaunchCallerGroup } type StructuredLaunchStateResult = { state: StructuredLaunchState - promptDeliveryResult?: Promise -} - -export type StructuredCodexLaunchOptions = { - prompt?: string - promptDelivery?: 'auto-submit' | 'submit-after-ready' - onPromptDelivered?: () => void + caller: StructuredLaunchCaller } export type StructuredCodexLaunchResult = { sessionId: string launchResult: Promise promptDeliveryResult?: Promise + isVisibilityUnknown: () => boolean claimDefinitiveRefusalFallback: (fallback: StructuredRefusalFallback) => Promise } @@ -72,33 +62,20 @@ function cleanupLaunchState(state: StructuredLaunchState): void { } } +function maybeCleanupLaunchState(state: StructuredLaunchState): void { + if (structuredLaunchCallersHavePendingWork(state.callers)) { + return + } + cleanupLaunchState(state) +} + function settleDefinitiveRefusalFallback(state: StructuredLaunchState): void { - if (state.refusalFallback.started) { + if (state.callers.outcome === 'refused') { return } - state.refusalFallback.started = true abandonStructuredAgentSessionLaunchIntent(state.intent) discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) - const fallback = state.refusalFallback.callback - if (!fallback) { - state.refusalFallback.resolve(false) - state.refusalFallback.resolvePromptDelivery(null) - cleanupLaunchState(state) - return - } - void Promise.resolve() - .then(fallback) - .then( - (result) => { - state.refusalFallback.resolve(true) - state.refusalFallback.resolvePromptDelivery(result ?? null) - }, - (error) => { - state.refusalFallback.reject(error) - state.refusalFallback.resolvePromptDelivery(null) - } - ) - .finally(() => cleanupLaunchState(state)) + settleStructuredLaunchCallersWithFallback(state.callers) } function trackLaunchSettlement( @@ -110,12 +87,8 @@ function trackLaunchSettlement( if (state.promise !== promise) { return } - state.refusalFallback.resolve(false) - if (state.promptDeliveryResult) { - void state.promptDeliveryResult.finally(() => cleanupLaunchState(state)) - } else { - cleanupLaunchState(state) - } + settleStructuredLaunchCallersWithoutFallback(state.callers, 'published') + maybeCleanupLaunchState(state) }, (error) => { if (state.promise !== promise || state.cancelled) { @@ -124,8 +97,10 @@ function trackLaunchSettlement( if (error instanceof StructuredAgentSessionCreateRefusalError) { settleDefinitiveRefusalFallback(state) } else if (!state.visibilityUnknown) { - state.refusalFallback.resolve(false) - cleanupLaunchState(state) + settleStructuredLaunchCallersWithoutFallback(state.callers, 'failed') + maybeCleanupLaunchState(state) + } else { + state.callers.outcome = 'unknown' } } ) @@ -138,7 +113,7 @@ function trackLaunchFailureToast(state: StructuredLaunchState): void { } if ( error instanceof StructuredAgentSessionCreateRefusalError && - (await state.refusalFallback.promise.catch(() => false)) + (await state.callers.refusalSettlement.promise.catch(() => false)) ) { return } @@ -160,61 +135,42 @@ function structuredCodexLaunchState( const existing = pendingStructuredLaunchesByIdentity.get(identity) if (existing) { if (existing.visibilityUnknown) { + existing.callers.outcome = 'pending' existing.promise = reconcileUnknownLaunch(existing) trackLaunchSettlement(existing, existing.promise) trackLaunchFailureToast(existing) } const text = options.prompt?.trim() ?? '' - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) - : null - const promptDeliveryResult = settleStructuredCodexLaunchPrompt({ - launchResult: existing.promise, - options, - stagedEntry: stagedPrompt - }) - const recoveredPromptDeliveryResult = promptDeliveryResult?.catch(async (error) => { - if (error instanceof StructuredAgentSessionCreateRefusalError) { - return ( - (await existing.refusalFallback.promptDeliveryPromise) ?? { - delivered: false, - failureNotified: true - } - ) - } - return { delivered: false, failureNotified: true } - }) + const stagedPrompt = + text && existing.callers.outcome !== 'refused' + ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) + : null return { state: existing, - ...(recoveredPromptDeliveryResult - ? { promptDeliveryResult: recoveredPromptDeliveryResult } - : {}) + caller: addStructuredLaunchCaller({ + group: existing.callers, + launchResult: existing.promise, + options, + stagedEntry: stagedPrompt + }) } } - const fallback = Promise.withResolvers() - const fallbackPromptDelivery = Promise.withResolvers() const intent = createStructuredCodexSessionLaunchIntent(worktreeId) const text = options.prompt?.trim() ?? '' const stagedPrompt = text ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) : null + const callers = createStructuredLaunchCallerGroup() const state: StructuredLaunchState = { identity, intent, promise: Promise.resolve({ sessionId: '', fence: 0 }), visibilityUnknown: false, cancelled: false, - refusalFallback: { - callback: null, - promise: fallback.promise, - resolve: fallback.resolve, - reject: fallback.reject, - promptDeliveryPromise: fallbackPromptDelivery.promise, - resolvePromptDelivery: fallbackPromptDelivery.resolve, - started: false - } + callers } + callers.onSettled = () => maybeCleanupLaunchState(state) state.promise = text && !stagedPrompt ? Promise.reject( @@ -223,28 +179,18 @@ function structuredCodexLaunchState( ) ) : launchAndReconcile(state) - const promptDeliveryResult = settleStructuredCodexLaunchPrompt({ + const caller = addStructuredLaunchCaller({ + group: state.callers, launchResult: state.promise, options, stagedEntry: stagedPrompt }) - state.promptDeliveryResult = promptDeliveryResult?.catch(async (error) => { - if (error instanceof StructuredAgentSessionCreateRefusalError) { - return ( - (await state.refusalFallback.promptDeliveryPromise) ?? { - delivered: false, - failureNotified: true - } - ) - } - return { delivered: false, failureNotified: true } - }) pendingStructuredLaunchesByIdentity.set(identity, state) trackLaunchSettlement(state, state.promise) trackLaunchFailureToast(state) return { state, - ...(state.promptDeliveryResult ? { promptDeliveryResult: state.promptDeliveryResult } : {}) + caller } } @@ -257,6 +203,7 @@ export function cancelStructuredCodexLaunch(worktreeId: string, sessionId: strin return false } state.cancelled = true + settleStructuredLaunchCallersWithoutFallback(state.callers, 'cancelled') cleanupLaunchState(state) discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) abandonStructuredAgentSessionLaunchIntent(state.intent) @@ -267,14 +214,13 @@ export function startStructuredCodexLaunch( worktreeId: string, options: StructuredCodexLaunchOptions = {} ): StructuredCodexLaunchResult { - const { state, promptDeliveryResult } = structuredCodexLaunchState(worktreeId, options) + const { state, caller } = structuredCodexLaunchState(worktreeId, options) return { sessionId: state.intent.sessionId, launchResult: state.promise, - ...(promptDeliveryResult ? { promptDeliveryResult } : {}), - claimDefinitiveRefusalFallback: (fallback) => { - state.refusalFallback.callback ??= fallback - return state.refusalFallback.promise - } + ...(caller.promptDeliveryResult ? { promptDeliveryResult: caller.promptDeliveryResult } : {}), + isVisibilityUnknown: () => state.visibilityUnknown, + claimDefinitiveRefusalFallback: (fallback) => + claimStructuredLaunchCallerFallback(state.callers, caller, fallback) } } diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index dee903baafe..c14148da43d 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -1,4 +1,5 @@ import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { preflightAgentTrust as preflightWorkspaceAgentTrust } from '@/lib/agent-trust-preflight' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' @@ -225,6 +226,16 @@ export async function executeWorktreeCreation( if (structuredSession.cancelled) { return } + if (structuredSession.visibilityUnknown) { + useAppStore.getState().updatePendingWorktreeCreation(creationId, { + status: 'error', + error: translate( + 'auto.lib.worktree.creation.flow.structured.launch.unknown', + 'Could not confirm whether Codex chat opened. Retry to check again.' + ) + }) + return + } } // Why: clearing synchronously right after activation lets React commit the 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 07ac93ff0f3..490d0dbb40c 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -81,6 +81,7 @@ describe('launchStructuredWorktreeSession', () => { mocks.startStructuredCodexLaunch.mockReturnValue({ sessionId: 'session-1', launchResult, + isVisibilityUnknown: () => false, claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) }) @@ -111,6 +112,7 @@ describe('launchStructuredWorktreeSession', () => { await expect(resultPromise).resolves.toEqual({ accepted: true, cancelled: true, + visibilityUnknown: false, activation: false, primaryTabId: null }) @@ -124,4 +126,44 @@ describe('launchStructuredWorktreeSession', () => { expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() expect(mocks.unsubscribe).toHaveBeenCalledOnce() }) + + it('reports an unknown launch without claiming a visible surface', async () => { + mocks.startStructuredCodexLaunch.mockReturnValue({ + sessionId: 'session-unknown', + launchResult: Promise.reject(new Error('connection lost')), + isVisibilityUnknown: () => true, + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request: { + repoId: 'repo-1', + name: 'routing-recovery', + setupDecision: 'run', + agent: 'codex', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: 'Fix the route', + quickTelemetry: null + }, + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + accepted: true, + cancelled: false, + visibilityUnknown: true, + activation: false, + primaryTabId: null + }) + + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index ff15e110096..f1b3ca1523c 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -17,6 +17,7 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' export type WorktreeCreationStructuredSessionResult = { accepted: boolean cancelled: boolean + visibilityUnknown: boolean activation: ActivateAndRevealResult | false primaryTabId: string | null } @@ -45,11 +46,12 @@ export async function launchStructuredWorktreeSession(args: { }): Promise { let { activation, primaryTabId } = args let accepted = true + let visibilityUnknown = false if (args.request.agent !== 'codex') { - return { accepted, cancelled: false, activation, primaryTabId } + return { accepted, cancelled: false, visibilityUnknown, activation, primaryTabId } } if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) { - return { accepted, cancelled: true, activation, primaryTabId } + return { accepted, cancelled: true, visibilityUnknown, activation, primaryTabId } } const launch = startStructuredCodexLaunch(args.worktreeId, { @@ -127,7 +129,7 @@ export async function launchStructuredWorktreeSession(args: { const receipt = await launch.launchResult if (cancelled) { await retireCancelledStructuredSession(args.worktreeId, launch.sessionId) - return { accepted, cancelled, activation, primaryTabId } + return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } } if (args.shouldActivateOnCompletion) { activateStructuredAgentSessionById({ @@ -138,13 +140,15 @@ export async function launchStructuredWorktreeSession(args: { } catch (error) { if (cancelled) { await retireCancelledStructuredSession(args.worktreeId, launch.sessionId) - return { accepted, cancelled, activation, primaryTabId } + return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } } if (error instanceof StructuredAgentSessionCreateRefusalError) { await refusalFallback + } else { + visibilityUnknown = launch.isVisibilityUnknown() } } finally { unsubscribe() } - return { accepted, cancelled, activation, primaryTabId } + return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } } diff --git a/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts b/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts new file mode 100644 index 00000000000..5b2830f6b53 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PendingWorktreeCreation, WorktreeCreationRequest } from './pending-worktree-creation' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + launchStructuredWorktreeSession: vi.fn() +})) + +const request: WorktreeCreationRequest = { + repoId: 'repo-1', + name: 'routing-recovery', + setupDecision: 'run', + agent: 'codex', + agentLaunchRoute: 'structured-native-chat', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: 'Recover the route', + quickTelemetry: null +} + +const store = { + activeView: 'terminal', + activePendingCreationId: 'creation-1' as string | null, + pendingWorktreeCreations: {} as Record, + repos: [], + createWorktree: vi.fn(), + updatePendingWorktreeCreation: vi.fn(), + removePendingWorktreeCreation: vi.fn(), + updateWorktreeMeta: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => store } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) + +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal +})) + +vi.mock('@/lib/new-workspace', () => ({ + ensureAgentStartupInTerminal: vi.fn() +})) + +vi.mock('@/lib/workspace-activation-terminal-focus', () => ({ + queueWorkspaceActivationTerminalFocus: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({ + prepareRequestForCreate: vi.fn(async () => request), + attachEphemeralVmRuntimeToWorkspace: vi.fn(), + cleanupEphemeralVmRuntimeForFailedCreate: vi.fn() +})) + +vi.mock('@/lib/provisioned-root-create-options', () => ({ + getProvisionedRootCreateOptions: vi.fn() +})) + +vi.mock('@/lib/worktree-creation-agent-seeds', () => ({ + seedAgentTabStateAfterWorktreeCreate: vi.fn() +})) + +vi.mock('@/lib/worktree-draft-startup-view-mode', () => ({ + resolveBackendDraftStartup: vi.fn() +})) + +vi.mock('@/lib/worktree-creation-flow-startup', () => ({ + buildWorktreeCreationStartupOpt: vi.fn() +})) + +vi.mock('@/lib/worktree-creation-structured-session', () => ({ + launchStructuredWorktreeSession: mocks.launchStructuredWorktreeSession +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { executeWorktreeCreation } from './worktree-creation-flow-execute' + +describe('structured worktree creation unknown outcome', () => { + beforeEach(() => { + vi.clearAllMocks() + store.pendingWorktreeCreations = { + 'creation-1': { + creationId: 'creation-1', + phase: 'creating', + status: 'creating', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + request + } + } + store.createWorktree.mockResolvedValue({ + worktree: { id: 'worktree-1', repoId: 'repo-1' } + }) + mocks.activateAndRevealWorktree.mockReturnValue(false) + mocks.launchStructuredWorktreeSession.mockResolvedValue({ + accepted: true, + cancelled: false, + visibilityUnknown: true, + activation: false, + primaryTabId: null + }) + }) + + it('keeps the operation on its retry surface instead of reporting completion', async () => { + await executeWorktreeCreation('creation-1', request) + + expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + status: 'error', + error: 'Could not confirm whether Codex chat opened. Retry to check again.' + }) + expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled() + expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + }) +})