From cf6088917beb63fee45498a04462dfaa1aa819e8 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Wed, 2 Sep 2026 15:34:23 -0700 Subject: [PATCH] fix(native-chat): reconcile merged session tab publications --- .../structured-agent-session-launch.test.ts | 27 +++++++++++++++ .../lib/structured-agent-session-launch.ts | 3 ++ .../src/runtime/web-session-tabs-sync.test.ts | 34 +++++++++++++++++++ .../publisher-identity-fences.ts | 16 +++++++++ .../tracking-decisions.ts | 22 ++++++++++-- 5 files changed, 99 insertions(+), 3 deletions(-) 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 8328182475d..303c2868bdd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -129,6 +129,33 @@ describe('startStructuredCodexLaunch', () => { expect(mocks.abandonIntent).toHaveBeenCalledWith(intent) }) + it('does not park or toast when close races the final verification retry', async () => { + const worktreeId = 'wt-close-final-verify' + const intent = launchIntent(worktreeId, 'session-close-final-verify') + let resolveFinalRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue(intent.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFinalRefresh = resolve + }) + ) + + startStructuredCodexLaunch(worktreeId) + await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3)) + expect(cancelStructuredCodexLaunch(worktreeId, intent.sessionId)).toBe(true) + resolveFinalRefresh([]) + await flushLaunchSettlement() + + expect(mocks.launch).toHaveBeenCalledTimes(2) + expect(toast.error).not.toHaveBeenCalled() + expect(mocks.abandonIntent).toHaveBeenCalledWith(intent) + }) + it('opens the chat without an informational progress toast', async () => { const worktreeId = 'wt-open-quiet' const intent = launchIntent(worktreeId, 'session-1') diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index c5b83d12226..82de6252c96 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -133,6 +133,9 @@ async function retrySameIntent(state: StructuredLaunchState, priorError: unknown try { return await verifyPublishedSession(state.intent) } catch { + if (state.cancelled) { + throw new StructuredAgentSessionLaunchCancelledError() + } state.visibilityUnknown = true notifyStructuredLaunchListeners() throw error ?? priorError diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 4b9633410a6..5fc871687b7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -169,6 +169,40 @@ describe('applyWebSessionTabsSnapshot', () => { expect(shouldApplyWebSessionTabsSnapshot(sameEpochOlder, ENV)).toBe(false) }) + it('keeps base and headless-merge publications in one freshness lineage', () => { + const renderer = makeSnapshot([], { + publicationEpoch: 'renderer:epoch-1', + snapshotVersion: 14, + activeTabType: null + }) + const merged = makeSnapshot([], { + publicationEpoch: 'renderer:epoch-1:headless-merge:abc123', + snapshotVersion: 8, + activeTabType: null + }) + const refreshedRenderer = makeSnapshot([], { + publicationEpoch: 'renderer:epoch-1', + snapshotVersion: 15, + activeTabType: null + }) + + expect(shouldApplyWebSessionTabsSnapshot(renderer, ENV)).toBe(true) + expect(shouldApplyWebSessionTabsSnapshot(merged, ENV)).toBe(true) + // listAll can return the renderer base after a host-side headless merge; + // the newer base revision must still be allowed to refresh the mirror. + expect(shouldApplyWebSessionTabsSnapshot(refreshedRenderer, ENV)).toBe(true) + expect( + shouldApplyWebSessionTabsSnapshot( + makeSnapshot([], { + publicationEpoch: 'renderer:epoch-1', + snapshotVersion: 7, + activeTabType: null + }), + ENV + ) + ).toBe(false) + }) + it('rejects a delayed frame from an epoch superseded by a later restart', () => { const beforeRestart = makeSnapshot([], { publicationEpoch: 'epoch-before-restart', diff --git a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts index 2fe1f45ab0c..96ebe2293c6 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts @@ -125,6 +125,22 @@ export function isRetiredSessionTabsPublicationEpoch( return hasRetiredValue(sessionTabsPublicationEpochHistoryByWorktree.get(key), publicationEpoch) } +/** + * A headless merge keeps the renderer publication as its base epoch while + * adding runtime-owned surfaces. Treat both forms as one ordering lineage. + */ +export function sameSessionTabsPublicationLineage(left: string, right: string): boolean { + return ( + left === right || + ((left.includes(':headless-merge:') || right.includes(':headless-merge:')) && + left.split(':headless-merge:')[0] === right.split(':headless-merge:')[0]) + ) +} + +export function isHeadlessMergeSessionTabsPublication(publicationEpoch: string): boolean { + return publicationEpoch.includes(':headless-merge:') +} + export function noteSessionTabsPublicationEpoch( key: string, publicationEpoch: string diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts index 764459fb3cb..a7985fc6ab8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -9,7 +9,9 @@ import { import { acceptSessionTabsRuntimeId, isRetiredSessionTabsPublicationEpoch, - noteSessionTabsPublicationEpoch + isHeadlessMergeSessionTabsPublication, + noteSessionTabsPublicationEpoch, + sameSessionTabsPublicationLineage } from './publisher-identity-fences' import { sessionTabsFreshnessKey, @@ -78,7 +80,20 @@ export function decideWebSessionTabsSnapshot( return WEB_SESSION_TABS_FRAME_UNMIRRORED } const current = latestSessionTabsSnapshotByWorktree.get(key) - if (isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch)) { + const currentSharesPublicationLineage = Boolean( + current && + sameSessionTabsPublicationLineage(current.publicationEpoch, snapshot.publicationEpoch) + ) + const currentIsHeadlessMerge = current + ? isHeadlessMergeSessionTabsPublication(current.publicationEpoch) + : false + const comparePublicationVersions = + currentSharesPublicationLineage && + (currentIsHeadlessMerge || !isHeadlessMergeSessionTabsPublication(snapshot.publicationEpoch)) + if ( + isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) && + !currentSharesPublicationLineage + ) { return WEB_SESSION_TABS_FRAME_OUTRANKED } const replayable = replayableSessionTabsSnapshotByWorktree.get(key) @@ -93,7 +108,8 @@ export function decideWebSessionTabsSnapshot( // Why: reject stale snapshots only within an epoch; host restarts create a new epoch. if ( current && - current.publicationEpoch === snapshot.publicationEpoch && + comparePublicationVersions && + sameSessionTabsPublicationLineage(current.publicationEpoch, snapshot.publicationEpoch) && snapshot.snapshotVersion <= current.snapshotVersion && !isExactCurrentReplay ) {