diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 87bd5991ce4..b823bfe3fa2 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -58,6 +58,10 @@ export function enqueueStructuredAgentSessionLaunchPrompt( return writeOutbox(sessionId, [...readOutbox(sessionId), entry]) ? entry : null } +export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { + writeOutbox(sessionId, []) +} + export function mutateStructuredAgentSessionLaunchPrompt( sessionId: string, clientMessageId: string, 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 6618c0b360c..5b60e2ae457 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -49,6 +49,7 @@ import { cancelStructuredCodexLaunch, startStructuredCodexLaunch } from './structured-agent-session-launch' +import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' function launchIntent( worktreeId: string, @@ -314,6 +315,29 @@ describe('startStructuredCodexLaunch', () => { storageFailure.mockRestore() }) + it('discards every coalesced prompt when the launch is definitively refused', async () => { + const worktreeId = 'wt-refused-coalesced-prompts' + const intent = launchIntent(worktreeId) + let rejectLaunch!: (error: unknown) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectLaunch = reject)) + ) + + const first = startStructuredCodexLaunch(worktreeId, { prompt: 'first prompt' }) + startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' }) + const fallback = first.claimDefinitiveRefusalFallback(vi.fn()) + expect(readOutbox(intent.sessionId)).toHaveLength(2) + + rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported')) + await expect(first.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await expect(fallback).resolves.toBe(true) + + expect(readOutbox(intent.sessionId)).toEqual([]) + }) + it('cancels a close-racing launch without retrying or toasting', async () => { const worktreeId = 'wt-close-race' const intent = launchIntent(worktreeId, 'session-close-race') @@ -335,6 +359,27 @@ describe('startStructuredCodexLaunch', () => { expect(toast.error).not.toHaveBeenCalled() }) + it('discards every coalesced prompt when a close cancels the launch', async () => { + const worktreeId = 'wt-close-coalesced-prompts' + const intent = launchIntent(worktreeId) + let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 }) + vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)) + ) + + startStructuredCodexLaunch(worktreeId, { prompt: 'first prompt' }) + startStructuredCodexLaunch(worktreeId, { prompt: 'second prompt' }) + await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce()) + expect(readOutbox(intent.sessionId)).toHaveLength(2) + + expect(cancelStructuredCodexLaunch(worktreeId, intent.sessionId)).toBe(true) + expect(readOutbox(intent.sessionId)).toEqual([]) + resolveRefresh([]) + await flushLaunchSettlement() + }) + it('suppresses a close that races the retry verification catch', async () => { const worktreeId = 'wt-retry-close-race' const intent = launchIntent(worktreeId, 'session-retry-close-race') diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index b71e7084642..25be7287562 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -6,8 +6,8 @@ import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' import { - enqueueStructuredAgentSessionLaunchPrompt, - mutateStructuredAgentSessionLaunchPrompt + discardStructuredAgentSessionLaunchOutbox, + enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' import { launchAndReconcile, @@ -30,7 +30,6 @@ type StructuredRefusalFallback = () => type StructuredLaunchState = StructuredLaunchRecoveryState & { identity: string - stagedPromptId: string | null promptDeliveryResult?: Promise refusalFallback: { callback: StructuredRefusalFallback | null @@ -67,14 +66,6 @@ function launchIdentity(worktreeId: string): string { return worktreeId } -function discardStagedPrompt(state: StructuredLaunchState): void { - if (!state.stagedPromptId) { - return - } - mutateStructuredAgentSessionLaunchPrompt(state.intent.sessionId, state.stagedPromptId, () => null) - state.stagedPromptId = null -} - function cleanupLaunchState(state: StructuredLaunchState): void { if (pendingStructuredLaunchesByIdentity.get(state.identity) === state) { pendingStructuredLaunchesByIdentity.delete(state.identity) @@ -87,7 +78,7 @@ function settleDefinitiveRefusalFallback(state: StructuredLaunchState): void { } state.refusalFallback.started = true abandonStructuredAgentSessionLaunchIntent(state.intent) - discardStagedPrompt(state) + discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) const fallback = state.refusalFallback.callback if (!fallback) { state.refusalFallback.resolve(false) @@ -214,7 +205,6 @@ function structuredCodexLaunchState( promise: Promise.resolve({ sessionId: '', fence: 0 }), visibilityUnknown: false, cancelled: false, - stagedPromptId: stagedPrompt?.clientMessageId ?? null, refusalFallback: { callback: null, promise: fallback.promise, @@ -268,7 +258,7 @@ export function cancelStructuredCodexLaunch(worktreeId: string, sessionId: strin } state.cancelled = true cleanupLaunchState(state) - discardStagedPrompt(state) + discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) abandonStructuredAgentSessionLaunchIntent(state.intent) return true }