From 09198f71085d971383a0ecb1d8f98fd278fe414d Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Fri, 21 Aug 2026 09:06:59 -0400 Subject: [PATCH 1/5] fix: serialize dashboard data loading --- src/viewer/index.html | 39 ++++++++++++++++++++++------------ test/viewer-session-id.test.ts | 33 +++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index de47f4f5f..a4bcd4389 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1467,22 +1467,33 @@

agentmemory

} } + var dashboardLoadPromise = null; async function loadDashboard() { + if (dashboardLoadPromise) return dashboardLoadPromise; + dashboardLoadPromise = loadDashboardOnce(); + try { + return await dashboardLoadPromise; + } finally { + dashboardLoadPromise = null; + } + } + + async function loadDashboardOnce() { var el = document.getElementById('view-dashboard'); if (!state.dashboard.loaded) el.innerHTML = '
Loading dashboard...
'; try { - var results = await Promise.all([ - api('health', { readErrorBody: true }), - apiGet('sessions'), - apiGet('memories?latest=true&limit=500'), - apiGet('graph/stats'), - apiGet('audit?limit=5'), - apiGet('semantic'), - apiGet('procedural'), - apiGet('relations'), - apiGet('lessons'), - apiGet('crystals') - ]); + var results = [ + await api('health', { readErrorBody: true }), + await apiGet('sessions'), + await apiGet('memories?latest=true&limit=500'), + await apiGet('graph/stats'), + await apiGet('audit?limit=5'), + await apiGet('semantic'), + await apiGet('procedural'), + await apiGet('relations'), + await apiGet('lessons'), + await apiGet('crystals') + ]; state.dashboard.health = results[0]; state.dashboard.sessions = (results[1] && results[1].sessions) || []; state.dashboard.memories = (results[2] && results[2].memories) || []; @@ -1496,8 +1507,8 @@

agentmemory

state.dashboard.loaded = true; renderDashboard(); } catch (err) { - // Without this catch, any uncaught error in the await Promise.all - // or the renderDashboard call leaves the dashboard stuck on + // Without this catch, an uncaught request or render error leaves + // the dashboard stuck on // "Loading dashboard..." forever with no indication to the user // (#323). apiGet() already swallows network/HTTP errors and // returns null, but renderDashboard can still throw on shape diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index a671df882..c4a6a90c3 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -166,7 +166,7 @@ function loadViewerSandbox() { }; const scriptWithoutAutoStart = scriptMatch[1].replace( - /\n\s*loadTab\('dashboard'\);\n\s*connectWs\(\);\n\s*startDashboardAutoRefresh\(\);\s*$/, + /\n switchTab\(tabFromRoute\(\), \{ replaceRoute: true \}\);\n \/\/ Resolve[\s\S]*?\n startDashboardAutoRefresh\(\);/, "\n", ); @@ -177,6 +177,37 @@ function loadViewerSandbox() { } describe("viewer session rendering", () => { + it("deduplicates dashboard loads and fetches every endpoint serially", async () => { + const { sandbox } = loadViewerSandbox(); + let active = 0; + let maxActive = 0; + const paths: string[] = []; + sandbox.fetch = async (url: string) => { + active += 1; + maxActive = Math.max(maxActive, active); + paths.push(new URL(url).pathname); + await Promise.resolve(); + active -= 1; + return { ok: true, json: async () => ({}) }; + }; + + await Promise.all([sandbox.loadDashboard(), sandbox.loadDashboard()]); + + expect(maxActive).toBe(1); + expect(paths).toEqual([ + "/agentmemory/health", + "/agentmemory/sessions", + "/agentmemory/memories", + "/agentmemory/graph/stats", + "/agentmemory/audit", + "/agentmemory/semantic", + "/agentmemory/procedural", + "/agentmemory/relations", + "/agentmemory/lessons", + "/agentmemory/crystals", + ]); + }); + it("attaches the saved viewer bearer to API calls", async () => { const { sandbox } = loadViewerSandbox(); const requests: Array<{ url: string; opts: { headers?: Record } }> = []; From 3fae2e54ef1966c5d795bd360768161714eba9d6 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Fri, 21 Aug 2026 09:26:03 -0400 Subject: [PATCH 2/5] fix: queue dashboard refreshes during loads --- src/viewer/index.html | 25 ++++++++++++++----------- test/viewer-session-id.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index a4bcd4389..5cfdccb04 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1468,14 +1468,18 @@

agentmemory

} var dashboardLoadPromise = null; + var dashboardRefreshPending = false; async function loadDashboard() { if (dashboardLoadPromise) return dashboardLoadPromise; - dashboardLoadPromise = loadDashboardOnce(); - try { - return await dashboardLoadPromise; - } finally { - dashboardLoadPromise = null; - } + do { + dashboardRefreshPending = false; + dashboardLoadPromise = loadDashboardOnce(); + try { + await dashboardLoadPromise; + } finally { + dashboardLoadPromise = null; + } + } while (dashboardRefreshPending); } async function loadDashboardOnce() { @@ -1793,7 +1797,8 @@

agentmemory

var dashboardTimer = null; function refreshDashboard() { state.dashboard.loaded = false; - loadDashboard(); + if (dashboardLoadPromise) dashboardRefreshPending = true; + return loadDashboard(); } function startDashboardAutoRefresh() { if (dashboardTimer) clearInterval(dashboardTimer); @@ -3810,8 +3815,7 @@

agentmemory

pollTimer = setInterval(function() { tick++; if (state.activeTab === 'dashboard') { - state.dashboard.loaded = false; - loadDashboard(); + refreshDashboard(); } else if (state.activeTab === 'memories') { state.memories.loaded = false; loadMemories(); @@ -3975,8 +3979,7 @@

agentmemory

} } if (state.activeTab === 'dashboard') { - state.dashboard.loaded = false; - loadDashboard(); + refreshDashboard(); } if (state.activeTab === 'activity' && msg.observation) { state.activity.observations.unshift(msg.observation); diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index c4a6a90c3..6c2f88834 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -208,6 +208,29 @@ describe("viewer session rendering", () => { ]); }); + it("runs one pending refresh after an in-flight dashboard load", async () => { + const { sandbox } = loadViewerSandbox(); + const paths: string[] = []; + let releaseSessions: (() => void) | undefined; + sandbox.fetch = async (url: string) => { + paths.push(new URL(url).pathname); + if (paths.length === 2) { + await new Promise((resolve) => { + releaseSessions = resolve; + }); + } + return { ok: true, json: async () => ({}) }; + }; + + const initialLoad = sandbox.loadDashboard(); + while (!releaseSessions) await Promise.resolve(); + const pendingRefresh = sandbox.refreshDashboard(); + releaseSessions(); + await Promise.all([initialLoad, pendingRefresh]); + + expect(paths).toHaveLength(20); + }); + it("attaches the saved viewer bearer to API calls", async () => { const { sandbox } = loadViewerSandbox(); const requests: Array<{ url: string; opts: { headers?: Record } }> = []; From 00aaca7dbd788141a1b694e9ad18a353bf72eb61 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Fri, 21 Aug 2026 09:36:39 -0400 Subject: [PATCH 3/5] fix: avoid unbounded dashboard collection reads --- src/viewer/index.html | 29 ++++++++++++----------------- test/viewer-session-id.test.ts | 7 +------ 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index 5cfdccb04..d43d3f9e5 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1491,23 +1491,13 @@

agentmemory

await apiGet('sessions'), await apiGet('memories?latest=true&limit=500'), await apiGet('graph/stats'), - await apiGet('audit?limit=5'), - await apiGet('semantic'), - await apiGet('procedural'), - await apiGet('relations'), - await apiGet('lessons'), - await apiGet('crystals') + await apiGet('audit?limit=5') ]; state.dashboard.health = results[0]; state.dashboard.sessions = (results[1] && results[1].sessions) || []; state.dashboard.memories = (results[2] && results[2].memories) || []; state.dashboard.graphStats = results[3]; state.dashboard.recentAudit = (results[4] && results[4].entries) || []; - state.dashboard.semantic = (results[5] && results[5].facts) || (results[5] && results[5].semantic) || []; - state.dashboard.procedural = (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || []; - state.dashboard.lessons = (results[8] && results[8].lessons) || []; - state.dashboard.crystals = (results[9] && results[9].crystals) || []; - state.dashboard.relations = (results[7] && results[7].relations) || []; state.dashboard.loaded = true; renderDashboard(); } catch (err) { @@ -1721,12 +1711,15 @@

agentmemory

var semFacts = d.semantic || []; var procItems = d.procedural || []; var relItems = d.relations || []; + var consolidationDeferred = d.semantic === undefined; html += '
'; html += '
'; html += '
Semantic Memory
'; - if (semFacts.length === 0) { + if (consolidationDeferred) { + html += '
Not loaded on the dashboard to keep large memory stores responsive.
'; + } else if (semFacts.length === 0) { html += '
No semantic facts yet. Observations will be consolidated into semantic memories over time.
'; } else { semFacts.slice(0, 5).forEach(function(f) { @@ -1744,7 +1737,9 @@

agentmemory

html += '
'; html += '
Procedural Memory
'; - if (procItems.length === 0) { + if (consolidationDeferred) { + html += '
Not loaded on the dashboard to keep large memory stores responsive.
'; + } else if (procItems.length === 0) { html += '
No procedures yet. Repeated patterns will be extracted as procedures.
'; } else { procItems.slice(0, 5).forEach(function(p) { @@ -1766,10 +1761,10 @@

agentmemory

html += '
'; html += '
Consolidation Status
'; - html += '
Semantic facts' + semFacts.length + '
'; - html += '
Procedures' + procItems.length + '
'; - html += '
Relations' + relItems.length + '
'; - if (semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) { + html += '
Semantic facts' + (consolidationDeferred ? '—' : semFacts.length) + '
'; + html += '
Procedures' + (consolidationDeferred ? '—' : procItems.length) + '
'; + html += '
Relations' + (consolidationDeferred ? '—' : relItems.length) + '
'; + if (!consolidationDeferred && semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) { html += '
Consolidation distills session observations into durable facts and repeatable procedures. It runs on a schedule when CONSOLIDATION_ENABLED=true and an LLM provider key are set, or on demand via memory_consolidate.
'; } html += '
'; diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 6c2f88834..292e1bd32 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -200,11 +200,6 @@ describe("viewer session rendering", () => { "/agentmemory/memories", "/agentmemory/graph/stats", "/agentmemory/audit", - "/agentmemory/semantic", - "/agentmemory/procedural", - "/agentmemory/relations", - "/agentmemory/lessons", - "/agentmemory/crystals", ]); }); @@ -228,7 +223,7 @@ describe("viewer session rendering", () => { releaseSessions(); await Promise.all([initialLoad, pendingRefresh]); - expect(paths).toHaveLength(20); + expect(paths).toHaveLength(10); }); it("attaches the saved viewer bearer to API calls", async () => { From 4079e32febd6e70276a9ace1d9a5110bb790da22 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Fri, 21 Aug 2026 09:42:35 -0400 Subject: [PATCH 4/5] fix(viewer): defer unbounded consolidation counts --- src/viewer/index.html | 6 ++---- test/viewer-session-id.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index d43d3f9e5..db0b5100c 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1550,10 +1550,8 @@

agentmemory

html += '
'; html += '
Sessions
' + d.sessions.length + '
' + activeSessions + ' active
'; html += '
Memories
' + d.memories.length + '
latest versions
'; - var lessonCount = (d.lessons || []).length; - var crystalCount = (d.crystals || []).length; - html += '
Lessons
' + lessonCount + '
confidence-scored
'; - html += '
Crystals
' + crystalCount + '
action digests
'; + html += '
Lessons
load tab to view
'; + html += '
Crystals
load tab to view
'; html += '
Graph Nodes
' + nodeCount + '
' + edgeCount + ' edges
'; html += '
Health
' + esc(healthStatus) + '
'; html += '
' + esc(snap.connectionState || 'unknown') + '
'; diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 292e1bd32..5bb5b18ef 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -226,6 +226,16 @@ describe("viewer session rendering", () => { expect(paths).toHaveLength(10); }); + it("marks lesson and crystal counts as deferred", () => { + const { sandbox, getElement } = loadViewerSandbox(); + + sandbox.renderDashboard(); + + const html = getElement("view-dashboard").innerHTML; + expect(html).toContain('
Lessons
'); + expect(html).toContain('
Crystals
'); + }); + it("attaches the saved viewer bearer to API calls", async () => { const { sandbox } = loadViewerSandbox(); const requests: Array<{ url: string; opts: { headers?: Record } }> = []; From 90c50322c232512004a946878a38035e990f7634 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Fri, 21 Aug 2026 09:59:55 -0400 Subject: [PATCH 5/5] fix: await queued dashboard refreshes --- src/viewer/index.html | 20 ++++++----- test/viewer-session-id.test.ts | 66 ++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index db0b5100c..3460c52a0 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1471,15 +1471,17 @@

agentmemory

var dashboardRefreshPending = false; async function loadDashboard() { if (dashboardLoadPromise) return dashboardLoadPromise; - do { - dashboardRefreshPending = false; - dashboardLoadPromise = loadDashboardOnce(); - try { - await dashboardLoadPromise; - } finally { - dashboardLoadPromise = null; - } - } while (dashboardRefreshPending); + dashboardLoadPromise = (async function() { + do { + dashboardRefreshPending = false; + await loadDashboardOnce(); + } while (dashboardRefreshPending); + })(); + try { + await dashboardLoadPromise; + } finally { + dashboardLoadPromise = null; + } } async function loadDashboardOnce() { diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 5bb5b18ef..269e01481 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -1,5 +1,5 @@ import * as vm from "node:vm"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { renderViewerDocument } from "../src/viewer/document.js"; function htmlEscape(value: string): string { @@ -169,6 +169,7 @@ function loadViewerSandbox() { /\n switchTab\(tabFromRoute\(\), \{ replaceRoute: true \}\);\n \/\/ Resolve[\s\S]*?\n startDashboardAutoRefresh\(\);/, "\n", ); + expect(scriptWithoutAutoStart).not.toBe(scriptMatch[1]); vm.createContext(sandbox); vm.runInContext(scriptWithoutAutoStart, sandbox); @@ -176,6 +177,14 @@ function loadViewerSandbox() { return { sandbox, getElement }; } +const DASHBOARD_PATHS = [ + "/agentmemory/health", + "/agentmemory/sessions", + "/agentmemory/memories", + "/agentmemory/graph/stats", + "/agentmemory/audit", +] as const; + describe("viewer session rendering", () => { it("deduplicates dashboard loads and fetches every endpoint serially", async () => { const { sandbox } = loadViewerSandbox(); @@ -194,36 +203,61 @@ describe("viewer session rendering", () => { await Promise.all([sandbox.loadDashboard(), sandbox.loadDashboard()]); expect(maxActive).toBe(1); - expect(paths).toEqual([ - "/agentmemory/health", - "/agentmemory/sessions", - "/agentmemory/memories", - "/agentmemory/graph/stats", - "/agentmemory/audit", - ]); + expect(paths).toEqual(DASHBOARD_PATHS); }); it("runs one pending refresh after an in-flight dashboard load", async () => { const { sandbox } = loadViewerSandbox(); const paths: string[] = []; - let releaseSessions: (() => void) | undefined; + let active = 0; + let maxActive = 0; + let releaseSessions = () => { + throw new Error("sessions request did not start"); + }; + let sessionsStarted = false; sandbox.fetch = async (url: string) => { + active += 1; + maxActive = Math.max(maxActive, active); paths.push(new URL(url).pathname); if (paths.length === 2) { await new Promise((resolve) => { releaseSessions = resolve; + sessionsStarted = true; }); } + active -= 1; return { ok: true, json: async () => ({}) }; }; const initialLoad = sandbox.loadDashboard(); - while (!releaseSessions) await Promise.resolve(); + await vi.waitFor(() => expect(sessionsStarted).toBe(true), { timeout: 1000 }); const pendingRefresh = sandbox.refreshDashboard(); releaseSessions(); - await Promise.all([initialLoad, pendingRefresh]); + await pendingRefresh; + const pathsWhenRefreshResolved = paths.slice(); + await initialLoad; + + expect(maxActive).toBe(1); + expect(pathsWhenRefreshResolved).toEqual([...DASHBOARD_PATHS, ...DASHBOARD_PATHS]); + expect(paths).toEqual([...DASHBOARD_PATHS, ...DASHBOARD_PATHS]); + }); + + it("starts a fresh dashboard cycle after a failed load", async () => { + const { sandbox } = loadViewerSandbox(); + const getElementById = sandbox.document.getElementById; + sandbox.document.getElementById = () => null; + + await expect(sandbox.loadDashboard()).rejects.toThrow(); + + sandbox.document.getElementById = getElementById; + const paths: string[] = []; + sandbox.fetch = async (url: string) => { + paths.push(new URL(url).pathname); + return { ok: true, json: async () => ({}) }; + }; + await sandbox.loadDashboard(); - expect(paths).toHaveLength(10); + expect(paths).toEqual(DASHBOARD_PATHS); }); it("marks lesson and crystal counts as deferred", () => { @@ -232,8 +266,12 @@ describe("viewer session rendering", () => { sandbox.renderDashboard(); const html = getElement("view-dashboard").innerHTML; - expect(html).toContain('
Lessons
'); - expect(html).toContain('
Crystals
'); + expect(html).toContain( + '
Lessons
load tab to view
', + ); + expect(html).toContain( + '
Crystals
load tab to view
', + ); }); it("attaches the saved viewer bearer to API calls", async () => {