';
- var lessonCount = (d.lessons || []).length;
- var crystalCount = (d.crystals || []).length;
- html += '
var semFacts = d.semantic || [];
var procItems = d.procedural || [];
var relItems = d.relations || [];
+ var consolidationDeferred = d.semantic === undefined;
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 } }> = [];