fix(native-chat): keep successful launches usable when tab lists lag (#33620)

* fix(native-chat): confirm launches from successful create replies

* test(native-chat): align launch guards with successful create replies
This commit is contained in:
Brennan Benson
2026-10-26 18:43:15 -07:00
committed by GitHub
parent a43b07625f
commit 28b4c555ee
4 changed files with 92 additions and 98 deletions
@@ -107,9 +107,6 @@ async function retrySameIntent(
() => state.cancelled
)
throwIfLaunchCancelled(state)
if (state.intent.createMessageSupport !== true) {
await verifyPublishedSession(state)
}
return receipt
} catch (error) {
if (state.cancelled) {
@@ -156,18 +153,8 @@ export async function launchAndReconcile(
return retrySameIntent(state, error)
}
}
try {
throwIfLaunchCancelled(state)
if (state.intent.createMessageSupport !== true) {
await verifyPublishedSession(state)
}
return receipt
} catch (error) {
if (state.cancelled) {
throw new StructuredAgentSessionLaunchCancelledError()
}
return retrySameIntent(state, error)
}
throwIfLaunchCancelled(state)
return receipt
}
export async function reconcileUnknownLaunch(
@@ -215,7 +215,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
// No initial chat mode proves availability rejection preceded terminal-tab adoption.
describe('structured chat adoption guard on the launch path', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetAllMocks()
store.unifiedTabsByWorktree = {
'wt-1': [
{
@@ -277,7 +277,6 @@ describe('structured chat adoption guard on the launch path', () => {
}
])
mockCreateSupport.mockResolvedValue({ supported: true })
mockToastError.mockReset()
hostCapabilities = STRUCTURED_HOST_CAPABILITIES
store.settings.experimentalNativeChat = true
store.settings.nativeChatSessionOptions = undefined
@@ -542,41 +541,29 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1)
resolveLaunch({ sessionId: 'codex-session-1', fence: 1 })
await Promise.all([first?.structuredSettlement, second?.structuredSettlement])
})
it('keeps the single-flight reservation until the published tab inventory is refreshed', async () => {
it('settles a successful create and opens its chat without an inventory refresh', async () => {
store.unifiedTabsByWorktree = {}
let resolveRefresh!: (snapshots: unknown[]) => void
mockRefreshLocalStructuredSessionTabs.mockImplementationOnce(
() => new Promise<unknown[]>((resolve) => (resolveRefresh = resolve))
)
mockLaunchStructuredCodexSession.mockResolvedValue({
sessionId: 'codex-session-1',
fence: 1
})
mockRefreshLocalStructuredSessionTabs.mockRejectedValue(new Error('inventory unavailable'))
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ requestId: 'double-click', agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(1))
const result = launchAgentInNewTab({
requestId: 'double-click',
agent: 'codex',
worktreeId: 'wt-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'] = [
{
id: 'structured-agent-session-codex-session-1',
contentType: 'agent-session',
entityId: 'codex-session-1',
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
resolveRefresh([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
])
await vi.waitFor(() => expect(mockToastError).not.toHaveBeenCalled())
await expect(result?.structuredSettlement).resolves.toEqual({
kind: 'structured',
sessionId: 'codex-session-1'
})
expect(store.unifiedTabsByWorktree['wt-1']).toContainEqual(
expect.objectContaining({ contentType: 'agent-session', entityId: 'codex-session-1' })
)
expect(mockRefreshLocalStructuredSessionTabs).not.toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
})
it('re-checks a chat whose visibility proof is unknown through its own Retry, with its own intent', async () => {
@@ -587,7 +574,7 @@ describe('structured chat adoption guard on the launch path', () => {
.mockReturnValueOnce(firstIntent)
.mockReturnValueOnce(secondIntent)
mockLaunchStructuredCodexSession
.mockResolvedValueOnce({ sessionId: firstIntent.sessionId, fence: 1 })
.mockRejectedValueOnce(new Error('response lost'))
.mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValueOnce({ sessionId: secondIntent.sessionId, fence: 1 })
mockRefreshLocalStructuredSessionTabs
@@ -607,9 +594,6 @@ describe('structured chat adoption guard on the launch path', () => {
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: firstIntent.sessionId }] }
])
})
.mockResolvedValueOnce([
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] }
])
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const unknown = launchAgentInNewTab({
@@ -638,22 +622,31 @@ describe('structured chat adoption guard on the launch path', () => {
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[0]).toBe(firstIntent)
expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[0]).toBe(firstIntent)
expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[2]).toBe(false)
expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[2]).toBe(true)
expect(mockRetryStructuredAgentSessionLaunchIntent).not.toHaveBeenCalled()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockCallStructuredAgentSession).toHaveBeenCalledWith(
firstIntent.target,
'agentSession.history',
expect.objectContaining({ sessionId: firstIntent.sessionId })
)
store.unifiedTabsByWorktree['wt-1'] = [
{
id: `structured-agent-session-${secondIntent.sessionId}`,
contentType: 'agent-session',
entityId: secondIntent.sessionId,
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
launchAgentInNewTab({ requestId: 'request-15', agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3))
const next = launchAgentInNewTab({
requestId: 'request-15',
agent: 'codex',
worktreeId: 'wt-1'
})
await expect(next?.structuredSettlement).resolves.toEqual({
kind: 'structured',
sessionId: secondIntent.sessionId
})
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3)
expect(mockLaunchStructuredCodexSession.mock.calls[2]?.[0]).toBe(secondIntent)
expect(store.unifiedTabsByWorktree['wt-1']?.map((tab) => tab.entityId)).toEqual(
expect.arrayContaining([firstIntent.sessionId, secondIntent.sessionId])
)
})
it('routes an auto-submitted Codex prompt through the structured outbox', async () => {
@@ -183,12 +183,12 @@ describe('a close that races a structured launch', () => {
})
})
it('cancels a close-racing launch without retrying or toasting', async () => {
it('cancels a close-racing unknown launch without retrying or toasting', async () => {
const worktreeId = 'wt-close-race'
const intent = launchIntent(worktreeId, 'session-close-race')
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
mocks.launch.mockRejectedValueOnce(new Error('response lost'))
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce(
() => new Promise((resolve) => (resolveRefresh = resolve))
)
@@ -234,7 +234,7 @@ describe('a close that races a structured launch', () => {
const intent = launchIntent(worktreeId)
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
mocks.launch.mockRejectedValueOnce(new Error('response lost'))
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce(
() => new Promise((resolve) => (resolveRefresh = resolve))
)
@@ -316,21 +316,19 @@ describe('startStructuredAgentLaunch', () => {
it('clears the draft seed when the launch is cancelled', async () => {
const worktreeId = 'wt-draft-cancelled'
const intent = launchIntent(worktreeId, 'cancelled-draft-session')
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
const create = Promise.withResolvers<{ sessionId: string; fence: number }>()
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce(
() => new Promise((resolve) => (resolveRefresh = resolve))
)
mocks.launch.mockReturnValueOnce(create.promise)
startStructuredAgentLaunch(worktreeId, 'codex', {
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-5',
prompt: 'review this',
promptDelivery: 'draft'
})
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
expect(mocks.launch).toHaveBeenCalledOnce()
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
resolveRefresh([])
create.resolve({ sessionId: intent.sessionId, fence: 1 })
await expect(launch.launchResult).rejects.toThrow('structured session launch cancelled')
await flushLaunchSettlement()
expect(mocks.clearDraft).toHaveBeenCalledWith(
@@ -427,32 +425,38 @@ describe('startStructuredAgentLaunch', () => {
expect(toast.message).not.toHaveBeenCalled()
})
it('requires authoritative inventory even when a matching local tab exists', async () => {
const worktreeId = 'wt-host-frame'
const intent = launchIntent(worktreeId, 'session-host-frame')
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockImplementationOnce(async () => {
mocks.rendererTabs[worktreeId] = [
{ contentType: 'agent-session', entityId: intent.sessionId, worktreeId }
]
for (const listener of mocks.listeners) {
listener({ unifiedTabsByWorktree: mocks.rendererTabs })
}
return { sessionId: intent.sessionId, fence: 1 }
})
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
it.each(['late', 'missing', 'unavailable'] as const)(
'uses an older host’s receipt to send while tab inventory is %s',
async (inventory) => {
const worktreeId = 'wt-host-frame'
const intent = launchIntent(worktreeId, 'session-host-frame')
intent.createMessageSupport = false
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
let reads = 0
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementation(async () => {
reads += 1
if (inventory === 'unavailable') {
throw new Error('inventory unavailable')
}
return inventory === 'late' && reads > 1
? [publishedSnapshot(worktreeId, intent.sessionId)]
: []
})
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-10' })
await flushLaunchSettlement()
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
requestId: 'request-10',
prompt: 'first task',
promptDelivery: 'submit-after-ready'
})
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true })
expect(mocks.launch).toHaveBeenCalledOnce()
expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledWith(undefined, {
authoritative: true
})
expect(toast.error).not.toHaveBeenCalled()
})
expect(mocks.launch).toHaveBeenCalledOnce()
expect(refreshLocalStructuredSessionTabs).not.toHaveBeenCalled()
expect(sends()).toHaveLength(1)
expect(sends()[0]?.[2]).toMatchObject({ envelope: { sessionId: intent.sessionId } })
}
)
it('coalesces a duplicate click silently while the launch is in flight', async () => {
const worktreeId = 'wt-duplicate-click'
@@ -706,9 +710,10 @@ describe('startStructuredAgentLaunch', () => {
expect(sends()).toHaveLength(0)
})
it('keeps a post-attach unknown outcome reserved for reconciliation', async () => {
it('repairs failed tab publication on Retry with the original chat and operation', async () => {
const worktreeId = 'wt-post-attach-unknown'
const intent = launchIntent(worktreeId)
intent.createMessageSupport = false
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockRejectedValue(
Object.assign(new Error('The chat may already exist.'), {
@@ -724,8 +729,17 @@ describe('startStructuredAgentLaunch', () => {
})
expect(launch.isVisibilityUnknown()).toBe(true)
expect(launch.releaseCallerAfterUnknownOutcome()).toBe(true)
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
await flushLaunchSettlement()
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBeNull()
expect(mocks.launch).toHaveBeenCalledTimes(3)
for (const [retried] of mocks.launch.mock.calls) {
expect(retried).toBe(intent)
}
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledTimes(2)
})
it('retries a definitively refused launch with the same session and a new operation', async () => {