Skip to content

Commit 5bbe75e

Browse files
committed
Invalidate pre-clear handoff generations
1 parent ce5e841 commit 5bbe75e

5 files changed

Lines changed: 66 additions & 14 deletions

File tree

src/chrome/src/ui/sidepanel.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,7 +1671,13 @@ function clearCachedTabChat(tabId) {
16711671
handoffOwnerId: tabChatHandoffOwnerId,
16721672
handoffGeneration,
16731673
});
1674-
if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') return clearResult;
1674+
if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') {
1675+
if (clearResult?.handoffOwnerId === tabChatHandoffOwnerId
1676+
&& Number.isFinite(Number(clearResult?.handoffGeneration))) {
1677+
tabChatHandoffGenerations.set(numericTabId, Number(clearResult.handoffGeneration));
1678+
}
1679+
return clearResult;
1680+
}
16751681
}
16761682
});
16771683
}
@@ -7428,11 +7434,13 @@ async function sendMessage(extraChatParams = {}) {
74287434
&& String(e?.message || '').startsWith(RUN_CAPTURE_START_ERROR_PREFIX);
74297435
contextMenuReservationRejected = e?.code === 'context-menu-reservation-rejected';
74307436
if (contextMenuReservationRejected) {
7431-
onContextMenuClaimRejected?.({
7437+
const rejection = {
74327438
reason: e.reason || 'claim-lost',
74337439
leaseExpiresAt: e.leaseExpiresAt,
74347440
retryAfterMs: e.retryAfterMs || 1_000,
7435-
});
7441+
};
7442+
if (contextMenuClaimOwned) await releaseOwnedContextMenuClaim(rejection);
7443+
else onContextMenuClaimRejected?.(rejection);
74367444
userEl?.remove();
74377445
assistantEl?.remove();
74387446
if (currentAssistantEl === assistantEl) currentAssistantEl = null;

src/chrome/src/ui/tab-chat-persistence.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,12 +361,23 @@ export function createTabChatHandoffCoordinator(storageArea, {
361361
}
362362
latestHtml.delete(queuedTabId);
363363
const keys = [TAB_CHAT_PREFIX + queuedTabId];
364-
if (!normalizedOwnerId) keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId);
364+
let nextGeneration = null;
365+
if (normalizedOwnerId) {
366+
nextGeneration = normalizedGeneration + 1;
367+
await storageArea.set({
368+
[TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: {
369+
ownerId: normalizedOwnerId,
370+
generation: nextGeneration,
371+
},
372+
});
373+
} else {
374+
keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId);
375+
}
365376
await storageArea.remove(keys);
366377
return {
367378
ok: true,
368379
handoffOwnerId: normalizedOwnerId || null,
369-
handoffGeneration: normalizedOwnerId ? normalizedGeneration : null,
380+
handoffGeneration: nextGeneration,
370381
};
371382
});
372383
}

