diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts index 4d01f2e9224..d9c7bc806b3 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts @@ -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( diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index ccd642c6b29..7e424dc33b0 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -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((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 () => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts index c4de36ef0b8..6193eaa7794 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts @@ -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)) ) 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 d84e3daab99..29ef26bad28 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -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 () => {