fix(native-chat): reconcile merged session tab publications

This commit is contained in:
Merge Sim
2026-09-02 15:34:23 -07:00
parent c52297dd93
commit cf6088917b
5 changed files with 99 additions and 3 deletions
@@ -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<RuntimeMobileSessionTabsResult[]>((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')
@@ -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
@@ -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',
@@ -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
@@ -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
) {