src/firefox/src/ui/sidepanel.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,7 +1536,13 @@ function clearCachedTabChat(tabId) {
15361536
handoffOwnerId: tabChatHandoffOwnerId,
15371537
handoffGeneration,
15381538
});
1539-
if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') return clearResult;
1539+
if (!clearResult?.skipped || clearResult.reason !== 'stale-handoff') {
1540+
if (clearResult?.handoffOwnerId === tabChatHandoffOwnerId
1541+
&& Number.isFinite(Number(clearResult?.handoffGeneration))) {
1542+
tabChatHandoffGenerations.set(numericTabId, Number(clearResult.handoffGeneration));
1543+
}
1544+
return clearResult;
1545+
}
15401546
}
15411547
});
15421548
}
@@ -7165,11 +7171,13 @@ async function sendMessage(extraChatParams = {}) {
71657171
&& String(e?.message || '').startsWith(RUN_CAPTURE_START_ERROR_PREFIX);
71667172
contextMenuReservationRejected = e?.code === 'context-menu-reservation-rejected';
71677173
if (contextMenuReservationRejected) {
7168-
onContextMenuClaimRejected?.({
7174+
const rejection = {
71697175
reason: e.reason || 'claim-lost',
71707176
leaseExpiresAt: e.leaseExpiresAt,
71717177
retryAfterMs: e.retryAfterMs || 1_000,
7172-
});
7178+
};
7179+
if (contextMenuClaimOwned) await releaseOwnedContextMenuClaim(rejection);
7180+
else onContextMenuClaimRejected?.(rejection);
71737181
userEl?.remove();
71747182
assistantEl?.remove();
71757183
if (currentAssistantEl === assistantEl) currentAssistantEl = null;

src/firefox/src/ui/tab-chat-persistence.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,12 +361,23 @@ export function createTabChatHandoffCoordinator(storageArea, {
361361
}
362362
latestHtml.delete(queuedTabId);
363363
const keys = [TAB_CHAT_PREFIX + queuedTabId];
364-
if (!normalizedOwnerId) keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId);
364+
let nextGeneration = null;
365+
if (normalizedOwnerId) {
366+
nextGeneration = normalizedGeneration + 1;
367+
await storageArea.set({
368+
[TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: {
369+
ownerId: normalizedOwnerId,
370+
generation: nextGeneration,
371+
},
372+
});
373+
} else {
374+
keys.push(TAB_CHAT_HANDOFF_PREFIX + queuedTabId);
375+
}
365376
await storageArea.remove(keys);
366377
return {
367378
ok: true,
368379
handoffOwnerId: normalizedOwnerId || null,
369-
handoffGeneration: normalizedOwnerId ? normalizedGeneration : null,
380+
handoffGeneration: nextGeneration,
370381
};
371382
});
372383
}

test/run.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18184,21 +18184,28 @@ test('tab-chat handoff coordinator orders a returning-panel read behind the outg
1818418184
handoffGeneration: 2,
1818518185
});
1818618186
assert.equal(cleared.ok, true, `${label}: the visible owner should clear its transcript`);
18187+
assert.equal(cleared.handoffGeneration, 3, `${label}: clear should rotate the visible owner's generation`);
1818718188
assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], undefined, `${label}: clear should remove transcript content`);
1818818189
assert.deepEqual(
1818918190
values[`${persistence.TAB_CHAT_HANDOFF_PREFIX}7`],
18190-
{ ownerId: 'returning-panel', generation: 2 },
18191-
`${label}: clear should preserve the visible document's validated handoff ownership`,
18191+
{ ownerId: 'returning-panel', generation: 3 },
18192+
`${label}: clear should preserve the visible document's ownership under a fresh generation`,
1819218193
);
18194+
const capturedAcknowledgementWrite = await coordinator.save(7, '<div>captured before clear</div>', {
18195+
ownerId: 'returning-panel',
18196+
handoffGeneration: 2,
18197+
});
18198+
assert.equal(capturedAcknowledgementWrite.skipped, true, `${label}: a pre-clear acknowledgement delivered after clear must be rejected`);
18199+
assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], undefined, `${label}: a delayed pre-clear acknowledgement must not restore cleared content`);
1819318200
const unversionedWrite = await coordinator.save(7, '<div>unversioned stale</div>', {
1819418201
ownerId: 'returning-panel',
1819518202
});
1819618203
assert.equal(unversionedWrite.skipped, true, `${label}: owner-tagged writes without a generation must fail closed`);
1819718204
const postClearWrite = await coordinator.save(7, '<div>new conversation</div>', {
1819818205
ownerId: 'returning-panel',
18199-
handoffGeneration: 2,
18206+
handoffGeneration: 3,
1820018207
});
18201-
assert.equal(postClearWrite.ok, true, `${label}: the preserved owner should persist the new conversation`);
18208+
assert.equal(postClearWrite.ok, true, `${label}: the owner should persist the new conversation under the rotated generation`);
1820218209
assert.equal(values[`${persistence.TAB_CHAT_PREFIX}7`], '<div>new conversation</div>', `${label}: the post-clear transcript should remain current`);
1820318210
}
1820418211
});
@@ -18225,6 +18232,7 @@ test('chrome sidepanel serializes tab-chat storage writes with clears and reads'
1822518232
assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'chrome: clearing should invalidate the pre-clear handoff snapshot before yielding');
1822618233
assert.match(clearBody, /while \(true\)[\s\S]*?clearResult\?\.reason === 'stale-handoff'[\s\S]*?sendToBackground\('load_tab_chat'[\s\S]*?clearResult = await sendToBackground\('clear_tab_chat'[\s\S]*?clearResult\.reason !== 'stale-handoff'/, 'chrome: a stale clear should reacquire ownership and retry before rendering the cleared conversation');
1822718234
assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'chrome: queued clears should retain or reacquire ownership before clearing shared state');
18235+
assert.match(clearBody, /clearResult\?\.handoffOwnerId === tabChatHandoffOwnerId[\s\S]*?tabChatHandoffGenerations\.set\(numericTabId, Number\(clearResult\.handoffGeneration\)\)/, 'chrome: the clearing panel should adopt the generation rotated by the coordinator');
1822818236
assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'chrome: clearing content must not discard the visible document handoff generation');
1822918237
});
1823018238

