@copilotkit/web-inspector 1.61.2 → 1.62.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.
@@ -1,4 +1,9 @@
1
- import { WebInspectorElement, ɵCpkThreadDetails } from "../index.js";
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,13 +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 }>;
324
349
  fetchMessages: (threadId: string) => Promise<void>;
325
350
  fetchEvents: (threadId: string) => Promise<void>;
326
351
  fetchState: (threadId: string) => Promise<void>;
352
+ renderTimeline: () => unknown;
327
353
  renderConversation: () => unknown;
328
354
  renderState: () => unknown;
329
355
  renderEvents: () => unknown;
356
+ timelineItemsFromEvents: (
357
+ events: Array<Record<string, unknown>>,
358
+ ) => Array<Record<string, unknown>>;
330
359
  };
331
360
 
332
361
  function createThreadDetails(): {
@@ -341,6 +370,35 @@ function createThreadDetails(): {
341
370
  return { el, internals };
342
371
  }
343
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
+
344
402
  describe("ɵCpkThreadDetails caching", () => {
345
403
  beforeEach(() => {
346
404
  document.body.innerHTML = "";
@@ -364,16 +422,25 @@ describe("ɵCpkThreadDetails caching", () => {
364
422
  await el.updateComplete;
365
423
  }
366
424
 
367
- it("threadId change drops all three template caches", async () => {
425
+ it("threadId change drops template and timeline item caches", async () => {
368
426
  const { el, internals } = createThreadDetails();
369
427
  await settleThread(el, internals, "t1");
370
428
 
371
429
  // Hand-build cache entries for all three panels so we don't have to
372
430
  // drive every render path through the DOM. The presence of any entry
373
431
  // is what the assertion below checks for; what they hold is irrelevant.
374
- internals._panelTplCache.set("conversation", { key: [], tpl: "c" });
375
- internals._panelTplCache.set("agent-state", { key: [], tpl: "s" });
376
- 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();
377
444
 
378
445
  // Switch to thread t2 — the threadId branch in `updated()` should
379
446
  // empty the cache map.
@@ -381,6 +448,7 @@ describe("ɵCpkThreadDetails caching", () => {
381
448
  await el.updateComplete;
382
449
 
383
450
  expect(internals._panelTplCache.size).toBe(0);
451
+ expect(internals._timelineItemsCache).toBeNull();
384
452
  });
385
453
 
386
454
  it("does not fetch messages, events, or state when threadInspectionAvailable is omitted", async () => {
@@ -434,11 +502,21 @@ describe("ɵCpkThreadDetails caching", () => {
434
502
  await internals.fetchEvents("thread one");
435
503
  await internals.fetchState("thread one");
436
504
 
437
- expect(fetchSpy.mock.calls.map((call) => String(call[0]))).toEqual([
505
+ const requestedUrls = fetchSpy.mock.calls.map((call) => String(call[0]));
506
+ expect(requestedUrls).toContain(
438
507
  "http://localhost:4000/api/threads/thread%20one/messages",
508
+ );
509
+ expect(requestedUrls).toContain(
439
510
  "http://localhost:4000/api/threads/thread%20one/events",
511
+ );
512
+ expect(requestedUrls).toContain(
440
513
  "http://localhost:4000/api/threads/thread%20one/state",
441
- ]);
514
+ );
515
+ expect(
516
+ requestedUrls.every(
517
+ (url) => !url.includes("/api//threads/") && !url.includes("one//"),
518
+ ),
519
+ ).toBe(true);
442
520
  } finally {
443
521
  fetchSpy.mockRestore();
444
522
  }
@@ -453,7 +531,7 @@ describe("ɵCpkThreadDetails caching", () => {
453
531
  ];
454
532
 
455
533
  const tplA = internals.renderConversation();
456
- expect(internals._panelTplCache.get("conversation")?.tpl).toBe(tplA);
534
+ expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(tplA);
457
535
 
458
536
  // Cache hit: same array reference, same expand sets — same TemplateResult.
459
537
  expect(internals.renderConversation()).toBe(tplA);
@@ -468,7 +546,7 @@ describe("ɵCpkThreadDetails caching", () => {
468
546
 
469
547
  const tplB = internals.renderConversation();
470
548
  expect(tplB).not.toBe(tplA);
471
- expect(internals._panelTplCache.get("conversation")?.tpl).toBe(tplB);
549
+ expect(internals._panelTplCache.get("timeline-fallback")?.tpl).toBe(tplB);
472
550
  });
473
551
 
474
552
  it("conversation cache invalidates when expand state changes", async () => {
@@ -507,12 +585,95 @@ describe("ɵCpkThreadDetails caching", () => {
507
585
  expect(internals.renderConversation()).not.toBe(expanded);
508
586
  });
509
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
+
510
664
  it("state and events caches invalidate when their fetched data is reassigned", async () => {
511
665
  const { el, internals } = createThreadDetails();
512
666
  await settleThread(el, internals, "t1");
513
667
 
514
668
  internals._fetchedState = { foo: "bar" };
515
- 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
+ ];
516
677
 
517
678
  const stateA = internals.renderState();
518
679
  const eventsA = internals.renderEvents();
@@ -521,13 +682,487 @@ describe("ɵCpkThreadDetails caching", () => {
521
682
 
522
683
  // Reassign both — fresh references after a refetch.
523
684
  internals._fetchedState = { foo: "baz" };
524
- 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
+ ];
525
693
 
526
694
  expect(internals.renderState()).not.toBe(stateA);
527
695
  expect(internals.renderEvents()).not.toBe(eventsA);
528
696
  });
529
697
  });
530
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
+
531
1166
  // ─────────────────────────────────────────────────────────────────────────
532
1167
  // Announcement preview (popout) dismissal MUST persist
533
1168
  // ─────────────────────────────────────────────────────────────────────────
@@ -817,6 +1452,75 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
817
1452
  });
818
1453
  });
819
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
+
820
1524
  it("shows the locked Intelligence state when thread listing is unavailable without fetching threads", async () => {
821
1525
  const { agent } = createMockAgent("alpha");
822
1526
  const harness = createHeaderMockCore(