fix(native-chat): clear abandoned launch outbox

This commit is contained in:
Merge Sim
2026-09-02 15:40:04 -07:00
parent 7ae79e6e7f
commit cd43fb375f
3 changed files with 53 additions and 14 deletions
@@ -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,
@@ -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')
@@ -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<StructuredPromptDeliveryResult>
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
}