@@ -18250,6 +18258,7 @@ test('firefox sidepanel serializes tab-chat storage writes with clears and reads
1825018258
assert.match(clearBody, /lastVisibleTabChatSnapshot[\s\S]*?sameTabId\(lastVisibleTabChatSnapshot\.tabId, tabId\)[\s\S]*?lastVisibleTabChatSnapshot = null;[\s\S]*?return enqueueTabChatOperation/, 'firefox: clearing should invalidate the pre-clear handoff snapshot before yielding');
1825118259
assert.match(clearBody, /while \(true\)[\s\S]*?clearResult\?\.reason === 'stale-handoff'[\s\S]*?sendToBackground\('load_tab_chat'[\s\S]*?clearResult = await sendToBackground\('clear_tab_chat'[\s\S]*?clearResult\.reason !== 'stale-handoff'/, 'firefox: a stale clear should reacquire ownership and retry before rendering the cleared conversation');
1825218260
assert.match(clearBody, /return enqueueTabChatOperation\(tabId, async \(numericTabId\) => \{[\s\S]*?tabChats\.delete\(numericTabId\);[\s\S]*?handoffGeneration = tabChatHandoffGenerations\.get\(numericTabId\);[\s\S]*?sendToBackground\('load_tab_chat', \{[\s\S]*?waitForHandoff: true,[\s\S]*?sendToBackground\('clear_tab_chat', \{[\s\S]*?handoffOwnerId: tabChatHandoffOwnerId,[\s\S]*?handoffGeneration,/, 'firefox: queued clears should retain or reacquire ownership before clearing shared state');
18261+
assert.match(clearBody, /clearResult\?\.handoffOwnerId === tabChatHandoffOwnerId[\s\S]*?tabChatHandoffGenerations\.set\(numericTabId, Number\(clearResult\.handoffGeneration\)\)/, 'firefox: the clearing panel should adopt the generation rotated by the coordinator');
1825318262
assert.doesNotMatch(clearBody, /tabChatHandoffGenerations\.delete/, 'firefox: clearing content must not discard the visible document handoff generation');
1825418263
});
1825518264

@@ -21998,6 +22007,11 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot
2199822007
5,
2199922008
`${label}: every visibility or preflight-abort exit should release the claim and schedule retry`,
2200022009
);
22010+
assert.match(
22011+
sendMessageBody,
22012+
/contextMenuReservationRejected = e\?\.code === 'context-menu-reservation-rejected';[\s\S]*?const rejection = \{[\s\S]*?if \(contextMenuClaimOwned\) await releaseOwnedContextMenuClaim\(rejection\);[\s\S]*?else onContextMenuClaimRejected\?\.\(rejection\);/,
22013+
`${label}: a run-active reservation rejection should release the renewed prompt lease before retrying`,
22014+
);
2200122015
assert.match(
2200222016
sendMessageBody,
2200322017
/if \(!retryOptions\) text = await parseSlashCommands\(text, tabId, \{ permissionSkipContext \}\);[\s\S]*?if \(!renderToCurrentTab\) \{\s*await releaseOwnedContextMenuClaim\(\);/,

0 commit comments

Comments
 (0)