diff --git a/src/viewer/index.html b/src/viewer/index.html index de47f4f5f..3460c52a0 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1467,37 +1467,44 @@

agentmemory

} } + var dashboardLoadPromise = null; + var dashboardRefreshPending = false; async function loadDashboard() { + if (dashboardLoadPromise) return dashboardLoadPromise; + dashboardLoadPromise = (async function() { + do { + dashboardRefreshPending = false; + await loadDashboardOnce(); + } while (dashboardRefreshPending); + })(); + try { + 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') + ]; 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) { - // 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 @@ -1545,10 +1552,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') + '
'; @@ -1706,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) { @@ -1729,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) { @@ -1751,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 += '
'; @@ -1782,7 +1792,8 @@

agentmemory

var dashboardTimer = null; function refreshDashboard() { state.dashboard.loaded = false; - loadDashboard(); + if (dashboardLoadPromise) dashboardRefreshPending = true; + return loadDashboard(); } function startDashboardAutoRefresh() { if (dashboardTimer) clearInterval(dashboardTimer); @@ -3799,8 +3810,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(); @@ -3964,8 +3974,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 a671df882..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 { @@ -166,9 +166,10 @@ 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", ); + expect(scriptWithoutAutoStart).not.toBe(scriptMatch[1]); vm.createContext(sandbox); vm.runInContext(scriptWithoutAutoStart, sandbox); @@ -176,7 +177,103 @@ 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(); + 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(DASHBOARD_PATHS); + }); + + it("runs one pending refresh after an in-flight dashboard load", async () => { + const { sandbox } = loadViewerSandbox(); + const paths: string[] = []; + 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(); + await vi.waitFor(() => expect(sessionsStarted).toBe(true), { timeout: 1000 }); + const pendingRefresh = sandbox.refreshDashboard(); + releaseSessions(); + 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).toEqual(DASHBOARD_PATHS); + }); + + it("marks lesson and crystal counts as deferred", () => { + const { sandbox, getElement } = loadViewerSandbox(); + + sandbox.renderDashboard(); + + const html = getElement("view-dashboard").innerHTML; + 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 () => { const { sandbox } = loadViewerSandbox(); const requests: Array<{ url: string; opts: { headers?: Record } }> = [];