diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 634f0268f9..e86f9f171a 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -62,3 +62,7 @@ CLI deadline regression: let the real appearance relay refuse at eight seconds, Attention identity regression: after a complete baseline, admit new Host attention IDs whose Source Events predate already observed items, including reverse timestamp order across notification kinds. Each permitted new identity alerts once. After more than 2,000 observed identities, remove old IDs from attention and reintroduce them: no replay, while a genuinely new older ID still alerts. Ordinary reconnect and overlapping subscriber replacement preserve identity history. Removing a Host, replacing its credential or final owner disposal releases that history; the new lifecycle first establishes a silent complete baseline. Identity memory grows with distinct observed IDs for that owner lifetime; it is not capped or persisted. Read-failure recovery: keep the same Host connected with the same credential after a complete notification baseline. Fail only an ordinary Activity query, then admit a new attention ID before recovery. The next successful complete scan alerts that new ID once and never replays the original IDs. Repeat with only the notification tail page failing. An initial failed/partial read never establishes a baseline; after a real disconnect, failed reads likewise cannot consume the silent reconnect baseline. A pending enable with either read failure leaves preference and storage unchanged; an explicit successful retry establishes its complete enable boundary before new alerts. + +Permanent Remove regression (F01): enable a Remote Host and change its notification kinds, then remove it through the icon context menu. Verify the Host revokes this browser, the local pairing disappears, open notification handles close and `botharness.remoteNotifications.v1` no longer contains that Host. Reload and pair the same Host again: notifications start off with default kinds. Repeat while activation is waiting; a late permission or notification-page response cannot restore the forgotten choice. Other Hosts retain their choices; temporary disconnection, reconnect and page disposal retain the existing choice. The phone-global preference is outside this browser operation. The historical T4 driver assertion `persistedChoiceUnchanged` was incorrect for permanent Remove; retain that historical artifact but do not count it as F01 proof. + +Storage-refusal regression: selectively reject deletion of the notification key (removeItem for the last choice, setItem when another choice remains), while pairing storage stays writable. Permanent Remove must still stop the connection/feed, close handles, cancel late activation and revoke the pairing. The existing Client diagnostic stream records only the fixed remote-notification-forget-persistence-failed code. In-memory removal is complete but persisted removal is not; do not promise reload/re-pair will forget a choice the browser refused to delete. Pairing-storage failures are outside this regression. diff --git a/.github/pr-screenshots/f01/a-d.png b/.github/pr-screenshots/f01/a-d.png new file mode 100644 index 0000000000..74d1c650c6 Binary files /dev/null and b/.github/pr-screenshots/f01/a-d.png differ diff --git a/.github/pr-screenshots/f01/a-l.png b/.github/pr-screenshots/f01/a-l.png new file mode 100644 index 0000000000..ff78964ebe Binary files /dev/null and b/.github/pr-screenshots/f01/a-l.png differ diff --git a/.github/pr-screenshots/f01/b-d.png b/.github/pr-screenshots/f01/b-d.png new file mode 100644 index 0000000000..cfc5a3e35e Binary files /dev/null and b/.github/pr-screenshots/f01/b-d.png differ diff --git a/.github/pr-screenshots/f01/b-l.png b/.github/pr-screenshots/f01/b-l.png new file mode 100644 index 0000000000..41e4c3db33 Binary files /dev/null and b/.github/pr-screenshots/f01/b-l.png differ diff --git a/changes/2500-remote-notification-removal.md b/changes/2500-remote-notification-removal.md new file mode 100644 index 0000000000..6c01cfea1f --- /dev/null +++ b/changes/2500-remote-notification-removal.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- Removing a Remote Host now forgets its notification choices in that browser. Pairing it again starts with notifications off; temporary disconnections and other Hosts keep their choices ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). + +## zh + +- 移除远程 Host 后,该浏览器会忘记它的通知选择,重新配对时默认关闭通知;临时断线和其他 Host 的通知选择不受影响([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index e231ed7989..5f6cf33e37 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1144,7 +1144,7 @@ Remote conversation presentation retains the source Channel Journal entry throug Activity Center consumes each Remote Host's shared Inbox through `RemoteHosts.activity`, reusing the existing `HumanInboxView` for Host groups. Its Host-scoped source supplies identity, pagination, source navigation and commands; it never receives This Host's `BridgeActions`. `createHostActivityActions` adapts the shared attention and request operations to the existing approval and question cards. Each operation resolves the current pairing before calling its Host client, and confirmed changes refresh that Host's Inbox. Filtered observers share the Host client and stop with their last subscriber. This Host still reads its existing bridge/store until that Host Feed slice migrates. -The phone owns its persisted four-kind Device Notification Preference and copies it to every paired Host on changes, pairing and retry. Existing Host switches seed it only before a device choice exists. The Host stores the copy independently of its Expo token and reads it only for delivery; Human Inbox facts are unchanged. A browser owns per-Remote-Host choices and consumes the shared Inbox while subscribed, suppressing the initial snapshot and reconnect backlog, deduplicating Host-scoped items and closing notifications on disposal. The browser retains exact seen attention identities per Host and credential for the active owner lifetime, including ordinary reconnects and overlapping subscriber changes. Source Event creation time is not attention arrival order. While the same Host connection and credential remain active, stale or failed Inbox reads preserve an established notification baseline and its seen identities; incomplete responses are not consumed. A connection interruption requires a new complete silent baseline for the reconnect backlog but retains identity history. An owner without a completed baseline stays uninitialized through read failures. There is no timestamp watermark or identity eviction: memory is O(distinct identities seen), not bounded to 2,000 entries. Removing the Host, replacing its credential or releasing the final subscriber clears this history; the next owner establishes a complete silent baseline. The browser has no closed-page notification path. Notification discovery uses the existing Host Feed client and live invalidation, with `humanAttention({ notifications: true })` paging Host-decided actions, unread group mentions/replies and ordinary DM replies independently of the Activity UI window. The shared Inbox publishes notification items only after every page completes; invalidation during a scan restarts it, and a failed page cannot replace the notification baseline. Browser activation keeps the previous choice until a fresh complete shared-Inbox scan succeeds; this acknowledged commit is the enable boundary, so an in-flight backlog cannot become new alerts. Activation has a ten-second deadline and is cancelled on target change, settings close, permission loss, removal or disposal; these cancellations do not create another feed. `deepseekbot inbox --notifications` exposes the same Host query. Phone copy failures retry while foreground with bounded backoff (1–30 seconds), without subscribing to every paired Host feed; backgrounding cancels the timer. Development-client AX invokes the existing phone owner through a precisely targeted loopback Metro inspector (`phone-notifications`); this does not provide release-client CLI parity. +The phone owns its persisted four-kind Device Notification Preference and copies it to every paired Host on changes, pairing and retry. Existing Host switches seed it only before a device choice exists. The Host stores the copy independently of its Expo token and reads it only for delivery; Human Inbox facts are unchanged. A browser owns per-Remote-Host choices and consumes the shared Inbox while subscribed, suppressing the initial snapshot and reconnect backlog, deduplicating Host-scoped items and closing notifications on disposal. The browser retains exact seen attention identities per Host and credential for the active owner lifetime, including ordinary reconnects and overlapping subscriber changes. Source Event creation time is not attention arrival order. While the same Host connection and credential remain active, stale or failed Inbox reads preserve an established notification baseline and its seen identities; incomplete responses are not consumed. A connection interruption requires a new complete silent baseline for the reconnect backlog but retains identity history. An owner without a completed baseline stays uninitialized through read failures. There is no timestamp watermark or identity eviction: memory is O(distinct identities seen), not bounded to 2,000 entries. Removing the Host, replacing its credential or releasing the final subscriber clears this history; the next owner establishes a complete silent baseline. Permanent removal also deletes that Host's persisted browser notification choice through the notification owner and cancels pending activation. Reloading or pairing that Host again starts with notifications off and default kinds; temporary disconnect, page disposal and other Hosts' choices are unaffected. This does not delete the phone's global Device Notification Preference. If notification storage refuses deletion, removal still clears the in-memory choice, cancels activation, closes handles and revokes the pairing; a bounded redacted `remote-notification-forget-persistence-failed` diagnostic reports that persistence failed. Reload may still read the old choice in that refusal case. The browser has no closed-page notification path. Notification discovery uses the existing Host Feed client and live invalidation, with `humanAttention({ notifications: true })` paging Host-decided actions, unread group mentions/replies and ordinary DM replies independently of the Activity UI window. The shared Inbox publishes notification items only after every page completes; invalidation during a scan restarts it, and a failed page cannot replace the notification baseline. Browser activation keeps the previous choice until a fresh complete shared-Inbox scan succeeds; this acknowledged commit is the enable boundary, so an in-flight backlog cannot become new alerts. Activation has a ten-second deadline and is cancelled on target change, settings close, permission loss, removal or disposal; these cancellations do not create another feed. `deepseekbot inbox --notifications` exposes the same Host query. Phone copy failures retry while foreground with bounded backoff (1–30 seconds), without subscribing to every paired Host feed; backgrounding cancels the timer. Development-client AX invokes the existing phone owner through a precisely targeted loopback Metro inspector (`phone-notifications`); this does not provide release-client CLI parity. Remote Host settings resolve the selected Host's existing client from `RemoteHosts`. The browser reuses `apps/mobile/src/host/model-settings`, `usage` and `storage` for default models, presets, usage/Memory activity and cleanup; the existing Web settings, model picker, charts and destructive confirmation controls present those operations. Settings unmount their content when the source is disconnected or removed. Model resources are keyed by client and scope; there is no This Host fallback, Bot-profile link or automatic write replay. Cleanup retains the owning Host's preview token, scope and explicit confirmation. Host looks remain device-local and independent of these Host-owned settings. diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index d371109999..f53c9fd876 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -283,6 +283,7 @@ export function createRemoteHosts(options: { const forget = (hostId: string): void => { const entry = clients.get(hostId); if (entry === undefined) return; + notifications.forget(hostId); entry.release(); stop(entry); clients.delete(hostId); diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index 6ccb7b7b7c..b9fc10572c 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -32,7 +32,8 @@ type Snapshot = { const off: Choice = { enabled: false, preferences: DEFAULT_NOTIFICATION_PREFERENCES }; type NotificationDiagnostic = | 'remote-notification-show-refused' - | 'remote-notification-show-failed'; + | 'remote-notification-show-failed' + | 'remote-notification-forget-persistence-failed'; function reportDiagnostic(code: NotificationDiagnostic): void { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('botharness:notification-diagnostic', { detail: code })); @@ -73,6 +74,11 @@ export function createRemoteNotifications(options: { let snapshot: Snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; const listeners = new Set<() => void>(); const reported = new Set(); + const report = (code: NotificationDiagnostic) => { + if (reported.has(code)) return; + reported.add(code); + (options.diagnostic ?? reportDiagnostic)(code); + }; const state = new Map< string, { @@ -119,6 +125,24 @@ export function createRemoteNotifications(options: { getSnapshot: () => snapshot, choice, cancel, + forget(hostId: string) { + cancel(hostId); + const updated = { ...hosts }; + delete updated[hostId]; + let persistenceFailed = false; + try { + if (Object.keys(updated).length === 0) storage?.removeItem(REMOTE_NOTIFICATION_KEY); + else storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated)); + } catch { + persistenceFailed = true; + } + hosts = updated; + state.delete(hostId); + revisions.delete(hostId); + close(hostId); + publish(); + if (persistenceFailed) report('remote-notification-forget-persistence-failed'); + }, subscribe(listener: () => void) { listeners.add(listener); return () => { @@ -310,10 +334,7 @@ export function createRemoteNotifications(options: { (error.name === 'NotAllowedError' || error.name === 'SecurityError') ? 'remote-notification-show-refused' : 'remote-notification-show-failed'; - if (!reported.has(code)) { - reported.add(code); - (options.diagnostic ?? reportDiagnostic)(code); - } + report(code); } } entry.baseline = true; diff --git a/packages/client/test/client-observer.test.ts b/packages/client/test/client-observer.test.ts index c7307bc754..6d2ccdcfec 100644 --- a/packages/client/test/client-observer.test.ts +++ b/packages/client/test/client-observer.test.ts @@ -14,32 +14,35 @@ describe('early real Client diagnostic observer', () => { } ).__BOTHARNESS_CLIENT_DIAGNOSTICS__; - it('records only fixed redacted notification failure codes through the existing diagnostic stream', async () => { - vi.useFakeTimers(); - const diagnostics = createClientDiagnostics(); - vi.stubGlobal( - 'fetch', - vi.fn((url, options) => diagnostics.fetch(new Request('http://localhost' + url, options))), - ); - installClientObserver(window); - window.dispatchEvent( - new CustomEvent('botharness:notification-diagnostic', { detail: 'private payload' }), - ); - window.dispatchEvent( - new CustomEvent('botharness:notification-diagnostic', { - detail: 'remote-notification-show-refused', - }), - ); - await vi.advanceTimersByTimeAsync(300); - const report = await ( - await diagnostics.fetch(new Request('http://localhost/api/botharness/client-diagnostics')) - ).json(); - expect(report.attempts[0].events).toContainEqual( - expect.objectContaining({ code: 'remote-notification-show-refused', source: 'lifecycle' }), - ); - expect(JSON.stringify(report)).not.toContain('private payload'); - expect(report.attempts[0].firstFailure).toBeUndefined(); - }); + it.each(['remote-notification-show-refused', 'remote-notification-forget-persistence-failed'])( + 'records only fixed redacted notification failure code %s through the existing diagnostic stream', + async (code) => { + vi.useFakeTimers(); + const diagnostics = createClientDiagnostics(); + vi.stubGlobal( + 'fetch', + vi.fn((url, options) => diagnostics.fetch(new Request('http://localhost' + url, options))), + ); + installClientObserver(window); + window.dispatchEvent( + new CustomEvent('botharness:notification-diagnostic', { detail: 'private payload' }), + ); + window.dispatchEvent( + new CustomEvent('botharness:notification-diagnostic', { + detail: code, + }), + ); + await vi.advanceTimersByTimeAsync(300); + const report = await ( + await diagnostics.fetch(new Request('http://localhost/api/botharness/client-diagnostics')) + ).json(); + expect(report.attempts[0].events).toContainEqual( + expect.objectContaining({ code, source: 'lifecycle' }), + ); + expect(JSON.stringify(report)).not.toContain('private payload'); + expect(report.attempts[0].firstFailure).toBeUndefined(); + }, + ); it('retains a late shell observation when its first report follows a warning flood', async () => { vi.useFakeTimers(); const diagnostics = createClientDiagnostics(); diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index e90034eec1..5a2f1db812 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -7,6 +7,7 @@ import { createHostActivityActions } from '../src/client/host-activity-actions.j import type { HumanAttentionItem } from '../src/client/store.js'; import { pairingLink } from '../../core/src/devices/http.js'; import { en, zh } from '../src/client/locale.js'; +import { REMOTE_NOTIFICATION_KEY } from '../src/client/remote-notifications.js'; import { ALL_HOSTS, HOST_TAB_STORAGE_KEY, @@ -166,6 +167,32 @@ describe('remote hosts', () => { sources.select('local'); expect(sources.getSnapshot().tab).toBe('local'); }); + + it('permanently forgets only the removed Host notification choice across reload and re-pair', async () => { + const preferences = { approval: false, question: true, 'private-input': false, reply: true }; + const retained = { enabled: true, preferences }; + const storage = memoryStorage({ + [REMOTE_NOTIFICATION_KEY]: JSON.stringify({ 'host-b': retained, 'host-c': retained }), + }); + const { carrier } = fakeCarrier(); + const sources = createRemoteHosts({ storage, carrier }); + await sources.pair(link(), 'Browser', 'host-a'); + expect(sources.notifications.choice('host-b')).toEqual(retained); + + await sources.remove('host-b'); + + expect(sources.notifications.getSnapshot().hosts).not.toHaveProperty('host-b'); + expect(JSON.parse(storage.values.get(REMOTE_NOTIFICATION_KEY)!)).toEqual({ + 'host-c': retained, + }); + const reloaded = createRemoteHosts({ storage, carrier }); + await reloaded.pair(link(), 'Browser', 'host-a'); + expect(reloaded.notifications.choice('host-b')).toEqual({ + enabled: false, + preferences: { approval: true, question: true, 'private-input': true, reply: true }, + }); + expect(reloaded.notifications.choice('host-c')).toEqual(retained); + }); }); const releases: Array<() => void> = []; @@ -206,6 +233,110 @@ function liveHost() { } describe('Host Feed lifecycle', () => { + it.each(['removeItem', 'setItem'] as const)( + 'finishes permanent removal and reports notification persistence refusal from %s', + async (operation) => { + const close = vi.fn(); + const show = vi.fn(); + const diagnostic = vi.fn(); + vi.stubGlobal('window', { + dispatchEvent: diagnostic, + Notification: class { + static permission = 'granted'; + onclick = null; + close = close; + constructor() { + show(); + } + }, + }); + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { + enabled: true, + preferences: { approval: false }, + }); + if (operation === 'setItem') + await sources.notifications.set('other', { preferences: { reply: false } }); + const other = sources.notifications.choice('other'); + const client = sources.client(host.hostId)!; + const received: HostAttentionItem = { + id: 'received', + kind: 'user-question', + category: 'action', + botSlug: 'ada', + createdAt: '2026-01-01', + summary: 'Question', + }; + host.methods.set('humanAttention', (args) => + args.notifications + ? { notificationVersion: 1, items: [received] } + : { queryVersion: 1, items: [] }, + ); + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => expect(show).toHaveBeenCalledOnce()); + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + let entered = false; + host.methods.set('humanAttention', async (args) => { + if (!args.notifications) return { queryVersion: 1, items: [] }; + entered = true; + await gate; + return { notificationVersion: 1, items: [received] }; + }); + const enabling = sources.notifications.set(host.hostId, { preferences: { approval: true } }); + const cancelled = expect(enabling).rejects.toThrow('notification-activation-cancelled'); + await vi.waitFor(() => expect(entered).toBe(true)); + const stored = storage.getItem(REMOTE_NOTIFICATION_KEY); + const original = storage[operation]; + if (operation === 'removeItem') { + const remove = storage.removeItem; + storage.removeItem = (key) => { + if (key === REMOTE_NOTIFICATION_KEY) throw new Error('private storage detail'); + remove(key); + }; + } else { + const set = storage.setItem; + storage.setItem = (key, value) => { + if (key === REMOTE_NOTIFICATION_KEY) throw new Error('private storage detail'); + set(key, value); + }; + } + try { + await expect(sources.remove(host.hostId)).resolves.toBeUndefined(); + await cancelled; + expect(host.requests).toContain('POST http://127.0.0.1:47001/botharness-device/unpair'); + expect(client.getSnapshot().connection.status).toBe('stopped'); + expect(sources.client(host.hostId)).toBeUndefined(); + expect(sources.getSnapshot().hosts).toEqual([]); + expect(storage.getItem(REMOTE_HOSTS_STORAGE_KEY)).toBeNull(); + expect(sources.notifications.getSnapshot().hosts).not.toHaveProperty(host.hostId); + expect(sources.notifications.choice('other')).toEqual(other); + expect(close).toHaveBeenCalledOnce(); + expect(diagnostic).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + type: 'botharness:notification-diagnostic', + detail: 'remote-notification-forget-persistence-failed', + }), + ); + finish(); + await Promise.resolve(); + await Promise.resolve(); + expect(sources.notifications.getSnapshot().hosts).not.toHaveProperty(host.hostId); + expect(show).toHaveBeenCalledOnce(); + expect(storage.getItem(REMOTE_NOTIFICATION_KEY)).toBe(stored); + } finally { + finish(); + Object.assign(storage, { [operation]: original }); + await cancelled; + } + }, + ); + it.each(['Activity', 'notification page'] as const)( 'preserves a completed notification baseline when %s alone fails on a connected Host', async (failure) => { diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts index 37c769bbb4..01bfaab796 100644 --- a/packages/client/test/remote-notifications.test.ts +++ b/packages/client/test/remote-notifications.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { createRemoteNotifications } from '../src/client/remote-notifications.js'; +import { + createRemoteNotifications, + REMOTE_NOTIFICATION_KEY, +} from '../src/client/remote-notifications.js'; import type { RemoteHost } from '../src/client/remote-hosts.js'; import type { DesktopNotificationHandle } from '../src/client/desktop-notifications.js'; import type { HumanAttentionItem } from '../../../apps/mobile/src/host/wire.js'; @@ -63,6 +66,75 @@ function fixture() { } describe('Remote Host browser alerts', () => { + it('forgets persisted choices and open handles only on permanent removal', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true, preferences: { approval: false } }); + await f.notices.set('b', { enabled: true, preferences: { reply: false } }); + const retained = f.notices.choice('b'); + f.notices.update([remote('a', []), remote('b', [])]); + f.notices.update([remote('a', [item('a-new')]), remote('b', [item('b-new')])]); + expect(f.handles).toHaveLength(2); + f.notices.forget('a'); + expect(f.handles[0]?.close).toHaveBeenCalledOnce(); + expect(f.handles[0]?.onclick).toBeNull(); + expect(f.handles[1]?.close).not.toHaveBeenCalled(); + expect(f.notices.getSnapshot().hosts).toEqual({ b: retained }); + expect(JSON.parse(f.options.storage.getItem(REMOTE_NOTIFICATION_KEY)!)).toEqual({ + b: retained, + }); + f.notices.update([remote('b', [item('b-new')])]); + const offline = remote('b', []); + offline.snapshot = { connection: { status: 'stopped', route: '' } }; + f.notices.update([offline]); + f.notices.update([remote('b', [item('b-new')])]); + f.notices.stop(); + const reloaded = createRemoteNotifications(f.options); + expect(reloaded.choice('a').enabled).toBe(false); + expect(reloaded.choice('a').preferences.approval).toBe(true); + expect(reloaded.choice('b')).toEqual(retained); + reloaded.forget('b'); + expect(f.options.storage.getItem(REMOTE_NOTIFICATION_KEY)).toBeNull(); + }); + + it.each(['permission', 'baseline'] as const)( + 'does not restore a forgotten choice after a late %s result or cancel a new pairing activation', + async (phase) => { + const f = fixture(); + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const prepare = vi.fn(async () => {}); + const notices = createRemoteNotifications({ ...f.options, prepareEnable: prepare }); + await notices.set('a', { enabled: false, preferences: { reply: false } }); + await notices.set('b', { enabled: false, preferences: { approval: false } }); + const retained = notices.choice('b'); + if (phase === 'permission') + f.request.mockImplementationOnce(async () => { + await gate; + f.permission('granted'); + return 'granted'; + }); + else { + f.permission('granted'); + prepare.mockImplementationOnce(() => gate); + } + const pending = notices.set('a', { enabled: true }); + const refused = expect(pending).rejects.toThrow('notification-activation-cancelled'); + notices.forget('a'); + await refused; + expect(notices.getSnapshot().hosts).toEqual({ b: retained }); + finish(); + await Promise.resolve(); + await Promise.resolve(); + expect(createRemoteNotifications(f.options).getSnapshot().hosts).toEqual({ b: retained }); + await notices.set('a', { enabled: true }); + expect(notices.choice('a').preferences.reply).toBe(true); + expect(notices.choice('a').enabled).toBe(true); + expect(notices.choice('b')).toEqual(retained); + }, + ); + it.each(['idle', 'loading', 'error', 'stale', 'partial'] as const)( 'does not establish or erase a notification baseline from a connected %s read', async (status) => { diff --git a/packages/core/src/diagnostics/client-diagnostics.ts b/packages/core/src/diagnostics/client-diagnostics.ts index 99002d548e..06a40c7e4b 100644 --- a/packages/core/src/diagnostics/client-diagnostics.ts +++ b/packages/core/src/diagnostics/client-diagnostics.ts @@ -7,6 +7,7 @@ export const CLIENT_DIAGNOSTICS_PATH = '/api/botharness/client-diagnostics'; const CODES = new Set([ 'remote-notification-show-refused', 'remote-notification-show-failed', + 'remote-notification-forget-persistence-failed', 'observation-deferred', 'resource-load-failed', 'observer-installed', diff --git a/packages/core/src/diagnostics/client-observer.ts b/packages/core/src/diagnostics/client-observer.ts index 36398b2920..fd1f9bbd25 100644 --- a/packages/core/src/diagnostics/client-observer.ts +++ b/packages/core/src/diagnostics/client-observer.ts @@ -144,7 +144,11 @@ export function installClientObserver(win: Window & typeof globalThis): void { const onNotificationDiagnostic = (event: Event): void => { if (!(event instanceof win.CustomEvent)) return; const code: unknown = event.detail; - if (code === 'remote-notification-show-refused' || code === 'remote-notification-show-failed') + if ( + code === 'remote-notification-show-refused' || + code === 'remote-notification-show-failed' || + code === 'remote-notification-forget-persistence-failed' + ) record(code, 'lifecycle'); }; win.addEventListener('botharness:notification-diagnostic', onNotificationDiagnostic); diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index 5761a97ca6..15af1da0cf 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -439,13 +439,14 @@ "scope": "Client transport / transient state", "surfaces": [ "storage:packages/client/src/client/remote-hosts.ts:removeItem(REMOTE_HOSTS_STORAGE_KEY)", + "storage:packages/client/src/client/remote-notifications.ts:removeItem(REMOTE_NOTIFICATION_KEY)", "storage:packages/client/src/client/remote-hosts.ts:setItem(REMOTE_HOSTS_STORAGE_KEY)", "storage:packages/client/src/client/remote-hosts.ts:setItem(HOST_TAB_STORAGE_KEY)", "storage:packages/client/src/client/remote-hosts.ts:removeItem(oldKey)", "storage:packages/client/src/client/remote-hosts.ts:setItem(key)", "storage:packages/client/src/client/host-looks.ts:setItem(HOST_LOOKS_STORAGE_KEY)" ], - "exemption": "Per-browser connections, legacy-key migration, the tab filter and Host looks belong to that browser profile, not to any Host (spec #2178, ADR-0243: Host looks are device-local), so no Host CLI can read or set them. The Remote Host's side is its existing device gateway: pair, unary calls, stream ticket and revoke.", + "exemption": "Per-browser connections, legacy-key migration, the tab filter and Host looks belong to that browser profile, not to any Host (spec #2178, ADR-0243: Host looks are device-local), so no Host CLI can read or set them. Permanent pairing removal also deletes its browser notification choice as automatic cleanup, not a separate setting. Browser notification reads and writes use the separately inventoried Client relay commands. The Remote Host's side is its existing device gateway: pair, unary calls, stream ticket and revoke.", "configurable": false }, {