fix(notifications): show one notification per session compaction (#1096)

One automatic compaction stacked several identical "Session … was compacted" notifications. The notification path was not idempotent: every delivered session.compaction.ended created a bubble (and a history entry when routed there), and the same event reaches a window once per project tab open on its folder (the server publishes one envelope per owning workspace) and again when the event stream replays it.

stores/compaction-notice.ts claims each compaction end once per window by its durable position (aggregate and sequence, or the event ID without one), within a bounded memory of recent compactions; later deliveries still update the session's status in their tab but no longer notify. One bubble per session: a later compaction of the same session replaces the bubble still shown, each bubble is forgotten when it expires unless a newer one replaced it, and a notice routed away from the screen retains nothing. Each distinct compaction still reaches the history when routed there.

Validation: unit tests for the claims and the bubble lifecycle; a browser regression delivers one compaction to two tabs on the same folder plus a replay through the real event dispatcher and toaster and asserts one bubble and one history entry, then one replaced bubble and a second entry for the next compaction (three bubbles without the claim, two without the replacement). notification-icon-badge and compaction-responsiveness pass; UI own-tsconfig typecheck and root typecheck pass. Gatekeeper review (GPT-6.1 Sol): one P3 (retained bubble handles) fixed, then zero actionable findings at 585140abfe6b9379a19186a5846fd9da1b2c4273.

Fixes #986
This commit is contained in:
Pascal André 2026-10-22 15:34:44 +02:00 • committed by GitHub
parent f8763d7dd5
commit 333d734521
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 203 additions and 3 deletions

View file

@ -336,6 +336,7 @@ jobs:
packages/ui/src/stores/client-state-codec.test.ts
packages/ui/src/stores/client-state-partitions.test.ts
packages/ui/src/stores/client-state.test.ts
packages/ui/src/stores/compaction-notice.test.ts
packages/ui/src/stores/form-settlements.test.ts
packages/ui/src/stores/instance-invalidation.test.ts
packages/ui/src/stores/plugin-controls.test.ts

View file

@ -0,0 +1,60 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { createCompactionNoticeClaims, createSessionBubbles } from "./compaction-notice"
const end = (id: string, seq?: number, sessionID = "ses_a") =>
({ id, data: { sessionID }, ...(seq === undefined ? {} : { durable: { aggregateID: sessionID, seq } }) })
describe("compaction notices", () => {
it("notifies once per compaction, whichever project tab or replay delivers it", () => {
const claim = createCompactionNoticeClaims()
assert.equal(claim(end("evt_1", 7)), true)
assert.equal(claim(end("evt_1", 7)), false, "the same event for another tab on the folder")
assert.equal(claim(end("evt_replayed", 7)), false, "a replay keeps its durable position")
assert.equal(claim(end("evt_2", 9)), true, "the session's next compaction")
assert.equal(claim(end("evt_3", 7, "ses_b")), true, "another session's log")
})
it("falls back to the event ID without a durable position", () => {
const claim = createCompactionNoticeClaims()
assert.equal(claim(end("evt_1")), true)
assert.equal(claim(end("evt_1")), false)
assert.equal(claim(end("evt_2")), true)
})
it("forgets the oldest compactions beyond its bound", () => {
const claim = createCompactionNoticeClaims(2)
for (const seq of [1, 2, 3]) assert.equal(claim(end(`evt_${seq}`, seq)), true)
assert.equal(claim(end("evt_3", 3)), false)
assert.equal(claim(end("evt_1", 1)), true, "the first one was forgotten")
})
})
describe("compaction bubbles", () => {
const setup = () => {
const expiries: Array<() => void> = [], dismissed: string[] = []
const bubbles = createSessionBubbles(expire => { expiries.push(expire) })
const notify = (id: string) => () => ({ id, dismiss: () => { dismissed.push(id) } })
return { bubbles, expiries, dismissed, notify }
}
it("replaces the session's bubble still shown and forgets each one when it expires", () => {
const { bubbles, expiries, dismissed, notify } = setup()
bubbles.show("ses_a", 10, notify("first"))
bubbles.show("ses_b", 10, notify("other"))
bubbles.show("ses_a", 10, notify("second"))
assert.deepEqual(dismissed, ["first"])
assert.equal(bubbles.size, 2)
expiries[0]()
assert.equal(bubbles.size, 2, "the replaced bubble's expiry keeps its replacement")
expiries[1](); expiries[2]()
assert.equal(bubbles.size, 0)
})
it("retains nothing for a notice routed away from the screen", () => {
const { bubbles, expiries, notify } = setup()
bubbles.show("ses_a", 10, notify(""))
assert.equal(bubbles.size, 0)
assert.equal(expiries.length, 0)
})
})

View file

@ -0,0 +1,48 @@
import type { ToastHandle } from "../lib/notifications"
/** Enough recent compactions to cover replays; older identities are forgotten first. */
const REMEMBERED_COMPACTIONS = 256
interface CompactionEndIdentity {
id: string
durable?: { aggregateID: string; seq: number }
data: { sessionID: string }
}
/**
* A compaction's end reaches a window once per project tab open on its folder,
* and again when the event stream replays it. Its durable position (or event
* ID) identifies it: only the first delivery may notify.
*/
export function createCompactionNoticeClaims(limit = REMEMBERED_COMPACTIONS) {
const claimed = new Set<string>()
return (event: CompactionEndIdentity): boolean => {
const key = event.durable
? `${event.durable.aggregateID}\0${event.durable.seq}`
: `${event.data.sessionID}\0${event.id}`
if (claimed.has(key)) return false
claimed.add(key)
if (claimed.size > limit) claimed.delete(claimed.values().next().value!)
return true
}
}
/**
* One bubble per session: a later notice replaces the one still shown. Each is
* forgotten when it expires, unless a newer one replaced it; a notice routed
* away from the screen leaves nothing to replace.
*/
export function createSessionBubbles(schedule: (expire: () => void, ms: number) => void = (expire, ms) => { setTimeout(expire, ms) }) {
const shown = new Map<string, ToastHandle>()
return {
show(sessionID: string, durationMs: number, notify: () => ToastHandle) {
shown.get(sessionID)?.dismiss()
shown.delete(sessionID)
const handle = notify()
if (!handle.id) return
shown.set(sessionID, handle)
schedule(() => { if (shown.get(sessionID) === handle) shown.delete(sessionID) }, durationMs)
},
get size() { return shown.size },
}
}

View file

@ -55,8 +55,12 @@ import {
import { messageStoreBus } from "./message-v2/bus"
import { updateSessionInfo } from "./message-v2/session-info"
import { handleConversationAssistantPartUpdated } from "./conversation-speech"
import { createCompactionNoticeClaims, createSessionBubbles } from "./compaction-notice"
const log = getLogger("sse")
const claimCompactionNotice = createCompactionNoticeClaims()
const compactionBubbles = createSessionBubbles()
const COMPACTION_TOAST_MS = 10000
const pendingSessionFetches = new Map<string, {
status: SessionStatus
retry?: SessionRetryState | null
@ -590,6 +594,7 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true })
else ensureSessionStatus(instanceId, sessionID, "working", event.location?.directory)
if (!claimCompactionNotice(event)) return
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionID)
const label = session?.title?.trim() ? session.title : sessionID
@ -597,12 +602,12 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
const displayLabel = label ? `"${label}"` : sessionID
showToastNotification({ category: "sessions",
compactionBubbles.show(sessionID, COMPACTION_TOAST_MS, () => showToastNotification({ category: "sessions",
title: instanceName,
message: tGlobal("sessionEvents.sessionCompactedToast", { label: displayLabel }),
variant: "info",
duration: 10000,
})
duration: COMPACTION_TOAST_MS,
}))
}
function handleSessionError(instanceId: string, event: SessionExecutionFailed): void {

View file

@ -0,0 +1,55 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import { createFixtureCache } from "./fixture-cache"
let server: ViteDevServer, browser: Browser, url: string
let cache: Awaited<ReturnType<typeof createFixtureCache>>
before(async () => {
cache = await createFixtureCache()
server = await createServer({ configFile: false, cacheDir: cache.cacheDir, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "compaction-notification-fixture", configureServer(s) {
s.middlewares.use("/fixture", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/compaction-notification.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } })
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close(); await cache?.dispose() })
test("one automatic compaction shows one notification, whichever tabs or replays deliver it", async () => {
const page = await browser.newPage()
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(url)
await page.waitForFunction(() => Boolean((window as any).fixture))
const message = 'Session "Compaction fixture" was compacted'
const bubbles = page.getByText(message, { exact: true })
await page.evaluate(() => {
const fixture = (window as any).fixture
fixture.compacted("tab-a", "evt_1", 7)
fixture.compacted("tab-b", "evt_1", 7)
// A reconnect replays the same durable event.
fixture.compacted("tab-a", "evt_replay", 7)
})
await bubbles.first().waitFor()
await page.waitForTimeout(300)
assert.equal(await bubbles.count(), 1)
assert.deepEqual(await page.evaluate(() => (window as any).fixture.history()), [message])
// The session's next compaction is new: it reaches the history and replaces the bubble.
await page.evaluate(() => (window as any).fixture.compacted("tab-b", "evt_2", 9))
await page.waitForFunction(() => (window as any).fixture.history().length === 2)
await page.waitForTimeout(600)
assert.equal(await bubbles.count(), 1)
assert.deepEqual(errors, [])
} finally { await page.close() }
})

View file

@ -0,0 +1,31 @@
import { render } from "solid-js/web"
import { Toaster } from "solid-toast"
import { sseManager } from "../../../src/lib/sse-manager"
import { getToastHistory } from "../../../src/lib/notifications"
import { addInstance } from "../../../src/stores/instances"
import { setSessions } from "../../../src/stores/session-state"
import "../../../src/stores/sessions"
import { applyUiSettings } from "./ui-settings"
import "../../../src/index.css"
// Two project tabs on one folder: the server delivers each event to both.
const tabs = ["tab-a", "tab-b"], sessionID = "ses_compacted"
await applyUiSettings({ locale: "en", notificationRouting: { sessions: { screen: true, history: true, system: false } } as never })
for (const id of tabs) {
addInstance({ id, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client: null })
setSessions(previous => new Map(previous).set(id, new Map([[sessionID, {
id: sessionID, instanceId: id, parentId: null, title: "Compaction fixture", agent: "build", model: { providerId: "fixture", modelId: "fixture" },
status: "working", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true, version: "1", projectID: "p",
location: { directory: "/fixture" }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
} as never]])))
}
render(() => <Toaster />, document.getElementById("root")!)
;(window as any).fixture = {
/** One `session.compaction.ended`, as the event stream delivers it to a tab. */
compacted: (tab: string, id: string, seq: number) => (sseManager as any).handleEvent(tab, {
id, type: "session.compaction.ended", created: seq, durable: { aggregateID: sessionID, seq, version: 1 },
location: { directory: "/fixture" }, data: { sessionID, reason: "auto", text: "Summary" },
}),
history: () => getToastHistory().map(item => item.message),
}