mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-24 07:11:14 +00:00
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:
parent
f8763d7dd5
commit
333d734521
6 changed files with 203 additions and 3 deletions
1
.github/workflows/pr-build.yml
vendored
1
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
60
packages/ui/src/stores/compaction-notice.test.ts
Normal file
60
packages/ui/src/stores/compaction-notice.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
48
packages/ui/src/stores/compaction-notice.ts
Normal file
48
packages/ui/src/stores/compaction-notice.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
55
packages/ui/tests/browser/compaction-notification.test.ts
Normal file
55
packages/ui/tests/browser/compaction-notification.test.ts
Normal 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() }
|
||||
})
|
||||
|
|
@ -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),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue