@copilotkit/web-inspector 1.66.4 → 1.67.1

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.
Files changed (56) hide show
  1. package/README.md +92 -13
  2. package/dist/index.cjs +1436 -429
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +118 -8
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.mts +118 -8
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +1438 -431
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/index.umd.js +1721 -533
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/lib/inspector-metadata.cjs +60 -0
  13. package/dist/lib/inspector-metadata.cjs.map +1 -0
  14. package/dist/lib/inspector-metadata.mjs +60 -0
  15. package/dist/lib/inspector-metadata.mjs.map +1 -0
  16. package/dist/lib/telemetry.cjs +117 -32
  17. package/dist/lib/telemetry.cjs.map +1 -1
  18. package/dist/lib/telemetry.mjs +116 -33
  19. package/dist/lib/telemetry.mjs.map +1 -1
  20. package/dist/lib/thread-selection.cjs +36 -0
  21. package/dist/lib/thread-selection.cjs.map +1 -0
  22. package/dist/lib/thread-selection.mjs +35 -0
  23. package/dist/lib/thread-selection.mjs.map +1 -0
  24. package/dist/package.cjs +1 -1
  25. package/dist/package.mjs +1 -1
  26. package/dist/styles/generated.cjs +1 -1
  27. package/dist/styles/generated.cjs.map +1 -1
  28. package/dist/styles/generated.mjs +1 -1
  29. package/dist/styles/generated.mjs.map +1 -1
  30. package/package.json +9 -4
  31. package/CHANGELOG.md +0 -505
  32. package/dev/css-raw-import.spec.ts +0 -31
  33. package/dev/index.html +0 -115
  34. package/dev/main.ts +0 -165
  35. package/dev/vite.config.ts +0 -30
  36. package/src/__tests__/memory-recall.spec.ts +0 -76
  37. package/src/__tests__/telemetry-egress-guard.spec.ts +0 -41
  38. package/src/__tests__/web-inspector.spec.ts +0 -3935
  39. package/src/assets/inspector-logo-icon.svg +0 -8
  40. package/src/assets/inspector-logo.svg +0 -40
  41. package/src/components.d.ts +0 -20
  42. package/src/index.ts +0 -11849
  43. package/src/lib/__tests__/telemetry.test.ts +0 -557
  44. package/src/lib/context-helpers.ts +0 -166
  45. package/src/lib/persistence.ts +0 -206
  46. package/src/lib/telemetry.ts +0 -474
  47. package/src/lib/testing/telemetry-egress-guard.ts +0 -33
  48. package/src/lib/types.ts +0 -19
  49. package/src/styles/generated.css +0 -2
  50. package/src/styles/tailwind.css +0 -23
  51. package/src/types/css.d.ts +0 -4
  52. package/src/types/svg.d.ts +0 -4
  53. package/tsconfig.json +0 -14
  54. package/tsdown.config.ts +0 -45
  55. package/vitest.config.ts +0 -66
  56. package/vitest.setup.ts +0 -84
@@ -1,3935 +0,0 @@
1
- import {
2
- CpkThreadInspector,
3
- WebInspectorElement,
4
- ɵbuildCapabilityRows,
5
- ɵCpkThreadDetails,
6
- } from "../index.js";
7
- import type { ThreadDebuggerProvider } from "../index.js";
8
- import type { CopilotKitCore } from "@copilotkit/core";
9
- import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
10
- import type { CopilotKitCoreSubscriber } from "@copilotkit/core";
11
- import type { Memory } from "@copilotkit/core";
12
- import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
13
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
14
-
15
- // --- Types for accessing LitElement-private reactive properties ---
16
- // WebInspectorElement stores these as private Lit reactive properties.
17
- // There's no public API to read them, so the cast is unavoidable in tests.
18
-
19
- type InspectorInternals = {
20
- flattenedEvents: Array<{ type: string }>;
21
- agentMessages: Map<string, Array<{ contentText?: string }>>;
22
- agentStates: Map<string, unknown>;
23
- cachedTools: Array<{ name: string }>;
24
- };
25
-
26
- type InspectorThreadViewInternals = {
27
- isOpen: boolean;
28
- selectedMenu: "ag-ui-events" | "threads";
29
- selectedThreadId: string | null;
30
- _threads: Array<{
31
- id: string;
32
- name?: string | null;
33
- agentId: string;
34
- updatedAt?: string | null;
35
- }>;
36
- _threadsByAgent: Map<
37
- string,
38
- Array<{
39
- id: string;
40
- name?: string | null;
41
- agentId: string;
42
- updatedAt?: string | null;
43
- }>
44
- >;
45
- };
46
-
47
- type InspectorContextInternals = {
48
- contextStore: Record<string, { description?: string; value: unknown }>;
49
- copyContextValue: (value: unknown, id: string) => Promise<void>;
50
- persistState: () => void;
51
- };
52
-
53
- // --- Mock agent factory ---
54
-
55
- type MockAgentExtras = Partial<{
56
- messages: unknown;
57
- state: unknown;
58
- toolHandlers: Record<string, unknown>;
59
- toolRenderers: Record<string, unknown>;
60
- }>;
61
-
62
- type MockAgentController = {
63
- // Each subscriber method has a different parameter shape — TypeScript
64
- // can't narrow a dynamic key lookup, so the internal cast is unavoidable.
65
- emit: (key: keyof AgentSubscriber, payload: unknown) => void;
66
- /** Simulate AbstractAgent.setState(): mutate the mock's state and notify subscribers. */
67
- simulateSetState: (newState: Record<string, unknown>) => void;
68
- };
69
-
70
- function createMockAgent(
71
- agentId: string,
72
- extras: MockAgentExtras = {},
73
- ): { agent: AbstractAgent; controller: MockAgentController } {
74
- const subscribers = new Set<AgentSubscriber>();
75
-
76
- const agentObj = {
77
- agentId,
78
- ...extras,
79
- subscribe(subscriber: AgentSubscriber) {
80
- subscribers.add(subscriber);
81
- return {
82
- unsubscribe: () => subscribers.delete(subscriber),
83
- };
84
- },
85
- };
86
-
87
- const emit = (key: keyof AgentSubscriber, payload: unknown) => {
88
- subscribers.forEach((subscriber) => {
89
- const handler = subscriber[key];
90
- if (handler) {
91
- (handler as (arg: unknown) => void)(payload);
92
- }
93
- });
94
- };
95
-
96
- const simulateSetState = (newState: Record<string, unknown>) => {
97
- agentObj.state = newState;
98
- emit("onStateChanged", {
99
- state: newState,
100
- messages: agentObj.messages ?? [],
101
- agent: agentObj,
102
- });
103
- };
104
-
105
- // AbstractAgent is an abstract class — our plain-object mock satisfies
106
- // the subset the inspector uses but can't extend the class.
107
- return {
108
- agent: agentObj as unknown as AbstractAgent,
109
- controller: { emit, simulateSetState },
110
- };
111
- }
112
-
113
- // --- Mock core factory ---
114
-
115
- // --- Minimal no-op memory store stub ---
116
- // The inspector calls core.getMemoryStore() lazily, on first Memories-tab
117
- // activation (NOT on attach). All mock cores still expose this method so that
118
- // tests which do activate the tab don't hit a TypeError. The stub below seeds
119
- // the store with empty memories and available=true, which is the right default
120
- // for tests that don't exercise the memory feature.
121
-
122
- type MockMemoryStoreState = {
123
- memories: never[];
124
- isLoading: boolean;
125
- isMutating: boolean;
126
- error: null;
127
- context: null;
128
- sessionId: number;
129
- available: boolean;
130
- realtimeStatus: "connecting" | "connected" | "unavailable";
131
- };
132
-
133
- function createNoopMemoryStore() {
134
- const state: MockMemoryStoreState = {
135
- memories: [],
136
- isLoading: false,
137
- isMutating: false,
138
- error: null,
139
- context: null,
140
- sessionId: 0,
141
- available: true,
142
- realtimeStatus: "connecting",
143
- };
144
- return {
145
- getState: () => state,
146
- select: <T>(selector: (s: MockMemoryStoreState) => T) => ({
147
- subscribe: (cb: (v: T) => void) => {
148
- cb(selector(state));
149
- return { unsubscribe: () => undefined };
150
- },
151
- }),
152
- };
153
- }
154
-
155
- type MockCore = {
156
- agents: Record<string, AbstractAgent>;
157
- context: Record<string, unknown>;
158
- properties: Record<string, unknown>;
159
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
160
- subscribe: (subscriber: CopilotKitCoreSubscriber) => {
161
- unsubscribe: () => void;
162
- };
163
- getThreadStores: () => Record<string, never>;
164
- getThreadStore: (agentId: string) => undefined;
165
- getMemoryStore: () => ReturnType<typeof createNoopMemoryStore>;
166
- };
167
-
168
- function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
169
- const subscribers = new Set<CopilotKitCoreSubscriber>();
170
- const core: MockCore = {
171
- agents: initialAgents,
172
- context: {},
173
- properties: {},
174
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
175
- subscribe(subscriber: CopilotKitCoreSubscriber) {
176
- subscribers.add(subscriber);
177
- return { unsubscribe: () => subscribers.delete(subscriber) };
178
- },
179
- getThreadStores() {
180
- return {};
181
- },
182
- getThreadStore(_agentId: string) {
183
- return undefined;
184
- },
185
- getMemoryStore() {
186
- return createNoopMemoryStore();
187
- },
188
- };
189
-
190
- return {
191
- core,
192
- emitAgentsChanged(nextAgents = core.agents) {
193
- core.agents = nextAgents;
194
- // CopilotKitCore is a full class — our mock only covers what the
195
- // inspector reads, so this cast is unavoidable.
196
- subscribers.forEach((subscriber) =>
197
- subscriber.onAgentsChanged?.({
198
- copilotkit: core as unknown as CopilotKitCore,
199
- agents: core.agents,
200
- }),
201
- );
202
- },
203
- emitContextChanged(nextContext: Record<string, unknown>) {
204
- core.context = nextContext;
205
- subscribers.forEach((subscriber) =>
206
- subscriber.onContextChanged?.({
207
- copilotkit: core as unknown as CopilotKitCore,
208
- context: core.context as unknown as Readonly<
209
- Record<string, { value: string; description: string }>
210
- >,
211
- }),
212
- );
213
- },
214
- };
215
- }
216
-
217
- // --- Test helpers ---
218
-
219
- /** Create inspector, attach to DOM, wire up mock core. */
220
- function createInspectorWithCore(core: MockCore) {
221
- const inspector = new WebInspectorElement();
222
- document.body.appendChild(inspector);
223
- // WebInspectorElement["core"] is a CopilotKitCore instance — our MockCore
224
- // only implements the subset exercised by these tests.
225
- inspector.core = core as unknown as WebInspectorElement["core"];
226
- return inspector;
227
- }
228
-
229
- /** Access private Lit reactive properties on the inspector. */
230
- function getInternals(inspector: WebInspectorElement) {
231
- return inspector as unknown as InspectorInternals;
232
- }
233
-
234
- /** Access context-related private properties on the inspector. */
235
- function getContextInternals(inspector: WebInspectorElement) {
236
- return inspector as unknown as InspectorContextInternals;
237
- }
238
-
239
- type TelemetryPost = { event: string; properties: Record<string, unknown> };
240
-
241
- /** Decode the `oss.inspector.*` payloads a stubbed fetch received. */
242
- function telemetryPostsFrom(fetchMock: {
243
- mock: { calls: unknown[][] };
244
- }): TelemetryPost[] {
245
- return fetchMock.mock.calls
246
- .filter(
247
- (call) =>
248
- String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
249
- (call[1] as RequestInit | undefined)?.method === "POST",
250
- )
251
- .map(
252
- (call) =>
253
- JSON.parse(
254
- ((call[1] as RequestInit | undefined)?.body as string) ?? "{}",
255
- ) as TelemetryPost,
256
- );
257
- }
258
-
259
- // --- Tests ---
260
-
261
- describe("WebInspectorElement", () => {
262
- let mockClipboard: { writeText: ReturnType<typeof vi.fn> };
263
-
264
- beforeEach(() => {
265
- document.body.innerHTML = "";
266
-
267
- const store: Record<string, string> = {};
268
- vi.stubGlobal("localStorage", {
269
- getItem: (key: string) => store[key] ?? null,
270
- setItem: (key: string, value: string) => {
271
- store[key] = value;
272
- },
273
- removeItem: (key: string) => {
274
- delete store[key];
275
- },
276
- clear: () => {
277
- for (const key of Object.keys(store)) delete store[key];
278
- },
279
- get length() {
280
- return Object.keys(store).length;
281
- },
282
- key: (index: number) => Object.keys(store)[index] ?? null,
283
- });
284
-
285
- mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
286
- // navigator.clipboard is readonly in DOM types — assigning
287
- // the mock requires a cast in jsdom-style test environments.
288
- (navigator as unknown as { clipboard: typeof mockClipboard }).clipboard =
289
- mockClipboard;
290
- });
291
-
292
- afterEach(() => {
293
- vi.clearAllTimers();
294
- });
295
-
296
- it("records agent events and syncs state/messages/tools", async () => {
297
- const { agent, controller } = createMockAgent("alpha", {
298
- messages: [{ id: "m1", role: "user", content: "hi there" }],
299
- state: { foo: "bar" },
300
- toolHandlers: {
301
- greet: { description: "hello", parameters: { type: "object" } },
302
- },
303
- });
304
- const { core, emitAgentsChanged } = createMockCore({ alpha: agent });
305
- const inspector = createInspectorWithCore(core);
306
-
307
- emitAgentsChanged();
308
- await inspector.updateComplete;
309
-
310
- controller.emit("onRunStartedEvent", { event: { id: "run-1" } });
311
- controller.emit("onMessagesSnapshotEvent", { event: { id: "msg-1" } });
312
- await inspector.updateComplete;
313
-
314
- const internals = getInternals(inspector);
315
-
316
- expect(
317
- internals.flattenedEvents.some((evt) => evt.type === "RUN_STARTED"),
318
- ).toBe(true);
319
- expect(
320
- internals.flattenedEvents.some((evt) => evt.type === "MESSAGES_SNAPSHOT"),
321
- ).toBe(true);
322
- expect(internals.agentMessages.get("alpha")?.[0]?.contentText).toContain(
323
- "hi there",
324
- );
325
- expect(internals.agentStates.get("alpha")).toBeDefined();
326
- expect(internals.cachedTools.some((tool) => tool.name === "greet")).toBe(
327
- true,
328
- );
329
- });
330
-
331
- it("records step lifecycle events", async () => {
332
- const { agent, controller } = createMockAgent("alpha");
333
- const { core, emitAgentsChanged } = createMockCore({ alpha: agent });
334
- const inspector = createInspectorWithCore(core);
335
-
336
- emitAgentsChanged();
337
- await inspector.updateComplete;
338
-
339
- controller.emit("onStepStartedEvent", {
340
- event: { stepName: "test-step" },
341
- });
342
- controller.emit("onStepFinishedEvent", {
343
- event: { stepName: "test-step" },
344
- });
345
- await inspector.updateComplete;
346
-
347
- const internals = getInternals(inspector);
348
-
349
- expect(internals.flattenedEvents.map((event) => event.type)).toEqual([
350
- "STEP_FINISHED",
351
- "STEP_STARTED",
352
- ]);
353
- });
354
-
355
- it("normalizes context, persists state, and copies context values", async () => {
356
- const { core, emitContextChanged } = createMockCore();
357
- const inspector = createInspectorWithCore(core);
358
-
359
- emitContextChanged({
360
- ctxA: { value: { nested: true } },
361
- ctxB: { description: "Described", value: 5 },
362
- });
363
- await inspector.updateComplete;
364
-
365
- const contextInternals = getContextInternals(inspector);
366
- const ctxA = contextInternals.contextStore.ctxA!;
367
- const ctxB = contextInternals.contextStore.ctxB!;
368
- expect(ctxA.value).toMatchObject({ nested: true });
369
- expect(ctxB.description).toBe("Described");
370
-
371
- await contextInternals.copyContextValue({ nested: true }, "ctxA");
372
- expect(mockClipboard.writeText).toHaveBeenCalledTimes(1);
373
-
374
- contextInternals.persistState();
375
- expect(localStorage.getItem("cpk:inspector:state")).toBeTruthy();
376
- });
377
-
378
- it("syncs agent state on direct setState (onStateChanged without pipeline events)", async () => {
379
- // Simulates a selfManagedAgent where agent.setState() is called directly
380
- // from UI code, bypassing the AG-UI event pipeline. Before the fix,
381
- // only pipeline event handlers (onStateSnapshotEvent, onStateDeltaEvent)
382
- // updated the inspector — onStateChanged was not subscribed to, so
383
- // direct setState() left the inspector stale.
384
- const { agent, controller } = createMockAgent("counter", {
385
- state: { counter: 0 },
386
- });
387
- const { core, emitAgentsChanged } = createMockCore({ counter: agent });
388
- const inspector = createInspectorWithCore(core);
389
-
390
- emitAgentsChanged();
391
- await inspector.updateComplete;
392
-
393
- const internals = getInternals(inspector);
394
-
395
- // Initial state should be captured on subscription
396
- expect(internals.agentStates.get("counter")).toEqual({ counter: 0 });
397
-
398
- // Simulate agent.setState({ counter: 1 })
399
- controller.simulateSetState({ counter: 1 });
400
- await inspector.updateComplete;
401
- expect(internals.agentStates.get("counter")).toEqual({ counter: 1 });
402
-
403
- // Simulate a second setState to verify repeated updates propagate
404
- controller.simulateSetState({ counter: 5 });
405
- await inspector.updateComplete;
406
- expect(internals.agentStates.get("counter")).toEqual({ counter: 5 });
407
- });
408
- });
409
-
410
- // ─────────────────────────────────────────────────────────────────────────
411
- // CpkThreadDetails — per-panel TemplateResult cache invariants
412
- // ─────────────────────────────────────────────────────────────────────────
413
- //
414
- // The timeline / state / raw-events panels each cache the rendered
415
- // TemplateResult by reference of the underlying data so tab switches don't
416
- // re-iterate over hundreds of items. These tests pin down the cache-key
417
- // contract: when the data reassigns, the cache MUST drop, and when the
418
- // thread changes, ALL three caches MUST reset. A future edit that mutates
419
- // the data in place (instead of reassigning) or forgets to null one of the
420
- // caches in `updated()` would silently render stale content under a new
421
- // thread, which is undetectable by manual QA on a single thread.
422
-
423
- type ThreadDetailsInternals = {
424
- threadId: string | null;
425
- runtimeUrl: string;
426
- headers: Record<string, string>;
427
- threadInspectionAvailable: boolean;
428
- liveMessageVersion: number;
429
- provider: ThreadDebuggerProvider | null;
430
- _fetchedMetadata: Record<string, unknown> | null;
431
- _conversation: Array<Record<string, unknown>>;
432
- agentEventsInput: Array<Record<string, unknown>>;
433
- _fetchedState: Record<string, unknown> | null;
434
- _fetchedEvents: Array<Record<string, unknown>> | null;
435
- _timelineItemsCache: {
436
- events: Array<Record<string, unknown>>;
437
- items: Array<Record<string, unknown>>;
438
- } | null;
439
- _expandedTools: Set<string>;
440
- _expandedMessages: Set<string>;
441
- _expandedTimelineDetails: Set<string>;
442
- _stateNotAvailable: boolean;
443
- _eventsNotAvailable: boolean;
444
- _loadingMessages: boolean;
445
- _loadingState: boolean;
446
- _loadingEvents: boolean;
447
- activeTimelineItems: Array<Record<string, unknown>>;
448
- _panelTplCache: Map<string, { key: readonly unknown[]; tpl: unknown }>;
449
- fetchMessages: (threadId: string) => Promise<void>;
450
- fetchEvents: (threadId: string) => Promise<void>;
451
- fetchState: (threadId: string) => Promise<void>;
452
- renderTimeline: () => unknown;
453
- renderConversation: () => unknown;
454
- renderState: () => unknown;
455
- renderEvents: () => unknown;
456
- timelineItemsFromEvents: (
457
- events: Array<Record<string, unknown>>,
458
- ) => Array<Record<string, unknown>>;
459
- mapMessages: (
460
- messages: Array<{
461
- id: string;
462
- role: string;
463
- content?: string | null;
464
- toolCallId?: string | null;
465
- toolCalls?: Array<{
466
- id: string;
467
- name: string;
468
- args: string;
469
- }>;
470
- }>,
471
- ) => Array<Record<string, unknown>>;
472
- };
473
-
474
- function createThreadDetails(): {
475
- el: ɵCpkThreadDetails;
476
- internals: ThreadDetailsInternals;
477
- } {
478
- const el = new ɵCpkThreadDetails();
479
- document.body.appendChild(el);
480
- // Same cast pattern as `getInternals` above — there's no public surface
481
- // for the cache slots, so the test reaches through a typed view.
482
- const internals = el as unknown as ThreadDetailsInternals;
483
- return { el, internals };
484
- }
485
-
486
- function createThreadInspector(): {
487
- el: CpkThreadInspector;
488
- internals: ThreadDetailsInternals;
489
- } {
490
- const el = new CpkThreadInspector();
491
- document.body.appendChild(el);
492
- const internals = el as unknown as ThreadDetailsInternals;
493
- return { el, internals };
494
- }
495
-
496
- async function flushProviderWork(el: CpkThreadInspector): Promise<void> {
497
- await Promise.resolve();
498
- await el.updateComplete;
499
- }
500
-
501
- function createDeferred<T>(): {
502
- promise: Promise<T>;
503
- resolve: (value: T) => void;
504
- reject: (reason?: unknown) => void;
505
- } {
506
- let resolve!: (value: T) => void;
507
- let reject!: (reason?: unknown) => void;
508
- const promise = new Promise<T>((res, rej) => {
509
- resolve = res;
510
- reject = rej;
511
- });
512
- return { promise, resolve, reject };
513
- }
514
- describe("ɵCpkThreadDetails caching", () => {
515
- beforeEach(() => {
516
- document.body.innerHTML = "";
517
- });
518
-
519
- /**
520
- * Drive the threadId-change `updated()` block once so its reset path
521
- * runs on entry, then seed the data the test cares about AFTER. If we
522
- * seed before the first updateComplete, `updated()` immediately nulls
523
- * `_fetchedState` / `_fetchedEvents` / `_conversation` (and
524
- * `fetchMessages` re-clears `_conversation` when no `runtimeUrl` is
525
- * configured, as in this jsdom test), so the assertions below would
526
- * be running against an empty element.
527
- */
528
- async function settleThread(
529
- el: ɵCpkThreadDetails,
530
- internals: ThreadDetailsInternals,
531
- threadId: string,
532
- ): Promise<void> {
533
- internals.threadId = threadId;
534
- await el.updateComplete;
535
- }
536
-
537
- it("threadId change drops template and timeline item caches", async () => {
538
- const { el, internals } = createThreadDetails();
539
- await settleThread(el, internals, "t1");
540
-
541
- // Hand-build cache entries for all three panels so we don't have to
542
- // drive every render path through the DOM. The presence of any entry
543
- // is what the assertion below checks for; what they hold is irrelevant.
544
- internals._panelTplCache.set("timeline", { key: [], tpl: "c" });
545
- internals._panelTplCache.set("state", { key: [], tpl: "s" });
546
- internals._panelTplCache.set("raw-events", { key: [], tpl: "e" });
547
- internals.agentEventsInput = [
548
- {
549
- type: "RUN_STARTED",
550
- timestamp: "2026-06-25T10:00:00.000Z",
551
- payload: {},
552
- },
553
- ];
554
- internals.renderTimeline();
555
- expect(internals._timelineItemsCache).not.toBeNull();
556
-
557
- // Switch to thread t2 — the threadId branch in `updated()` should
558
- // empty the cache map.
559
- internals.threadId = "t2";
560
- await el.updateComplete;
561
-
562
- expect(internals._panelTplCache.size).toBe(0);
563
- expect(internals._timelineItemsCache).toBeNull();
564
- });
565
-
566
- it("does not fetch messages, events, or state when threadInspectionAvailable is omitted", async () => {
567
- const fetchSpy = vi
568
- .spyOn(globalThis, "fetch")
569
- .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
570
- try {
571
- const { el, internals } = createThreadDetails();
572
-
573
- internals.runtimeUrl = "http://localhost:4000";
574
- internals.headers = { Authorization: "Bearer test-token" };
575
- internals.threadId = "t1";
576
- await el.updateComplete;
577
-
578
- expect(internals.threadInspectionAvailable).toBe(false);
579
- expect(fetchSpy).not.toHaveBeenCalled();
580
-
581
- await internals.fetchEvents("t1");
582
- await internals.fetchState("t1");
583
-
584
- expect(fetchSpy).not.toHaveBeenCalled();
585
- } finally {
586
- fetchSpy.mockRestore();
587
- }
588
- });
589
-
590
- it("joins thread inspection URLs without double slashes when runtimeUrl has a trailing slash", async () => {
591
- const fetchSpy = vi
592
- .spyOn(globalThis, "fetch")
593
- .mockImplementation(async (input) => {
594
- const url = String(input);
595
- if (url.endsWith("/messages")) {
596
- return new Response(JSON.stringify({ messages: [] }), {
597
- status: 200,
598
- });
599
- }
600
- if (url.endsWith("/events")) {
601
- return new Response(JSON.stringify({ events: [] }), { status: 200 });
602
- }
603
- return new Response(JSON.stringify({ state: null }), { status: 200 });
604
- });
605
- try {
606
- const { el, internals } = createThreadDetails();
607
- internals.runtimeUrl = "http://localhost:4000/api/";
608
- internals.threadInspectionAvailable = true;
609
- internals.threadId = "thread one";
610
- await el.updateComplete;
611
- fetchSpy.mockClear();
612
-
613
- await internals.fetchMessages("thread one");
614
- await internals.fetchEvents("thread one");
615
- await internals.fetchState("thread one");
616
-
617
- const requestedUrls = fetchSpy.mock.calls.map((call) => String(call[0]));
618
- expect(requestedUrls).toContain(
619
- "http://localhost:4000/api/threads/thread%20one/messages",
620
- );
621
- expect(requestedUrls).toContain(
622
- "http://localhost:4000/api/threads/thread%20one/events",
623
- );
624
- expect(requestedUrls).toContain(
625
- "http://localhost:4000/api/threads/thread%20one/state",
626
- );
627
- expect(
628
- requestedUrls.every(
629
- (url) => !url.includes("/api//threads/") && !url.includes("one//"),
630
- ),
631
- ).toBe(true);
632
- } finally {
633
- fetchSpy.mockRestore();
634
- }
635
- });
636
-
637
- it("conversation cache invalidates when _conversation is reassigned", async () => {
638
- const { el, internals } = createThreadDetails();
639
- await settleThread(el, internals, "t1");
640
-
641
- internals._conversation = [
642
- { id: "m1", type: "user", content: "hi", createdAt: "" },
643
- ];
644
-
645
- const tplA = internals.renderConversation();
646
- expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(tplA);
647
-
648
- // Cache hit: same array reference, same expand sets — same TemplateResult.
649
- expect(internals.renderConversation()).toBe(tplA);
650
-
651
- // New array reference (the streaming refetch path always reassigns
652
- // via `this._conversation = this.mapMessages(...)` rather than
653
- // mutating in place — that contract is what this test pins).
654
- internals._conversation = [
655
- { id: "m1", type: "user", content: "hi", createdAt: "" },
656
- { id: "m2", type: "assistant", content: "hello", createdAt: "" },
657
- ];
658
-
659
- const tplB = internals.renderConversation();
660
- expect(tplB).not.toBe(tplA);
661
- expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(tplB);
662
- });
663
-
664
- it("conversation cache invalidates when expand state changes", async () => {
665
- // Regression guard: an earlier version keyed the cache only on
666
- // `_conversation`, so toggling a tool-call expand or a "Show more"
667
- // on a long message returned the pre-toggle template. The cache key
668
- // now includes both expand sets.
669
- const { el, internals } = createThreadDetails();
670
- await settleThread(el, internals, "t1");
671
-
672
- internals._conversation = [
673
- {
674
- id: "tc1",
675
- type: "tool_call",
676
- toolName: "doThing",
677
- toolCallId: "tc1",
678
- arguments: { x: 1 },
679
- result: null,
680
- createdAt: "",
681
- },
682
- ];
683
-
684
- const collapsed = internals.renderConversation();
685
- expect(internals.renderConversation()).toBe(collapsed);
686
-
687
- // Simulating `toggleToolExpand("tc1")` — production code always
688
- // builds a fresh Set, so reference equality flips.
689
- internals._expandedTools = new Set(["tc1"]);
690
-
691
- const expanded = internals.renderConversation();
692
- expect(expanded).not.toBe(collapsed);
693
-
694
- // Same for the long-message "Show more" path.
695
- internals._expandedMessages = new Set(["m1"]);
696
-
697
- expect(internals.renderConversation()).not.toBe(expanded);
698
- });
699
-
700
- it("keeps timeline and message fallback template caches separate", async () => {
701
- const { el, internals } = createThreadDetails();
702
- await settleThread(el, internals, "t1");
703
-
704
- const events = [
705
- {
706
- type: "RUN_STARTED",
707
- timestamp: "2026-06-25T10:00:00.000Z",
708
- payload: {},
709
- sourceIndex: 1,
710
- },
711
- ];
712
- internals._fetchedEvents = events;
713
-
714
- const timelineTpl = internals.renderTimeline();
715
- expect(internals._panelTplCache.get("timeline")?.tpl).toBe(timelineTpl);
716
-
717
- internals._fetchedEvents = [];
718
- internals._conversation = [
719
- { id: "m1", type: "user", content: "fallback", createdAt: "" },
720
- ];
721
-
722
- const fallbackTpl = internals.renderTimeline();
723
- expect(fallbackTpl).not.toBe(timelineTpl);
724
- expect(internals._panelTplCache.get("timeline")?.tpl).toBe(timelineTpl);
725
- expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(
726
- fallbackTpl,
727
- );
728
-
729
- internals._fetchedEvents = events;
730
- expect(internals.renderTimeline()).toBe(timelineTpl);
731
- });
732
-
733
- it("does not recompute timeline items when renderTimeline returns a cached template", async () => {
734
- const { el, internals } = createThreadDetails();
735
- await settleThread(el, internals, "t1");
736
-
737
- internals._fetchedEvents = [
738
- {
739
- type: "RUN_STARTED",
740
- timestamp: "2026-06-25T10:00:00.000Z",
741
- payload: {},
742
- sourceIndex: 1,
743
- },
744
- ];
745
-
746
- const normalizeSpy = vi.spyOn(internals, "timelineItemsFromEvents");
747
-
748
- const timelineTpl = internals.renderTimeline();
749
-
750
- expect(internals.renderTimeline()).toBe(timelineTpl);
751
- expect(normalizeSpy).toHaveBeenCalledTimes(1);
752
- });
753
-
754
- it("keeps live fallback event references stable for timeline and raw event caches", async () => {
755
- const { el, internals } = createThreadDetails();
756
- await settleThread(el, internals, "t1");
757
-
758
- internals.agentEventsInput = [
759
- {
760
- type: "RUN_STARTED",
761
- timestamp: "2026-06-25T10:00:00.000Z",
762
- payload: {},
763
- },
764
- ];
765
-
766
- const normalizeSpy = vi.spyOn(internals, "timelineItemsFromEvents");
767
-
768
- const timelineTpl = internals.renderTimeline();
769
- const eventsTpl = internals.renderEvents();
770
-
771
- expect(internals.renderTimeline()).toBe(timelineTpl);
772
- expect(internals.renderEvents()).toBe(eventsTpl);
773
- expect(normalizeSpy).toHaveBeenCalledTimes(1);
774
- });
775
-
776
- it("state and events caches invalidate when their fetched data is reassigned", async () => {
777
- const { el, internals } = createThreadDetails();
778
- await settleThread(el, internals, "t1");
779
-
780
- internals._fetchedState = { foo: "bar" };
781
- internals._fetchedEvents = [
782
- {
783
- type: "RUN_STARTED",
784
- timestamp: "2026-06-25T10:00:00.000Z",
785
- payload: {},
786
- sourceIndex: 1,
787
- },
788
- ];
789
-
790
- const stateA = internals.renderState();
791
- const eventsA = internals.renderEvents();
792
- expect(internals.renderState()).toBe(stateA);
793
- expect(internals.renderEvents()).toBe(eventsA);
794
-
795
- // Reassign both — fresh references after a refetch.
796
- internals._fetchedState = { foo: "baz" };
797
- internals._fetchedEvents = [
798
- {
799
- type: "RUN_FINISHED",
800
- timestamp: "2026-06-25T10:00:01.000Z",
801
- payload: {},
802
- sourceIndex: 1,
803
- },
804
- ];
805
-
806
- expect(internals.renderState()).not.toBe(stateA);
807
- expect(internals.renderEvents()).not.toBe(eventsA);
808
- });
809
-
810
- it("maps empty tool arguments and results as empty objects", () => {
811
- const { internals } = createThreadDetails();
812
-
813
- const items = internals.mapMessages([
814
- {
815
- id: "assistant-1",
816
- role: "assistant",
817
- content: "",
818
- toolCalls: [
819
- {
820
- id: "call-empty",
821
- name: "lookupUser",
822
- args: "",
823
- },
824
- {
825
- id: "call-whitespace",
826
- name: "lookupOrder",
827
- args: " ",
828
- },
829
- ],
830
- },
831
- {
832
- id: "tool-empty",
833
- role: "tool",
834
- toolCallId: "call-empty",
835
- content: "",
836
- },
837
- {
838
- id: "tool-whitespace",
839
- role: "tool",
840
- toolCallId: "call-whitespace",
841
- content: " ",
842
- },
843
- ]);
844
-
845
- expect(items).toMatchObject([
846
- {
847
- type: "tool_call",
848
- toolCallId: "call-empty",
849
- arguments: {},
850
- result: {},
851
- },
852
- {
853
- type: "tool_call",
854
- toolCallId: "call-whitespace",
855
- arguments: {},
856
- result: {},
857
- },
858
- ]);
859
- });
860
- });
861
-
862
- describe("CpkThreadInspector provider contract", () => {
863
- beforeEach(() => {
864
- document.body.innerHTML = "";
865
- });
866
-
867
- afterEach(() => {
868
- vi.unstubAllGlobals();
869
- });
870
-
871
- it("registers the stable custom element and renders a normalized provider-backed timeline", async () => {
872
- expect(customElements.get("cpk-thread-inspector")).toBe(CpkThreadInspector);
873
-
874
- const provider: ThreadDebuggerProvider = {
875
- getThreadMetadata: vi.fn().mockResolvedValue({
876
- id: "thread-1234567890",
877
- agentId: "agent-a",
878
- endUserId: "user-a",
879
- status: "active",
880
- createdAt: "2026-06-25T10:00:00.000Z",
881
- updatedAt: "2026-06-25T10:00:01.000Z",
882
- }),
883
- getMessages: vi.fn().mockResolvedValue([]),
884
- getEvents: vi.fn().mockResolvedValue([
885
- {
886
- type: "RUN_STARTED",
887
- timestamp: "2026-06-25T10:00:00.000Z",
888
- payload: { runId: "run-1" },
889
- },
890
- {
891
- type: "TEXT_MESSAGE_START",
892
- timestamp: "2026-06-25T10:00:00.100Z",
893
- payload: { messageId: "m1", role: "assistant" },
894
- },
895
- {
896
- type: "TEXT_MESSAGE_CONTENT",
897
- timestamp: "2026-06-25T10:00:00.200Z",
898
- payload: { messageId: "m1", delta: "hello from events" },
899
- },
900
- {
901
- type: "TOOL_CALL_START",
902
- timestamp: "2026-06-25T10:00:00.300Z",
903
- payload: { toolCallId: "tc1", toolCallName: "lookup_docs" },
904
- },
905
- {
906
- type: "TOOL_CALL_ARGS",
907
- timestamp: "2026-06-25T10:00:00.400Z",
908
- payload: { toolCallId: "tc1", delta: '{"broken":' },
909
- },
910
- {
911
- type: "TOOL_CALL_END",
912
- timestamp: "2026-06-25T10:00:00.500Z",
913
- payload: { toolCallId: "tc1" },
914
- },
915
- ]),
916
- };
917
- const { el, internals } = createThreadInspector();
918
-
919
- internals.provider = provider;
920
- internals.threadId = "thread-1234567890";
921
- await flushProviderWork(el);
922
-
923
- expect(provider.getThreadMetadata).toHaveBeenCalledWith(
924
- "thread-1234567890",
925
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
926
- );
927
- expect(provider.getEvents).toHaveBeenCalledWith(
928
- "thread-1234567890",
929
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
930
- );
931
- expect(provider.getMessages).not.toHaveBeenCalled();
932
- expect(internals._fetchedMetadata?.agentId).toBe("agent-a");
933
- expect(internals._fetchedEvents).toHaveLength(6);
934
-
935
- const text = el.shadowRoot?.textContent ?? "";
936
- expect(text).toContain("Timeline");
937
- expect(text).toContain("Raw AG-UI Events");
938
- expect(text).toContain("State");
939
- expect(text).toContain("Run started");
940
- expect(text).toContain("assistant message");
941
- expect(text).toContain("hello from events");
942
- expect(text).toContain("lookup_docs");
943
- expect(text).toContain("Could not decode tool call arguments");
944
- expect(text).toContain("Source event #1");
945
- expect(text).toContain("Source event #6");
946
- expect(text).toContain("agent-a");
947
- expect(text).toContain("user-a");
948
- expect(text).not.toContain("Rename");
949
- expect(text).not.toContain("Archive");
950
- expect(text).not.toContain("Delete");
951
- });
952
-
953
- it("source-event references reveal raw events and state stays lazy-loaded", async () => {
954
- const provider: ThreadDebuggerProvider = {
955
- getMessages: vi.fn().mockResolvedValue([]),
956
- getEvents: vi.fn().mockResolvedValue([
957
- {
958
- type: "RUN_STARTED",
959
- timestamp: "2026-06-25T10:00:00.000Z",
960
- payload: { runId: "run-1" },
961
- },
962
- ]),
963
- getState: vi.fn().mockResolvedValue({ step: 1 }),
964
- };
965
- const { el, internals } = createThreadInspector();
966
-
967
- internals.provider = provider;
968
- internals.threadId = "thread-1";
969
- await flushProviderWork(el);
970
-
971
- expect(provider.getEvents).toHaveBeenCalledTimes(1);
972
- expect(provider.getState).not.toHaveBeenCalled();
973
- expect(internals._fetchedEvents?.[0]).toMatchObject({
974
- type: "RUN_STARTED",
975
- payload: { runId: "run-1" },
976
- });
977
-
978
- el.shadowRoot
979
- ?.querySelector<HTMLButtonElement>(".cpk-td__source-link")
980
- ?.click();
981
- await flushProviderWork(el);
982
-
983
- expect(
984
- el.shadowRoot?.querySelector<HTMLElement>(
985
- '.cpk-td__event[data-source-index="1"]',
986
- ),
987
- ).not.toBeNull();
988
- expect(provider.getEvents).toHaveBeenCalledTimes(1);
989
-
990
- el.shadowRoot
991
- ?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[2]
992
- ?.click();
993
- await flushProviderWork(el);
994
-
995
- expect(provider.getState).toHaveBeenCalledWith(
996
- "thread-1",
997
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
998
- );
999
- expect(internals._fetchedState).toEqual({ step: 1 });
1000
- });
1001
-
1002
- it("reloads when a provider arrives after threadId and when provider identity swaps for the same thread", async () => {
1003
- const firstEvents =
1004
- createDeferred<
1005
- Awaited<ReturnType<NonNullable<ThreadDebuggerProvider["getEvents"]>>>
1006
- >();
1007
- const firstSignals: AbortSignal[] = [];
1008
- const firstProvider: ThreadDebuggerProvider = {
1009
- getEvents: vi.fn((_threadId, options) => {
1010
- firstSignals.push(options.signal);
1011
- return firstEvents.promise;
1012
- }),
1013
- };
1014
- const secondProvider: ThreadDebuggerProvider = {
1015
- getEvents: vi.fn().mockResolvedValue([
1016
- {
1017
- type: "RUN_FINISHED",
1018
- timestamp: "2026-06-25T10:00:01.000Z",
1019
- payload: { runId: "second-run" },
1020
- },
1021
- ]),
1022
- };
1023
- const { el, internals } = createThreadInspector();
1024
-
1025
- internals.threadId = "thread-1";
1026
- await flushProviderWork(el);
1027
-
1028
- expect(firstProvider.getEvents).not.toHaveBeenCalled();
1029
-
1030
- internals.provider = firstProvider;
1031
- await flushProviderWork(el);
1032
-
1033
- expect(firstProvider.getEvents).toHaveBeenCalledWith(
1034
- "thread-1",
1035
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
1036
- );
1037
-
1038
- internals.provider = secondProvider;
1039
- await flushProviderWork(el);
1040
-
1041
- expect(firstSignals[0]?.aborted).toBe(true);
1042
- await vi.waitFor(() => {
1043
- expect(secondProvider.getEvents).toHaveBeenCalledWith(
1044
- "thread-1",
1045
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
1046
- );
1047
- expect(internals._fetchedEvents?.[0]?.type).toBe("RUN_FINISHED");
1048
- expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1049
- runId: "second-run",
1050
- });
1051
- });
1052
-
1053
- firstEvents.resolve([
1054
- {
1055
- type: "RUN_STARTED",
1056
- timestamp: "2026-06-25T10:00:00.000Z",
1057
- payload: { runId: "stale-run" },
1058
- },
1059
- ]);
1060
- await Promise.resolve();
1061
-
1062
- expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1063
- runId: "second-run",
1064
- });
1065
- });
1066
-
1067
- it("renders raw AG-UI events with top-level fields preserved when payload is present", async () => {
1068
- const provider: ThreadDebuggerProvider = {
1069
- getEvents: vi.fn().mockResolvedValue([
1070
- {
1071
- type: "TEXT_MESSAGE_CONTENT",
1072
- timestamp: "2026-06-25T10:00:00.000Z",
1073
- payload: { messageId: "m1", delta: "hello" },
1074
- runId: "top-level-run",
1075
- sequence: 42,
1076
- },
1077
- ]),
1078
- };
1079
- const { el, internals } = createThreadInspector();
1080
-
1081
- internals.provider = provider;
1082
- internals.threadId = "thread-raw";
1083
- await flushProviderWork(el);
1084
-
1085
- expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1086
- messageId: "m1",
1087
- delta: "hello",
1088
- });
1089
- expect(internals._fetchedEvents?.[0]?.rawEvent).toMatchObject({
1090
- runId: "top-level-run",
1091
- sequence: 42,
1092
- payload: { messageId: "m1", delta: "hello" },
1093
- });
1094
-
1095
- el.shadowRoot
1096
- ?.querySelector<HTMLButtonElement>(".cpk-td__source-link")
1097
- ?.click();
1098
- await flushProviderWork(el);
1099
-
1100
- const rawEvent = el.shadowRoot?.querySelector<HTMLElement>(
1101
- '.cpk-td__event[data-source-index="1"]',
1102
- );
1103
- expect(rawEvent?.textContent ?? "").toContain("Show details");
1104
- expect(rawEvent?.textContent ?? "").not.toContain("top-level-run");
1105
- expect(rawEvent?.textContent ?? "").not.toContain("sequence");
1106
- expect(rawEvent?.textContent ?? "").not.toContain("messageId");
1107
- expect(rawEvent?.textContent ?? "").not.toContain("hello");
1108
-
1109
- rawEvent
1110
- ?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
1111
- ?.click();
1112
- await el.updateComplete;
1113
-
1114
- const text = el.shadowRoot?.textContent ?? "";
1115
- expect(text).toContain("Hide details");
1116
- expect(text).toContain("top-level-run");
1117
- expect(text).toContain("sequence");
1118
- expect(text).toContain("messageId");
1119
- expect(text).toContain("hello");
1120
- });
1121
-
1122
- it("ignores stale runtime 501 availability results after the selected thread changes", async () => {
1123
- const t1Events = createDeferred<Response>();
1124
- const t1State = createDeferred<Response>();
1125
- const t2Events = createDeferred<Response>();
1126
- const t2State = createDeferred<Response>();
1127
- const fetchMock = vi.fn((url: string) => {
1128
- if (url.endsWith("/threads/t1/events")) return t1Events.promise;
1129
- if (url.endsWith("/threads/t1/state")) return t1State.promise;
1130
- if (url.endsWith("/threads/t2/events")) return t2Events.promise;
1131
- if (url.endsWith("/threads/t2/state")) return t2State.promise;
1132
- if (url.endsWith("/threads/t2/messages")) {
1133
- return Promise.resolve(
1134
- new Response(JSON.stringify({ messages: [] }), { status: 200 }),
1135
- );
1136
- }
1137
- return Promise.reject(new Error(`Unexpected URL ${url}`));
1138
- });
1139
- vi.stubGlobal("fetch", fetchMock);
1140
- const { el, internals } = createThreadInspector();
1141
-
1142
- internals.runtimeUrl = "http://runtime";
1143
- internals.threadInspectionAvailable = true;
1144
- internals.threadId = "t1";
1145
- await flushProviderWork(el);
1146
- const t1StatePromise = internals.fetchState("t1");
1147
-
1148
- internals.threadId = "t2";
1149
- await flushProviderWork(el);
1150
- const t2StatePromise = internals.fetchState("t2");
1151
-
1152
- t1Events.resolve(new Response(null, { status: 501 }));
1153
- t1State.resolve(new Response(null, { status: 501 }));
1154
- t2Events.resolve(
1155
- new Response(
1156
- JSON.stringify({
1157
- events: [
1158
- {
1159
- type: "RUN_STARTED",
1160
- timestamp: "2026-06-25T10:00:00.000Z",
1161
- payload: { runId: "fresh" },
1162
- },
1163
- ],
1164
- }),
1165
- { status: 200 },
1166
- ),
1167
- );
1168
- t2State.resolve(
1169
- new Response(JSON.stringify({ state: { current: "fresh" } }), {
1170
- status: 200,
1171
- }),
1172
- );
1173
-
1174
- await Promise.all([t1StatePromise, t2StatePromise]);
1175
- await vi.waitFor(() => {
1176
- expect(internals._eventsNotAvailable).toBe(false);
1177
- expect(internals._stateNotAvailable).toBe(false);
1178
- expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1179
- runId: "fresh",
1180
- });
1181
- expect(internals._fetchedState).toEqual({ current: "fresh" });
1182
- });
1183
- });
1184
-
1185
- it("refetches runtime thread data when headers change for the same thread", async () => {
1186
- const fetchMock = vi.fn(
1187
- (_url: string, init?: { headers?: Record<string, string> }) =>
1188
- Promise.resolve(
1189
- new Response(
1190
- JSON.stringify({
1191
- events: [
1192
- {
1193
- type: "RUN_STARTED",
1194
- timestamp: "2026-06-25T10:00:00.000Z",
1195
- payload: {
1196
- auth: init?.headers?.Authorization,
1197
- },
1198
- },
1199
- ],
1200
- }),
1201
- { status: 200 },
1202
- ),
1203
- ),
1204
- );
1205
- vi.stubGlobal("fetch", fetchMock);
1206
- const { el, internals } = createThreadInspector();
1207
-
1208
- // Count only thread-inspection requests. This stub replaces the global
1209
- // fetch, so a fire-and-forget telemetry POST from an earlier case can land
1210
- // here too; the assertion is about refetches, not total network calls.
1211
- const threadFetches = () =>
1212
- fetchMock.mock.calls.filter((call) =>
1213
- String(call[0]).startsWith("http://runtime"),
1214
- );
1215
-
1216
- internals.runtimeUrl = "http://runtime";
1217
- internals.threadInspectionAvailable = true;
1218
- internals.headers = { Authorization: "Bearer first" };
1219
- internals.threadId = "thread-1";
1220
- await flushProviderWork(el);
1221
-
1222
- await vi.waitFor(() => {
1223
- expect(threadFetches()).toHaveLength(1);
1224
- expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1225
- auth: "Bearer first",
1226
- });
1227
- });
1228
-
1229
- internals.headers = { Authorization: "Bearer second" };
1230
- await flushProviderWork(el);
1231
-
1232
- await vi.waitFor(() => {
1233
- expect(threadFetches()).toHaveLength(2);
1234
- expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1235
- auth: "Bearer second",
1236
- });
1237
- });
1238
- expect(headersOf(threadFetches().at(-1)!)).toMatchObject({
1239
- Authorization: "Bearer second",
1240
- });
1241
- });
1242
-
1243
- it("loads runtime messages when event history is empty so timeline fallback and counts work", async () => {
1244
- const fetchMock = vi.fn((url: string) => {
1245
- if (url.endsWith("/threads/thread-1/events")) {
1246
- return Promise.resolve(
1247
- new Response(JSON.stringify({ events: [] }), { status: 200 }),
1248
- );
1249
- }
1250
- if (url.endsWith("/threads/thread-1/messages")) {
1251
- return Promise.resolve(
1252
- new Response(
1253
- JSON.stringify({
1254
- messages: [
1255
- { id: "u1", role: "user", content: "historical hello" },
1256
- { id: "a1", role: "assistant", content: "historical reply" },
1257
- ],
1258
- }),
1259
- { status: 200 },
1260
- ),
1261
- );
1262
- }
1263
- return Promise.reject(new Error(`Unexpected URL ${url}`));
1264
- });
1265
- vi.stubGlobal("fetch", fetchMock);
1266
- const { el, internals } = createThreadInspector();
1267
-
1268
- internals.runtimeUrl = "http://runtime";
1269
- internals.threadInspectionAvailable = true;
1270
- internals.threadId = "thread-1";
1271
- await flushProviderWork(el);
1272
-
1273
- await vi.waitFor(() => {
1274
- expect(internals._fetchedEvents).toEqual([]);
1275
- expect(internals._conversation).toHaveLength(2);
1276
- expect(el.shadowRoot?.textContent ?? "").toContain("historical hello");
1277
- expect(el.shadowRoot?.textContent ?? "").toContain("historical reply");
1278
- });
1279
-
1280
- el.shadowRoot
1281
- ?.querySelector<HTMLButtonElement>(".cpk-td__panel-toggle")
1282
- ?.click();
1283
- await flushProviderWork(el);
1284
-
1285
- expect(el.shadowRoot?.textContent ?? "").toContain("Messages");
1286
- expect(el.shadowRoot?.textContent ?? "").toContain("2");
1287
- });
1288
-
1289
- it("renders unsupported raw events as timeline rows instead of leaving the first tab empty", async () => {
1290
- const provider: ThreadDebuggerProvider = {
1291
- getEvents: vi.fn().mockResolvedValue([
1292
- {
1293
- type: "THREAD_STATE_WRITTEN",
1294
- timestamp: "2026-06-25T10:00:00.000Z",
1295
- payload: { checkpointId: "state-1", status: "ok" },
1296
- },
1297
- ]),
1298
- };
1299
- const { el, internals } = createThreadInspector();
1300
-
1301
- internals.provider = provider;
1302
- internals.threadId = "thread-raw-event-only";
1303
- await flushProviderWork(el);
1304
-
1305
- expect(internals.activeTimelineItems).toHaveLength(1);
1306
- expect(el.shadowRoot?.textContent ?? "").toContain("THREAD_STATE_WRITTEN");
1307
- expect(el.shadowRoot?.textContent ?? "").toContain("Show details");
1308
- expect(el.shadowRoot?.textContent ?? "").not.toContain("checkpointId");
1309
- expect(el.shadowRoot?.textContent ?? "").toContain("Source event #1");
1310
- expect(el.shadowRoot?.textContent ?? "").not.toContain(
1311
- "No timeline events captured",
1312
- );
1313
- el.shadowRoot
1314
- ?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
1315
- ?.click();
1316
- await el.updateComplete;
1317
-
1318
- expect(el.shadowRoot?.textContent ?? "").toContain("checkpointId");
1319
- });
1320
-
1321
- it("collapses structured timeline event details by default", async () => {
1322
- const provider: ThreadDebuggerProvider = {
1323
- getEvents: vi.fn().mockResolvedValue([
1324
- {
1325
- type: "RUN_STARTED",
1326
- timestamp: "2026-06-25T10:00:00.000Z",
1327
- payload: {
1328
- input: {
1329
- tools: [
1330
- {
1331
- name: "generateSandboxedUi",
1332
- description: "very chonky run-started payload",
1333
- },
1334
- ],
1335
- },
1336
- },
1337
- },
1338
- ]),
1339
- };
1340
- const { el, internals } = createThreadInspector();
1341
-
1342
- internals.provider = provider;
1343
- internals.threadId = "thread-chonky-run-started";
1344
- await flushProviderWork(el);
1345
-
1346
- expect(el.shadowRoot?.textContent ?? "").toContain("Run started");
1347
- expect(el.shadowRoot?.textContent ?? "").toContain("Show details");
1348
- expect(el.shadowRoot?.textContent ?? "").not.toContain(
1349
- "very chonky run-started payload",
1350
- );
1351
- el.shadowRoot
1352
- ?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
1353
- ?.click();
1354
- await el.updateComplete;
1355
-
1356
- expect(el.shadowRoot?.textContent ?? "").toContain("Hide details");
1357
- expect(el.shadowRoot?.textContent ?? "").toContain(
1358
- "very chonky run-started payload",
1359
- );
1360
- });
1361
-
1362
- it("shows expanded timeline details when an event also has a summary body", async () => {
1363
- const provider: ThreadDebuggerProvider = {
1364
- getEvents: vi.fn().mockResolvedValue([
1365
- {
1366
- type: "RUN_ERROR",
1367
- timestamp: "2026-06-25T10:00:00.000Z",
1368
- payload: {
1369
- message: "Tool failed",
1370
- errorCode: "ERR_TOOL_TIMEOUT",
1371
- },
1372
- },
1373
- ]),
1374
- };
1375
- const { el, internals } = createThreadInspector();
1376
-
1377
- internals.provider = provider;
1378
- internals.threadId = "thread-error-details";
1379
- await flushProviderWork(el);
1380
-
1381
- expect(el.shadowRoot?.textContent ?? "").toContain("Tool failed");
1382
- expect(el.shadowRoot?.textContent ?? "").toContain("Show details");
1383
- expect(el.shadowRoot?.textContent ?? "").not.toContain("ERR_TOOL_TIMEOUT");
1384
- el.shadowRoot
1385
- ?.querySelector<HTMLButtonElement>(".cpk-td__timeline-details-toggle")
1386
- ?.click();
1387
- await el.updateComplete;
1388
-
1389
- expect(el.shadowRoot?.textContent ?? "").toContain("Hide details");
1390
- expect(el.shadowRoot?.textContent ?? "").toContain("ERR_TOOL_TIMEOUT");
1391
- });
1392
-
1393
- it("keeps the first-visible timeline intentional while provider message fallback is loading", async () => {
1394
- const messages =
1395
- createDeferred<
1396
- Awaited<ReturnType<NonNullable<ThreadDebuggerProvider["getMessages"]>>>
1397
- >();
1398
- const provider: ThreadDebuggerProvider = {
1399
- getEvents: vi.fn().mockResolvedValue([]),
1400
- getMessages: vi.fn().mockReturnValue(messages.promise),
1401
- };
1402
- const { el, internals } = createThreadInspector();
1403
-
1404
- internals.provider = provider;
1405
- internals.threadId = "thread-messages-only";
1406
- await flushProviderWork(el);
1407
-
1408
- expect(provider.getMessages).toHaveBeenCalledWith(
1409
- "thread-messages-only",
1410
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
1411
- );
1412
- expect(internals._loadingMessages).toBe(true);
1413
- expect(el.shadowRoot?.textContent ?? "").toContain("Loading messages");
1414
- expect(el.shadowRoot?.textContent ?? "").not.toContain(
1415
- "No timeline events captured",
1416
- );
1417
-
1418
- messages.resolve([
1419
- { id: "u1", role: "user", content: "provider hello" },
1420
- { id: "a1", role: "assistant", content: "provider reply" },
1421
- ]);
1422
-
1423
- await vi.waitFor(() => {
1424
- const text = el.shadowRoot?.textContent ?? "";
1425
- expect(text).toContain("provider hello");
1426
- expect(text).toContain("provider reply");
1427
- });
1428
- });
1429
- });
1430
-
1431
- // ─────────────────────────────────────────────────────────────────────────
1432
- // Announcement preview (popout) dismissal MUST persist
1433
- // ─────────────────────────────────────────────────────────────────────────
1434
- //
1435
- // The preview bubble that pops out of the floating button carries an X. Clicking
1436
- // it MUST persist the announcement timestamp to localStorage. Otherwise
1437
- // fetchAnnouncement() recomputes `showAnnouncementPreview` from the (still
1438
- // empty) stored timestamp on the next mount and the bubble pops straight back
1439
- // out — the regression these tests guard against. Persistence lives only in
1440
- // markAnnouncementSeen(); the body-click / open paths clear the flag in memory
1441
- // only and are intentionally NOT persistent.
1442
-
1443
- const ANNOUNCEMENT_STORAGE_KEY = "cpk:inspector:announcements";
1444
-
1445
- type AnnouncementInternals = {
1446
- hasUnseenAnnouncement: boolean;
1447
- showAnnouncementPreview: boolean;
1448
- announcementPreviewText: string | null;
1449
- announcementTimestamp: string | null;
1450
- isOpen: boolean;
1451
- };
1452
-
1453
- describe("WebInspectorElement announcement preview dismissal", () => {
1454
- let store: Record<string, string>;
1455
-
1456
- beforeEach(() => {
1457
- document.body.innerHTML = "";
1458
- store = {};
1459
- vi.stubGlobal("localStorage", {
1460
- getItem: (key: string) => store[key] ?? null,
1461
- setItem: (key: string, value: string) => {
1462
- store[key] = value;
1463
- },
1464
- removeItem: (key: string) => {
1465
- delete store[key];
1466
- },
1467
- clear: () => {
1468
- for (const key of Object.keys(store)) delete store[key];
1469
- },
1470
- get length() {
1471
- return Object.keys(store).length;
1472
- },
1473
- key: (index: number) => Object.keys(store)[index] ?? null,
1474
- });
1475
- });
1476
-
1477
- /** Mount a closed inspector with an unseen announcement so the popout renders. */
1478
- async function mountWithUnseenAnnouncement(timestamp: string) {
1479
- const { core } = createMockCore();
1480
- const inspector = createInspectorWithCore(core);
1481
- const a = inspector as unknown as AnnouncementInternals;
1482
- a.announcementTimestamp = timestamp;
1483
- a.announcementPreviewText = "Slack early access is here!";
1484
- a.hasUnseenAnnouncement = true;
1485
- a.showAnnouncementPreview = true;
1486
- inspector.requestUpdate();
1487
- await inspector.updateComplete;
1488
- return { inspector, a };
1489
- }
1490
-
1491
- it("persists the announcement timestamp when the popout X is clicked", async () => {
1492
- const timestamp = "2026-06-11T13:00:00.000Z";
1493
- const { inspector, a } = await mountWithUnseenAnnouncement(timestamp);
1494
-
1495
- const dismiss = inspector.shadowRoot?.querySelector<HTMLElement>(
1496
- ".announcement-preview__dismiss",
1497
- );
1498
- expect(dismiss, "popout dismiss control should render").not.toBeNull();
1499
-
1500
- dismiss?.click();
1501
- await inspector.updateComplete;
1502
-
1503
- // The dismissal is persisted, so a remount would stay closed.
1504
- expect(store[ANNOUNCEMENT_STORAGE_KEY]).toBe(JSON.stringify({ timestamp }));
1505
- // In-memory flags cleared and the bubble is gone.
1506
- expect(a.hasUnseenAnnouncement).toBe(false);
1507
- expect(a.showAnnouncementPreview).toBe(false);
1508
- expect(
1509
- inspector.shadowRoot?.querySelector(".announcement-preview"),
1510
- ).toBeNull();
1511
- });
1512
-
1513
- it("dismissing the popout X does not open the inspector", async () => {
1514
- const { inspector, a } = await mountWithUnseenAnnouncement(
1515
- "2026-06-11T13:00:00.000Z",
1516
- );
1517
- expect(a.isOpen).toBe(false);
1518
-
1519
- inspector.shadowRoot
1520
- ?.querySelector<HTMLElement>(".announcement-preview__dismiss")
1521
- ?.click();
1522
- await inspector.updateComplete;
1523
-
1524
- // X dismisses without opening (only a body click opens the inspector).
1525
- expect(a.isOpen).toBe(false);
1526
- });
1527
-
1528
- it("emits banner_dismissed with the bubble surface alongside banner_clicked", async () => {
1529
- const fetchMock = vi
1530
- .spyOn(globalThis, "fetch")
1531
- .mockResolvedValue(new Response(null, { status: 204 }));
1532
- const timestamp = "2026-06-11T13:00:00.000Z";
1533
- const { inspector } = await mountWithUnseenAnnouncement(timestamp);
1534
-
1535
- inspector.shadowRoot
1536
- ?.querySelector<HTMLElement>(".announcement-preview__dismiss")
1537
- ?.click();
1538
- await inspector.updateComplete;
1539
-
1540
- const posts = telemetryPostsFrom(fetchMock);
1541
- const dismissed = posts.find(
1542
- (post) => post.event === "oss.inspector.banner_dismissed",
1543
- );
1544
- expect(dismissed?.properties).toMatchObject({
1545
- banner_id: timestamp,
1546
- surface: "collapsed_preview",
1547
- });
1548
- // The legacy signal keeps flowing so existing dashboards don't zero out.
1549
- const clicked = posts.find(
1550
- (post) => post.event === "oss.inspector.banner_clicked",
1551
- );
1552
- expect(clicked?.properties).toMatchObject({
1553
- banner_id: timestamp,
1554
- cta: "dismiss",
1555
- });
1556
- });
1557
-
1558
- it("clicking the popout body opens the inspector without persisting", async () => {
1559
- const { inspector, a } = await mountWithUnseenAnnouncement(
1560
- "2026-06-11T13:00:00.000Z",
1561
- );
1562
-
1563
- inspector.shadowRoot
1564
- ?.querySelector<HTMLElement>(".announcement-preview")
1565
- ?.click();
1566
- await inspector.updateComplete;
1567
-
1568
- // Body click is engagement, not dismissal: it opens but must NOT persist,
1569
- // so the in-window banner still shows the announcement.
1570
- expect(a.isOpen).toBe(true);
1571
- expect(store[ANNOUNCEMENT_STORAGE_KEY]).toBeUndefined();
1572
- expect(a.hasUnseenAnnouncement).toBe(true);
1573
- });
1574
- });
1575
-
1576
- // ─────────────────────────────────────────────────────────────────────────
1577
- // Panel-open + banner-surface telemetry (OSS-566 / OSS-568)
1578
- // ─────────────────────────────────────────────────────────────────────────
1579
- //
1580
- // `oss.inspector.opened` exists because opens were previously only inferable
1581
- // from in-panel activity (a floor) or from `banner_clicked` cta=body (which
1582
- // misses the floating-button path). `banner_viewed` carries a `surface`
1583
- // because the bubble on the collapsed widget and the card inside the opened
1584
- // panel are separate impressions — reach on one says nothing about the other.
1585
-
1586
- const ANNOUNCEMENT_URL = "https://cdn.copilotkit.ai/announcements.json";
1587
-
1588
- type OpenTelemetryInternals = {
1589
- isOpen: boolean;
1590
- announcementTimestamp: string | null;
1591
- fetchAnnouncement: () => Promise<void>;
1592
- openInspector: (source: "floating_button" | "announcement_preview") => void;
1593
- };
1594
-
1595
- describe("WebInspectorElement open + banner surface telemetry", () => {
1596
- let fetchMock: ReturnType<typeof vi.fn>;
1597
- const timestamp = "2026-07-01T09:00:00.000Z";
1598
-
1599
- beforeEach(() => {
1600
- document.body.innerHTML = "";
1601
- fetchMock = vi.fn((input: unknown) => {
1602
- if (String(input) === ANNOUNCEMENT_URL) {
1603
- return Promise.resolve(
1604
- new Response(
1605
- JSON.stringify({
1606
- timestamp,
1607
- previewText: "Channels are here",
1608
- announcement: "Channels are here — [read more](https://x.test)",
1609
- }),
1610
- { status: 200 },
1611
- ),
1612
- );
1613
- }
1614
- return Promise.resolve(new Response(null, { status: 204 }));
1615
- });
1616
- vi.stubGlobal("fetch", fetchMock);
1617
- });
1618
-
1619
- afterEach(() => {
1620
- vi.unstubAllGlobals();
1621
- });
1622
-
1623
- /** Mount an inspector attached to a connected core with telemetry on. */
1624
- function mount(telemetryDisabled = false, connected = true) {
1625
- const { agent } = createMockAgent("alpha");
1626
- const harness = createHeaderMockCore(
1627
- { alpha: agent },
1628
- {},
1629
- {},
1630
- telemetryDisabled,
1631
- );
1632
- if (!connected) {
1633
- harness.core.runtimeConnectionStatus =
1634
- CopilotKitCoreRuntimeConnectionStatus.Disconnected;
1635
- }
1636
- const inspector = new WebInspectorElement();
1637
- document.body.appendChild(inspector);
1638
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
1639
- return {
1640
- inspector,
1641
- harness,
1642
- internals: inspector as unknown as OpenTelemetryInternals,
1643
- };
1644
- }
1645
-
1646
- const posts = () => telemetryPostsFrom(fetchMock);
1647
- const eventsNamed = (name: string) =>
1648
- posts().filter((post) => post.event === name);
1649
-
1650
- it("records the collapsed bubble impression, then the in-panel card on open", async () => {
1651
- const { inspector, internals } = mount();
1652
-
1653
- await internals.fetchAnnouncement();
1654
- await inspector.updateComplete;
1655
-
1656
- const first = eventsNamed("oss.inspector.banner_viewed");
1657
- expect(first).toHaveLength(1);
1658
- expect(first[0]!.properties).toMatchObject({
1659
- banner_id: timestamp,
1660
- surface: "collapsed_preview",
1661
- });
1662
-
1663
- // Opening reveals the in-panel card — a distinct impression.
1664
- inspector.shadowRoot
1665
- ?.querySelector<HTMLElement>(".announcement-preview")
1666
- ?.click();
1667
- await inspector.updateComplete;
1668
-
1669
- const surfaces = eventsNamed("oss.inspector.banner_viewed").map(
1670
- (post) => post.properties.surface,
1671
- );
1672
- expect(surfaces).toEqual(["collapsed_preview", "expanded_card"]);
1673
- });
1674
-
1675
- it("does not re-record an impression for a surface already seen this mount", async () => {
1676
- const { inspector, internals } = mount();
1677
-
1678
- await internals.fetchAnnouncement();
1679
- await internals.fetchAnnouncement();
1680
- await inspector.updateComplete;
1681
-
1682
- expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(1);
1683
- });
1684
-
1685
- it("emits opened with the floating-button source and announcement context", async () => {
1686
- const { inspector, internals } = mount();
1687
-
1688
- await internals.fetchAnnouncement();
1689
- await inspector.updateComplete;
1690
-
1691
- inspector.shadowRoot
1692
- ?.querySelector<HTMLElement>('button[aria-label="Web Inspector"]')
1693
- ?.click();
1694
- await inspector.updateComplete;
1695
-
1696
- const opened = eventsNamed("oss.inspector.opened");
1697
- expect(opened).toHaveLength(1);
1698
- expect(opened[0]!.properties).toMatchObject({
1699
- open_source: "floating_button",
1700
- has_unseen_announcement: true,
1701
- package_name: "@copilotkit/web-inspector",
1702
- });
1703
- });
1704
-
1705
- it("attributes an open through the announcement bubble to that surface", async () => {
1706
- const { inspector, internals } = mount();
1707
-
1708
- await internals.fetchAnnouncement();
1709
- await inspector.updateComplete;
1710
-
1711
- inspector.shadowRoot
1712
- ?.querySelector<HTMLElement>(".announcement-preview")
1713
- ?.click();
1714
- await inspector.updateComplete;
1715
-
1716
- expect(eventsNamed("oss.inspector.opened")[0]!.properties).toMatchObject({
1717
- open_source: "announcement_preview",
1718
- });
1719
- });
1720
-
1721
- it("counts one open per open, and nothing for an already-open panel", async () => {
1722
- const { inspector, internals } = mount();
1723
- await inspector.updateComplete;
1724
-
1725
- internals.openInspector("floating_button");
1726
- internals.openInspector("floating_button");
1727
- await inspector.updateComplete;
1728
-
1729
- expect(eventsNamed("oss.inspector.opened")).toHaveLength(1);
1730
- });
1731
-
1732
- it("does not count a restored-open panel as an open", async () => {
1733
- const { inspector, internals } = mount();
1734
- // Restoring persisted state assigns isOpen directly rather than calling
1735
- // openInspector — otherwise every reload / dev-server hot reload would
1736
- // register as a fresh open.
1737
- internals.isOpen = true;
1738
- inspector.requestUpdate();
1739
- await inspector.updateComplete;
1740
-
1741
- expect(eventsNamed("oss.inspector.opened")).toHaveLength(0);
1742
- });
1743
-
1744
- // An open while disconnected is still a real open, so it is reported — but the
1745
- // /info-derived dimensions are omitted rather than guessed. Recording the
1746
- // `sse` default would permanently misattribute an Intelligence runtime.
1747
- it("omits license and runtime mode for an open before the handshake", async () => {
1748
- const { inspector, internals } = mount(false, false);
1749
-
1750
- internals.openInspector("floating_button");
1751
- await inspector.updateComplete;
1752
-
1753
- const opened = eventsNamed("oss.inspector.opened");
1754
- expect(opened).toHaveLength(1);
1755
- expect(opened[0]!.properties).toMatchObject({
1756
- open_source: "floating_button",
1757
- // Config-derived, so accurate immediately.
1758
- runtime_url_type: "localhost",
1759
- });
1760
- expect(opened[0]!.properties).not.toHaveProperty("runtime_mode");
1761
- expect(opened[0]!.properties).not.toHaveProperty("license_status");
1762
- });
1763
-
1764
- it("records license and runtime mode for an open against a connected Intelligence runtime", async () => {
1765
- const { inspector, harness, internals } = mount(false, false);
1766
- harness.completeHandshake({
1767
- telemetryDisabled: false,
1768
- runtimeMode: "intelligence",
1769
- licenseStatus: "valid",
1770
- });
1771
- await inspector.updateComplete;
1772
-
1773
- internals.openInspector("floating_button");
1774
- await inspector.updateComplete;
1775
-
1776
- expect(eventsNamed("oss.inspector.opened")[0]!.properties).toMatchObject({
1777
- open_source: "floating_button",
1778
- runtime_mode: "intelligence",
1779
- license_status: "valid",
1780
- });
1781
- });
1782
-
1783
- // The impression deferral predates the surface split and must survive it: the
1784
- // runtime's opt-out only arrives with /info.
1785
- it("holds a banner impression until the handshake, then drops it when telemetry is disabled", async () => {
1786
- const { inspector, harness, internals } = mount(false, false);
1787
-
1788
- await internals.fetchAnnouncement();
1789
- await inspector.updateComplete;
1790
- expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(0);
1791
-
1792
- harness.completeHandshake({ telemetryDisabled: true });
1793
- await inspector.updateComplete;
1794
-
1795
- expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(0);
1796
- });
1797
-
1798
- it("releases a held banner impression once the runtime allows telemetry", async () => {
1799
- const { inspector, harness, internals } = mount(false, false);
1800
-
1801
- await internals.fetchAnnouncement();
1802
- await inspector.updateComplete;
1803
- expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(0);
1804
-
1805
- harness.completeHandshake({ telemetryDisabled: false });
1806
- await inspector.updateComplete;
1807
-
1808
- const viewed = eventsNamed("oss.inspector.banner_viewed");
1809
- expect(viewed).toHaveLength(1);
1810
- expect(viewed[0]!.properties).toMatchObject({
1811
- surface: "collapsed_preview",
1812
- });
1813
- });
1814
-
1815
- it("emits nothing when the runtime has telemetry disabled", async () => {
1816
- const { inspector, internals } = mount(true);
1817
-
1818
- await internals.fetchAnnouncement();
1819
- inspector.shadowRoot
1820
- ?.querySelector<HTMLElement>(".announcement-preview")
1821
- ?.click();
1822
- await inspector.updateComplete;
1823
-
1824
- expect(posts()).toEqual([]);
1825
- });
1826
- });
1827
-
1828
- // --- Owned thread store header forwarding (issue #5581) ---
1829
- //
1830
- // When useThreads() isn't mounted, the inspector creates its own thread store
1831
- // per agent (ensureOwnedThreadStore). That store's /threads requests must carry
1832
- // the headers configured on <CopilotKit> (e.g. X-CSRF / auth), otherwise the
1833
- // requests 403 in environments that enforce CSRF/auth checks.
1834
-
1835
- type HeaderMockCore = {
1836
- agents: Record<string, AbstractAgent>;
1837
- context: Record<string, unknown>;
1838
- properties: Record<string, unknown>;
1839
- telemetryDisabled: boolean;
1840
- // Mirrors CopilotKitCore's pre-handshake state (`agent-registry.ts:77`):
1841
- // `runtimeMode` already reads its `sse` default and `licenseStatus` is absent,
1842
- // both of which only become real once /info answers. Without this, telemetry
1843
- // assertions about pre-handshake segmentation pass for the wrong reason.
1844
- runtimeMode: string;
1845
- licenseStatus?: string;
1846
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
1847
- runtimeUrl: string;
1848
- headers: Record<string, string>;
1849
- threadEndpoints: {
1850
- list: boolean;
1851
- inspect: boolean;
1852
- mutations: boolean;
1853
- realtimeMetadata: boolean;
1854
- };
1855
- subscribe: (subscriber: CopilotKitCoreSubscriber) => {
1856
- unsubscribe: () => void;
1857
- };
1858
- getThreadStores: () => Record<string, never>;
1859
- getThreadStore: (agentId: string) => undefined;
1860
- registerThreadStore: (agentId: string, store: unknown) => void;
1861
- unregisterThreadStore: (agentId: string) => void;
1862
- getMemoryStore: () => ReturnType<typeof createNoopMemoryStore>;
1863
- };
1864
-
1865
- function createHeaderMockCore(
1866
- agents: Record<string, AbstractAgent>,
1867
- headers: Record<string, string>,
1868
- endpointOverrides: Partial<HeaderMockCore["threadEndpoints"]> = {},
1869
- telemetryDisabled = true,
1870
- ) {
1871
- const subscribers = new Set<CopilotKitCoreSubscriber>();
1872
- const core: HeaderMockCore = {
1873
- agents,
1874
- context: {},
1875
- properties: {},
1876
- telemetryDisabled,
1877
- runtimeMode: "sse",
1878
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
1879
- runtimeUrl: "http://localhost/api",
1880
- headers,
1881
- threadEndpoints: {
1882
- list: true,
1883
- inspect: true,
1884
- mutations: true,
1885
- realtimeMetadata: true,
1886
- ...endpointOverrides,
1887
- },
1888
- subscribe(subscriber: CopilotKitCoreSubscriber) {
1889
- subscribers.add(subscriber);
1890
- return { unsubscribe: () => subscribers.delete(subscriber) };
1891
- },
1892
- getThreadStores() {
1893
- return {};
1894
- },
1895
- getThreadStore() {
1896
- return undefined;
1897
- },
1898
- registerThreadStore() {},
1899
- unregisterThreadStore() {},
1900
- getMemoryStore() {
1901
- return createNoopMemoryStore();
1902
- },
1903
- };
1904
-
1905
- const asCore = () => core as unknown as CopilotKitCore;
1906
- return {
1907
- core,
1908
- emitAgentsChanged() {
1909
- subscribers.forEach((s) =>
1910
- s.onAgentsChanged?.({ copilotkit: asCore(), agents: core.agents }),
1911
- );
1912
- },
1913
- emitHeadersChanged(nextHeaders: Record<string, string>) {
1914
- core.headers = nextHeaders;
1915
- subscribers.forEach((s) =>
1916
- s.onHeadersChanged?.({ copilotkit: asCore(), headers: nextHeaders }),
1917
- );
1918
- },
1919
- /**
1920
- * Simulates the /info handshake landing: applies what the runtime reported,
1921
- * then transitions to `connected`. Lets a test observe what is sent after
1922
- * the handshake versus before it — the segmentation fields do not exist
1923
- * until this point.
1924
- */
1925
- completeHandshake(
1926
- reported: {
1927
- telemetryDisabled?: boolean;
1928
- licenseStatus?: string;
1929
- runtimeMode?: string;
1930
- } = {},
1931
- ) {
1932
- Object.assign(core, reported);
1933
- core.runtimeConnectionStatus =
1934
- CopilotKitCoreRuntimeConnectionStatus.Connected;
1935
- subscribers.forEach((s) =>
1936
- s.onRuntimeConnectionStatusChanged?.({
1937
- copilotkit: asCore(),
1938
- status: core.runtimeConnectionStatus,
1939
- }),
1940
- );
1941
- },
1942
- };
1943
- }
1944
-
1945
- const headersOf = (call: unknown[]) =>
1946
- (call[1] as { headers?: Record<string, string> } | undefined)?.headers ?? {};
1947
-
1948
- describe("WebInspectorElement owned thread store headers (#5581)", () => {
1949
- let fetchMock: ReturnType<typeof vi.fn>;
1950
-
1951
- const threadListCalls = () =>
1952
- fetchMock.mock.calls.filter((call) =>
1953
- String(call[0]).includes("/threads?"),
1954
- );
1955
- const telemetryPosts = () =>
1956
- fetchMock.mock.calls
1957
- .filter(
1958
- (call) =>
1959
- String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
1960
- (call[1] as RequestInit | undefined)?.method === "POST",
1961
- )
1962
- .map((call) => {
1963
- const body =
1964
- ((call[1] as RequestInit | undefined)?.body as string) ?? "{}";
1965
- return JSON.parse(body) as {
1966
- event: string;
1967
- properties: Record<string, unknown>;
1968
- };
1969
- });
1970
- const threadListText = (inspector: WebInspectorElement) =>
1971
- inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot
1972
- ?.textContent ?? "";
1973
- const expectNoUtmParams = (url: URL) => {
1974
- expect(url.searchParams.has("utm_source")).toBe(false);
1975
- expect(url.searchParams.has("utm_medium")).toBe(false);
1976
- expect(url.searchParams.has("utm_campaign")).toBe(false);
1977
- };
1978
-
1979
- beforeEach(() => {
1980
- document.body.innerHTML = "";
1981
- fetchMock = vi.fn(() =>
1982
- Promise.resolve({
1983
- ok: true,
1984
- status: 200,
1985
- json: () => Promise.resolve({ threads: [] }),
1986
- }),
1987
- );
1988
- // The owned store captures globalThis.fetch when it's created, so stub
1989
- // before the inspector attaches to the core.
1990
- vi.stubGlobal("fetch", fetchMock);
1991
- });
1992
-
1993
- afterEach(() => {
1994
- vi.useRealTimers();
1995
- vi.unstubAllGlobals();
1996
- });
1997
-
1998
- it("forwards core headers on the owned store's /threads request", async () => {
1999
- const { agent } = createMockAgent("alpha");
2000
- const harness = createHeaderMockCore(
2001
- { alpha: agent },
2002
- { "X-CSRF": "1", Authorization: "Bearer abc" },
2003
- );
2004
-
2005
- const inspector = new WebInspectorElement();
2006
- document.body.appendChild(inspector);
2007
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2008
- harness.emitAgentsChanged();
2009
-
2010
- await vi.waitFor(() => {
2011
- expect(threadListCalls().length).toBeGreaterThan(0);
2012
- });
2013
-
2014
- expect(headersOf(threadListCalls()[0]!)).toMatchObject({
2015
- "X-CSRF": "1",
2016
- Authorization: "Bearer abc",
2017
- });
2018
- });
2019
-
2020
- it("re-applies headers on the owned store when core headers change", async () => {
2021
- const { agent } = createMockAgent("alpha");
2022
- const harness = createHeaderMockCore({ alpha: agent }, { "X-CSRF": "1" });
2023
-
2024
- const inspector = new WebInspectorElement();
2025
- document.body.appendChild(inspector);
2026
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2027
- harness.emitAgentsChanged();
2028
-
2029
- await vi.waitFor(() => {
2030
- expect(threadListCalls().length).toBeGreaterThan(0);
2031
- });
2032
- const callsBefore = threadListCalls().length;
2033
-
2034
- harness.emitHeadersChanged({ "X-CSRF": "2" });
2035
-
2036
- await vi.waitFor(() => {
2037
- expect(threadListCalls().length).toBeGreaterThan(callsBefore);
2038
- });
2039
-
2040
- expect(headersOf(threadListCalls().at(-1)!)).toMatchObject({
2041
- "X-CSRF": "2",
2042
- });
2043
- });
2044
-
2045
- it("rerenders selected thread details so core header changes refetch events with new headers", async () => {
2046
- fetchMock.mockImplementation(
2047
- (url: string, init?: { headers?: Record<string, string> }) => {
2048
- if (url.endsWith("/threads/thread-1/events")) {
2049
- return Promise.resolve(
2050
- new Response(
2051
- JSON.stringify({
2052
- events: [
2053
- {
2054
- type: "RUN_STARTED",
2055
- timestamp: "2026-06-25T10:00:00.000Z",
2056
- payload: { csrf: init?.headers?.["X-CSRF"] },
2057
- },
2058
- ],
2059
- }),
2060
- { status: 200 },
2061
- ),
2062
- );
2063
- }
2064
- if (url.includes("/threads?")) {
2065
- return Promise.resolve(
2066
- new Response(JSON.stringify({ threads: [] }), { status: 200 }),
2067
- );
2068
- }
2069
- return Promise.reject(new Error(`Unexpected URL ${url}`));
2070
- },
2071
- );
2072
- const harness = createHeaderMockCore({}, { "X-CSRF": "1" });
2073
- const inspector = new WebInspectorElement();
2074
- document.body.appendChild(inspector);
2075
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2076
-
2077
- const internals = inspector as unknown as InspectorThreadViewInternals;
2078
- internals.isOpen = true;
2079
- internals.selectedMenu = "threads";
2080
- internals.selectedThreadId = "thread-1";
2081
- internals._threads = [
2082
- {
2083
- id: "thread-1",
2084
- name: "Thread 1",
2085
- agentId: "alpha",
2086
- updatedAt: "2026-06-25T10:00:00.000Z",
2087
- },
2088
- ];
2089
- inspector.requestUpdate();
2090
- await inspector.updateComplete;
2091
-
2092
- await vi.waitFor(() => {
2093
- expect(
2094
- fetchMock.mock.calls.filter((call) =>
2095
- String(call[0]).endsWith("/threads/thread-1/events"),
2096
- ),
2097
- ).toHaveLength(1);
2098
- });
2099
-
2100
- harness.emitHeadersChanged({ "X-CSRF": "2" });
2101
-
2102
- await vi.waitFor(() => {
2103
- expect(
2104
- fetchMock.mock.calls.filter((call) =>
2105
- String(call[0]).endsWith("/threads/thread-1/events"),
2106
- ),
2107
- ).toHaveLength(2);
2108
- });
2109
- expect(headersOf(fetchMock.mock.calls.at(-1)!)).toMatchObject({
2110
- "X-CSRF": "2",
2111
- });
2112
- });
2113
-
2114
- it("shows the locked Intelligence state when thread listing is unavailable without fetching threads", async () => {
2115
- const { agent } = createMockAgent("alpha");
2116
- const harness = createHeaderMockCore(
2117
- { alpha: agent },
2118
- { "X-CSRF": "1" },
2119
- { list: false },
2120
- true,
2121
- );
2122
-
2123
- const inspector = new WebInspectorElement();
2124
- document.body.appendChild(inspector);
2125
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2126
- harness.emitAgentsChanged();
2127
-
2128
- const internals = inspector as unknown as {
2129
- isOpen: boolean;
2130
- handleMenuSelect: (key: "threads") => void;
2131
- };
2132
- internals.isOpen = true;
2133
- internals.handleMenuSelect("threads");
2134
- await inspector.updateComplete;
2135
-
2136
- const text = inspector.shadowRoot?.textContent ?? "";
2137
- expect(text).toMatch(/Enable Intelligence to inspect Threads\./);
2138
- expect(text).toContain("Talk to an Engineer");
2139
- expect(text).toContain("Sign up for Intelligence");
2140
- const ctaLabels = Array.from(
2141
- inspector.shadowRoot?.querySelectorAll<HTMLAnchorElement>("a") ?? [],
2142
- ).map((anchor) => anchor.textContent?.trim());
2143
- expect(ctaLabels).toEqual([
2144
- "Talk to an Engineer",
2145
- "Sign up for Intelligence",
2146
- ]);
2147
- const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
2148
- 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
2149
- );
2150
- expect(engineer?.closest("#cpk-main-scroll")).toBeNull();
2151
- expect(text).not.toContain("No threads yet");
2152
- expect(
2153
- fetchMock.mock.calls.some((call) => String(call[0]).includes("/threads")),
2154
- ).toBe(false);
2155
- });
2156
-
2157
- it("adds inspector attribution to locked-state CTAs", async () => {
2158
- const { agent } = createMockAgent("alpha");
2159
- const harness = createHeaderMockCore(
2160
- { alpha: agent },
2161
- {},
2162
- { list: false },
2163
- false,
2164
- );
2165
-
2166
- const inspector = new WebInspectorElement();
2167
- document.body.appendChild(inspector);
2168
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2169
- harness.emitAgentsChanged();
2170
-
2171
- const internals = inspector as unknown as {
2172
- isOpen: boolean;
2173
- handleMenuSelect: (key: "threads") => void;
2174
- };
2175
- internals.isOpen = true;
2176
- internals.handleMenuSelect("threads");
2177
- await inspector.updateComplete;
2178
-
2179
- const signup = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
2180
- 'a[href^="https://dashboard.operations.copilotkit.ai/sign-in"]',
2181
- );
2182
- const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
2183
- 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
2184
- );
2185
-
2186
- expect(signup).not.toBeNull();
2187
- expect(engineer).not.toBeNull();
2188
-
2189
- const signupUrl = new URL(signup!.href);
2190
- expect(signupUrl.origin).toBe("https://dashboard.operations.copilotkit.ai");
2191
- expect(signupUrl.pathname).toBe("/sign-in");
2192
- expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector");
2193
- expectNoUtmParams(signupUrl);
2194
- const distinctId = signupUrl.searchParams.get("posthog_distinct_id");
2195
- expect(distinctId).toMatch(/^[0-9a-f-]{36}$/);
2196
-
2197
- const engineerUrl = new URL(engineer!.href);
2198
- expect(engineerUrl.origin).toBe("https://www.copilotkit.ai");
2199
- expect(engineerUrl.pathname).toBe("/talk-to-an-engineer");
2200
- expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
2201
- expectNoUtmParams(engineerUrl);
2202
- expect(engineerUrl.searchParams.get("posthog_distinct_id")).toBe(
2203
- distinctId,
2204
- );
2205
- });
2206
-
2207
- it("tracks Threads tab clicks through the rendered inspector menu", async () => {
2208
- const { agent } = createMockAgent("alpha");
2209
- const harness = createHeaderMockCore(
2210
- { alpha: agent },
2211
- {},
2212
- { list: false },
2213
- false,
2214
- );
2215
-
2216
- const inspector = new WebInspectorElement();
2217
- document.body.appendChild(inspector);
2218
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2219
- harness.emitAgentsChanged();
2220
-
2221
- const internals = inspector as unknown as { isOpen: boolean };
2222
- internals.isOpen = true;
2223
- inspector.requestUpdate();
2224
- await inspector.updateComplete;
2225
-
2226
- const threadsButton = Array.from(
2227
- inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
2228
- ).find((button) => button.textContent?.trim() === "Threads");
2229
- expect(threadsButton, "Threads menu button should render").toBeDefined();
2230
-
2231
- threadsButton!.click();
2232
- await inspector.updateComplete;
2233
- await Promise.resolve();
2234
-
2235
- const threadsTabClick = telemetryPosts().find(
2236
- (post) => post.event === "oss.inspector.threads_tab_clicked",
2237
- );
2238
- expect(threadsTabClick).toBeDefined();
2239
- expect(threadsTabClick!.properties).toMatchObject({
2240
- intelligence_status: "intelligence_not_enabled",
2241
- thread_service_status: "unavailable",
2242
- telemetry_disabled: false,
2243
- });
2244
- expect(threadsTabClick!.properties.distinct_id).toMatch(/^[0-9a-f-]{36}$/);
2245
- if (threadsTabClick!.properties.posthog_distinct_id !== undefined) {
2246
- expect(threadsTabClick!.properties.posthog_distinct_id).toBe(
2247
- threadsTabClick!.properties.distinct_id,
2248
- );
2249
- }
2250
- });
2251
-
2252
- it("renders example threads and the deselected overview when enabled thread history is empty", async () => {
2253
- const { agent } = createMockAgent("alpha");
2254
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true);
2255
-
2256
- const inspector = new WebInspectorElement();
2257
- document.body.appendChild(inspector);
2258
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2259
- harness.emitAgentsChanged();
2260
-
2261
- const internals = inspector as unknown as {
2262
- isOpen: boolean;
2263
- handleMenuSelect: (key: "threads") => void;
2264
- };
2265
- internals.isOpen = true;
2266
- internals.handleMenuSelect("threads");
2267
- await inspector.updateComplete;
2268
-
2269
- await vi.waitFor(() => {
2270
- const text = threadListText(inspector);
2271
- expect(text).toContain("Realtime thread sync");
2272
- expect(text).toContain("Manage saved conversations");
2273
- expect(text).toContain("Inspect durable run history");
2274
- });
2275
-
2276
- const text = inspector.shadowRoot?.textContent ?? "";
2277
- expect(text).toContain("Threads are persistent, inspectable conversations");
2278
- expect(text).toContain(
2279
- "Take a tour with the example threads in the sidebar.",
2280
- );
2281
- const threadsDocs = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
2282
- 'a[href^="https://docs.copilotkit.ai/threads"]',
2283
- );
2284
- expect(threadsDocs?.textContent?.trim()).toBe("Learn how Threads work");
2285
- const threadsDocsUrl = new URL(threadsDocs!.href);
2286
- expect(threadsDocsUrl.origin).toBe("https://docs.copilotkit.ai");
2287
- expect(threadsDocsUrl.pathname).toBe("/threads");
2288
- expect(threadsDocsUrl.searchParams.get("ref")).toBe(
2289
- "cpk-inspector-threads",
2290
- );
2291
- expectNoUtmParams(threadsDocsUrl);
2292
- const selfHosted = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
2293
- 'a[href^="https://docs.copilotkit.ai/premium/self-hosting"]',
2294
- );
2295
- expect(selfHosted?.textContent?.trim()).toBe(
2296
- "Explore self-hosted Intelligence",
2297
- );
2298
- const selfHostedUrl = new URL(selfHosted!.href);
2299
- expect(selfHostedUrl.origin).toBe("https://docs.copilotkit.ai");
2300
- expect(selfHostedUrl.pathname).toBe("/premium/self-hosting");
2301
- expect(selfHostedUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
2302
- expectNoUtmParams(selfHostedUrl);
2303
- expect(threadListText(inspector)).toContain("Example");
2304
- expect(text).not.toContain("No threads yet");
2305
- expect(
2306
- inspector.shadowRoot?.querySelector("cpk-thread-details"),
2307
- ).toBeNull();
2308
- expect(
2309
- (inspector as unknown as InspectorThreadViewInternals).selectedThreadId,
2310
- ).toBeNull();
2311
-
2312
- const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
2313
- 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
2314
- );
2315
- const engineerUrl = new URL(engineer!.href);
2316
- expect(engineerUrl.origin).toBe("https://www.copilotkit.ai");
2317
- expect(engineerUrl.pathname).toBe("/talk-to-an-engineer");
2318
- expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
2319
- expectNoUtmParams(engineerUrl);
2320
- expect(engineer?.closest("#cpk-main-scroll")).toBeNull();
2321
- });
2322
-
2323
- it("defers loading the empty overview video until after the overview paints", async () => {
2324
- vi.useFakeTimers();
2325
- vi.stubGlobal("matchMedia", () => ({ matches: false }));
2326
-
2327
- const { agent } = createMockAgent("alpha");
2328
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
2329
-
2330
- const inspector = new WebInspectorElement();
2331
- document.body.appendChild(inspector);
2332
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2333
- harness.emitAgentsChanged();
2334
-
2335
- const internals = inspector as unknown as {
2336
- isOpen: boolean;
2337
- handleMenuSelect: (key: "threads") => void;
2338
- };
2339
- internals.isOpen = true;
2340
- internals.handleMenuSelect("threads");
2341
- await inspector.updateComplete;
2342
-
2343
- expect(inspector.shadowRoot?.textContent ?? "").toContain(
2344
- "Threads are persistent, inspectable conversations",
2345
- );
2346
- expect(
2347
- inspector.shadowRoot?.querySelector(".cpk-threads-overview-video-frame"),
2348
- ).not.toBeNull();
2349
- expect(
2350
- inspector.shadowRoot?.querySelector(".cpk-threads-overview-video"),
2351
- ).toBeNull();
2352
-
2353
- await vi.advanceTimersByTimeAsync(450);
2354
- await inspector.updateComplete;
2355
-
2356
- const video = inspector.shadowRoot?.querySelector<HTMLVideoElement>(
2357
- ".cpk-threads-overview-video",
2358
- );
2359
- expect(video?.src).toBe(
2360
- "https://cdn.copilotkit.ai/corp-site/videos/copilotkit-generative-ui-agentic-frontend-demo.webm",
2361
- );
2362
- });
2363
-
2364
- it("does not load the empty overview video when reduced motion is preferred", async () => {
2365
- vi.useFakeTimers();
2366
- vi.stubGlobal("matchMedia", () => ({ matches: true }));
2367
-
2368
- const { agent } = createMockAgent("alpha");
2369
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
2370
-
2371
- const inspector = new WebInspectorElement();
2372
- document.body.appendChild(inspector);
2373
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2374
- harness.emitAgentsChanged();
2375
-
2376
- const internals = inspector as unknown as {
2377
- isOpen: boolean;
2378
- handleMenuSelect: (key: "threads") => void;
2379
- };
2380
- internals.isOpen = true;
2381
- internals.handleMenuSelect("threads");
2382
- await inspector.updateComplete;
2383
-
2384
- expect(inspector.shadowRoot?.textContent ?? "").toContain(
2385
- "Threads are persistent, inspectable conversations",
2386
- );
2387
- expect(
2388
- inspector.shadowRoot?.querySelector(".cpk-threads-overview-video-frame"),
2389
- ).not.toBeNull();
2390
-
2391
- await vi.advanceTimersByTimeAsync(1200);
2392
- await inspector.updateComplete;
2393
-
2394
- expect(
2395
- inspector.shadowRoot?.querySelector(".cpk-threads-overview-video"),
2396
- ).toBeNull();
2397
- expect(vi.getTimerCount()).toBe(0);
2398
- });
2399
-
2400
- it("clears the deferred video timeout when disconnected before load", async () => {
2401
- vi.useFakeTimers();
2402
- vi.stubGlobal("matchMedia", () => ({ matches: false }));
2403
- Object.defineProperty(window, "requestIdleCallback", {
2404
- configurable: true,
2405
- value: undefined,
2406
- });
2407
- const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
2408
-
2409
- const { agent } = createMockAgent("alpha");
2410
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
2411
-
2412
- const inspector = new WebInspectorElement();
2413
- document.body.appendChild(inspector);
2414
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2415
- harness.emitAgentsChanged();
2416
-
2417
- const internals = inspector as unknown as {
2418
- isOpen: boolean;
2419
- handleMenuSelect: (key: "threads") => void;
2420
- };
2421
- internals.isOpen = true;
2422
- internals.handleMenuSelect("threads");
2423
- await inspector.updateComplete;
2424
-
2425
- expect(vi.getTimerCount()).toBe(1);
2426
- inspector.remove();
2427
- expect(clearTimeoutSpy).toHaveBeenCalled();
2428
- expect(vi.getTimerCount()).toBe(0);
2429
- });
2430
-
2431
- it("cancels the deferred video idle callback when disconnected before load", async () => {
2432
- vi.useFakeTimers();
2433
- vi.stubGlobal("matchMedia", () => ({ matches: false }));
2434
- const requestIdleCallback = vi.fn(() => 123);
2435
- const cancelIdleCallback = vi.fn();
2436
- Object.defineProperty(window, "requestIdleCallback", {
2437
- configurable: true,
2438
- value: requestIdleCallback,
2439
- });
2440
- Object.defineProperty(window, "cancelIdleCallback", {
2441
- configurable: true,
2442
- value: cancelIdleCallback,
2443
- });
2444
-
2445
- const { agent } = createMockAgent("alpha");
2446
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
2447
-
2448
- const inspector = new WebInspectorElement();
2449
- document.body.appendChild(inspector);
2450
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2451
- harness.emitAgentsChanged();
2452
-
2453
- const internals = inspector as unknown as {
2454
- isOpen: boolean;
2455
- handleMenuSelect: (key: "threads") => void;
2456
- };
2457
- internals.isOpen = true;
2458
- internals.handleMenuSelect("threads");
2459
- await inspector.updateComplete;
2460
-
2461
- expect(requestIdleCallback).toHaveBeenCalledTimes(1);
2462
- inspector.remove();
2463
- expect(cancelIdleCallback).toHaveBeenCalledWith(123);
2464
- });
2465
-
2466
- it("does not render example threads once real threads are present", async () => {
2467
- const inspector = new WebInspectorElement();
2468
- document.body.appendChild(inspector);
2469
- inspector.core = createHeaderMockCore({}, {}, {}, true)
2470
- .core as unknown as WebInspectorElement["core"];
2471
-
2472
- const internals = inspector as unknown as InspectorThreadViewInternals;
2473
- internals.isOpen = true;
2474
- internals.selectedMenu = "threads";
2475
- internals._threads = [
2476
- {
2477
- id: "real-thread",
2478
- name: "Real customer thread",
2479
- agentId: "alpha",
2480
- updatedAt: "2026-06-25T10:00:00.000Z",
2481
- },
2482
- ];
2483
- internals._threadsByAgent = new Map([["alpha", internals._threads]]);
2484
- inspector.requestUpdate();
2485
- await inspector.updateComplete;
2486
-
2487
- const text = threadListText(inspector);
2488
- expect(text).toContain("Real customer thread");
2489
- expect(text).not.toContain("Realtime thread sync");
2490
- expect(text).not.toContain("Example");
2491
- });
2492
-
2493
- it("selects an example thread, shows the tour, and toggles back to the overview on second click", async () => {
2494
- const { agent } = createMockAgent("alpha");
2495
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true);
2496
-
2497
- const inspector = new WebInspectorElement();
2498
- document.body.appendChild(inspector);
2499
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2500
- harness.emitAgentsChanged();
2501
-
2502
- const internals = inspector as unknown as {
2503
- isOpen: boolean;
2504
- handleMenuSelect: (key: "threads") => void;
2505
- selectedThreadId: string | null;
2506
- };
2507
- internals.isOpen = true;
2508
- internals.handleMenuSelect("threads");
2509
- await inspector.updateComplete;
2510
-
2511
- await vi.waitFor(() => {
2512
- expect(threadListText(inspector)).toContain("Realtime thread sync");
2513
- });
2514
-
2515
- const threadList = inspector.shadowRoot?.querySelector("cpk-thread-list");
2516
- const firstRow =
2517
- threadList?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
2518
- expect(firstRow).toBeDefined();
2519
-
2520
- firstRow!.click();
2521
- await inspector.updateComplete;
2522
- await vi.waitFor(() => {
2523
- expect(internals.selectedThreadId).toBe("example-realtime-sync");
2524
- expect(
2525
- inspector.shadowRoot?.querySelector("cpk-thread-details"),
2526
- ).not.toBe(null);
2527
- expect(inspector.shadowRoot?.textContent ?? "").toContain(
2528
- "Read the run as a story",
2529
- );
2530
- });
2531
-
2532
- firstRow!.click();
2533
- await inspector.updateComplete;
2534
-
2535
- await vi.waitFor(() => {
2536
- expect(internals.selectedThreadId).toBeNull();
2537
- expect(
2538
- inspector.shadowRoot?.querySelector("cpk-thread-details"),
2539
- ).toBeNull();
2540
- expect(inspector.shadowRoot?.textContent ?? "").toContain(
2541
- "Threads are persistent, inspectable conversations",
2542
- );
2543
- });
2544
- });
2545
-
2546
- it("persists example tour dismissal so it does not auto-open again", async () => {
2547
- const stored = new Map<string, string>();
2548
- vi.stubGlobal("localStorage", {
2549
- getItem: (key: string) => stored.get(key) ?? null,
2550
- setItem: (key: string, value: string) => stored.set(key, value),
2551
- removeItem: (key: string) => stored.delete(key),
2552
- clear: () => stored.clear(),
2553
- get length() {
2554
- return stored.size;
2555
- },
2556
- key: (index: number) => Array.from(stored.keys())[index] ?? null,
2557
- });
2558
-
2559
- const { agent } = createMockAgent("alpha");
2560
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
2561
-
2562
- const inspector = new WebInspectorElement();
2563
- document.body.appendChild(inspector);
2564
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2565
- harness.emitAgentsChanged();
2566
-
2567
- const internals = inspector as unknown as {
2568
- isOpen: boolean;
2569
- handleMenuSelect: (key: "threads") => void;
2570
- };
2571
- internals.isOpen = true;
2572
- internals.handleMenuSelect("threads");
2573
- await inspector.updateComplete;
2574
-
2575
- await vi.waitFor(() => {
2576
- expect(threadListText(inspector)).toContain("Realtime thread sync");
2577
- });
2578
-
2579
- const firstRow = inspector.shadowRoot
2580
- ?.querySelector("cpk-thread-list")
2581
- ?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
2582
- firstRow!.click();
2583
- await inspector.updateComplete;
2584
-
2585
- await vi.waitFor(() => {
2586
- expect(inspector.shadowRoot?.textContent ?? "").toContain(
2587
- "Read the run as a story",
2588
- );
2589
- });
2590
-
2591
- const skip = Array.from(
2592
- inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
2593
- ).find((button) => button.textContent?.trim() === "Skip");
2594
- expect(skip).toBeDefined();
2595
- skip!.click();
2596
- await inspector.updateComplete;
2597
-
2598
- expect(stored.get("cpk:inspector:threads-example-tour:v1")).toContain(
2599
- '"dismissed":true',
2600
- );
2601
-
2602
- const secondInspector = new WebInspectorElement();
2603
- document.body.appendChild(secondInspector);
2604
- secondInspector.core =
2605
- harness.core as unknown as WebInspectorElement["core"];
2606
- harness.emitAgentsChanged();
2607
- const secondInternals = secondInspector as unknown as {
2608
- isOpen: boolean;
2609
- handleMenuSelect: (key: "threads") => void;
2610
- };
2611
- secondInternals.isOpen = true;
2612
- secondInternals.handleMenuSelect("threads");
2613
- await secondInspector.updateComplete;
2614
-
2615
- await vi.waitFor(() => {
2616
- expect(threadListText(secondInspector)).toContain("Realtime thread sync");
2617
- });
2618
-
2619
- const secondRow = secondInspector.shadowRoot
2620
- ?.querySelector("cpk-thread-list")
2621
- ?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
2622
- secondRow!.click();
2623
- await secondInspector.updateComplete;
2624
-
2625
- await vi.waitFor(() => {
2626
- expect(
2627
- secondInspector.shadowRoot?.querySelector("cpk-thread-details"),
2628
- ).not.toBeNull();
2629
- });
2630
- expect(secondInspector.shadowRoot?.textContent ?? "").not.toContain(
2631
- "Read the run as a story",
2632
- );
2633
- expect(secondInspector.shadowRoot?.textContent ?? "").toContain(
2634
- "Show tour",
2635
- );
2636
-
2637
- const showTour = Array.from(
2638
- secondInspector.shadowRoot?.querySelectorAll<HTMLButtonElement>(
2639
- "button",
2640
- ) ?? [],
2641
- ).find((button) => button.textContent?.trim() === "Show tour");
2642
- expect(showTour).toBeDefined();
2643
- showTour!.click();
2644
- await secondInspector.updateComplete;
2645
-
2646
- await vi.waitFor(() => {
2647
- expect(secondInspector.shadowRoot?.textContent ?? "").toContain(
2648
- "Read the run as a story",
2649
- );
2650
- });
2651
- });
2652
-
2653
- it("tracks example thread selection and tour dismissal telemetry", async () => {
2654
- const { agent } = createMockAgent("alpha");
2655
- const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
2656
-
2657
- const inspector = new WebInspectorElement();
2658
- document.body.appendChild(inspector);
2659
- inspector.core = harness.core as unknown as WebInspectorElement["core"];
2660
- harness.emitAgentsChanged();
2661
-
2662
- const internals = inspector as unknown as {
2663
- isOpen: boolean;
2664
- handleMenuSelect: (key: "threads") => void;
2665
- };
2666
- internals.isOpen = true;
2667
- internals.handleMenuSelect("threads");
2668
- await inspector.updateComplete;
2669
-
2670
- await vi.waitFor(() => {
2671
- expect(threadListText(inspector)).toContain("Realtime thread sync");
2672
- });
2673
-
2674
- const firstRow = inspector.shadowRoot
2675
- ?.querySelector("cpk-thread-list")
2676
- ?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
2677
- firstRow!.click();
2678
- await inspector.updateComplete;
2679
-
2680
- await vi.waitFor(() => {
2681
- expect(inspector.shadowRoot?.textContent ?? "").toContain(
2682
- "Read the run as a story",
2683
- );
2684
- });
2685
-
2686
- const skip = Array.from(
2687
- inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
2688
- ).find((button) => button.textContent?.trim() === "Skip");
2689
- skip!.click();
2690
- await inspector.updateComplete;
2691
-
2692
- const posts = telemetryPosts();
2693
- expect(
2694
- posts.some(
2695
- (post) => post.event === "oss.inspector.threads_example_selected",
2696
- ),
2697
- ).toBe(true);
2698
- expect(
2699
- posts.some(
2700
- (post) => post.event === "oss.inspector.threads_example_tour_started",
2701
- ),
2702
- ).toBe(true);
2703
- const stepViewed = posts.find(
2704
- (post) => post.event === "oss.inspector.threads_example_tour_step_viewed",
2705
- );
2706
- expect(stepViewed?.properties).toMatchObject({
2707
- example_thread_id: "example-realtime-sync",
2708
- tour_step: 1,
2709
- });
2710
- const dismissed = posts.find(
2711
- (post) => post.event === "oss.inspector.threads_example_tour_dismissed",
2712
- );
2713
- expect(dismissed?.properties).toMatchObject({
2714
- example_thread_id: "example-realtime-sync",
2715
- dismiss_method: "skip",
2716
- });
2717
- });
2718
- });
2719
-
2720
- // ─────────────────────────────────────────────────────────────────────────
2721
- // Wave 6 — Memories tab + cpk-memory-list coverage
2722
- // ─────────────────────────────────────────────────────────────────────────
2723
- //
2724
- // 6.1 Helpers: makeCoreWithMemory / makeCoreNoIntelligence / mountMemories
2725
- // 6.2 Subscription: inspector._memories is seeded from store
2726
- // 6.3 Tab presence: "Memory" label appears in the rendered menu
2727
- // 6.4 View states: locked teaser vs. enabled empty vs. enabled with cards
2728
- // 6.5 cpk-memory-list: cards, kind filter, search filter, empty state
2729
- // 6.6 Passive guard: inspector reads from core.getMemoryStore(), never creates its own
2730
-
2731
- // ── 6.1 Helpers ──────────────────────────────────────────────────────────
2732
-
2733
- type MemoryStoreState = {
2734
- memories: Memory[];
2735
- isLoading: boolean;
2736
- isMutating: boolean;
2737
- error: Error | null;
2738
- context: null;
2739
- sessionId: number;
2740
- available: boolean;
2741
- realtimeStatus: "connecting" | "connected" | "unavailable";
2742
- };
2743
-
2744
- /**
2745
- * Returns a minimal mock memory store seeded with the given memories and
2746
- * availability flag. The `select(selector)` method returns an Observable-like
2747
- * that calls the subscriber once synchronously with the derived value, then
2748
- * never again — sufficient for the inspector's subscription wiring.
2749
- */
2750
- function makeMockMemoryStore(
2751
- memories: Memory[],
2752
- available: boolean,
2753
- realtimeStatus: MemoryStoreState["realtimeStatus"] = "connected",
2754
- ): { store: ReturnType<typeof buildStore>; state: MemoryStoreState } {
2755
- const state: MemoryStoreState = {
2756
- memories,
2757
- isLoading: false,
2758
- isMutating: false,
2759
- error: null,
2760
- context: null,
2761
- sessionId: 0,
2762
- available,
2763
- realtimeStatus,
2764
- };
2765
-
2766
- function buildStore() {
2767
- return {
2768
- getState: () => state,
2769
- select: <T>(selector: (s: MemoryStoreState) => T) => ({
2770
- subscribe: (cb: (v: T) => void) => {
2771
- cb(selector(state));
2772
- return { unsubscribe: () => undefined };
2773
- },
2774
- }),
2775
- };
2776
- }
2777
-
2778
- const store = buildStore();
2779
- return { store, state };
2780
- }
2781
-
2782
- type MemoryMockCore = {
2783
- agents: Record<string, AbstractAgent>;
2784
- context: Record<string, unknown>;
2785
- properties: Record<string, unknown>;
2786
- telemetryDisabled: boolean;
2787
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
2788
- intelligence: { wsUrl: string } | undefined;
2789
- subscribe: (subscriber: CopilotKitCoreSubscriber) => {
2790
- unsubscribe: () => void;
2791
- };
2792
- getThreadStores: () => Record<string, never>;
2793
- getThreadStore: (agentId: string) => undefined;
2794
- getMemoryStore: () => ReturnType<typeof makeMockMemoryStore>["store"];
2795
- };
2796
-
2797
- /**
2798
- * Returns a mock core with an intelligence property set (so the memories view
2799
- * is not locked by the intelligence guard) and a memory store seeded with the
2800
- * supplied memories. Pass `available: false` to simulate memories being
2801
- * unavailable (which also locks the view).
2802
- */
2803
- function makeCoreWithMemory(
2804
- memories: Memory[],
2805
- opts: {
2806
- available?: boolean;
2807
- telemetryDisabled?: boolean;
2808
- realtimeStatus?: MemoryStoreState["realtimeStatus"];
2809
- } = {},
2810
- ): MemoryMockCore {
2811
- const available = opts.available ?? true;
2812
- const { store } = makeMockMemoryStore(
2813
- memories,
2814
- available,
2815
- opts.realtimeStatus ?? "connected",
2816
- );
2817
-
2818
- return {
2819
- agents: {},
2820
- context: {},
2821
- properties: {},
2822
- telemetryDisabled: opts.telemetryDisabled ?? false,
2823
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
2824
- // Intelligence present → locked teaser is NOT shown (unless available=false).
2825
- intelligence: { wsUrl: "wss://localhost" },
2826
- subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
2827
- unsubscribe: () => undefined,
2828
- }),
2829
- getThreadStores: () => ({}),
2830
- getThreadStore: (_agentId: string) => undefined,
2831
- getMemoryStore: () => store,
2832
- };
2833
- }
2834
-
2835
- /**
2836
- * Returns a mock core that has NO intelligence property. Used to assert the
2837
- * locked teaser regardless of memory availability.
2838
- */
2839
- function makeCoreNoIntelligence(): MemoryMockCore {
2840
- const { store } = makeMockMemoryStore([], true);
2841
-
2842
- return {
2843
- agents: {},
2844
- context: {},
2845
- properties: {},
2846
- telemetryDisabled: false,
2847
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
2848
- intelligence: undefined,
2849
- subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
2850
- unsubscribe: () => undefined,
2851
- }),
2852
- getThreadStores: () => ({}),
2853
- getThreadStore: (_agentId: string) => undefined,
2854
- getMemoryStore: () => store,
2855
- };
2856
- }
2857
-
2858
- /**
2859
- * Mounts a `<cpk-web-inspector>` with the given core, opens it, and switches
2860
- * to the memories tab. Returns the element ready for assertion.
2861
- */
2862
- async function mountMemories(
2863
- core: MemoryMockCore,
2864
- ): Promise<WebInspectorElement> {
2865
- const el = new WebInspectorElement();
2866
- document.body.appendChild(el);
2867
- // CopilotKitCore is a full class — our mock covers what the inspector reads.
2868
- el.core = core as unknown as WebInspectorElement["core"];
2869
-
2870
- // Open the inspector window so the tab content is rendered.
2871
- const internals = el as unknown as {
2872
- isOpen: boolean;
2873
- handleMenuSelect: (key: string) => void;
2874
- };
2875
- internals.isOpen = true;
2876
- internals.handleMenuSelect("memories");
2877
-
2878
- await el.updateComplete;
2879
- return el;
2880
- }
2881
-
2882
- // ── 6.2 Subscription ─────────────────────────────────────────────────────
2883
-
2884
- describe("WebInspectorElement memories — subscription", () => {
2885
- beforeEach(() => {
2886
- document.body.innerHTML = "";
2887
- vi.stubGlobal("localStorage", {
2888
- getItem: () => null,
2889
- setItem: () => undefined,
2890
- removeItem: () => undefined,
2891
- clear: () => undefined,
2892
- get length() {
2893
- return 0;
2894
- },
2895
- key: () => null,
2896
- });
2897
- });
2898
-
2899
- afterEach(() => {
2900
- vi.unstubAllGlobals();
2901
- });
2902
-
2903
- it("seeds _memories from core.getMemoryStore() on Memories-tab activation", async () => {
2904
- const oneMemory: Memory = {
2905
- id: "m1",
2906
- kind: "topical",
2907
- scope: "user",
2908
- content: "Likes dogs",
2909
- sourceThreadIds: [],
2910
- invalidatedAt: null,
2911
- };
2912
-
2913
- const core = makeCoreWithMemory([oneMemory]);
2914
- const el = await mountMemories(core);
2915
-
2916
- const ids = (el as unknown as { _memories: Memory[] })._memories.map(
2917
- (m) => m.id,
2918
- );
2919
-
2920
- expect(ids).toEqual(["m1"]);
2921
- });
2922
- });
2923
-
2924
- // ── 6.3 Tab presence ─────────────────────────────────────────────────────
2925
-
2926
- describe("WebInspectorElement memories — tab presence", () => {
2927
- beforeEach(() => {
2928
- document.body.innerHTML = "";
2929
- vi.stubGlobal("localStorage", {
2930
- getItem: () => null,
2931
- setItem: () => undefined,
2932
- removeItem: () => undefined,
2933
- clear: () => undefined,
2934
- get length() {
2935
- return 0;
2936
- },
2937
- key: () => null,
2938
- });
2939
- });
2940
-
2941
- afterEach(() => {
2942
- vi.unstubAllGlobals();
2943
- });
2944
-
2945
- it("renders a Memory tab button in the inspector menu", async () => {
2946
- const core = makeCoreWithMemory([]);
2947
- const el = await mountMemories(core);
2948
-
2949
- const buttons = Array.from(
2950
- el.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
2951
- );
2952
- const memoriesButton = buttons.find((btn) =>
2953
- btn.textContent?.trim().includes("Memory"),
2954
- );
2955
-
2956
- expect(memoriesButton, "Memory tab button should render").toBeDefined();
2957
- });
2958
- });
2959
-
2960
- // ── 6.4 View states ──────────────────────────────────────────────────────
2961
-
2962
- describe("WebInspectorElement memories — view states", () => {
2963
- beforeEach(() => {
2964
- document.body.innerHTML = "";
2965
- vi.stubGlobal("localStorage", {
2966
- getItem: () => null,
2967
- setItem: () => undefined,
2968
- removeItem: () => undefined,
2969
- clear: () => undefined,
2970
- get length() {
2971
- return 0;
2972
- },
2973
- key: () => null,
2974
- });
2975
- });
2976
-
2977
- afterEach(() => {
2978
- vi.unstubAllGlobals();
2979
- });
2980
-
2981
- it("renders the locked teaser when intelligence is absent", async () => {
2982
- const core = makeCoreNoIntelligence();
2983
- const el = await mountMemories(core);
2984
-
2985
- const text = el.shadowRoot?.textContent ?? "";
2986
- expect(text).toContain("Long-term memory");
2987
- expect(text).toContain(
2988
- "Long-term memory isn't enabled on this deployment.",
2989
- );
2990
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
2991
- expect(
2992
- memoryList,
2993
- "cpk-memory-list should NOT render when locked",
2994
- ).toBeNull();
2995
- });
2996
-
2997
- it("does not use Threads onboarding UTM attribution for locked memory CTAs", async () => {
2998
- const core = makeCoreNoIntelligence();
2999
- const el = await mountMemories(core);
3000
-
3001
- const talkToEngineer = el.shadowRoot?.querySelector<HTMLAnchorElement>(
3002
- 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
3003
- );
3004
- const signup = el.shadowRoot?.querySelector<HTMLAnchorElement>(
3005
- 'a[href^="https://go.copilotkit.ai/intelligence-signup"]',
3006
- );
3007
-
3008
- expect(talkToEngineer).not.toBeNull();
3009
- expect(signup).not.toBeNull();
3010
-
3011
- for (const href of [talkToEngineer!.href, signup!.href]) {
3012
- const url = new URL(href);
3013
- expect(url.searchParams.get("ref")).toBeTruthy();
3014
- expect(url.searchParams.has("utm_source")).toBe(false);
3015
- expect(url.searchParams.has("utm_medium")).toBe(false);
3016
- expect(url.searchParams.has("utm_campaign")).toBe(false);
3017
- }
3018
- });
3019
-
3020
- it("renders the locked teaser when memories are unavailable", async () => {
3021
- const core = makeCoreWithMemory([], { available: false });
3022
- const el = await mountMemories(core);
3023
-
3024
- const text = el.shadowRoot?.textContent ?? "";
3025
- expect(text).toContain("Long-term memory");
3026
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
3027
- expect(
3028
- memoryList,
3029
- "cpk-memory-list should NOT render when unavailable",
3030
- ).toBeNull();
3031
- });
3032
-
3033
- it("renders cpk-memory-list with empty state when available and no memories", async () => {
3034
- const core = makeCoreWithMemory([], { available: true });
3035
- const el = await mountMemories(core);
3036
-
3037
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
3038
- expect(
3039
- memoryList,
3040
- "cpk-memory-list should render when enabled",
3041
- ).not.toBeNull();
3042
-
3043
- await (memoryList as unknown as { updateComplete: Promise<void> })
3044
- .updateComplete;
3045
- const listText = memoryList?.shadowRoot?.textContent ?? "";
3046
- expect(listText).toContain("No memories yet");
3047
- });
3048
-
3049
- it("keeps the list rendered (not the full-screen error) when a mutation error arrives with memories present", async () => {
3050
- // INSP-2: a failed remove/update sets the store error while a valid list is
3051
- // already on screen. That must NOT blank the list with the full-screen
3052
- // "Failed to load memories" state — the error is surfaced inline instead.
3053
- const oneMemory: Memory = {
3054
- id: "m1",
3055
- kind: "topical",
3056
- scope: "user",
3057
- content: "Likes dogs",
3058
- sourceThreadIds: [],
3059
- invalidatedAt: null,
3060
- };
3061
-
3062
- const core = makeCoreWithMemory([oneMemory]);
3063
- const el = await mountMemories(core);
3064
-
3065
- // Simulate a mutation failure landing after the list is rendered.
3066
- (el as unknown as { _memoriesError: Error | null })._memoriesError =
3067
- new Error("could not delete memory");
3068
- el.requestUpdate();
3069
- await el.updateComplete;
3070
-
3071
- // The list survives.
3072
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
3073
- expect(
3074
- memoryList,
3075
- "cpk-memory-list must remain rendered on a mutation error",
3076
- ).not.toBeNull();
3077
-
3078
- const text = el.shadowRoot?.textContent ?? "";
3079
- // Inline, non-blocking error with distinct copy.
3080
- expect(text).toContain("Action failed: could not delete memory");
3081
- // The full-screen load-failure copy must NOT appear.
3082
- expect(text).not.toContain("Failed to load memories");
3083
- });
3084
-
3085
- it("shows the full-screen load error only when no memories are loaded", async () => {
3086
- // INSP-2 counterpart: a snapshot-load failure (empty list) still shows the
3087
- // full-screen "Failed to load memories" state.
3088
- const core = makeCoreWithMemory([]);
3089
- const el = await mountMemories(core);
3090
-
3091
- (el as unknown as { _memoriesError: Error | null })._memoriesError =
3092
- new Error("network down");
3093
- el.requestUpdate();
3094
- await el.updateComplete;
3095
-
3096
- const text = el.shadowRoot?.textContent ?? "";
3097
- expect(text).toContain("Failed to load memories");
3098
- expect(text).toContain("network down");
3099
- expect(text).not.toContain("Action failed:");
3100
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
3101
- expect(memoryList).toBeNull();
3102
- });
3103
-
3104
- it("shows the 'live' indicator only when realtime is connected", async () => {
3105
- const core = makeCoreWithMemory([], {
3106
- available: true,
3107
- realtimeStatus: "connected",
3108
- });
3109
- const el = await mountMemories(core);
3110
-
3111
- const text = el.shadowRoot?.textContent ?? "";
3112
- expect(text).toContain("live");
3113
- expect(text).not.toContain("offline");
3114
- expect(text).not.toContain("reconnecting");
3115
- });
3116
-
3117
- it("shows a muted 'reconnecting' indicator while realtime is connecting", async () => {
3118
- const core = makeCoreWithMemory([], {
3119
- available: true,
3120
- realtimeStatus: "connecting",
3121
- });
3122
- const el = await mountMemories(core);
3123
-
3124
- const text = el.shadowRoot?.textContent ?? "";
3125
- expect(text).toContain("reconnecting");
3126
- // It must NOT claim "live" while still connecting.
3127
- expect(text).not.toMatch(/>\s*live\s*</);
3128
- });
3129
-
3130
- it("shows a muted 'offline' indicator when realtime has permanently given up", async () => {
3131
- const core = makeCoreWithMemory([], {
3132
- available: true,
3133
- realtimeStatus: "unavailable",
3134
- });
3135
- const el = await mountMemories(core);
3136
-
3137
- const text = el.shadowRoot?.textContent ?? "";
3138
- expect(text).toContain("offline");
3139
- // The frozen snapshot must NOT be labelled "live".
3140
- expect(text).not.toMatch(/>\s*live\s*</);
3141
- });
3142
-
3143
- it("renders cpk-memory-list with a card when one memory is present", async () => {
3144
- const oneMemory: Memory = {
3145
- id: "m1",
3146
- kind: "topical",
3147
- scope: "user",
3148
- content: "Prefers dark mode",
3149
- sourceThreadIds: [],
3150
- invalidatedAt: null,
3151
- };
3152
-
3153
- const core = makeCoreWithMemory([oneMemory]);
3154
- const el = await mountMemories(core);
3155
-
3156
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
3157
- expect(memoryList, "cpk-memory-list should render").not.toBeNull();
3158
-
3159
- await (memoryList as unknown as { updateComplete: Promise<void> })
3160
- .updateComplete;
3161
- const cards = memoryList?.shadowRoot?.querySelectorAll(".cpk-ml__card");
3162
- expect(cards?.length).toBe(1);
3163
- });
3164
- });
3165
-
3166
- // ── 6.5 cpk-memory-list ──────────────────────────────────────────────────
3167
-
3168
- describe("cpk-memory-list", () => {
3169
- const threeMemories: Memory[] = [
3170
- {
3171
- id: "t1",
3172
- kind: "topical",
3173
- scope: "user",
3174
- content: "Likes cats",
3175
- sourceThreadIds: [],
3176
- invalidatedAt: null,
3177
- },
3178
- {
3179
- id: "e1",
3180
- kind: "episodic",
3181
- scope: "user",
3182
- content: "First login was on a Monday",
3183
- sourceThreadIds: [],
3184
- invalidatedAt: null,
3185
- },
3186
- {
3187
- id: "o1",
3188
- kind: "operational",
3189
- scope: "user",
3190
- content: "Deploys on Thursdays",
3191
- sourceThreadIds: [],
3192
- invalidatedAt: null,
3193
- },
3194
- ];
3195
-
3196
- /** Create and mount a standalone cpk-memory-list element. */
3197
- async function mountList(memories: Memory[]): Promise<Element> {
3198
- const el = document.createElement("cpk-memory-list");
3199
- document.body.appendChild(el);
3200
- // Assign memories via property (same as Lit's .memories=${...} binding).
3201
- (el as unknown as { memories: Memory[] }).memories = memories;
3202
- // Trigger update if the element is a Lit element.
3203
- if ("updateComplete" in el) {
3204
- await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
3205
- }
3206
- return el;
3207
- }
3208
-
3209
- beforeEach(() => {
3210
- document.body.innerHTML = "";
3211
- });
3212
-
3213
- it("renders one card per memory in order", async () => {
3214
- const el = await mountList(threeMemories);
3215
- const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
3216
- expect(cards?.length).toBe(3);
3217
- const contents = Array.from(cards ?? []).map((card) =>
3218
- card.querySelector(".cpk-ml__content")?.textContent?.trim(),
3219
- );
3220
- expect(contents).toEqual([
3221
- "Likes cats",
3222
- "First login was on a Monday",
3223
- "Deploys on Thursdays",
3224
- ]);
3225
- });
3226
-
3227
- it("narrows cards when an operational kind filter is clicked", async () => {
3228
- const el = await mountList(threeMemories);
3229
-
3230
- const operationalSeg = el.shadowRoot?.querySelector<HTMLElement>(
3231
- '[data-kind="operational"]',
3232
- );
3233
- expect(
3234
- operationalSeg,
3235
- "operational filter segment should exist",
3236
- ).not.toBeNull();
3237
-
3238
- operationalSeg!.click();
3239
- await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
3240
-
3241
- const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
3242
- expect(cards?.length).toBe(1);
3243
- expect(
3244
- cards?.[0]?.querySelector(".cpk-ml__content")?.textContent?.trim(),
3245
- ).toBe("Deploys on Thursdays");
3246
- });
3247
-
3248
- it("filters cards by search text (case-insensitive)", async () => {
3249
- const el = await mountList(threeMemories);
3250
-
3251
- const searchInput = el.shadowRoot?.querySelector<HTMLInputElement>(
3252
- ".cpk-ml__search-input",
3253
- );
3254
- expect(searchInput, "search input should exist").not.toBeNull();
3255
-
3256
- searchInput!.value = "deploy";
3257
- searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
3258
- await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
3259
-
3260
- const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
3261
- expect(cards?.length).toBe(1);
3262
- expect(
3263
- cards?.[0]?.querySelector(".cpk-ml__content")?.textContent?.trim(),
3264
- ).toBe("Deploys on Thursdays");
3265
- });
3266
-
3267
- it("shows the empty state when memories is empty", async () => {
3268
- const el = await mountList([]);
3269
- const empty = el.shadowRoot?.querySelector(".cpk-ml__empty");
3270
- expect(empty, "empty state should render").not.toBeNull();
3271
- expect(el.shadowRoot?.textContent ?? "").toContain("No memories yet");
3272
- const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
3273
- expect(cards?.length ?? 0).toBe(0);
3274
- });
3275
- });
3276
-
3277
- // ── 6.6 Passive guard ────────────────────────────────────────────────────
3278
-
3279
- describe("WebInspectorElement memories — passive store guard", () => {
3280
- beforeEach(() => {
3281
- document.body.innerHTML = "";
3282
- vi.stubGlobal("localStorage", {
3283
- getItem: () => null,
3284
- setItem: () => undefined,
3285
- removeItem: () => undefined,
3286
- clear: () => undefined,
3287
- get length() {
3288
- return 0;
3289
- },
3290
- key: () => null,
3291
- });
3292
- });
3293
-
3294
- afterEach(() => {
3295
- vi.unstubAllGlobals();
3296
- });
3297
-
3298
- it("calls core.getMemoryStore() on tab activation and reads from the returned store", async () => {
3299
- const core = makeCoreWithMemory([]);
3300
- const spy = vi.spyOn(core, "getMemoryStore");
3301
-
3302
- await mountMemories(core);
3303
-
3304
- expect(spy).toHaveBeenCalled();
3305
-
3306
- // The store instance that spy captured is the exact same object that
3307
- // core.getMemoryStore() returns — inspector reads from it, never wraps it.
3308
- const returnedStore = spy.mock.results[0]?.value;
3309
- expect(returnedStore).toBeDefined();
3310
- // Verify the inspector consumed the store by checking its getState was accessible
3311
- // (if the inspector had created its own store instead, this reference would differ).
3312
- expect(typeof returnedStore.getState).toBe("function");
3313
- expect(typeof returnedStore.select).toBe("function");
3314
- });
3315
-
3316
- it("does NOT call core.getMemoryStore() merely by attaching the inspector", async () => {
3317
- // INSP-1: getMemoryStore() lazily creates + starts the store and opens
3318
- // realtime, so attaching the inspector must touch nothing. The store is
3319
- // only created when the user activates the Memories tab.
3320
- const core = makeCoreWithMemory([]);
3321
- const spy = vi.spyOn(core, "getMemoryStore");
3322
-
3323
- const el = new WebInspectorElement();
3324
- document.body.appendChild(el);
3325
- el.core = core as unknown as WebInspectorElement["core"];
3326
- (el as unknown as { isOpen: boolean }).isOpen = true;
3327
- await el.updateComplete;
3328
-
3329
- expect(spy).not.toHaveBeenCalled();
3330
-
3331
- // Activating the Memories tab is what creates + subscribes to the store.
3332
- (
3333
- el as unknown as { handleMenuSelect: (k: string) => void }
3334
- ).handleMenuSelect("memories");
3335
- await el.updateComplete;
3336
-
3337
- expect(spy).toHaveBeenCalledTimes(1);
3338
- });
3339
-
3340
- it("does not double-subscribe when the Memories tab is re-activated", async () => {
3341
- const core = makeCoreWithMemory([]);
3342
- const spy = vi.spyOn(core, "getMemoryStore");
3343
-
3344
- const el = await mountMemories(core);
3345
- expect(spy).toHaveBeenCalledTimes(1);
3346
-
3347
- // Re-activate the Memories tab — the guard must prevent a second
3348
- // getMemoryStore() call (which would create a second store/realtime).
3349
- (
3350
- el as unknown as { handleMenuSelect: (k: string) => void }
3351
- ).handleMenuSelect("memories");
3352
- await el.updateComplete;
3353
-
3354
- expect(spy).toHaveBeenCalledTimes(1);
3355
- });
3356
-
3357
- it("re-subscribes after detach when the Memories tab is activated again", async () => {
3358
- const core = makeCoreWithMemory([]);
3359
- const spy = vi.spyOn(core, "getMemoryStore");
3360
-
3361
- const el = await mountMemories(core);
3362
- expect(spy).toHaveBeenCalledTimes(1);
3363
-
3364
- // Detach (core = null) must reset the lazy-subscription guard.
3365
- el.core = null;
3366
- await el.updateComplete;
3367
-
3368
- // Re-attach + re-activate the tab → a fresh subscription is created.
3369
- el.core = core as unknown as WebInspectorElement["core"];
3370
- (el as unknown as { isOpen: boolean }).isOpen = true;
3371
- (
3372
- el as unknown as { handleMenuSelect: (k: string) => void }
3373
- ).handleMenuSelect("memories");
3374
- await el.updateComplete;
3375
-
3376
- expect(spy).toHaveBeenCalledTimes(2);
3377
- });
3378
- });
3379
-
3380
- // ── 6.6.1 Active-on-boot subscription ─────────────────────────────────────
3381
- //
3382
- // The memory subscription is normally created on a Memories-tab CLICK
3383
- // (handleMenuSelect → ensureMemorySubscription). But when the inspector boots
3384
- // with the Memories tab ALREADY active — e.g. a persisted
3385
- // `selectedMenu: "memories"` restored by hydrateStateFromStorageEarly — no
3386
- // click ever fires, so historically no subscription was created: the realtime
3387
- // indicator stayed stuck on the default "connecting" (rendered "reconnecting")
3388
- // and the list was empty until the user toggled tabs. The fix subscribes when
3389
- // the Memories tab is the active tab on boot, gated on the active tab so it
3390
- // still does not subscribe in apps not viewing memory (INSP-1).
3391
-
3392
- describe("WebInspectorElement memories — active-on-boot subscription", () => {
3393
- beforeEach(() => {
3394
- document.body.innerHTML = "";
3395
- // Persist `selectedMenu: "memories"` so hydrateStateFromStorageEarly (run in
3396
- // connectedCallback, before any user interaction) restores the Memories tab
3397
- // as the active tab — reproducing the stuck-indicator boot scenario.
3398
- const store: Record<string, string> = {
3399
- "cpk:inspector:state": JSON.stringify({ selectedMenu: "memories" }),
3400
- };
3401
- vi.stubGlobal("localStorage", {
3402
- getItem: (key: string) => store[key] ?? null,
3403
- setItem: (key: string, value: string) => {
3404
- store[key] = value;
3405
- },
3406
- removeItem: (key: string) => {
3407
- delete store[key];
3408
- },
3409
- clear: () => {
3410
- for (const key of Object.keys(store)) delete store[key];
3411
- },
3412
- get length() {
3413
- return Object.keys(store).length;
3414
- },
3415
- key: (index: number) => Object.keys(store)[index] ?? null,
3416
- });
3417
- });
3418
-
3419
- afterEach(() => {
3420
- vi.unstubAllGlobals();
3421
- });
3422
-
3423
- it("subscribes to the memory store on boot when the Memories tab is already active (no click)", async () => {
3424
- // The store reports a live realtime status. If the inspector subscribes on
3425
- // boot, _memoriesRealtimeStatus reflects "connected"; if it does NOT (the
3426
- // bug), it stays on the default "connecting".
3427
- const core = makeCoreWithMemory([], { realtimeStatus: "connected" });
3428
- const spy = vi.spyOn(core, "getMemoryStore");
3429
-
3430
- const el = new WebInspectorElement();
3431
- document.body.appendChild(el);
3432
- // connectedCallback has already restored selectedMenu = "memories".
3433
- // Assigning core (the realistic boot path) must trigger the subscription
3434
- // without any handleMenuSelect click.
3435
- el.core = core as unknown as WebInspectorElement["core"];
3436
- (el as unknown as { isOpen: boolean }).isOpen = true;
3437
- await el.updateComplete;
3438
-
3439
- expect(
3440
- (el as unknown as { selectedMenu: string }).selectedMenu,
3441
- "persisted Memories tab should be the active tab on boot",
3442
- ).toBe("memories");
3443
-
3444
- // The store was created + subscribed WITHOUT a tab click.
3445
- expect(
3446
- spy,
3447
- "core.getMemoryStore() must be called on boot when Memories is active",
3448
- ).toHaveBeenCalled();
3449
-
3450
- // The live status from the store is reflected — not the stuck default.
3451
- expect(
3452
- (el as unknown as { _memoriesRealtimeStatus: string })
3453
- ._memoriesRealtimeStatus,
3454
- "realtime status must reflect the store, not the default 'connecting'",
3455
- ).toBe("connected");
3456
- });
3457
-
3458
- it("does not double-subscribe when boot subscription is followed by a Memories-tab click", async () => {
3459
- // The boot subscription must be idempotent: a later explicit click must not
3460
- // create a second store/realtime connection.
3461
- const core = makeCoreWithMemory([], { realtimeStatus: "connected" });
3462
- const spy = vi.spyOn(core, "getMemoryStore");
3463
-
3464
- const el = new WebInspectorElement();
3465
- document.body.appendChild(el);
3466
- el.core = core as unknown as WebInspectorElement["core"];
3467
- (el as unknown as { isOpen: boolean }).isOpen = true;
3468
- await el.updateComplete;
3469
-
3470
- expect(spy).toHaveBeenCalledTimes(1);
3471
-
3472
- (
3473
- el as unknown as { handleMenuSelect: (k: string) => void }
3474
- ).handleMenuSelect("memories");
3475
- await el.updateComplete;
3476
-
3477
- expect(spy).toHaveBeenCalledTimes(1);
3478
- });
3479
- });
3480
-
3481
- // ── 6.7 Older-core compat: missing getMemoryStore ────────────────────────
3482
- //
3483
- // An inspector attached to an older @copilotkit/core that predates
3484
- // getMemoryStore must not throw. The guard added in attachToCore must fall
3485
- // through to the else branch, set _memoriesAvailable = false, and leave the
3486
- // memories tab in the locked-teaser state — exactly like a core that defines
3487
- // the method but returns available=false.
3488
-
3489
- describe("WebInspectorElement memories — older-core compat (no getMemoryStore)", () => {
3490
- beforeEach(() => {
3491
- document.body.innerHTML = "";
3492
- vi.stubGlobal("localStorage", {
3493
- getItem: () => null,
3494
- setItem: () => undefined,
3495
- removeItem: () => undefined,
3496
- clear: () => undefined,
3497
- get length() {
3498
- return 0;
3499
- },
3500
- key: () => null,
3501
- });
3502
- });
3503
-
3504
- afterEach(() => {
3505
- vi.unstubAllGlobals();
3506
- });
3507
-
3508
- it("does not throw when core lacks getMemoryStore, and renders the locked teaser", async () => {
3509
- // Build a minimal core that does NOT define getMemoryStore — simulating an
3510
- // older @copilotkit/core package. We deliberately omit the method rather
3511
- // than setting it to undefined so the typeof guard fires correctly.
3512
- const olderCore = {
3513
- agents: {},
3514
- context: {},
3515
- properties: {},
3516
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
3517
- // intelligence present so the only lock-cause is the missing store method
3518
- intelligence: { wsUrl: "wss://localhost" },
3519
- subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
3520
- unsubscribe: () => undefined,
3521
- }),
3522
- getThreadStores: () => ({}),
3523
- getThreadStore: (_agentId: string) => undefined,
3524
- // getMemoryStore intentionally absent
3525
- };
3526
-
3527
- const el = new WebInspectorElement();
3528
- document.body.appendChild(el);
3529
-
3530
- // Assigning core must not throw even though getMemoryStore is missing.
3531
- expect(() => {
3532
- el.core = olderCore as unknown as WebInspectorElement["core"];
3533
- }).not.toThrow();
3534
-
3535
- // Open and switch to the memories tab so the view state is rendered.
3536
- const internals = el as unknown as {
3537
- isOpen: boolean;
3538
- handleMenuSelect: (key: string) => void;
3539
- };
3540
- internals.isOpen = true;
3541
- internals.handleMenuSelect("memories");
3542
- await el.updateComplete;
3543
-
3544
- // The locked teaser must render — cpk-memory-list must NOT appear.
3545
- const text = el.shadowRoot?.textContent ?? "";
3546
- expect(text).toContain("Long-term memory");
3547
- const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
3548
- expect(
3549
- memoryList,
3550
- "cpk-memory-list must not render when getMemoryStore is absent",
3551
- ).toBeNull();
3552
- });
3553
-
3554
- it("shows the SDK-upgrade teaser (distinct from the not-enabled teaser) when getMemoryStore is absent", async () => {
3555
- // INSP-3: an older @copilotkit/core (no getMemoryStore) must guide an SDK
3556
- // upgrade, with copy distinct from the genuine "not enabled on this
3557
- // deployment" teaser shown by a current SDK against a memory-less backend.
3558
- const olderCore = {
3559
- agents: {},
3560
- context: {},
3561
- properties: {},
3562
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
3563
- intelligence: { wsUrl: "wss://localhost" },
3564
- subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
3565
- unsubscribe: () => undefined,
3566
- }),
3567
- getThreadStores: () => ({}),
3568
- getThreadStore: (_agentId: string) => undefined,
3569
- // getMemoryStore intentionally absent
3570
- };
3571
-
3572
- const el = new WebInspectorElement();
3573
- document.body.appendChild(el);
3574
- el.core = olderCore as unknown as WebInspectorElement["core"];
3575
- const internals = el as unknown as {
3576
- isOpen: boolean;
3577
- handleMenuSelect: (key: string) => void;
3578
- };
3579
- internals.isOpen = true;
3580
- internals.handleMenuSelect("memories");
3581
- await el.updateComplete;
3582
-
3583
- const text = el.shadowRoot?.textContent ?? "";
3584
- expect(text).toContain("@copilotkit SDK");
3585
- expect(text).toContain("Upgrade");
3586
- // Must NOT show the deployment-not-enabled copy in this case.
3587
- expect(text).not.toContain(
3588
- "Long-term memory isn't enabled on this deployment.",
3589
- );
3590
- });
3591
-
3592
- it("shows the not-enabled teaser (distinct from the upgrade teaser) when the current SDK reports memory unavailable", async () => {
3593
- // INSP-3 counterpart: a current SDK (getMemoryStore present) whose store
3594
- // reports available=false shows the deployment teaser, NOT upgrade copy.
3595
- const core = makeCoreWithMemory([], { available: false });
3596
- const el = await mountMemories(core);
3597
-
3598
- const text = el.shadowRoot?.textContent ?? "";
3599
- expect(text).toContain(
3600
- "Long-term memory isn't enabled on this deployment.",
3601
- );
3602
- expect(text).not.toContain("@copilotkit SDK");
3603
- });
3604
- });
3605
-
3606
- // ── 6.8 Memories tab telemetry gating (A7) + detach reset (A8) ────────────
3607
- //
3608
- // The memories tab is the only telemetry call site that must honor the host
3609
- // `core.telemetryDisabled` opt-out and must not re-fire on every click. These
3610
- // tests mirror the Threads tab-click telemetry test. They also cover that
3611
- // detachFromCore resets the memory view state so a later attach to an older
3612
- // core never leaks stale memory counts into telemetry.
3613
-
3614
- describe("WebInspectorElement memories — tab telemetry + detach reset", () => {
3615
- let fetchMock: ReturnType<typeof vi.fn>;
3616
-
3617
- const telemetryPosts = () =>
3618
- fetchMock.mock.calls
3619
- .filter(
3620
- (call) =>
3621
- String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
3622
- (call[1] as RequestInit | undefined)?.method === "POST",
3623
- )
3624
- .map((call) => {
3625
- const body =
3626
- ((call[1] as RequestInit | undefined)?.body as string) ?? "{}";
3627
- return JSON.parse(body) as {
3628
- event: string;
3629
- properties: Record<string, unknown>;
3630
- };
3631
- });
3632
-
3633
- const memoriesTabClicks = () =>
3634
- telemetryPosts().filter(
3635
- (post) => post.event === "oss.inspector.memories_tab_clicked",
3636
- );
3637
-
3638
- beforeEach(() => {
3639
- document.body.innerHTML = "";
3640
- // localStorage not opted out → track() proceeds to the fetch sink.
3641
- vi.stubGlobal("localStorage", {
3642
- getItem: () => null,
3643
- setItem: () => undefined,
3644
- removeItem: () => undefined,
3645
- clear: () => undefined,
3646
- get length() {
3647
- return 0;
3648
- },
3649
- key: () => null,
3650
- });
3651
- fetchMock = vi.fn(() =>
3652
- Promise.resolve({
3653
- ok: true,
3654
- status: 200,
3655
- json: () => Promise.resolve({}),
3656
- }),
3657
- );
3658
- vi.stubGlobal("fetch", fetchMock);
3659
- });
3660
-
3661
- afterEach(() => {
3662
- vi.unstubAllGlobals();
3663
- });
3664
-
3665
- it("posts memories_tab_clicked when the Memories tab is selected", async () => {
3666
- const oneMemory: Memory = {
3667
- id: "m1",
3668
- kind: "topical",
3669
- scope: "user",
3670
- content: "Likes dogs",
3671
- sourceThreadIds: [],
3672
- invalidatedAt: null,
3673
- };
3674
-
3675
- const core = makeCoreWithMemory([oneMemory]);
3676
- await mountMemories(core);
3677
- await Promise.resolve();
3678
-
3679
- const clicks = memoriesTabClicks();
3680
- expect(clicks).toHaveLength(1);
3681
- expect(clicks[0]!.properties).toMatchObject({
3682
- memory_count: 1,
3683
- available: true,
3684
- });
3685
- });
3686
-
3687
- it("does NOT post memories_tab_clicked when core.telemetryDisabled is true", async () => {
3688
- const core = makeCoreWithMemory([], { telemetryDisabled: true });
3689
- await mountMemories(core);
3690
- await Promise.resolve();
3691
-
3692
- expect(memoriesTabClicks()).toHaveLength(0);
3693
- });
3694
-
3695
- it("does not double-fire when the already-active Memories tab is re-selected", async () => {
3696
- const core = makeCoreWithMemory([]);
3697
- const el = await mountMemories(core);
3698
- await Promise.resolve();
3699
-
3700
- // Re-select the already-active Memories tab.
3701
- const internals = el as unknown as {
3702
- handleMenuSelect: (key: string) => void;
3703
- };
3704
- internals.handleMenuSelect("memories");
3705
- await el.updateComplete;
3706
- await Promise.resolve();
3707
-
3708
- expect(memoriesTabClicks()).toHaveLength(1);
3709
- });
3710
-
3711
- it("resets memory view state on detachFromCore (memories empty, count 0)", async () => {
3712
- const oneMemory: Memory = {
3713
- id: "m1",
3714
- kind: "topical",
3715
- scope: "user",
3716
- content: "Likes dogs",
3717
- sourceThreadIds: [],
3718
- invalidatedAt: null,
3719
- };
3720
-
3721
- const core = makeCoreWithMemory([oneMemory]);
3722
- const el = await mountMemories(core);
3723
- await Promise.resolve();
3724
-
3725
- // Sanity: the seeded memory is present before detach.
3726
- expect(
3727
- (el as unknown as { _memories: Memory[] })._memories.map((m) => m.id),
3728
- ).toEqual(["m1"]);
3729
-
3730
- // Reassigning core triggers detachFromCore(); null means no re-attach.
3731
- el.core = null;
3732
- await el.updateComplete;
3733
-
3734
- const state = el as unknown as {
3735
- _memories: Memory[];
3736
- _memoriesLoading: boolean;
3737
- _memoriesError: Error | null;
3738
- _memoriesAvailable: boolean;
3739
- };
3740
- expect(state._memories).toEqual([]);
3741
- expect(state._memories).toHaveLength(0);
3742
- expect(state._memoriesLoading).toBe(false);
3743
- expect(state._memoriesError).toBeNull();
3744
- expect(state._memoriesAvailable).toBe(true);
3745
- });
3746
- });
3747
-
3748
- describe("ɵbuildCapabilityRows", () => {
3749
- it("maps core.tools to rows, reflects isToolEnabled, and sorts by agentId then name", () => {
3750
- const enabled = new Set(["b-tool"]);
3751
- const core = {
3752
- tools: [
3753
- { name: "z-tool", agentId: "agent-2", description: "zed" },
3754
- { name: "a-tool", agentId: "agent-1" },
3755
- { name: "b-tool" },
3756
- ],
3757
- isToolEnabled: (name: string) => enabled.has(name),
3758
- };
3759
- const rows = ɵbuildCapabilityRows(core);
3760
- expect(rows.map((r) => r.name)).toEqual(["b-tool", "a-tool", "z-tool"]);
3761
- expect(rows[0]).toMatchObject({
3762
- key: ":b-tool",
3763
- name: "b-tool",
3764
- agentId: undefined,
3765
- enabled: true,
3766
- });
3767
- expect(rows.find((r) => r.name === "a-tool")).toMatchObject({
3768
- key: "agent-1:a-tool",
3769
- enabled: false,
3770
- });
3771
- expect(rows.find((r) => r.name === "z-tool")).toMatchObject({
3772
- description: "zed",
3773
- enabled: false,
3774
- });
3775
- });
3776
-
3777
- it("passes isToolEnabled the tool's agentId (per-agent enablement)", () => {
3778
- const calls: Array<[string, string | undefined]> = [];
3779
- const core = {
3780
- tools: [{ name: "t", agentId: "agent-x" }],
3781
- isToolEnabled: (name: string, agentId?: string) => {
3782
- calls.push([name, agentId]);
3783
- return true;
3784
- },
3785
- };
3786
- ɵbuildCapabilityRows(core);
3787
- expect(calls).toEqual([["t", "agent-x"]]);
3788
- });
3789
-
3790
- it("returns an empty array when there are no tools", () => {
3791
- expect(
3792
- ɵbuildCapabilityRows({ tools: [], isToolEnabled: () => false }),
3793
- ).toEqual([]);
3794
- expect(ɵbuildCapabilityRows({ isToolEnabled: () => false })).toEqual([]);
3795
- });
3796
- });
3797
-
3798
- describe("WebInspectorElement Capabilities tab", () => {
3799
- beforeEach(() => {
3800
- document.body.innerHTML = "";
3801
- const store: Record<string, string> = {};
3802
- vi.stubGlobal("localStorage", {
3803
- getItem: (k: string) => store[k] ?? null,
3804
- setItem: (k: string, v: string) => {
3805
- store[k] = v;
3806
- },
3807
- removeItem: (k: string) => {
3808
- delete store[k];
3809
- },
3810
- clear: () => {
3811
- for (const k of Object.keys(store)) delete store[k];
3812
- },
3813
- get length() {
3814
- return Object.keys(store).length;
3815
- },
3816
- key: (i: number) => Object.keys(store)[i] ?? null,
3817
- });
3818
- });
3819
-
3820
- function createCapabilitiesCore() {
3821
- const toolEnabled: Record<string, boolean> = { greet: true, hide: true };
3822
- const catalogEnabled: Record<string, boolean> = { Chart: true };
3823
- const setToolEnabled = vi.fn(
3824
- (name: string, enabled: boolean, _agentId?: string) => {
3825
- toolEnabled[name] = enabled;
3826
- },
3827
- );
3828
- const setCatalogComponentEnabled = vi.fn(
3829
- (name: string, enabled: boolean) => {
3830
- catalogEnabled[name] = enabled;
3831
- },
3832
- );
3833
- const core = {
3834
- agents: {},
3835
- context: {},
3836
- properties: {},
3837
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
3838
- subscribe: () => ({ unsubscribe: () => undefined }),
3839
- getThreadStores: () => ({}),
3840
- getThreadStore: () => undefined,
3841
- getMemoryStore: () => ({
3842
- getState: () => ({ available: true }),
3843
- select: () => ({
3844
- subscribe: (cb: (v: unknown) => void) => {
3845
- cb(undefined);
3846
- return { unsubscribe: () => undefined };
3847
- },
3848
- }),
3849
- }),
3850
- tools: [{ name: "greet", description: "Say hi" }, { name: "hide" }],
3851
- isToolEnabled: (name: string) => toolEnabled[name] ?? true,
3852
- setToolEnabled,
3853
- catalogComponents: [{ name: "Chart", schema: {} }],
3854
- isCatalogComponentEnabled: (name: string) => catalogEnabled[name] ?? true,
3855
- setCatalogComponentEnabled,
3856
- };
3857
- return { core, setToolEnabled, setCatalogComponentEnabled };
3858
- }
3859
-
3860
- it("shows the Capabilities tab and renders both sections", async () => {
3861
- const { core } = createCapabilitiesCore();
3862
- const inspector = new WebInspectorElement();
3863
- document.body.appendChild(inspector);
3864
- inspector.core = core as unknown as WebInspectorElement["core"];
3865
- (inspector as unknown as { isOpen: boolean }).isOpen = true;
3866
- (
3867
- inspector as unknown as { handleMenuSelect: (k: string) => void }
3868
- ).handleMenuSelect("capabilities");
3869
- await inspector.updateComplete;
3870
- const text = inspector.shadowRoot?.textContent ?? "";
3871
- expect(text).toContain("Frontend tools");
3872
- expect(text).toContain("A2UI catalog components");
3873
- expect(text).toContain("greet");
3874
- expect(text).toContain("Chart");
3875
- });
3876
-
3877
- it("calls setToolEnabled(false) when a tool switch is toggled off", async () => {
3878
- const { core, setToolEnabled } = createCapabilitiesCore();
3879
- const inspector = new WebInspectorElement();
3880
- document.body.appendChild(inspector);
3881
- inspector.core = core as unknown as WebInspectorElement["core"];
3882
- (inspector as unknown as { isOpen: boolean }).isOpen = true;
3883
- (
3884
- inspector as unknown as { handleMenuSelect: (k: string) => void }
3885
- ).handleMenuSelect("capabilities");
3886
- await inspector.updateComplete;
3887
- const switches =
3888
- inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>(
3889
- 'button[role="switch"]',
3890
- ) ?? [];
3891
- switches[0]?.click();
3892
- await inspector.updateComplete;
3893
- expect(setToolEnabled).toHaveBeenCalledWith("greet", false, undefined);
3894
- const refreshed =
3895
- inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>(
3896
- 'button[role="switch"]',
3897
- ) ?? [];
3898
- expect(refreshed[0]?.getAttribute("aria-checked")).toBe("false");
3899
- });
3900
-
3901
- it("calls setCatalogComponentEnabled when a catalog switch is toggled", async () => {
3902
- const { core, setCatalogComponentEnabled } = createCapabilitiesCore();
3903
- const inspector = new WebInspectorElement();
3904
- document.body.appendChild(inspector);
3905
- inspector.core = core as unknown as WebInspectorElement["core"];
3906
- (inspector as unknown as { isOpen: boolean }).isOpen = true;
3907
- (
3908
- inspector as unknown as { handleMenuSelect: (k: string) => void }
3909
- ).handleMenuSelect("capabilities");
3910
- await inspector.updateComplete;
3911
- const switches =
3912
- inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>(
3913
- 'button[role="switch"]',
3914
- ) ?? [];
3915
- switches[switches.length - 1]?.click();
3916
- await inspector.updateComplete;
3917
- expect(setCatalogComponentEnabled).toHaveBeenCalledWith("Chart", false);
3918
- });
3919
-
3920
- it("hides the catalog section when catalogComponents is empty", async () => {
3921
- const { core } = createCapabilitiesCore();
3922
- (core as { catalogComponents: unknown[] }).catalogComponents = [];
3923
- const inspector = new WebInspectorElement();
3924
- document.body.appendChild(inspector);
3925
- inspector.core = core as unknown as WebInspectorElement["core"];
3926
- (inspector as unknown as { isOpen: boolean }).isOpen = true;
3927
- (
3928
- inspector as unknown as { handleMenuSelect: (k: string) => void }
3929
- ).handleMenuSelect("capabilities");
3930
- await inspector.updateComplete;
3931
- const text = inspector.shadowRoot?.textContent ?? "";
3932
- expect(text).toContain("Frontend tools");
3933
- expect(text).not.toContain("A2UI catalog components");
3934
- });
3935
- });