@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.
- package/README.md +92 -13
- package/dist/index.cjs +1436 -429
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -8
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +118 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1438 -431
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1721 -533
- package/dist/index.umd.js.map +1 -1
- package/dist/lib/inspector-metadata.cjs +60 -0
- package/dist/lib/inspector-metadata.cjs.map +1 -0
- package/dist/lib/inspector-metadata.mjs +60 -0
- package/dist/lib/inspector-metadata.mjs.map +1 -0
- package/dist/lib/telemetry.cjs +117 -32
- package/dist/lib/telemetry.cjs.map +1 -1
- package/dist/lib/telemetry.mjs +116 -33
- package/dist/lib/telemetry.mjs.map +1 -1
- package/dist/lib/thread-selection.cjs +36 -0
- package/dist/lib/thread-selection.cjs.map +1 -0
- package/dist/lib/thread-selection.mjs +35 -0
- package/dist/lib/thread-selection.mjs.map +1 -0
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/styles/generated.cjs +1 -1
- package/dist/styles/generated.cjs.map +1 -1
- package/dist/styles/generated.mjs +1 -1
- package/dist/styles/generated.mjs.map +1 -1
- package/package.json +9 -4
- package/CHANGELOG.md +0 -505
- package/dev/css-raw-import.spec.ts +0 -31
- package/dev/index.html +0 -115
- package/dev/main.ts +0 -165
- package/dev/vite.config.ts +0 -30
- package/src/__tests__/memory-recall.spec.ts +0 -76
- package/src/__tests__/telemetry-egress-guard.spec.ts +0 -41
- package/src/__tests__/web-inspector.spec.ts +0 -3935
- package/src/assets/inspector-logo-icon.svg +0 -8
- package/src/assets/inspector-logo.svg +0 -40
- package/src/components.d.ts +0 -20
- package/src/index.ts +0 -11849
- package/src/lib/__tests__/telemetry.test.ts +0 -557
- package/src/lib/context-helpers.ts +0 -166
- package/src/lib/persistence.ts +0 -206
- package/src/lib/telemetry.ts +0 -474
- package/src/lib/testing/telemetry-egress-guard.ts +0 -33
- package/src/lib/types.ts +0 -19
- package/src/styles/generated.css +0 -2
- package/src/styles/tailwind.css +0 -23
- package/src/types/css.d.ts +0 -4
- package/src/types/svg.d.ts +0 -4
- package/tsconfig.json +0 -14
- package/tsdown.config.ts +0 -45
- package/vitest.config.ts +0 -66
- 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
|
-
});
|