@copilotkit/web-inspector 1.62.1 → 1.62.2-canary.1783457132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +975 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +45 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +977 -26
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1012 -55
- package/dist/index.umd.js.map +1 -1
- package/dist/lib/telemetry.cjs +7 -2
- package/dist/lib/telemetry.cjs.map +1 -1
- package/dist/lib/telemetry.mjs +7 -3
- package/dist/lib/telemetry.mjs.map +1 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/package.json +2 -2
- package/src/__tests__/web-inspector.spec.ts +1150 -1
- package/src/index.ts +1208 -60
- package/src/lib/telemetry.ts +18 -1
- package/src/styles/generated.css +1 -1
|
@@ -7,6 +7,7 @@ import type { ThreadDebuggerProvider } from "../index.js";
|
|
|
7
7
|
import type { CopilotKitCore } from "@copilotkit/core";
|
|
8
8
|
import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
|
|
9
9
|
import type { CopilotKitCoreSubscriber } from "@copilotkit/core";
|
|
10
|
+
import type { Memory } from "@copilotkit/core";
|
|
10
11
|
import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
|
|
11
12
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
12
13
|
|
|
@@ -101,6 +102,46 @@ function createMockAgent(
|
|
|
101
102
|
|
|
102
103
|
// --- Mock core factory ---
|
|
103
104
|
|
|
105
|
+
// --- Minimal no-op memory store stub ---
|
|
106
|
+
// The inspector calls core.getMemoryStore() lazily, on first Memories-tab
|
|
107
|
+
// activation (NOT on attach). All mock cores still expose this method so that
|
|
108
|
+
// tests which do activate the tab don't hit a TypeError. The stub below seeds
|
|
109
|
+
// the store with empty memories and available=true, which is the right default
|
|
110
|
+
// for tests that don't exercise the memory feature.
|
|
111
|
+
|
|
112
|
+
type MockMemoryStoreState = {
|
|
113
|
+
memories: never[];
|
|
114
|
+
isLoading: boolean;
|
|
115
|
+
isMutating: boolean;
|
|
116
|
+
error: null;
|
|
117
|
+
context: null;
|
|
118
|
+
sessionId: number;
|
|
119
|
+
available: boolean;
|
|
120
|
+
realtimeStatus: "connecting" | "connected" | "unavailable";
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function createNoopMemoryStore() {
|
|
124
|
+
const state: MockMemoryStoreState = {
|
|
125
|
+
memories: [],
|
|
126
|
+
isLoading: false,
|
|
127
|
+
isMutating: false,
|
|
128
|
+
error: null,
|
|
129
|
+
context: null,
|
|
130
|
+
sessionId: 0,
|
|
131
|
+
available: true,
|
|
132
|
+
realtimeStatus: "connecting",
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
getState: () => state,
|
|
136
|
+
select: <T>(selector: (s: MockMemoryStoreState) => T) => ({
|
|
137
|
+
subscribe: (cb: (v: T) => void) => {
|
|
138
|
+
cb(selector(state));
|
|
139
|
+
return { unsubscribe: () => undefined };
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
104
145
|
type MockCore = {
|
|
105
146
|
agents: Record<string, AbstractAgent>;
|
|
106
147
|
context: Record<string, unknown>;
|
|
@@ -111,6 +152,7 @@ type MockCore = {
|
|
|
111
152
|
};
|
|
112
153
|
getThreadStores: () => Record<string, never>;
|
|
113
154
|
getThreadStore: (agentId: string) => undefined;
|
|
155
|
+
getMemoryStore: () => ReturnType<typeof createNoopMemoryStore>;
|
|
114
156
|
};
|
|
115
157
|
|
|
116
158
|
function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
|
|
@@ -130,6 +172,9 @@ function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
|
|
|
130
172
|
getThreadStore(_agentId: string) {
|
|
131
173
|
return undefined;
|
|
132
174
|
},
|
|
175
|
+
getMemoryStore() {
|
|
176
|
+
return createNoopMemoryStore();
|
|
177
|
+
},
|
|
133
178
|
};
|
|
134
179
|
|
|
135
180
|
return {
|
|
@@ -339,6 +384,7 @@ type ThreadDetailsInternals = {
|
|
|
339
384
|
} | null;
|
|
340
385
|
_expandedTools: Set<string>;
|
|
341
386
|
_expandedMessages: Set<string>;
|
|
387
|
+
_expandedTimelineDetails: Set<string>;
|
|
342
388
|
_stateNotAvailable: boolean;
|
|
343
389
|
_eventsNotAvailable: boolean;
|
|
344
390
|
_loadingMessages: boolean;
|
|
@@ -934,7 +980,22 @@ describe("CpkThreadInspector provider contract", () => {
|
|
|
934
980
|
?.click();
|
|
935
981
|
await flushProviderWork(el);
|
|
936
982
|
|
|
983
|
+
const rawEvent = el.shadowRoot?.querySelector<HTMLElement>(
|
|
984
|
+
'.cpk-td__event[data-source-index="1"]',
|
|
985
|
+
);
|
|
986
|
+
expect(rawEvent?.textContent ?? "").toContain("Show details");
|
|
987
|
+
expect(rawEvent?.textContent ?? "").not.toContain("top-level-run");
|
|
988
|
+
expect(rawEvent?.textContent ?? "").not.toContain("sequence");
|
|
989
|
+
expect(rawEvent?.textContent ?? "").not.toContain("messageId");
|
|
990
|
+
expect(rawEvent?.textContent ?? "").not.toContain("hello");
|
|
991
|
+
|
|
992
|
+
rawEvent
|
|
993
|
+
?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
|
|
994
|
+
?.click();
|
|
995
|
+
await el.updateComplete;
|
|
996
|
+
|
|
937
997
|
const text = el.shadowRoot?.textContent ?? "";
|
|
998
|
+
expect(text).toContain("Hide details");
|
|
938
999
|
expect(text).toContain("top-level-run");
|
|
939
1000
|
expect(text).toContain("sequence");
|
|
940
1001
|
expect(text).toContain("messageId");
|
|
@@ -1118,11 +1179,90 @@ describe("CpkThreadInspector provider contract", () => {
|
|
|
1118
1179
|
|
|
1119
1180
|
expect(internals.activeTimelineItems).toHaveLength(1);
|
|
1120
1181
|
expect(el.shadowRoot?.textContent ?? "").toContain("THREAD_STATE_WRITTEN");
|
|
1121
|
-
expect(el.shadowRoot?.textContent ?? "").toContain("
|
|
1182
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Show details");
|
|
1183
|
+
expect(el.shadowRoot?.textContent ?? "").not.toContain("checkpointId");
|
|
1122
1184
|
expect(el.shadowRoot?.textContent ?? "").toContain("Source event #1");
|
|
1123
1185
|
expect(el.shadowRoot?.textContent ?? "").not.toContain(
|
|
1124
1186
|
"No timeline events captured",
|
|
1125
1187
|
);
|
|
1188
|
+
el.shadowRoot
|
|
1189
|
+
?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
|
|
1190
|
+
?.click();
|
|
1191
|
+
await el.updateComplete;
|
|
1192
|
+
|
|
1193
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("checkpointId");
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
it("collapses structured timeline event details by default", async () => {
|
|
1197
|
+
const provider: ThreadDebuggerProvider = {
|
|
1198
|
+
getEvents: vi.fn().mockResolvedValue([
|
|
1199
|
+
{
|
|
1200
|
+
type: "RUN_STARTED",
|
|
1201
|
+
timestamp: "2026-06-25T10:00:00.000Z",
|
|
1202
|
+
payload: {
|
|
1203
|
+
input: {
|
|
1204
|
+
tools: [
|
|
1205
|
+
{
|
|
1206
|
+
name: "generateSandboxedUi",
|
|
1207
|
+
description: "very chonky run-started payload",
|
|
1208
|
+
},
|
|
1209
|
+
],
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
]),
|
|
1214
|
+
};
|
|
1215
|
+
const { el, internals } = createThreadInspector();
|
|
1216
|
+
|
|
1217
|
+
internals.provider = provider;
|
|
1218
|
+
internals.threadId = "thread-chonky-run-started";
|
|
1219
|
+
await flushProviderWork(el);
|
|
1220
|
+
|
|
1221
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Run started");
|
|
1222
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Show details");
|
|
1223
|
+
expect(el.shadowRoot?.textContent ?? "").not.toContain(
|
|
1224
|
+
"very chonky run-started payload",
|
|
1225
|
+
);
|
|
1226
|
+
el.shadowRoot
|
|
1227
|
+
?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
|
|
1228
|
+
?.click();
|
|
1229
|
+
await el.updateComplete;
|
|
1230
|
+
|
|
1231
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Hide details");
|
|
1232
|
+
expect(el.shadowRoot?.textContent ?? "").toContain(
|
|
1233
|
+
"very chonky run-started payload",
|
|
1234
|
+
);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("shows expanded timeline details when an event also has a summary body", async () => {
|
|
1238
|
+
const provider: ThreadDebuggerProvider = {
|
|
1239
|
+
getEvents: vi.fn().mockResolvedValue([
|
|
1240
|
+
{
|
|
1241
|
+
type: "RUN_ERROR",
|
|
1242
|
+
timestamp: "2026-06-25T10:00:00.000Z",
|
|
1243
|
+
payload: {
|
|
1244
|
+
message: "Tool failed",
|
|
1245
|
+
errorCode: "ERR_TOOL_TIMEOUT",
|
|
1246
|
+
},
|
|
1247
|
+
},
|
|
1248
|
+
]),
|
|
1249
|
+
};
|
|
1250
|
+
const { el, internals } = createThreadInspector();
|
|
1251
|
+
|
|
1252
|
+
internals.provider = provider;
|
|
1253
|
+
internals.threadId = "thread-error-details";
|
|
1254
|
+
await flushProviderWork(el);
|
|
1255
|
+
|
|
1256
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Tool failed");
|
|
1257
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Show details");
|
|
1258
|
+
expect(el.shadowRoot?.textContent ?? "").not.toContain("ERR_TOOL_TIMEOUT");
|
|
1259
|
+
el.shadowRoot
|
|
1260
|
+
?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
|
|
1261
|
+
?.click();
|
|
1262
|
+
await el.updateComplete;
|
|
1263
|
+
|
|
1264
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("Hide details");
|
|
1265
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("ERR_TOOL_TIMEOUT");
|
|
1126
1266
|
});
|
|
1127
1267
|
|
|
1128
1268
|
it("keeps the first-visible timeline intentional while provider message fallback is loading", async () => {
|
|
@@ -1306,6 +1446,7 @@ type HeaderMockCore = {
|
|
|
1306
1446
|
getThreadStore: (agentId: string) => undefined;
|
|
1307
1447
|
registerThreadStore: (agentId: string, store: unknown) => void;
|
|
1308
1448
|
unregisterThreadStore: (agentId: string) => void;
|
|
1449
|
+
getMemoryStore: () => ReturnType<typeof createNoopMemoryStore>;
|
|
1309
1450
|
};
|
|
1310
1451
|
|
|
1311
1452
|
function createHeaderMockCore(
|
|
@@ -1342,6 +1483,9 @@ function createHeaderMockCore(
|
|
|
1342
1483
|
},
|
|
1343
1484
|
registerThreadStore() {},
|
|
1344
1485
|
unregisterThreadStore() {},
|
|
1486
|
+
getMemoryStore() {
|
|
1487
|
+
return createNoopMemoryStore();
|
|
1488
|
+
},
|
|
1345
1489
|
};
|
|
1346
1490
|
|
|
1347
1491
|
const asCore = () => core as unknown as CopilotKitCore;
|
|
@@ -1677,3 +1821,1008 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1677
1821
|
);
|
|
1678
1822
|
});
|
|
1679
1823
|
});
|
|
1824
|
+
|
|
1825
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1826
|
+
// Wave 6 — Memories tab + cpk-memory-list coverage
|
|
1827
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1828
|
+
//
|
|
1829
|
+
// 6.1 Helpers: makeCoreWithMemory / makeCoreNoIntelligence / mountMemories
|
|
1830
|
+
// 6.2 Subscription: inspector._memories is seeded from store
|
|
1831
|
+
// 6.3 Tab presence: "Learning" label appears in the rendered menu
|
|
1832
|
+
// 6.4 View states: locked teaser vs. enabled empty vs. enabled with cards
|
|
1833
|
+
// 6.5 cpk-memory-list: cards, kind filter, search filter, empty state
|
|
1834
|
+
// 6.6 Passive guard: inspector reads from core.getMemoryStore(), never creates its own
|
|
1835
|
+
|
|
1836
|
+
// ── 6.1 Helpers ──────────────────────────────────────────────────────────
|
|
1837
|
+
|
|
1838
|
+
type MemoryStoreState = {
|
|
1839
|
+
memories: Memory[];
|
|
1840
|
+
isLoading: boolean;
|
|
1841
|
+
isMutating: boolean;
|
|
1842
|
+
error: Error | null;
|
|
1843
|
+
context: null;
|
|
1844
|
+
sessionId: number;
|
|
1845
|
+
available: boolean;
|
|
1846
|
+
realtimeStatus: "connecting" | "connected" | "unavailable";
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Returns a minimal mock memory store seeded with the given memories and
|
|
1851
|
+
* availability flag. The `select(selector)` method returns an Observable-like
|
|
1852
|
+
* that calls the subscriber once synchronously with the derived value, then
|
|
1853
|
+
* never again — sufficient for the inspector's subscription wiring.
|
|
1854
|
+
*/
|
|
1855
|
+
function makeMockMemoryStore(
|
|
1856
|
+
memories: Memory[],
|
|
1857
|
+
available: boolean,
|
|
1858
|
+
realtimeStatus: MemoryStoreState["realtimeStatus"] = "connected",
|
|
1859
|
+
): { store: ReturnType<typeof buildStore>; state: MemoryStoreState } {
|
|
1860
|
+
const state: MemoryStoreState = {
|
|
1861
|
+
memories,
|
|
1862
|
+
isLoading: false,
|
|
1863
|
+
isMutating: false,
|
|
1864
|
+
error: null,
|
|
1865
|
+
context: null,
|
|
1866
|
+
sessionId: 0,
|
|
1867
|
+
available,
|
|
1868
|
+
realtimeStatus,
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
function buildStore() {
|
|
1872
|
+
return {
|
|
1873
|
+
getState: () => state,
|
|
1874
|
+
select: <T>(selector: (s: MemoryStoreState) => T) => ({
|
|
1875
|
+
subscribe: (cb: (v: T) => void) => {
|
|
1876
|
+
cb(selector(state));
|
|
1877
|
+
return { unsubscribe: () => undefined };
|
|
1878
|
+
},
|
|
1879
|
+
}),
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const store = buildStore();
|
|
1884
|
+
return { store, state };
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
type MemoryMockCore = {
|
|
1888
|
+
agents: Record<string, AbstractAgent>;
|
|
1889
|
+
context: Record<string, unknown>;
|
|
1890
|
+
properties: Record<string, unknown>;
|
|
1891
|
+
telemetryDisabled: boolean;
|
|
1892
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
|
|
1893
|
+
intelligence: { wsUrl: string } | undefined;
|
|
1894
|
+
subscribe: (subscriber: CopilotKitCoreSubscriber) => {
|
|
1895
|
+
unsubscribe: () => void;
|
|
1896
|
+
};
|
|
1897
|
+
getThreadStores: () => Record<string, never>;
|
|
1898
|
+
getThreadStore: (agentId: string) => undefined;
|
|
1899
|
+
getMemoryStore: () => ReturnType<typeof makeMockMemoryStore>["store"];
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Returns a mock core with an intelligence property set (so the memories view
|
|
1904
|
+
* is not locked by the intelligence guard) and a memory store seeded with the
|
|
1905
|
+
* supplied memories. Pass `available: false` to simulate memories being
|
|
1906
|
+
* unavailable (which also locks the view).
|
|
1907
|
+
*/
|
|
1908
|
+
function makeCoreWithMemory(
|
|
1909
|
+
memories: Memory[],
|
|
1910
|
+
opts: {
|
|
1911
|
+
available?: boolean;
|
|
1912
|
+
telemetryDisabled?: boolean;
|
|
1913
|
+
realtimeStatus?: MemoryStoreState["realtimeStatus"];
|
|
1914
|
+
} = {},
|
|
1915
|
+
): MemoryMockCore {
|
|
1916
|
+
const available = opts.available ?? true;
|
|
1917
|
+
const { store } = makeMockMemoryStore(
|
|
1918
|
+
memories,
|
|
1919
|
+
available,
|
|
1920
|
+
opts.realtimeStatus ?? "connected",
|
|
1921
|
+
);
|
|
1922
|
+
|
|
1923
|
+
return {
|
|
1924
|
+
agents: {},
|
|
1925
|
+
context: {},
|
|
1926
|
+
properties: {},
|
|
1927
|
+
telemetryDisabled: opts.telemetryDisabled ?? false,
|
|
1928
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
1929
|
+
// Intelligence present → locked teaser is NOT shown (unless available=false).
|
|
1930
|
+
intelligence: { wsUrl: "wss://localhost" },
|
|
1931
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
1932
|
+
unsubscribe: () => undefined,
|
|
1933
|
+
}),
|
|
1934
|
+
getThreadStores: () => ({}),
|
|
1935
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
1936
|
+
getMemoryStore: () => store,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/**
|
|
1941
|
+
* Returns a mock core that has NO intelligence property. Used to assert the
|
|
1942
|
+
* locked teaser regardless of memory availability.
|
|
1943
|
+
*/
|
|
1944
|
+
function makeCoreNoIntelligence(): MemoryMockCore {
|
|
1945
|
+
const { store } = makeMockMemoryStore([], true);
|
|
1946
|
+
|
|
1947
|
+
return {
|
|
1948
|
+
agents: {},
|
|
1949
|
+
context: {},
|
|
1950
|
+
properties: {},
|
|
1951
|
+
telemetryDisabled: false,
|
|
1952
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
1953
|
+
intelligence: undefined,
|
|
1954
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
1955
|
+
unsubscribe: () => undefined,
|
|
1956
|
+
}),
|
|
1957
|
+
getThreadStores: () => ({}),
|
|
1958
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
1959
|
+
getMemoryStore: () => store,
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Mounts a `<cpk-web-inspector>` with the given core, opens it, and switches
|
|
1965
|
+
* to the memories tab. Returns the element ready for assertion.
|
|
1966
|
+
*/
|
|
1967
|
+
async function mountMemories(
|
|
1968
|
+
core: MemoryMockCore,
|
|
1969
|
+
): Promise<WebInspectorElement> {
|
|
1970
|
+
const el = new WebInspectorElement();
|
|
1971
|
+
document.body.appendChild(el);
|
|
1972
|
+
// CopilotKitCore is a full class — our mock covers what the inspector reads.
|
|
1973
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
1974
|
+
|
|
1975
|
+
// Open the inspector window so the tab content is rendered.
|
|
1976
|
+
const internals = el as unknown as {
|
|
1977
|
+
isOpen: boolean;
|
|
1978
|
+
handleMenuSelect: (key: string) => void;
|
|
1979
|
+
};
|
|
1980
|
+
internals.isOpen = true;
|
|
1981
|
+
internals.handleMenuSelect("memories");
|
|
1982
|
+
|
|
1983
|
+
await el.updateComplete;
|
|
1984
|
+
return el;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// ── 6.2 Subscription ─────────────────────────────────────────────────────
|
|
1988
|
+
|
|
1989
|
+
describe("WebInspectorElement memories — subscription", () => {
|
|
1990
|
+
beforeEach(() => {
|
|
1991
|
+
document.body.innerHTML = "";
|
|
1992
|
+
vi.stubGlobal("localStorage", {
|
|
1993
|
+
getItem: () => null,
|
|
1994
|
+
setItem: () => undefined,
|
|
1995
|
+
removeItem: () => undefined,
|
|
1996
|
+
clear: () => undefined,
|
|
1997
|
+
get length() {
|
|
1998
|
+
return 0;
|
|
1999
|
+
},
|
|
2000
|
+
key: () => null,
|
|
2001
|
+
});
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
afterEach(() => {
|
|
2005
|
+
vi.unstubAllGlobals();
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
it("seeds _memories from core.getMemoryStore() on Memories-tab activation", async () => {
|
|
2009
|
+
const oneMemory: Memory = {
|
|
2010
|
+
id: "m1",
|
|
2011
|
+
kind: "topical",
|
|
2012
|
+
scope: "user",
|
|
2013
|
+
content: "Likes dogs",
|
|
2014
|
+
sourceThreadIds: [],
|
|
2015
|
+
invalidatedAt: null,
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2019
|
+
const el = await mountMemories(core);
|
|
2020
|
+
|
|
2021
|
+
const ids = (el as unknown as { _memories: Memory[] })._memories.map(
|
|
2022
|
+
(m) => m.id,
|
|
2023
|
+
);
|
|
2024
|
+
|
|
2025
|
+
expect(ids).toEqual(["m1"]);
|
|
2026
|
+
});
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
// ── 6.3 Tab presence ─────────────────────────────────────────────────────
|
|
2030
|
+
|
|
2031
|
+
describe("WebInspectorElement memories — tab presence", () => {
|
|
2032
|
+
beforeEach(() => {
|
|
2033
|
+
document.body.innerHTML = "";
|
|
2034
|
+
vi.stubGlobal("localStorage", {
|
|
2035
|
+
getItem: () => null,
|
|
2036
|
+
setItem: () => undefined,
|
|
2037
|
+
removeItem: () => undefined,
|
|
2038
|
+
clear: () => undefined,
|
|
2039
|
+
get length() {
|
|
2040
|
+
return 0;
|
|
2041
|
+
},
|
|
2042
|
+
key: () => null,
|
|
2043
|
+
});
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
afterEach(() => {
|
|
2047
|
+
vi.unstubAllGlobals();
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
it("renders a Learning tab button in the inspector menu", async () => {
|
|
2051
|
+
const core = makeCoreWithMemory([]);
|
|
2052
|
+
const el = await mountMemories(core);
|
|
2053
|
+
|
|
2054
|
+
const buttons = Array.from(
|
|
2055
|
+
el.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
|
|
2056
|
+
);
|
|
2057
|
+
const memoriesButton = buttons.find((btn) =>
|
|
2058
|
+
btn.textContent?.trim().includes("Learning"),
|
|
2059
|
+
);
|
|
2060
|
+
|
|
2061
|
+
expect(memoriesButton, "Learning tab button should render").toBeDefined();
|
|
2062
|
+
});
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
// ── 6.4 View states ──────────────────────────────────────────────────────
|
|
2066
|
+
|
|
2067
|
+
describe("WebInspectorElement memories — view states", () => {
|
|
2068
|
+
beforeEach(() => {
|
|
2069
|
+
document.body.innerHTML = "";
|
|
2070
|
+
vi.stubGlobal("localStorage", {
|
|
2071
|
+
getItem: () => null,
|
|
2072
|
+
setItem: () => undefined,
|
|
2073
|
+
removeItem: () => undefined,
|
|
2074
|
+
clear: () => undefined,
|
|
2075
|
+
get length() {
|
|
2076
|
+
return 0;
|
|
2077
|
+
},
|
|
2078
|
+
key: () => null,
|
|
2079
|
+
});
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
afterEach(() => {
|
|
2083
|
+
vi.unstubAllGlobals();
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
it("renders the locked teaser when intelligence is absent", async () => {
|
|
2087
|
+
const core = makeCoreNoIntelligence();
|
|
2088
|
+
const el = await mountMemories(core);
|
|
2089
|
+
|
|
2090
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2091
|
+
expect(text).toContain("Long-term memory");
|
|
2092
|
+
expect(text).toContain(
|
|
2093
|
+
"Long-term memory isn't enabled on this deployment.",
|
|
2094
|
+
);
|
|
2095
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2096
|
+
expect(
|
|
2097
|
+
memoryList,
|
|
2098
|
+
"cpk-memory-list should NOT render when locked",
|
|
2099
|
+
).toBeNull();
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
it("renders the locked teaser when memories are unavailable", async () => {
|
|
2103
|
+
const core = makeCoreWithMemory([], { available: false });
|
|
2104
|
+
const el = await mountMemories(core);
|
|
2105
|
+
|
|
2106
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2107
|
+
expect(text).toContain("Long-term memory");
|
|
2108
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2109
|
+
expect(
|
|
2110
|
+
memoryList,
|
|
2111
|
+
"cpk-memory-list should NOT render when unavailable",
|
|
2112
|
+
).toBeNull();
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
it("renders cpk-memory-list with empty state when available and no memories", async () => {
|
|
2116
|
+
const core = makeCoreWithMemory([], { available: true });
|
|
2117
|
+
const el = await mountMemories(core);
|
|
2118
|
+
|
|
2119
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2120
|
+
expect(
|
|
2121
|
+
memoryList,
|
|
2122
|
+
"cpk-memory-list should render when enabled",
|
|
2123
|
+
).not.toBeNull();
|
|
2124
|
+
|
|
2125
|
+
await (memoryList as unknown as { updateComplete: Promise<void> })
|
|
2126
|
+
.updateComplete;
|
|
2127
|
+
const listText = memoryList?.shadowRoot?.textContent ?? "";
|
|
2128
|
+
expect(listText).toContain("No memories yet");
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
it("keeps the list rendered (not the full-screen error) when a mutation error arrives with memories present", async () => {
|
|
2132
|
+
// INSP-2: a failed remove/update sets the store error while a valid list is
|
|
2133
|
+
// already on screen. That must NOT blank the list with the full-screen
|
|
2134
|
+
// "Failed to load memories" state — the error is surfaced inline instead.
|
|
2135
|
+
const oneMemory: Memory = {
|
|
2136
|
+
id: "m1",
|
|
2137
|
+
kind: "topical",
|
|
2138
|
+
scope: "user",
|
|
2139
|
+
content: "Likes dogs",
|
|
2140
|
+
sourceThreadIds: [],
|
|
2141
|
+
invalidatedAt: null,
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2145
|
+
const el = await mountMemories(core);
|
|
2146
|
+
|
|
2147
|
+
// Simulate a mutation failure landing after the list is rendered.
|
|
2148
|
+
(el as unknown as { _memoriesError: Error | null })._memoriesError =
|
|
2149
|
+
new Error("could not delete memory");
|
|
2150
|
+
el.requestUpdate();
|
|
2151
|
+
await el.updateComplete;
|
|
2152
|
+
|
|
2153
|
+
// The list survives.
|
|
2154
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2155
|
+
expect(
|
|
2156
|
+
memoryList,
|
|
2157
|
+
"cpk-memory-list must remain rendered on a mutation error",
|
|
2158
|
+
).not.toBeNull();
|
|
2159
|
+
|
|
2160
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2161
|
+
// Inline, non-blocking error with distinct copy.
|
|
2162
|
+
expect(text).toContain("Action failed: could not delete memory");
|
|
2163
|
+
// The full-screen load-failure copy must NOT appear.
|
|
2164
|
+
expect(text).not.toContain("Failed to load memories");
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
it("shows the full-screen load error only when no memories are loaded", async () => {
|
|
2168
|
+
// INSP-2 counterpart: a snapshot-load failure (empty list) still shows the
|
|
2169
|
+
// full-screen "Failed to load memories" state.
|
|
2170
|
+
const core = makeCoreWithMemory([]);
|
|
2171
|
+
const el = await mountMemories(core);
|
|
2172
|
+
|
|
2173
|
+
(el as unknown as { _memoriesError: Error | null })._memoriesError =
|
|
2174
|
+
new Error("network down");
|
|
2175
|
+
el.requestUpdate();
|
|
2176
|
+
await el.updateComplete;
|
|
2177
|
+
|
|
2178
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2179
|
+
expect(text).toContain("Failed to load memories");
|
|
2180
|
+
expect(text).toContain("network down");
|
|
2181
|
+
expect(text).not.toContain("Action failed:");
|
|
2182
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2183
|
+
expect(memoryList).toBeNull();
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
it("shows the 'live' indicator only when realtime is connected", async () => {
|
|
2187
|
+
const core = makeCoreWithMemory([], {
|
|
2188
|
+
available: true,
|
|
2189
|
+
realtimeStatus: "connected",
|
|
2190
|
+
});
|
|
2191
|
+
const el = await mountMemories(core);
|
|
2192
|
+
|
|
2193
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2194
|
+
expect(text).toContain("live");
|
|
2195
|
+
expect(text).not.toContain("offline");
|
|
2196
|
+
expect(text).not.toContain("reconnecting");
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
it("shows a muted 'reconnecting' indicator while realtime is connecting", async () => {
|
|
2200
|
+
const core = makeCoreWithMemory([], {
|
|
2201
|
+
available: true,
|
|
2202
|
+
realtimeStatus: "connecting",
|
|
2203
|
+
});
|
|
2204
|
+
const el = await mountMemories(core);
|
|
2205
|
+
|
|
2206
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2207
|
+
expect(text).toContain("reconnecting");
|
|
2208
|
+
// It must NOT claim "live" while still connecting.
|
|
2209
|
+
expect(text).not.toMatch(/>\s*live\s*</);
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
it("shows a muted 'offline' indicator when realtime has permanently given up", async () => {
|
|
2213
|
+
const core = makeCoreWithMemory([], {
|
|
2214
|
+
available: true,
|
|
2215
|
+
realtimeStatus: "unavailable",
|
|
2216
|
+
});
|
|
2217
|
+
const el = await mountMemories(core);
|
|
2218
|
+
|
|
2219
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2220
|
+
expect(text).toContain("offline");
|
|
2221
|
+
// The frozen snapshot must NOT be labelled "live".
|
|
2222
|
+
expect(text).not.toMatch(/>\s*live\s*</);
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
it("renders cpk-memory-list with a card when one memory is present", async () => {
|
|
2226
|
+
const oneMemory: Memory = {
|
|
2227
|
+
id: "m1",
|
|
2228
|
+
kind: "topical",
|
|
2229
|
+
scope: "user",
|
|
2230
|
+
content: "Prefers dark mode",
|
|
2231
|
+
sourceThreadIds: [],
|
|
2232
|
+
invalidatedAt: null,
|
|
2233
|
+
};
|
|
2234
|
+
|
|
2235
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2236
|
+
const el = await mountMemories(core);
|
|
2237
|
+
|
|
2238
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2239
|
+
expect(memoryList, "cpk-memory-list should render").not.toBeNull();
|
|
2240
|
+
|
|
2241
|
+
await (memoryList as unknown as { updateComplete: Promise<void> })
|
|
2242
|
+
.updateComplete;
|
|
2243
|
+
const cards = memoryList?.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2244
|
+
expect(cards?.length).toBe(1);
|
|
2245
|
+
});
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// ── 6.5 cpk-memory-list ──────────────────────────────────────────────────
|
|
2249
|
+
|
|
2250
|
+
describe("cpk-memory-list", () => {
|
|
2251
|
+
const threeMemories: Memory[] = [
|
|
2252
|
+
{
|
|
2253
|
+
id: "t1",
|
|
2254
|
+
kind: "topical",
|
|
2255
|
+
scope: "user",
|
|
2256
|
+
content: "Likes cats",
|
|
2257
|
+
sourceThreadIds: [],
|
|
2258
|
+
invalidatedAt: null,
|
|
2259
|
+
},
|
|
2260
|
+
{
|
|
2261
|
+
id: "e1",
|
|
2262
|
+
kind: "episodic",
|
|
2263
|
+
scope: "user",
|
|
2264
|
+
content: "First login was on a Monday",
|
|
2265
|
+
sourceThreadIds: [],
|
|
2266
|
+
invalidatedAt: null,
|
|
2267
|
+
},
|
|
2268
|
+
{
|
|
2269
|
+
id: "o1",
|
|
2270
|
+
kind: "operational",
|
|
2271
|
+
scope: "user",
|
|
2272
|
+
content: "Deploys on Thursdays",
|
|
2273
|
+
sourceThreadIds: [],
|
|
2274
|
+
invalidatedAt: null,
|
|
2275
|
+
},
|
|
2276
|
+
];
|
|
2277
|
+
|
|
2278
|
+
/** Create and mount a standalone cpk-memory-list element. */
|
|
2279
|
+
async function mountList(memories: Memory[]): Promise<Element> {
|
|
2280
|
+
const el = document.createElement("cpk-memory-list");
|
|
2281
|
+
document.body.appendChild(el);
|
|
2282
|
+
// Assign memories via property (same as Lit's .memories=${...} binding).
|
|
2283
|
+
(el as unknown as { memories: Memory[] }).memories = memories;
|
|
2284
|
+
// Trigger update if the element is a Lit element.
|
|
2285
|
+
if ("updateComplete" in el) {
|
|
2286
|
+
await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
|
|
2287
|
+
}
|
|
2288
|
+
return el;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
beforeEach(() => {
|
|
2292
|
+
document.body.innerHTML = "";
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
it("renders one card per memory in order", async () => {
|
|
2296
|
+
const el = await mountList(threeMemories);
|
|
2297
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2298
|
+
expect(cards?.length).toBe(3);
|
|
2299
|
+
const contents = Array.from(cards ?? []).map((card) =>
|
|
2300
|
+
card.querySelector(".cpk-ml__content")?.textContent?.trim(),
|
|
2301
|
+
);
|
|
2302
|
+
expect(contents).toEqual([
|
|
2303
|
+
"Likes cats",
|
|
2304
|
+
"First login was on a Monday",
|
|
2305
|
+
"Deploys on Thursdays",
|
|
2306
|
+
]);
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
it("narrows cards when an operational kind filter is clicked", async () => {
|
|
2310
|
+
const el = await mountList(threeMemories);
|
|
2311
|
+
|
|
2312
|
+
const operationalSeg = el.shadowRoot?.querySelector<HTMLElement>(
|
|
2313
|
+
'[data-kind="operational"]',
|
|
2314
|
+
);
|
|
2315
|
+
expect(
|
|
2316
|
+
operationalSeg,
|
|
2317
|
+
"operational filter segment should exist",
|
|
2318
|
+
).not.toBeNull();
|
|
2319
|
+
|
|
2320
|
+
operationalSeg!.click();
|
|
2321
|
+
await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
|
|
2322
|
+
|
|
2323
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2324
|
+
expect(cards?.length).toBe(1);
|
|
2325
|
+
expect(
|
|
2326
|
+
cards?.[0]?.querySelector(".cpk-ml__content")?.textContent?.trim(),
|
|
2327
|
+
).toBe("Deploys on Thursdays");
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
it("filters cards by search text (case-insensitive)", async () => {
|
|
2331
|
+
const el = await mountList(threeMemories);
|
|
2332
|
+
|
|
2333
|
+
const searchInput = el.shadowRoot?.querySelector<HTMLInputElement>(
|
|
2334
|
+
".cpk-ml__search-input",
|
|
2335
|
+
);
|
|
2336
|
+
expect(searchInput, "search input should exist").not.toBeNull();
|
|
2337
|
+
|
|
2338
|
+
searchInput!.value = "deploy";
|
|
2339
|
+
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2340
|
+
await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
|
|
2341
|
+
|
|
2342
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2343
|
+
expect(cards?.length).toBe(1);
|
|
2344
|
+
expect(
|
|
2345
|
+
cards?.[0]?.querySelector(".cpk-ml__content")?.textContent?.trim(),
|
|
2346
|
+
).toBe("Deploys on Thursdays");
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
it("shows the empty state when memories is empty", async () => {
|
|
2350
|
+
const el = await mountList([]);
|
|
2351
|
+
const empty = el.shadowRoot?.querySelector(".cpk-ml__empty");
|
|
2352
|
+
expect(empty, "empty state should render").not.toBeNull();
|
|
2353
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("No memories yet");
|
|
2354
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2355
|
+
expect(cards?.length ?? 0).toBe(0);
|
|
2356
|
+
});
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
// ── 6.6 Passive guard ────────────────────────────────────────────────────
|
|
2360
|
+
|
|
2361
|
+
describe("WebInspectorElement memories — passive store guard", () => {
|
|
2362
|
+
beforeEach(() => {
|
|
2363
|
+
document.body.innerHTML = "";
|
|
2364
|
+
vi.stubGlobal("localStorage", {
|
|
2365
|
+
getItem: () => null,
|
|
2366
|
+
setItem: () => undefined,
|
|
2367
|
+
removeItem: () => undefined,
|
|
2368
|
+
clear: () => undefined,
|
|
2369
|
+
get length() {
|
|
2370
|
+
return 0;
|
|
2371
|
+
},
|
|
2372
|
+
key: () => null,
|
|
2373
|
+
});
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
afterEach(() => {
|
|
2377
|
+
vi.unstubAllGlobals();
|
|
2378
|
+
});
|
|
2379
|
+
|
|
2380
|
+
it("calls core.getMemoryStore() on tab activation and reads from the returned store", async () => {
|
|
2381
|
+
const core = makeCoreWithMemory([]);
|
|
2382
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2383
|
+
|
|
2384
|
+
await mountMemories(core);
|
|
2385
|
+
|
|
2386
|
+
expect(spy).toHaveBeenCalled();
|
|
2387
|
+
|
|
2388
|
+
// The store instance that spy captured is the exact same object that
|
|
2389
|
+
// core.getMemoryStore() returns — inspector reads from it, never wraps it.
|
|
2390
|
+
const returnedStore = spy.mock.results[0]?.value;
|
|
2391
|
+
expect(returnedStore).toBeDefined();
|
|
2392
|
+
// Verify the inspector consumed the store by checking its getState was accessible
|
|
2393
|
+
// (if the inspector had created its own store instead, this reference would differ).
|
|
2394
|
+
expect(typeof returnedStore.getState).toBe("function");
|
|
2395
|
+
expect(typeof returnedStore.select).toBe("function");
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
it("does NOT call core.getMemoryStore() merely by attaching the inspector", async () => {
|
|
2399
|
+
// INSP-1: getMemoryStore() lazily creates + starts the store and opens
|
|
2400
|
+
// realtime, so attaching the inspector must touch nothing. The store is
|
|
2401
|
+
// only created when the user activates the Memories tab.
|
|
2402
|
+
const core = makeCoreWithMemory([]);
|
|
2403
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2404
|
+
|
|
2405
|
+
const el = new WebInspectorElement();
|
|
2406
|
+
document.body.appendChild(el);
|
|
2407
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2408
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
2409
|
+
await el.updateComplete;
|
|
2410
|
+
|
|
2411
|
+
expect(spy).not.toHaveBeenCalled();
|
|
2412
|
+
|
|
2413
|
+
// Activating the Memories tab is what creates + subscribes to the store.
|
|
2414
|
+
(
|
|
2415
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2416
|
+
).handleMenuSelect("memories");
|
|
2417
|
+
await el.updateComplete;
|
|
2418
|
+
|
|
2419
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2420
|
+
});
|
|
2421
|
+
|
|
2422
|
+
it("does not double-subscribe when the Memories tab is re-activated", async () => {
|
|
2423
|
+
const core = makeCoreWithMemory([]);
|
|
2424
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2425
|
+
|
|
2426
|
+
const el = await mountMemories(core);
|
|
2427
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2428
|
+
|
|
2429
|
+
// Re-activate the Memories tab — the guard must prevent a second
|
|
2430
|
+
// getMemoryStore() call (which would create a second store/realtime).
|
|
2431
|
+
(
|
|
2432
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2433
|
+
).handleMenuSelect("memories");
|
|
2434
|
+
await el.updateComplete;
|
|
2435
|
+
|
|
2436
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
it("re-subscribes after detach when the Memories tab is activated again", async () => {
|
|
2440
|
+
const core = makeCoreWithMemory([]);
|
|
2441
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2442
|
+
|
|
2443
|
+
const el = await mountMemories(core);
|
|
2444
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2445
|
+
|
|
2446
|
+
// Detach (core = null) must reset the lazy-subscription guard.
|
|
2447
|
+
el.core = null;
|
|
2448
|
+
await el.updateComplete;
|
|
2449
|
+
|
|
2450
|
+
// Re-attach + re-activate the tab → a fresh subscription is created.
|
|
2451
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2452
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
2453
|
+
(
|
|
2454
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2455
|
+
).handleMenuSelect("memories");
|
|
2456
|
+
await el.updateComplete;
|
|
2457
|
+
|
|
2458
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
2459
|
+
});
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
// ── 6.6.1 Active-on-boot subscription ─────────────────────────────────────
|
|
2463
|
+
//
|
|
2464
|
+
// The memory subscription is normally created on a Memories-tab CLICK
|
|
2465
|
+
// (handleMenuSelect → ensureMemorySubscription). But when the inspector boots
|
|
2466
|
+
// with the Memories tab ALREADY active — e.g. a persisted
|
|
2467
|
+
// `selectedMenu: "memories"` restored by hydrateStateFromStorageEarly — no
|
|
2468
|
+
// click ever fires, so historically no subscription was created: the realtime
|
|
2469
|
+
// indicator stayed stuck on the default "connecting" (rendered "reconnecting")
|
|
2470
|
+
// and the list was empty until the user toggled tabs. The fix subscribes when
|
|
2471
|
+
// the Memories tab is the active tab on boot, gated on the active tab so it
|
|
2472
|
+
// still does not subscribe in apps not viewing memory (INSP-1).
|
|
2473
|
+
|
|
2474
|
+
describe("WebInspectorElement memories — active-on-boot subscription", () => {
|
|
2475
|
+
beforeEach(() => {
|
|
2476
|
+
document.body.innerHTML = "";
|
|
2477
|
+
// Persist `selectedMenu: "memories"` so hydrateStateFromStorageEarly (run in
|
|
2478
|
+
// connectedCallback, before any user interaction) restores the Memories tab
|
|
2479
|
+
// as the active tab — reproducing the stuck-indicator boot scenario.
|
|
2480
|
+
const store: Record<string, string> = {
|
|
2481
|
+
"cpk:inspector:state": JSON.stringify({ selectedMenu: "memories" }),
|
|
2482
|
+
};
|
|
2483
|
+
vi.stubGlobal("localStorage", {
|
|
2484
|
+
getItem: (key: string) => store[key] ?? null,
|
|
2485
|
+
setItem: (key: string, value: string) => {
|
|
2486
|
+
store[key] = value;
|
|
2487
|
+
},
|
|
2488
|
+
removeItem: (key: string) => {
|
|
2489
|
+
delete store[key];
|
|
2490
|
+
},
|
|
2491
|
+
clear: () => {
|
|
2492
|
+
for (const key of Object.keys(store)) delete store[key];
|
|
2493
|
+
},
|
|
2494
|
+
get length() {
|
|
2495
|
+
return Object.keys(store).length;
|
|
2496
|
+
},
|
|
2497
|
+
key: (index: number) => Object.keys(store)[index] ?? null,
|
|
2498
|
+
});
|
|
2499
|
+
});
|
|
2500
|
+
|
|
2501
|
+
afterEach(() => {
|
|
2502
|
+
vi.unstubAllGlobals();
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
it("subscribes to the memory store on boot when the Memories tab is already active (no click)", async () => {
|
|
2506
|
+
// The store reports a live realtime status. If the inspector subscribes on
|
|
2507
|
+
// boot, _memoriesRealtimeStatus reflects "connected"; if it does NOT (the
|
|
2508
|
+
// bug), it stays on the default "connecting".
|
|
2509
|
+
const core = makeCoreWithMemory([], { realtimeStatus: "connected" });
|
|
2510
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2511
|
+
|
|
2512
|
+
const el = new WebInspectorElement();
|
|
2513
|
+
document.body.appendChild(el);
|
|
2514
|
+
// connectedCallback has already restored selectedMenu = "memories".
|
|
2515
|
+
// Assigning core (the realistic boot path) must trigger the subscription
|
|
2516
|
+
// without any handleMenuSelect click.
|
|
2517
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2518
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
2519
|
+
await el.updateComplete;
|
|
2520
|
+
|
|
2521
|
+
expect(
|
|
2522
|
+
(el as unknown as { selectedMenu: string }).selectedMenu,
|
|
2523
|
+
"persisted Memories tab should be the active tab on boot",
|
|
2524
|
+
).toBe("memories");
|
|
2525
|
+
|
|
2526
|
+
// The store was created + subscribed WITHOUT a tab click.
|
|
2527
|
+
expect(
|
|
2528
|
+
spy,
|
|
2529
|
+
"core.getMemoryStore() must be called on boot when Memories is active",
|
|
2530
|
+
).toHaveBeenCalled();
|
|
2531
|
+
|
|
2532
|
+
// The live status from the store is reflected — not the stuck default.
|
|
2533
|
+
expect(
|
|
2534
|
+
(el as unknown as { _memoriesRealtimeStatus: string })
|
|
2535
|
+
._memoriesRealtimeStatus,
|
|
2536
|
+
"realtime status must reflect the store, not the default 'connecting'",
|
|
2537
|
+
).toBe("connected");
|
|
2538
|
+
});
|
|
2539
|
+
|
|
2540
|
+
it("does not double-subscribe when boot subscription is followed by a Memories-tab click", async () => {
|
|
2541
|
+
// The boot subscription must be idempotent: a later explicit click must not
|
|
2542
|
+
// create a second store/realtime connection.
|
|
2543
|
+
const core = makeCoreWithMemory([], { realtimeStatus: "connected" });
|
|
2544
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2545
|
+
|
|
2546
|
+
const el = new WebInspectorElement();
|
|
2547
|
+
document.body.appendChild(el);
|
|
2548
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2549
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
2550
|
+
await el.updateComplete;
|
|
2551
|
+
|
|
2552
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2553
|
+
|
|
2554
|
+
(
|
|
2555
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2556
|
+
).handleMenuSelect("memories");
|
|
2557
|
+
await el.updateComplete;
|
|
2558
|
+
|
|
2559
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2560
|
+
});
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
// ── 6.7 Older-core compat: missing getMemoryStore ────────────────────────
|
|
2564
|
+
//
|
|
2565
|
+
// An inspector attached to an older @copilotkit/core that predates
|
|
2566
|
+
// getMemoryStore must not throw. The guard added in attachToCore must fall
|
|
2567
|
+
// through to the else branch, set _memoriesAvailable = false, and leave the
|
|
2568
|
+
// memories tab in the locked-teaser state — exactly like a core that defines
|
|
2569
|
+
// the method but returns available=false.
|
|
2570
|
+
|
|
2571
|
+
describe("WebInspectorElement memories — older-core compat (no getMemoryStore)", () => {
|
|
2572
|
+
beforeEach(() => {
|
|
2573
|
+
document.body.innerHTML = "";
|
|
2574
|
+
vi.stubGlobal("localStorage", {
|
|
2575
|
+
getItem: () => null,
|
|
2576
|
+
setItem: () => undefined,
|
|
2577
|
+
removeItem: () => undefined,
|
|
2578
|
+
clear: () => undefined,
|
|
2579
|
+
get length() {
|
|
2580
|
+
return 0;
|
|
2581
|
+
},
|
|
2582
|
+
key: () => null,
|
|
2583
|
+
});
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
afterEach(() => {
|
|
2587
|
+
vi.unstubAllGlobals();
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
it("does not throw when core lacks getMemoryStore, and renders the locked teaser", async () => {
|
|
2591
|
+
// Build a minimal core that does NOT define getMemoryStore — simulating an
|
|
2592
|
+
// older @copilotkit/core package. We deliberately omit the method rather
|
|
2593
|
+
// than setting it to undefined so the typeof guard fires correctly.
|
|
2594
|
+
const olderCore = {
|
|
2595
|
+
agents: {},
|
|
2596
|
+
context: {},
|
|
2597
|
+
properties: {},
|
|
2598
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
2599
|
+
// intelligence present so the only lock-cause is the missing store method
|
|
2600
|
+
intelligence: { wsUrl: "wss://localhost" },
|
|
2601
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
2602
|
+
unsubscribe: () => undefined,
|
|
2603
|
+
}),
|
|
2604
|
+
getThreadStores: () => ({}),
|
|
2605
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
2606
|
+
// getMemoryStore intentionally absent
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
const el = new WebInspectorElement();
|
|
2610
|
+
document.body.appendChild(el);
|
|
2611
|
+
|
|
2612
|
+
// Assigning core must not throw even though getMemoryStore is missing.
|
|
2613
|
+
expect(() => {
|
|
2614
|
+
el.core = olderCore as unknown as WebInspectorElement["core"];
|
|
2615
|
+
}).not.toThrow();
|
|
2616
|
+
|
|
2617
|
+
// Open and switch to the memories tab so the view state is rendered.
|
|
2618
|
+
const internals = el as unknown as {
|
|
2619
|
+
isOpen: boolean;
|
|
2620
|
+
handleMenuSelect: (key: string) => void;
|
|
2621
|
+
};
|
|
2622
|
+
internals.isOpen = true;
|
|
2623
|
+
internals.handleMenuSelect("memories");
|
|
2624
|
+
await el.updateComplete;
|
|
2625
|
+
|
|
2626
|
+
// The locked teaser must render — cpk-memory-list must NOT appear.
|
|
2627
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2628
|
+
expect(text).toContain("Long-term memory");
|
|
2629
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2630
|
+
expect(
|
|
2631
|
+
memoryList,
|
|
2632
|
+
"cpk-memory-list must not render when getMemoryStore is absent",
|
|
2633
|
+
).toBeNull();
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
it("shows the SDK-upgrade teaser (distinct from the not-enabled teaser) when getMemoryStore is absent", async () => {
|
|
2637
|
+
// INSP-3: an older @copilotkit/core (no getMemoryStore) must guide an SDK
|
|
2638
|
+
// upgrade, with copy distinct from the genuine "not enabled on this
|
|
2639
|
+
// deployment" teaser shown by a current SDK against a memory-less backend.
|
|
2640
|
+
const olderCore = {
|
|
2641
|
+
agents: {},
|
|
2642
|
+
context: {},
|
|
2643
|
+
properties: {},
|
|
2644
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
2645
|
+
intelligence: { wsUrl: "wss://localhost" },
|
|
2646
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
2647
|
+
unsubscribe: () => undefined,
|
|
2648
|
+
}),
|
|
2649
|
+
getThreadStores: () => ({}),
|
|
2650
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
2651
|
+
// getMemoryStore intentionally absent
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
const el = new WebInspectorElement();
|
|
2655
|
+
document.body.appendChild(el);
|
|
2656
|
+
el.core = olderCore as unknown as WebInspectorElement["core"];
|
|
2657
|
+
const internals = el as unknown as {
|
|
2658
|
+
isOpen: boolean;
|
|
2659
|
+
handleMenuSelect: (key: string) => void;
|
|
2660
|
+
};
|
|
2661
|
+
internals.isOpen = true;
|
|
2662
|
+
internals.handleMenuSelect("memories");
|
|
2663
|
+
await el.updateComplete;
|
|
2664
|
+
|
|
2665
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2666
|
+
expect(text).toContain("@copilotkit SDK");
|
|
2667
|
+
expect(text).toContain("Upgrade");
|
|
2668
|
+
// Must NOT show the deployment-not-enabled copy in this case.
|
|
2669
|
+
expect(text).not.toContain(
|
|
2670
|
+
"Long-term memory isn't enabled on this deployment.",
|
|
2671
|
+
);
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
it("shows the not-enabled teaser (distinct from the upgrade teaser) when the current SDK reports memory unavailable", async () => {
|
|
2675
|
+
// INSP-3 counterpart: a current SDK (getMemoryStore present) whose store
|
|
2676
|
+
// reports available=false shows the deployment teaser, NOT upgrade copy.
|
|
2677
|
+
const core = makeCoreWithMemory([], { available: false });
|
|
2678
|
+
const el = await mountMemories(core);
|
|
2679
|
+
|
|
2680
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2681
|
+
expect(text).toContain(
|
|
2682
|
+
"Long-term memory isn't enabled on this deployment.",
|
|
2683
|
+
);
|
|
2684
|
+
expect(text).not.toContain("@copilotkit SDK");
|
|
2685
|
+
});
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
// ── 6.8 Memories tab telemetry gating (A7) + detach reset (A8) ────────────
|
|
2689
|
+
//
|
|
2690
|
+
// The memories tab is the only telemetry call site that must honor the host
|
|
2691
|
+
// `core.telemetryDisabled` opt-out and must not re-fire on every click. These
|
|
2692
|
+
// tests mirror the Threads tab-click telemetry test. They also cover that
|
|
2693
|
+
// detachFromCore resets the memory view state so a later attach to an older
|
|
2694
|
+
// core never leaks stale memory counts into telemetry.
|
|
2695
|
+
|
|
2696
|
+
describe("WebInspectorElement memories — tab telemetry + detach reset", () => {
|
|
2697
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
2698
|
+
|
|
2699
|
+
const telemetryPosts = () =>
|
|
2700
|
+
fetchMock.mock.calls
|
|
2701
|
+
.filter(
|
|
2702
|
+
(call) =>
|
|
2703
|
+
String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
|
|
2704
|
+
(call[1] as RequestInit | undefined)?.method === "POST",
|
|
2705
|
+
)
|
|
2706
|
+
.map((call) => {
|
|
2707
|
+
const body =
|
|
2708
|
+
((call[1] as RequestInit | undefined)?.body as string) ?? "{}";
|
|
2709
|
+
return JSON.parse(body) as {
|
|
2710
|
+
event: string;
|
|
2711
|
+
properties: Record<string, unknown>;
|
|
2712
|
+
};
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2715
|
+
const memoriesTabClicks = () =>
|
|
2716
|
+
telemetryPosts().filter(
|
|
2717
|
+
(post) => post.event === "oss.inspector.memories_tab_clicked",
|
|
2718
|
+
);
|
|
2719
|
+
|
|
2720
|
+
beforeEach(() => {
|
|
2721
|
+
document.body.innerHTML = "";
|
|
2722
|
+
// localStorage not opted out → track() proceeds to the fetch sink.
|
|
2723
|
+
vi.stubGlobal("localStorage", {
|
|
2724
|
+
getItem: () => null,
|
|
2725
|
+
setItem: () => undefined,
|
|
2726
|
+
removeItem: () => undefined,
|
|
2727
|
+
clear: () => undefined,
|
|
2728
|
+
get length() {
|
|
2729
|
+
return 0;
|
|
2730
|
+
},
|
|
2731
|
+
key: () => null,
|
|
2732
|
+
});
|
|
2733
|
+
fetchMock = vi.fn(() =>
|
|
2734
|
+
Promise.resolve({
|
|
2735
|
+
ok: true,
|
|
2736
|
+
status: 200,
|
|
2737
|
+
json: () => Promise.resolve({}),
|
|
2738
|
+
}),
|
|
2739
|
+
);
|
|
2740
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2741
|
+
});
|
|
2742
|
+
|
|
2743
|
+
afterEach(() => {
|
|
2744
|
+
vi.unstubAllGlobals();
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
it("posts memories_tab_clicked when the Memories tab is selected", async () => {
|
|
2748
|
+
const oneMemory: Memory = {
|
|
2749
|
+
id: "m1",
|
|
2750
|
+
kind: "topical",
|
|
2751
|
+
scope: "user",
|
|
2752
|
+
content: "Likes dogs",
|
|
2753
|
+
sourceThreadIds: [],
|
|
2754
|
+
invalidatedAt: null,
|
|
2755
|
+
};
|
|
2756
|
+
|
|
2757
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2758
|
+
await mountMemories(core);
|
|
2759
|
+
await Promise.resolve();
|
|
2760
|
+
|
|
2761
|
+
const clicks = memoriesTabClicks();
|
|
2762
|
+
expect(clicks).toHaveLength(1);
|
|
2763
|
+
expect(clicks[0]!.properties).toMatchObject({
|
|
2764
|
+
memory_count: 1,
|
|
2765
|
+
available: true,
|
|
2766
|
+
});
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
it("does NOT post memories_tab_clicked when core.telemetryDisabled is true", async () => {
|
|
2770
|
+
const core = makeCoreWithMemory([], { telemetryDisabled: true });
|
|
2771
|
+
await mountMemories(core);
|
|
2772
|
+
await Promise.resolve();
|
|
2773
|
+
|
|
2774
|
+
expect(memoriesTabClicks()).toHaveLength(0);
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
it("does not double-fire when the already-active Memories tab is re-selected", async () => {
|
|
2778
|
+
const core = makeCoreWithMemory([]);
|
|
2779
|
+
const el = await mountMemories(core);
|
|
2780
|
+
await Promise.resolve();
|
|
2781
|
+
|
|
2782
|
+
// Re-select the already-active Memories tab.
|
|
2783
|
+
const internals = el as unknown as {
|
|
2784
|
+
handleMenuSelect: (key: string) => void;
|
|
2785
|
+
};
|
|
2786
|
+
internals.handleMenuSelect("memories");
|
|
2787
|
+
await el.updateComplete;
|
|
2788
|
+
await Promise.resolve();
|
|
2789
|
+
|
|
2790
|
+
expect(memoriesTabClicks()).toHaveLength(1);
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
it("resets memory view state on detachFromCore (memories empty, count 0)", async () => {
|
|
2794
|
+
const oneMemory: Memory = {
|
|
2795
|
+
id: "m1",
|
|
2796
|
+
kind: "topical",
|
|
2797
|
+
scope: "user",
|
|
2798
|
+
content: "Likes dogs",
|
|
2799
|
+
sourceThreadIds: [],
|
|
2800
|
+
invalidatedAt: null,
|
|
2801
|
+
};
|
|
2802
|
+
|
|
2803
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2804
|
+
const el = await mountMemories(core);
|
|
2805
|
+
await Promise.resolve();
|
|
2806
|
+
|
|
2807
|
+
// Sanity: the seeded memory is present before detach.
|
|
2808
|
+
expect(
|
|
2809
|
+
(el as unknown as { _memories: Memory[] })._memories.map((m) => m.id),
|
|
2810
|
+
).toEqual(["m1"]);
|
|
2811
|
+
|
|
2812
|
+
// Reassigning core triggers detachFromCore(); null means no re-attach.
|
|
2813
|
+
el.core = null;
|
|
2814
|
+
await el.updateComplete;
|
|
2815
|
+
|
|
2816
|
+
const state = el as unknown as {
|
|
2817
|
+
_memories: Memory[];
|
|
2818
|
+
_memoriesLoading: boolean;
|
|
2819
|
+
_memoriesError: Error | null;
|
|
2820
|
+
_memoriesAvailable: boolean;
|
|
2821
|
+
};
|
|
2822
|
+
expect(state._memories).toEqual([]);
|
|
2823
|
+
expect(state._memories).toHaveLength(0);
|
|
2824
|
+
expect(state._memoriesLoading).toBe(false);
|
|
2825
|
+
expect(state._memoriesError).toBeNull();
|
|
2826
|
+
expect(state._memoriesAvailable).toBe(true);
|
|
2827
|
+
});
|
|
2828
|
+
});
|