@copilotkit/web-inspector 1.61.1 → 1.62.0

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.
@@ -1,4 +1,9 @@
1
- import { WebInspectorElement, ɵCpkThreadDetails } from "../index";
1
+ import {
2
+ CpkThreadInspector,
3
+ WebInspectorElement,
4
+ ɵCpkThreadDetails,
5
+ } from "../index.js";
6
+ import type { ThreadDebuggerProvider } from "../index.js";
2
7
  import type { CopilotKitCore } from "@copilotkit/core";
3
8
  import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
4
9
  import type { CopilotKitCoreSubscriber } from "@copilotkit/core";
@@ -16,6 +21,18 @@ type InspectorInternals = {
16
21
  cachedTools: Array<{ name: string }>;
17
22
  };
18
23
 
24
+ type InspectorThreadViewInternals = {
25
+ isOpen: boolean;
26
+ selectedMenu: "threads";
27
+ selectedThreadId: string | null;
28
+ _threads: Array<{
29
+ id: string;
30
+ name?: string | null;
31
+ agentId: string;
32
+ updatedAt?: string | null;
33
+ }>;
34
+ };
35
+
19
36
  type InspectorContextInternals = {
20
37
  contextStore: Record<string, { description?: string; value: unknown }>;
21
38
  copyContextValue: (value: unknown, id: string) => Promise<void>;
@@ -295,7 +312,7 @@ describe("WebInspectorElement", () => {
295
312
  // CpkThreadDetails — per-panel TemplateResult cache invariants
296
313
  // ─────────────────────────────────────────────────────────────────────────
297
314
  //
298
- // The conversation / agent-state / events panels each cache the rendered
315
+ // The timeline / state / raw-events panels each cache the rendered
299
316
  // TemplateResult by reference of the underlying data so tab switches don't
300
317
  // re-iterate over hundreds of items. These tests pin down the cache-key
301
318
  // contract: when the data reassigns, the cache MUST drop, and when the
@@ -310,9 +327,16 @@ type ThreadDetailsInternals = {
310
327
  headers: Record<string, string>;
311
328
  threadInspectionAvailable: boolean;
312
329
  liveMessageVersion: number;
330
+ provider: ThreadDebuggerProvider | null;
331
+ _fetchedMetadata: Record<string, unknown> | null;
313
332
  _conversation: Array<Record<string, unknown>>;
333
+ agentEventsInput: Array<Record<string, unknown>>;
314
334
  _fetchedState: Record<string, unknown> | null;
315
- _fetchedEvents: Array<unknown> | null;
335
+ _fetchedEvents: Array<Record<string, unknown>> | null;
336
+ _timelineItemsCache: {
337
+ events: Array<Record<string, unknown>>;
338
+ items: Array<Record<string, unknown>>;
339
+ } | null;
316
340
  _expandedTools: Set<string>;
317
341
  _expandedMessages: Set<string>;
318
342
  _stateNotAvailable: boolean;
@@ -320,12 +344,18 @@ type ThreadDetailsInternals = {
320
344
  _loadingMessages: boolean;
321
345
  _loadingState: boolean;
322
346
  _loadingEvents: boolean;
347
+ activeTimelineItems: Array<Record<string, unknown>>;
323
348
  _panelTplCache: Map<string, { key: readonly unknown[]; tpl: unknown }>;
349
+ fetchMessages: (threadId: string) => Promise<void>;
324
350
  fetchEvents: (threadId: string) => Promise<void>;
325
351
  fetchState: (threadId: string) => Promise<void>;
352
+ renderTimeline: () => unknown;
326
353
  renderConversation: () => unknown;
327
354
  renderState: () => unknown;
328
355
  renderEvents: () => unknown;
356
+ timelineItemsFromEvents: (
357
+ events: Array<Record<string, unknown>>,
358
+ ) => Array<Record<string, unknown>>;
329
359
  };
330
360
 
331
361
  function createThreadDetails(): {
@@ -340,6 +370,35 @@ function createThreadDetails(): {
340
370
  return { el, internals };
341
371
  }
342
372
 
373
+ function createThreadInspector(): {
374
+ el: CpkThreadInspector;
375
+ internals: ThreadDetailsInternals;
376
+ } {
377
+ const el = new CpkThreadInspector();
378
+ document.body.appendChild(el);
379
+ const internals = el as unknown as ThreadDetailsInternals;
380
+ return { el, internals };
381
+ }
382
+
383
+ async function flushProviderWork(el: CpkThreadInspector): Promise<void> {
384
+ await Promise.resolve();
385
+ await el.updateComplete;
386
+ }
387
+
388
+ function createDeferred<T>(): {
389
+ promise: Promise<T>;
390
+ resolve: (value: T) => void;
391
+ reject: (reason?: unknown) => void;
392
+ } {
393
+ let resolve!: (value: T) => void;
394
+ let reject!: (reason?: unknown) => void;
395
+ const promise = new Promise<T>((res, rej) => {
396
+ resolve = res;
397
+ reject = rej;
398
+ });
399
+ return { promise, resolve, reject };
400
+ }
401
+
343
402
  describe("ɵCpkThreadDetails caching", () => {
344
403
  beforeEach(() => {
345
404
  document.body.innerHTML = "";
@@ -363,16 +422,25 @@ describe("ɵCpkThreadDetails caching", () => {
363
422
  await el.updateComplete;
364
423
  }
365
424
 
366
- it("threadId change drops all three template caches", async () => {
425
+ it("threadId change drops template and timeline item caches", async () => {
367
426
  const { el, internals } = createThreadDetails();
368
427
  await settleThread(el, internals, "t1");
369
428
 
370
429
  // Hand-build cache entries for all three panels so we don't have to
371
430
  // drive every render path through the DOM. The presence of any entry
372
431
  // is what the assertion below checks for; what they hold is irrelevant.
373
- internals._panelTplCache.set("conversation", { key: [], tpl: "c" });
374
- internals._panelTplCache.set("agent-state", { key: [], tpl: "s" });
375
- internals._panelTplCache.set("ag-ui-events", { key: [], tpl: "e" });
432
+ internals._panelTplCache.set("timeline", { key: [], tpl: "c" });
433
+ internals._panelTplCache.set("state", { key: [], tpl: "s" });
434
+ internals._panelTplCache.set("raw-events", { key: [], tpl: "e" });
435
+ internals.agentEventsInput = [
436
+ {
437
+ type: "RUN_STARTED",
438
+ timestamp: "2026-06-25T10:00:00.000Z",
439
+ payload: {},
440
+ },
441
+ ];
442
+ internals.renderTimeline();
443
+ expect(internals._timelineItemsCache).not.toBeNull();
376
444
 
377
445
  // Switch to thread t2 — the threadId branch in `updated()` should
378
446
  // empty the cache map.
@@ -380,6 +448,7 @@ describe("ɵCpkThreadDetails caching", () => {
380
448
  await el.updateComplete;
381
449
 
382
450
  expect(internals._panelTplCache.size).toBe(0);
451
+ expect(internals._timelineItemsCache).toBeNull();
383
452
  });
384
453
 
385
454
  it("does not fetch messages, events, or state when threadInspectionAvailable is omitted", async () => {
@@ -406,6 +475,53 @@ describe("ɵCpkThreadDetails caching", () => {
406
475
  }
407
476
  });
408
477
 
478
+ it("joins thread inspection URLs without double slashes when runtimeUrl has a trailing slash", async () => {
479
+ const fetchSpy = vi
480
+ .spyOn(globalThis, "fetch")
481
+ .mockImplementation(async (input) => {
482
+ const url = String(input);
483
+ if (url.endsWith("/messages")) {
484
+ return new Response(JSON.stringify({ messages: [] }), {
485
+ status: 200,
486
+ });
487
+ }
488
+ if (url.endsWith("/events")) {
489
+ return new Response(JSON.stringify({ events: [] }), { status: 200 });
490
+ }
491
+ return new Response(JSON.stringify({ state: null }), { status: 200 });
492
+ });
493
+ try {
494
+ const { el, internals } = createThreadDetails();
495
+ internals.runtimeUrl = "http://localhost:4000/api/";
496
+ internals.threadInspectionAvailable = true;
497
+ internals.threadId = "thread one";
498
+ await el.updateComplete;
499
+ fetchSpy.mockClear();
500
+
501
+ await internals.fetchMessages("thread one");
502
+ await internals.fetchEvents("thread one");
503
+ await internals.fetchState("thread one");
504
+
505
+ const requestedUrls = fetchSpy.mock.calls.map((call) => String(call[0]));
506
+ expect(requestedUrls).toContain(
507
+ "http://localhost:4000/api/threads/thread%20one/messages",
508
+ );
509
+ expect(requestedUrls).toContain(
510
+ "http://localhost:4000/api/threads/thread%20one/events",
511
+ );
512
+ expect(requestedUrls).toContain(
513
+ "http://localhost:4000/api/threads/thread%20one/state",
514
+ );
515
+ expect(
516
+ requestedUrls.every(
517
+ (url) => !url.includes("/api//threads/") && !url.includes("one//"),
518
+ ),
519
+ ).toBe(true);
520
+ } finally {
521
+ fetchSpy.mockRestore();
522
+ }
523
+ });
524
+
409
525
  it("conversation cache invalidates when _conversation is reassigned", async () => {
410
526
  const { el, internals } = createThreadDetails();
411
527
  await settleThread(el, internals, "t1");
@@ -415,7 +531,7 @@ describe("ɵCpkThreadDetails caching", () => {
415
531
  ];
416
532
 
417
533
  const tplA = internals.renderConversation();
418
- expect(internals._panelTplCache.get("conversation")?.tpl).toBe(tplA);
534
+ expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(tplA);
419
535
 
420
536
  // Cache hit: same array reference, same expand sets — same TemplateResult.
421
537
  expect(internals.renderConversation()).toBe(tplA);
@@ -430,7 +546,7 @@ describe("ɵCpkThreadDetails caching", () => {
430
546
 
431
547
  const tplB = internals.renderConversation();
432
548
  expect(tplB).not.toBe(tplA);
433
- expect(internals._panelTplCache.get("conversation")?.tpl).toBe(tplB);
549
+ expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(tplB);
434
550
  });
435
551
 
436
552
  it("conversation cache invalidates when expand state changes", async () => {
@@ -469,12 +585,95 @@ describe("ɵCpkThreadDetails caching", () => {
469
585
  expect(internals.renderConversation()).not.toBe(expanded);
470
586
  });
471
587
 
588
+ it("keeps timeline and message fallback template caches separate", async () => {
589
+ const { el, internals } = createThreadDetails();
590
+ await settleThread(el, internals, "t1");
591
+
592
+ const events = [
593
+ {
594
+ type: "RUN_STARTED",
595
+ timestamp: "2026-06-25T10:00:00.000Z",
596
+ payload: {},
597
+ sourceIndex: 1,
598
+ },
599
+ ];
600
+ internals._fetchedEvents = events;
601
+
602
+ const timelineTpl = internals.renderTimeline();
603
+ expect(internals._panelTplCache.get("timeline")?.tpl).toBe(timelineTpl);
604
+
605
+ internals._fetchedEvents = [];
606
+ internals._conversation = [
607
+ { id: "m1", type: "user", content: "fallback", createdAt: "" },
608
+ ];
609
+
610
+ const fallbackTpl = internals.renderTimeline();
611
+ expect(fallbackTpl).not.toBe(timelineTpl);
612
+ expect(internals._panelTplCache.get("timeline")?.tpl).toBe(timelineTpl);
613
+ expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(
614
+ fallbackTpl,
615
+ );
616
+
617
+ internals._fetchedEvents = events;
618
+ expect(internals.renderTimeline()).toBe(timelineTpl);
619
+ });
620
+
621
+ it("does not recompute timeline items when renderTimeline returns a cached template", async () => {
622
+ const { el, internals } = createThreadDetails();
623
+ await settleThread(el, internals, "t1");
624
+
625
+ internals._fetchedEvents = [
626
+ {
627
+ type: "RUN_STARTED",
628
+ timestamp: "2026-06-25T10:00:00.000Z",
629
+ payload: {},
630
+ sourceIndex: 1,
631
+ },
632
+ ];
633
+
634
+ const normalizeSpy = vi.spyOn(internals, "timelineItemsFromEvents");
635
+
636
+ const timelineTpl = internals.renderTimeline();
637
+
638
+ expect(internals.renderTimeline()).toBe(timelineTpl);
639
+ expect(normalizeSpy).toHaveBeenCalledTimes(1);
640
+ });
641
+
642
+ it("keeps live fallback event references stable for timeline and raw event caches", async () => {
643
+ const { el, internals } = createThreadDetails();
644
+ await settleThread(el, internals, "t1");
645
+
646
+ internals.agentEventsInput = [
647
+ {
648
+ type: "RUN_STARTED",
649
+ timestamp: "2026-06-25T10:00:00.000Z",
650
+ payload: {},
651
+ },
652
+ ];
653
+
654
+ const normalizeSpy = vi.spyOn(internals, "timelineItemsFromEvents");
655
+
656
+ const timelineTpl = internals.renderTimeline();
657
+ const eventsTpl = internals.renderEvents();
658
+
659
+ expect(internals.renderTimeline()).toBe(timelineTpl);
660
+ expect(internals.renderEvents()).toBe(eventsTpl);
661
+ expect(normalizeSpy).toHaveBeenCalledTimes(1);
662
+ });
663
+
472
664
  it("state and events caches invalidate when their fetched data is reassigned", async () => {
473
665
  const { el, internals } = createThreadDetails();
474
666
  await settleThread(el, internals, "t1");
475
667
 
476
668
  internals._fetchedState = { foo: "bar" };
477
- internals._fetchedEvents = [{ type: "RUN_STARTED" }];
669
+ internals._fetchedEvents = [
670
+ {
671
+ type: "RUN_STARTED",
672
+ timestamp: "2026-06-25T10:00:00.000Z",
673
+ payload: {},
674
+ sourceIndex: 1,
675
+ },
676
+ ];
478
677
 
479
678
  const stateA = internals.renderState();
480
679
  const eventsA = internals.renderEvents();
@@ -483,13 +682,487 @@ describe("ɵCpkThreadDetails caching", () => {
483
682
 
484
683
  // Reassign both — fresh references after a refetch.
485
684
  internals._fetchedState = { foo: "baz" };
486
- internals._fetchedEvents = [{ type: "RUN_FINISHED" }];
685
+ internals._fetchedEvents = [
686
+ {
687
+ type: "RUN_FINISHED",
688
+ timestamp: "2026-06-25T10:00:01.000Z",
689
+ payload: {},
690
+ sourceIndex: 1,
691
+ },
692
+ ];
487
693
 
488
694
  expect(internals.renderState()).not.toBe(stateA);
489
695
  expect(internals.renderEvents()).not.toBe(eventsA);
490
696
  });
491
697
  });
492
698
 
699
+ describe("CpkThreadInspector provider contract", () => {
700
+ beforeEach(() => {
701
+ document.body.innerHTML = "";
702
+ });
703
+
704
+ afterEach(() => {
705
+ vi.unstubAllGlobals();
706
+ });
707
+
708
+ it("registers the stable custom element and renders a normalized provider-backed timeline", async () => {
709
+ expect(customElements.get("cpk-thread-inspector")).toBe(CpkThreadInspector);
710
+
711
+ const provider: ThreadDebuggerProvider = {
712
+ getThreadMetadata: vi.fn().mockResolvedValue({
713
+ id: "thread-1234567890",
714
+ agentId: "agent-a",
715
+ endUserId: "user-a",
716
+ status: "active",
717
+ createdAt: "2026-06-25T10:00:00.000Z",
718
+ updatedAt: "2026-06-25T10:00:01.000Z",
719
+ }),
720
+ getMessages: vi.fn().mockResolvedValue([]),
721
+ getEvents: vi.fn().mockResolvedValue([
722
+ {
723
+ type: "RUN_STARTED",
724
+ timestamp: "2026-06-25T10:00:00.000Z",
725
+ payload: { runId: "run-1" },
726
+ },
727
+ {
728
+ type: "TEXT_MESSAGE_START",
729
+ timestamp: "2026-06-25T10:00:00.100Z",
730
+ payload: { messageId: "m1", role: "assistant" },
731
+ },
732
+ {
733
+ type: "TEXT_MESSAGE_CONTENT",
734
+ timestamp: "2026-06-25T10:00:00.200Z",
735
+ payload: { messageId: "m1", delta: "hello from events" },
736
+ },
737
+ {
738
+ type: "TOOL_CALL_START",
739
+ timestamp: "2026-06-25T10:00:00.300Z",
740
+ payload: { toolCallId: "tc1", toolCallName: "lookup_docs" },
741
+ },
742
+ {
743
+ type: "TOOL_CALL_ARGS",
744
+ timestamp: "2026-06-25T10:00:00.400Z",
745
+ payload: { toolCallId: "tc1", delta: '{"broken":' },
746
+ },
747
+ {
748
+ type: "TOOL_CALL_END",
749
+ timestamp: "2026-06-25T10:00:00.500Z",
750
+ payload: { toolCallId: "tc1" },
751
+ },
752
+ ]),
753
+ };
754
+ const { el, internals } = createThreadInspector();
755
+
756
+ internals.provider = provider;
757
+ internals.threadId = "thread-1234567890";
758
+ await flushProviderWork(el);
759
+
760
+ expect(provider.getThreadMetadata).toHaveBeenCalledWith(
761
+ "thread-1234567890",
762
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
763
+ );
764
+ expect(provider.getEvents).toHaveBeenCalledWith(
765
+ "thread-1234567890",
766
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
767
+ );
768
+ expect(provider.getMessages).not.toHaveBeenCalled();
769
+ expect(internals._fetchedMetadata?.agentId).toBe("agent-a");
770
+ expect(internals._fetchedEvents).toHaveLength(6);
771
+
772
+ const text = el.shadowRoot?.textContent ?? "";
773
+ expect(text).toContain("Timeline");
774
+ expect(text).toContain("Raw AG-UI Events");
775
+ expect(text).toContain("State");
776
+ expect(text).toContain("Run started");
777
+ expect(text).toContain("assistant message");
778
+ expect(text).toContain("hello from events");
779
+ expect(text).toContain("lookup_docs");
780
+ expect(text).toContain("Could not decode tool call arguments");
781
+ expect(text).toContain("Source event #1");
782
+ expect(text).toContain("Source event #6");
783
+ expect(text).toContain("agent-a");
784
+ expect(text).toContain("user-a");
785
+ expect(text).not.toContain("Rename");
786
+ expect(text).not.toContain("Archive");
787
+ expect(text).not.toContain("Delete");
788
+ });
789
+
790
+ it("source-event references reveal raw events and state stays lazy-loaded", async () => {
791
+ const provider: ThreadDebuggerProvider = {
792
+ getMessages: vi.fn().mockResolvedValue([]),
793
+ getEvents: vi.fn().mockResolvedValue([
794
+ {
795
+ type: "RUN_STARTED",
796
+ timestamp: "2026-06-25T10:00:00.000Z",
797
+ payload: { runId: "run-1" },
798
+ },
799
+ ]),
800
+ getState: vi.fn().mockResolvedValue({ step: 1 }),
801
+ };
802
+ const { el, internals } = createThreadInspector();
803
+
804
+ internals.provider = provider;
805
+ internals.threadId = "thread-1";
806
+ await flushProviderWork(el);
807
+
808
+ expect(provider.getEvents).toHaveBeenCalledTimes(1);
809
+ expect(provider.getState).not.toHaveBeenCalled();
810
+ expect(internals._fetchedEvents?.[0]).toMatchObject({
811
+ type: "RUN_STARTED",
812
+ payload: { runId: "run-1" },
813
+ });
814
+
815
+ el.shadowRoot
816
+ ?.querySelector<HTMLButtonElement>(".cpk-td__source-link")
817
+ ?.click();
818
+ await flushProviderWork(el);
819
+
820
+ expect(
821
+ el.shadowRoot?.querySelector<HTMLElement>(
822
+ '.cpk-td__event[data-source-index="1"]',
823
+ ),
824
+ ).not.toBeNull();
825
+ expect(provider.getEvents).toHaveBeenCalledTimes(1);
826
+
827
+ el.shadowRoot
828
+ ?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[2]
829
+ ?.click();
830
+ await flushProviderWork(el);
831
+
832
+ expect(provider.getState).toHaveBeenCalledWith(
833
+ "thread-1",
834
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
835
+ );
836
+ expect(internals._fetchedState).toEqual({ step: 1 });
837
+ });
838
+
839
+ it("reloads when a provider arrives after threadId and when provider identity swaps for the same thread", async () => {
840
+ const firstEvents =
841
+ createDeferred<
842
+ Awaited<ReturnType<NonNullable<ThreadDebuggerProvider["getEvents"]>>>
843
+ >();
844
+ const firstSignals: AbortSignal[] = [];
845
+ const firstProvider: ThreadDebuggerProvider = {
846
+ getEvents: vi.fn((_threadId, options) => {
847
+ firstSignals.push(options.signal);
848
+ return firstEvents.promise;
849
+ }),
850
+ };
851
+ const secondProvider: ThreadDebuggerProvider = {
852
+ getEvents: vi.fn().mockResolvedValue([
853
+ {
854
+ type: "RUN_FINISHED",
855
+ timestamp: "2026-06-25T10:00:01.000Z",
856
+ payload: { runId: "second-run" },
857
+ },
858
+ ]),
859
+ };
860
+ const { el, internals } = createThreadInspector();
861
+
862
+ internals.threadId = "thread-1";
863
+ await flushProviderWork(el);
864
+
865
+ expect(firstProvider.getEvents).not.toHaveBeenCalled();
866
+
867
+ internals.provider = firstProvider;
868
+ await flushProviderWork(el);
869
+
870
+ expect(firstProvider.getEvents).toHaveBeenCalledWith(
871
+ "thread-1",
872
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
873
+ );
874
+
875
+ internals.provider = secondProvider;
876
+ await flushProviderWork(el);
877
+
878
+ expect(firstSignals[0]?.aborted).toBe(true);
879
+ await vi.waitFor(() => {
880
+ expect(secondProvider.getEvents).toHaveBeenCalledWith(
881
+ "thread-1",
882
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
883
+ );
884
+ expect(internals._fetchedEvents?.[0]?.type).toBe("RUN_FINISHED");
885
+ expect(internals._fetchedEvents?.[0]?.payload).toEqual({
886
+ runId: "second-run",
887
+ });
888
+ });
889
+
890
+ firstEvents.resolve([
891
+ {
892
+ type: "RUN_STARTED",
893
+ timestamp: "2026-06-25T10:00:00.000Z",
894
+ payload: { runId: "stale-run" },
895
+ },
896
+ ]);
897
+ await Promise.resolve();
898
+
899
+ expect(internals._fetchedEvents?.[0]?.payload).toEqual({
900
+ runId: "second-run",
901
+ });
902
+ });
903
+
904
+ it("renders raw AG-UI events with top-level fields preserved when payload is present", async () => {
905
+ const provider: ThreadDebuggerProvider = {
906
+ getEvents: vi.fn().mockResolvedValue([
907
+ {
908
+ type: "TEXT_MESSAGE_CONTENT",
909
+ timestamp: "2026-06-25T10:00:00.000Z",
910
+ payload: { messageId: "m1", delta: "hello" },
911
+ runId: "top-level-run",
912
+ sequence: 42,
913
+ },
914
+ ]),
915
+ };
916
+ const { el, internals } = createThreadInspector();
917
+
918
+ internals.provider = provider;
919
+ internals.threadId = "thread-raw";
920
+ await flushProviderWork(el);
921
+
922
+ expect(internals._fetchedEvents?.[0]?.payload).toEqual({
923
+ messageId: "m1",
924
+ delta: "hello",
925
+ });
926
+ expect(internals._fetchedEvents?.[0]?.rawEvent).toMatchObject({
927
+ runId: "top-level-run",
928
+ sequence: 42,
929
+ payload: { messageId: "m1", delta: "hello" },
930
+ });
931
+
932
+ el.shadowRoot
933
+ ?.querySelector<HTMLButtonElement>(".cpk-td__source-link")
934
+ ?.click();
935
+ await flushProviderWork(el);
936
+
937
+ const text = el.shadowRoot?.textContent ?? "";
938
+ expect(text).toContain("top-level-run");
939
+ expect(text).toContain("sequence");
940
+ expect(text).toContain("messageId");
941
+ expect(text).toContain("hello");
942
+ });
943
+
944
+ it("ignores stale runtime 501 availability results after the selected thread changes", async () => {
945
+ const t1Events = createDeferred<Response>();
946
+ const t1State = createDeferred<Response>();
947
+ const t2Events = createDeferred<Response>();
948
+ const t2State = createDeferred<Response>();
949
+ const fetchMock = vi.fn((url: string) => {
950
+ if (url.endsWith("/threads/t1/events")) return t1Events.promise;
951
+ if (url.endsWith("/threads/t1/state")) return t1State.promise;
952
+ if (url.endsWith("/threads/t2/events")) return t2Events.promise;
953
+ if (url.endsWith("/threads/t2/state")) return t2State.promise;
954
+ if (url.endsWith("/threads/t2/messages")) {
955
+ return Promise.resolve(
956
+ new Response(JSON.stringify({ messages: [] }), { status: 200 }),
957
+ );
958
+ }
959
+ return Promise.reject(new Error(`Unexpected URL ${url}`));
960
+ });
961
+ vi.stubGlobal("fetch", fetchMock);
962
+ const { el, internals } = createThreadInspector();
963
+
964
+ internals.runtimeUrl = "http://runtime";
965
+ internals.threadInspectionAvailable = true;
966
+ internals.threadId = "t1";
967
+ await flushProviderWork(el);
968
+ const t1StatePromise = internals.fetchState("t1");
969
+
970
+ internals.threadId = "t2";
971
+ await flushProviderWork(el);
972
+ const t2StatePromise = internals.fetchState("t2");
973
+
974
+ t1Events.resolve(new Response(null, { status: 501 }));
975
+ t1State.resolve(new Response(null, { status: 501 }));
976
+ t2Events.resolve(
977
+ new Response(
978
+ JSON.stringify({
979
+ events: [
980
+ {
981
+ type: "RUN_STARTED",
982
+ timestamp: "2026-06-25T10:00:00.000Z",
983
+ payload: { runId: "fresh" },
984
+ },
985
+ ],
986
+ }),
987
+ { status: 200 },
988
+ ),
989
+ );
990
+ t2State.resolve(
991
+ new Response(JSON.stringify({ state: { current: "fresh" } }), {
992
+ status: 200,
993
+ }),
994
+ );
995
+
996
+ await Promise.all([t1StatePromise, t2StatePromise]);
997
+ await vi.waitFor(() => {
998
+ expect(internals._eventsNotAvailable).toBe(false);
999
+ expect(internals._stateNotAvailable).toBe(false);
1000
+ expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1001
+ runId: "fresh",
1002
+ });
1003
+ expect(internals._fetchedState).toEqual({ current: "fresh" });
1004
+ });
1005
+ });
1006
+
1007
+ it("refetches runtime thread data when headers change for the same thread", async () => {
1008
+ const fetchMock = vi.fn(
1009
+ (_url: string, init?: { headers?: Record<string, string> }) =>
1010
+ Promise.resolve(
1011
+ new Response(
1012
+ JSON.stringify({
1013
+ events: [
1014
+ {
1015
+ type: "RUN_STARTED",
1016
+ timestamp: "2026-06-25T10:00:00.000Z",
1017
+ payload: {
1018
+ auth: init?.headers?.Authorization,
1019
+ },
1020
+ },
1021
+ ],
1022
+ }),
1023
+ { status: 200 },
1024
+ ),
1025
+ ),
1026
+ );
1027
+ vi.stubGlobal("fetch", fetchMock);
1028
+ const { el, internals } = createThreadInspector();
1029
+
1030
+ internals.runtimeUrl = "http://runtime";
1031
+ internals.threadInspectionAvailable = true;
1032
+ internals.headers = { Authorization: "Bearer first" };
1033
+ internals.threadId = "thread-1";
1034
+ await flushProviderWork(el);
1035
+
1036
+ await vi.waitFor(() => {
1037
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1038
+ expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1039
+ auth: "Bearer first",
1040
+ });
1041
+ });
1042
+
1043
+ internals.headers = { Authorization: "Bearer second" };
1044
+ await flushProviderWork(el);
1045
+
1046
+ await vi.waitFor(() => {
1047
+ expect(fetchMock).toHaveBeenCalledTimes(2);
1048
+ expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1049
+ auth: "Bearer second",
1050
+ });
1051
+ });
1052
+ expect(headersOf(fetchMock.mock.calls.at(-1)!)).toMatchObject({
1053
+ Authorization: "Bearer second",
1054
+ });
1055
+ });
1056
+
1057
+ it("loads runtime messages when event history is empty so timeline fallback and counts work", async () => {
1058
+ const fetchMock = vi.fn((url: string) => {
1059
+ if (url.endsWith("/threads/thread-1/events")) {
1060
+ return Promise.resolve(
1061
+ new Response(JSON.stringify({ events: [] }), { status: 200 }),
1062
+ );
1063
+ }
1064
+ if (url.endsWith("/threads/thread-1/messages")) {
1065
+ return Promise.resolve(
1066
+ new Response(
1067
+ JSON.stringify({
1068
+ messages: [
1069
+ { id: "u1", role: "user", content: "historical hello" },
1070
+ { id: "a1", role: "assistant", content: "historical reply" },
1071
+ ],
1072
+ }),
1073
+ { status: 200 },
1074
+ ),
1075
+ );
1076
+ }
1077
+ return Promise.reject(new Error(`Unexpected URL ${url}`));
1078
+ });
1079
+ vi.stubGlobal("fetch", fetchMock);
1080
+ const { el, internals } = createThreadInspector();
1081
+
1082
+ internals.runtimeUrl = "http://runtime";
1083
+ internals.threadInspectionAvailable = true;
1084
+ internals.threadId = "thread-1";
1085
+ await flushProviderWork(el);
1086
+
1087
+ await vi.waitFor(() => {
1088
+ expect(internals._fetchedEvents).toEqual([]);
1089
+ expect(internals._conversation).toHaveLength(2);
1090
+ expect(el.shadowRoot?.textContent ?? "").toContain("historical hello");
1091
+ expect(el.shadowRoot?.textContent ?? "").toContain("historical reply");
1092
+ });
1093
+
1094
+ el.shadowRoot
1095
+ ?.querySelector<HTMLButtonElement>(".cpk-td__panel-toggle")
1096
+ ?.click();
1097
+ await flushProviderWork(el);
1098
+
1099
+ expect(el.shadowRoot?.textContent ?? "").toContain("Messages");
1100
+ expect(el.shadowRoot?.textContent ?? "").toContain("2");
1101
+ });
1102
+
1103
+ it("renders unsupported raw events as timeline rows instead of leaving the first tab empty", async () => {
1104
+ const provider: ThreadDebuggerProvider = {
1105
+ getEvents: vi.fn().mockResolvedValue([
1106
+ {
1107
+ type: "THREAD_STATE_WRITTEN",
1108
+ timestamp: "2026-06-25T10:00:00.000Z",
1109
+ payload: { checkpointId: "state-1", status: "ok" },
1110
+ },
1111
+ ]),
1112
+ };
1113
+ const { el, internals } = createThreadInspector();
1114
+
1115
+ internals.provider = provider;
1116
+ internals.threadId = "thread-raw-event-only";
1117
+ await flushProviderWork(el);
1118
+
1119
+ expect(internals.activeTimelineItems).toHaveLength(1);
1120
+ expect(el.shadowRoot?.textContent ?? "").toContain("THREAD_STATE_WRITTEN");
1121
+ expect(el.shadowRoot?.textContent ?? "").toContain("checkpointId");
1122
+ expect(el.shadowRoot?.textContent ?? "").toContain("Source event #1");
1123
+ expect(el.shadowRoot?.textContent ?? "").not.toContain(
1124
+ "No timeline events captured",
1125
+ );
1126
+ });
1127
+
1128
+ it("keeps the first-visible timeline intentional while provider message fallback is loading", async () => {
1129
+ const messages =
1130
+ createDeferred<
1131
+ Awaited<ReturnType<NonNullable<ThreadDebuggerProvider["getMessages"]>>>
1132
+ >();
1133
+ const provider: ThreadDebuggerProvider = {
1134
+ getEvents: vi.fn().mockResolvedValue([]),
1135
+ getMessages: vi.fn().mockReturnValue(messages.promise),
1136
+ };
1137
+ const { el, internals } = createThreadInspector();
1138
+
1139
+ internals.provider = provider;
1140
+ internals.threadId = "thread-messages-only";
1141
+ await flushProviderWork(el);
1142
+
1143
+ expect(provider.getMessages).toHaveBeenCalledWith(
1144
+ "thread-messages-only",
1145
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
1146
+ );
1147
+ expect(internals._loadingMessages).toBe(true);
1148
+ expect(el.shadowRoot?.textContent ?? "").toContain("Loading messages");
1149
+ expect(el.shadowRoot?.textContent ?? "").not.toContain(
1150
+ "No timeline events captured",
1151
+ );
1152
+
1153
+ messages.resolve([
1154
+ { id: "u1", role: "user", content: "provider hello" },
1155
+ { id: "a1", role: "assistant", content: "provider reply" },
1156
+ ]);
1157
+
1158
+ await vi.waitFor(() => {
1159
+ const text = el.shadowRoot?.textContent ?? "";
1160
+ expect(text).toContain("provider hello");
1161
+ expect(text).toContain("provider reply");
1162
+ });
1163
+ });
1164
+ });
1165
+
493
1166
  // ─────────────────────────────────────────────────────────────────────────
494
1167
  // Announcement preview (popout) dismissal MUST persist
495
1168
  // ─────────────────────────────────────────────────────────────────────────
@@ -616,6 +1289,7 @@ type HeaderMockCore = {
616
1289
  agents: Record<string, AbstractAgent>;
617
1290
  context: Record<string, unknown>;
618
1291
  properties: Record<string, unknown>;
1292
+ telemetryDisabled: boolean;
619
1293
  runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
620
1294
  runtimeUrl: string;
621
1295
  headers: Record<string, string>;
@@ -637,12 +1311,15 @@ type HeaderMockCore = {
637
1311
  function createHeaderMockCore(
638
1312
  agents: Record<string, AbstractAgent>,
639
1313
  headers: Record<string, string>,
1314
+ endpointOverrides: Partial<HeaderMockCore["threadEndpoints"]> = {},
1315
+ telemetryDisabled = true,
640
1316
  ) {
641
1317
  const subscribers = new Set<CopilotKitCoreSubscriber>();
642
1318
  const core: HeaderMockCore = {
643
1319
  agents,
644
1320
  context: {},
645
1321
  properties: {},
1322
+ telemetryDisabled,
646
1323
  runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
647
1324
  runtimeUrl: "http://localhost/api",
648
1325
  headers,
@@ -651,6 +1328,7 @@ function createHeaderMockCore(
651
1328
  inspect: true,
652
1329
  mutations: true,
653
1330
  realtimeMetadata: true,
1331
+ ...endpointOverrides,
654
1332
  },
655
1333
  subscribe(subscriber: CopilotKitCoreSubscriber) {
656
1334
  subscribers.add(subscriber);
@@ -693,6 +1371,21 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
693
1371
  fetchMock.mock.calls.filter((call) =>
694
1372
  String(call[0]).includes("/threads?"),
695
1373
  );
1374
+ const telemetryPosts = () =>
1375
+ fetchMock.mock.calls
1376
+ .filter(
1377
+ (call) =>
1378
+ String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
1379
+ (call[1] as RequestInit | undefined)?.method === "POST",
1380
+ )
1381
+ .map((call) => {
1382
+ const body =
1383
+ ((call[1] as RequestInit | undefined)?.body as string) ?? "{}";
1384
+ return JSON.parse(body) as {
1385
+ event: string;
1386
+ properties: Record<string, unknown>;
1387
+ };
1388
+ });
696
1389
 
697
1390
  beforeEach(() => {
698
1391
  document.body.innerHTML = "";
@@ -758,4 +1451,229 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
758
1451
  "X-CSRF": "2",
759
1452
  });
760
1453
  });
1454
+
1455
+ it("rerenders selected thread details so core header changes refetch events with new headers", async () => {
1456
+ fetchMock.mockImplementation(
1457
+ (url: string, init?: { headers?: Record<string, string> }) => {
1458
+ if (url.endsWith("/threads/thread-1/events")) {
1459
+ return Promise.resolve(
1460
+ new Response(
1461
+ JSON.stringify({
1462
+ events: [
1463
+ {
1464
+ type: "RUN_STARTED",
1465
+ timestamp: "2026-06-25T10:00:00.000Z",
1466
+ payload: { csrf: init?.headers?.["X-CSRF"] },
1467
+ },
1468
+ ],
1469
+ }),
1470
+ { status: 200 },
1471
+ ),
1472
+ );
1473
+ }
1474
+ if (url.includes("/threads?")) {
1475
+ return Promise.resolve(
1476
+ new Response(JSON.stringify({ threads: [] }), { status: 200 }),
1477
+ );
1478
+ }
1479
+ return Promise.reject(new Error(`Unexpected URL ${url}`));
1480
+ },
1481
+ );
1482
+ const harness = createHeaderMockCore({}, { "X-CSRF": "1" });
1483
+ const inspector = new WebInspectorElement();
1484
+ document.body.appendChild(inspector);
1485
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
1486
+
1487
+ const internals = inspector as unknown as InspectorThreadViewInternals;
1488
+ internals.isOpen = true;
1489
+ internals.selectedMenu = "threads";
1490
+ internals.selectedThreadId = "thread-1";
1491
+ internals._threads = [
1492
+ {
1493
+ id: "thread-1",
1494
+ name: "Thread 1",
1495
+ agentId: "alpha",
1496
+ updatedAt: "2026-06-25T10:00:00.000Z",
1497
+ },
1498
+ ];
1499
+ inspector.requestUpdate();
1500
+ await inspector.updateComplete;
1501
+
1502
+ await vi.waitFor(() => {
1503
+ expect(
1504
+ fetchMock.mock.calls.filter((call) =>
1505
+ String(call[0]).endsWith("/threads/thread-1/events"),
1506
+ ),
1507
+ ).toHaveLength(1);
1508
+ });
1509
+
1510
+ harness.emitHeadersChanged({ "X-CSRF": "2" });
1511
+
1512
+ await vi.waitFor(() => {
1513
+ expect(
1514
+ fetchMock.mock.calls.filter((call) =>
1515
+ String(call[0]).endsWith("/threads/thread-1/events"),
1516
+ ),
1517
+ ).toHaveLength(2);
1518
+ });
1519
+ expect(headersOf(fetchMock.mock.calls.at(-1)!)).toMatchObject({
1520
+ "X-CSRF": "2",
1521
+ });
1522
+ });
1523
+
1524
+ it("shows the locked Intelligence state when thread listing is unavailable without fetching threads", async () => {
1525
+ const { agent } = createMockAgent("alpha");
1526
+ const harness = createHeaderMockCore(
1527
+ { alpha: agent },
1528
+ { "X-CSRF": "1" },
1529
+ { list: false },
1530
+ true,
1531
+ );
1532
+
1533
+ const inspector = new WebInspectorElement();
1534
+ document.body.appendChild(inspector);
1535
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
1536
+ harness.emitAgentsChanged();
1537
+
1538
+ const internals = inspector as unknown as {
1539
+ isOpen: boolean;
1540
+ handleMenuSelect: (key: "threads") => void;
1541
+ };
1542
+ internals.isOpen = true;
1543
+ internals.handleMenuSelect("threads");
1544
+ await inspector.updateComplete;
1545
+
1546
+ const text = inspector.shadowRoot?.textContent ?? "";
1547
+ expect(text).toMatch(/Enable Intelligence to inspect Threads\./);
1548
+ expect(text).toContain("Talk to an Engineer");
1549
+ expect(text).toContain("Sign up for Intelligence");
1550
+ const ctaLabels = Array.from(
1551
+ inspector.shadowRoot?.querySelectorAll<HTMLAnchorElement>("a") ?? [],
1552
+ ).map((anchor) => anchor.textContent?.trim());
1553
+ expect(ctaLabels).toEqual([
1554
+ "Talk to an Engineer",
1555
+ "Sign up for Intelligence",
1556
+ ]);
1557
+ expect(text).not.toContain("No threads yet");
1558
+ expect(
1559
+ fetchMock.mock.calls.some((call) => String(call[0]).includes("/threads")),
1560
+ ).toBe(false);
1561
+ });
1562
+
1563
+ it("adds ref and posthog distinct ID attribution to locked-state CTAs", async () => {
1564
+ const { agent } = createMockAgent("alpha");
1565
+ const harness = createHeaderMockCore(
1566
+ { alpha: agent },
1567
+ {},
1568
+ { list: false },
1569
+ false,
1570
+ );
1571
+
1572
+ const inspector = new WebInspectorElement();
1573
+ document.body.appendChild(inspector);
1574
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
1575
+ harness.emitAgentsChanged();
1576
+
1577
+ const internals = inspector as unknown as {
1578
+ isOpen: boolean;
1579
+ handleMenuSelect: (key: "threads") => void;
1580
+ };
1581
+ internals.isOpen = true;
1582
+ internals.handleMenuSelect("threads");
1583
+ await inspector.updateComplete;
1584
+
1585
+ const signup = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
1586
+ 'a[href^="https://go.copilotkit.ai/intelligence-signup"]',
1587
+ );
1588
+ const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
1589
+ 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
1590
+ );
1591
+
1592
+ expect(signup).not.toBeNull();
1593
+ expect(engineer).not.toBeNull();
1594
+
1595
+ const signupUrl = new URL(signup!.href);
1596
+ expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector");
1597
+ const distinctId = signupUrl.searchParams.get("posthog_distinct_id");
1598
+ expect(distinctId).toMatch(/^[0-9a-f-]{36}$/);
1599
+
1600
+ const engineerUrl = new URL(engineer!.href);
1601
+ expect(engineerUrl.origin).toBe("https://www.copilotkit.ai");
1602
+ expect(engineerUrl.pathname).toBe("/talk-to-an-engineer");
1603
+ expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
1604
+ expect(engineerUrl.searchParams.get("posthog_distinct_id")).toBe(
1605
+ distinctId,
1606
+ );
1607
+ });
1608
+
1609
+ it("tracks Threads tab clicks through the rendered inspector menu", async () => {
1610
+ const { agent } = createMockAgent("alpha");
1611
+ const harness = createHeaderMockCore(
1612
+ { alpha: agent },
1613
+ {},
1614
+ { list: false },
1615
+ false,
1616
+ );
1617
+
1618
+ const inspector = new WebInspectorElement();
1619
+ document.body.appendChild(inspector);
1620
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
1621
+ harness.emitAgentsChanged();
1622
+
1623
+ const internals = inspector as unknown as { isOpen: boolean };
1624
+ internals.isOpen = true;
1625
+ inspector.requestUpdate();
1626
+ await inspector.updateComplete;
1627
+
1628
+ const threadsButton = Array.from(
1629
+ inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
1630
+ ).find((button) => button.textContent?.trim() === "Threads");
1631
+ expect(threadsButton, "Threads menu button should render").toBeDefined();
1632
+
1633
+ threadsButton!.click();
1634
+ await inspector.updateComplete;
1635
+ await Promise.resolve();
1636
+
1637
+ const threadsTabClick = telemetryPosts().find(
1638
+ (post) => post.event === "oss.inspector.threads_tab_clicked",
1639
+ );
1640
+ expect(threadsTabClick).toBeDefined();
1641
+ expect(threadsTabClick!.properties).toMatchObject({
1642
+ intelligence_status: "intelligence_not_enabled",
1643
+ thread_service_status: "unavailable",
1644
+ telemetry_disabled: false,
1645
+ });
1646
+ expect(threadsTabClick!.properties.distinct_id).toMatch(/^[0-9a-f-]{36}$/);
1647
+ if (threadsTabClick!.properties.posthog_distinct_id !== undefined) {
1648
+ expect(threadsTabClick!.properties.posthog_distinct_id).toBe(
1649
+ threadsTabClick!.properties.distinct_id,
1650
+ );
1651
+ }
1652
+ });
1653
+
1654
+ it("keeps the enabled empty Threads state when thread listing is available", async () => {
1655
+ const { agent } = createMockAgent("alpha");
1656
+ const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true);
1657
+
1658
+ const inspector = new WebInspectorElement();
1659
+ document.body.appendChild(inspector);
1660
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
1661
+ harness.emitAgentsChanged();
1662
+
1663
+ const internals = inspector as unknown as {
1664
+ isOpen: boolean;
1665
+ handleMenuSelect: (key: "threads") => void;
1666
+ };
1667
+ internals.isOpen = true;
1668
+ internals.handleMenuSelect("threads");
1669
+ await inspector.updateComplete;
1670
+
1671
+ expect(inspector.shadowRoot?.textContent ?? "").toContain("No threads yet");
1672
+ const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
1673
+ 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
1674
+ );
1675
+ expect(engineer?.href).toBe(
1676
+ "https://www.copilotkit.ai/talk-to-an-engineer?ref=cpk-inspector-threads",
1677
+ );
1678
+ });
761
1679
  });