@copilotkit/web-inspector 1.62.0 → 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.
@@ -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("checkpointId");
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
+ });