@copilotkit/web-inspector 1.61.0 → 1.61.2

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,4 @@
1
- import { WebInspectorElement, ɵCpkThreadDetails } from "../index";
1
+ import { WebInspectorElement, ɵCpkThreadDetails } from "../index.js";
2
2
  import type { CopilotKitCore } from "@copilotkit/core";
3
3
  import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
4
4
  import type { CopilotKitCoreSubscriber } from "@copilotkit/core";
@@ -306,6 +306,9 @@ describe("WebInspectorElement", () => {
306
306
 
307
307
  type ThreadDetailsInternals = {
308
308
  threadId: string | null;
309
+ runtimeUrl: string;
310
+ headers: Record<string, string>;
311
+ threadInspectionAvailable: boolean;
309
312
  liveMessageVersion: number;
310
313
  _conversation: Array<Record<string, unknown>>;
311
314
  _fetchedState: Record<string, unknown> | null;
@@ -318,6 +321,9 @@ type ThreadDetailsInternals = {
318
321
  _loadingState: boolean;
319
322
  _loadingEvents: boolean;
320
323
  _panelTplCache: Map<string, { key: readonly unknown[]; tpl: unknown }>;
324
+ fetchMessages: (threadId: string) => Promise<void>;
325
+ fetchEvents: (threadId: string) => Promise<void>;
326
+ fetchState: (threadId: string) => Promise<void>;
321
327
  renderConversation: () => unknown;
322
328
  renderState: () => unknown;
323
329
  renderEvents: () => unknown;
@@ -377,6 +383,67 @@ describe("ɵCpkThreadDetails caching", () => {
377
383
  expect(internals._panelTplCache.size).toBe(0);
378
384
  });
379
385
 
386
+ it("does not fetch messages, events, or state when threadInspectionAvailable is omitted", async () => {
387
+ const fetchSpy = vi
388
+ .spyOn(globalThis, "fetch")
389
+ .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
390
+ try {
391
+ const { el, internals } = createThreadDetails();
392
+
393
+ internals.runtimeUrl = "http://localhost:4000";
394
+ internals.headers = { Authorization: "Bearer test-token" };
395
+ internals.threadId = "t1";
396
+ await el.updateComplete;
397
+
398
+ expect(internals.threadInspectionAvailable).toBe(false);
399
+ expect(fetchSpy).not.toHaveBeenCalled();
400
+
401
+ await internals.fetchEvents("t1");
402
+ await internals.fetchState("t1");
403
+
404
+ expect(fetchSpy).not.toHaveBeenCalled();
405
+ } finally {
406
+ fetchSpy.mockRestore();
407
+ }
408
+ });
409
+
410
+ it("joins thread inspection URLs without double slashes when runtimeUrl has a trailing slash", async () => {
411
+ const fetchSpy = vi
412
+ .spyOn(globalThis, "fetch")
413
+ .mockImplementation(async (input) => {
414
+ const url = String(input);
415
+ if (url.endsWith("/messages")) {
416
+ return new Response(JSON.stringify({ messages: [] }), {
417
+ status: 200,
418
+ });
419
+ }
420
+ if (url.endsWith("/events")) {
421
+ return new Response(JSON.stringify({ events: [] }), { status: 200 });
422
+ }
423
+ return new Response(JSON.stringify({ state: null }), { status: 200 });
424
+ });
425
+ try {
426
+ const { el, internals } = createThreadDetails();
427
+ internals.runtimeUrl = "http://localhost:4000/api/";
428
+ internals.threadInspectionAvailable = true;
429
+ internals.threadId = "thread one";
430
+ await el.updateComplete;
431
+ fetchSpy.mockClear();
432
+
433
+ await internals.fetchMessages("thread one");
434
+ await internals.fetchEvents("thread one");
435
+ await internals.fetchState("thread one");
436
+
437
+ expect(fetchSpy.mock.calls.map((call) => String(call[0]))).toEqual([
438
+ "http://localhost:4000/api/threads/thread%20one/messages",
439
+ "http://localhost:4000/api/threads/thread%20one/events",
440
+ "http://localhost:4000/api/threads/thread%20one/state",
441
+ ]);
442
+ } finally {
443
+ fetchSpy.mockRestore();
444
+ }
445
+ });
446
+
380
447
  it("conversation cache invalidates when _conversation is reassigned", async () => {
381
448
  const { el, internals } = createThreadDetails();
382
449
  await settleThread(el, internals, "t1");
@@ -575,3 +642,334 @@ describe("WebInspectorElement announcement preview dismissal", () => {
575
642
  expect(a.hasUnseenAnnouncement).toBe(true);
576
643
  });
577
644
  });
645
+
646
+ // --- Owned thread store header forwarding (issue #5581) ---
647
+ //
648
+ // When useThreads() isn't mounted, the inspector creates its own thread store
649
+ // per agent (ensureOwnedThreadStore). That store's /threads requests must carry
650
+ // the headers configured on <CopilotKit> (e.g. X-CSRF / auth), otherwise the
651
+ // requests 403 in environments that enforce CSRF/auth checks.
652
+
653
+ type HeaderMockCore = {
654
+ agents: Record<string, AbstractAgent>;
655
+ context: Record<string, unknown>;
656
+ properties: Record<string, unknown>;
657
+ telemetryDisabled: boolean;
658
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
659
+ runtimeUrl: string;
660
+ headers: Record<string, string>;
661
+ threadEndpoints: {
662
+ list: boolean;
663
+ inspect: boolean;
664
+ mutations: boolean;
665
+ realtimeMetadata: boolean;
666
+ };
667
+ subscribe: (subscriber: CopilotKitCoreSubscriber) => {
668
+ unsubscribe: () => void;
669
+ };
670
+ getThreadStores: () => Record<string, never>;
671
+ getThreadStore: (agentId: string) => undefined;
672
+ registerThreadStore: (agentId: string, store: unknown) => void;
673
+ unregisterThreadStore: (agentId: string) => void;
674
+ };
675
+
676
+ function createHeaderMockCore(
677
+ agents: Record<string, AbstractAgent>,
678
+ headers: Record<string, string>,
679
+ endpointOverrides: Partial<HeaderMockCore["threadEndpoints"]> = {},
680
+ telemetryDisabled = true,
681
+ ) {
682
+ const subscribers = new Set<CopilotKitCoreSubscriber>();
683
+ const core: HeaderMockCore = {
684
+ agents,
685
+ context: {},
686
+ properties: {},
687
+ telemetryDisabled,
688
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
689
+ runtimeUrl: "http://localhost/api",
690
+ headers,
691
+ threadEndpoints: {
692
+ list: true,
693
+ inspect: true,
694
+ mutations: true,
695
+ realtimeMetadata: true,
696
+ ...endpointOverrides,
697
+ },
698
+ subscribe(subscriber: CopilotKitCoreSubscriber) {
699
+ subscribers.add(subscriber);
700
+ return { unsubscribe: () => subscribers.delete(subscriber) };
701
+ },
702
+ getThreadStores() {
703
+ return {};
704
+ },
705
+ getThreadStore() {
706
+ return undefined;
707
+ },
708
+ registerThreadStore() {},
709
+ unregisterThreadStore() {},
710
+ };
711
+
712
+ const asCore = () => core as unknown as CopilotKitCore;
713
+ return {
714
+ core,
715
+ emitAgentsChanged() {
716
+ subscribers.forEach((s) =>
717
+ s.onAgentsChanged?.({ copilotkit: asCore(), agents: core.agents }),
718
+ );
719
+ },
720
+ emitHeadersChanged(nextHeaders: Record<string, string>) {
721
+ core.headers = nextHeaders;
722
+ subscribers.forEach((s) =>
723
+ s.onHeadersChanged?.({ copilotkit: asCore(), headers: nextHeaders }),
724
+ );
725
+ },
726
+ };
727
+ }
728
+
729
+ const headersOf = (call: unknown[]) =>
730
+ (call[1] as { headers?: Record<string, string> } | undefined)?.headers ?? {};
731
+
732
+ describe("WebInspectorElement owned thread store headers (#5581)", () => {
733
+ let fetchMock: ReturnType<typeof vi.fn>;
734
+
735
+ const threadListCalls = () =>
736
+ fetchMock.mock.calls.filter((call) =>
737
+ String(call[0]).includes("/threads?"),
738
+ );
739
+ const telemetryPosts = () =>
740
+ fetchMock.mock.calls
741
+ .filter(
742
+ (call) =>
743
+ String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
744
+ (call[1] as RequestInit | undefined)?.method === "POST",
745
+ )
746
+ .map((call) => {
747
+ const body =
748
+ ((call[1] as RequestInit | undefined)?.body as string) ?? "{}";
749
+ return JSON.parse(body) as {
750
+ event: string;
751
+ properties: Record<string, unknown>;
752
+ };
753
+ });
754
+
755
+ beforeEach(() => {
756
+ document.body.innerHTML = "";
757
+ fetchMock = vi.fn(() =>
758
+ Promise.resolve({
759
+ ok: true,
760
+ status: 200,
761
+ json: () => Promise.resolve({ threads: [] }),
762
+ }),
763
+ );
764
+ // The owned store captures globalThis.fetch when it's created, so stub
765
+ // before the inspector attaches to the core.
766
+ vi.stubGlobal("fetch", fetchMock);
767
+ });
768
+
769
+ afterEach(() => {
770
+ vi.unstubAllGlobals();
771
+ });
772
+
773
+ it("forwards core headers on the owned store's /threads request", async () => {
774
+ const { agent } = createMockAgent("alpha");
775
+ const harness = createHeaderMockCore(
776
+ { alpha: agent },
777
+ { "X-CSRF": "1", Authorization: "Bearer abc" },
778
+ );
779
+
780
+ const inspector = new WebInspectorElement();
781
+ document.body.appendChild(inspector);
782
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
783
+ harness.emitAgentsChanged();
784
+
785
+ await vi.waitFor(() => {
786
+ expect(threadListCalls().length).toBeGreaterThan(0);
787
+ });
788
+
789
+ expect(headersOf(threadListCalls()[0]!)).toMatchObject({
790
+ "X-CSRF": "1",
791
+ Authorization: "Bearer abc",
792
+ });
793
+ });
794
+
795
+ it("re-applies headers on the owned store when core headers change", async () => {
796
+ const { agent } = createMockAgent("alpha");
797
+ const harness = createHeaderMockCore({ alpha: agent }, { "X-CSRF": "1" });
798
+
799
+ const inspector = new WebInspectorElement();
800
+ document.body.appendChild(inspector);
801
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
802
+ harness.emitAgentsChanged();
803
+
804
+ await vi.waitFor(() => {
805
+ expect(threadListCalls().length).toBeGreaterThan(0);
806
+ });
807
+ const callsBefore = threadListCalls().length;
808
+
809
+ harness.emitHeadersChanged({ "X-CSRF": "2" });
810
+
811
+ await vi.waitFor(() => {
812
+ expect(threadListCalls().length).toBeGreaterThan(callsBefore);
813
+ });
814
+
815
+ expect(headersOf(threadListCalls().at(-1)!)).toMatchObject({
816
+ "X-CSRF": "2",
817
+ });
818
+ });
819
+
820
+ it("shows the locked Intelligence state when thread listing is unavailable without fetching threads", async () => {
821
+ const { agent } = createMockAgent("alpha");
822
+ const harness = createHeaderMockCore(
823
+ { alpha: agent },
824
+ { "X-CSRF": "1" },
825
+ { list: false },
826
+ true,
827
+ );
828
+
829
+ const inspector = new WebInspectorElement();
830
+ document.body.appendChild(inspector);
831
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
832
+ harness.emitAgentsChanged();
833
+
834
+ const internals = inspector as unknown as {
835
+ isOpen: boolean;
836
+ handleMenuSelect: (key: "threads") => void;
837
+ };
838
+ internals.isOpen = true;
839
+ internals.handleMenuSelect("threads");
840
+ await inspector.updateComplete;
841
+
842
+ const text = inspector.shadowRoot?.textContent ?? "";
843
+ expect(text).toMatch(/Enable Intelligence to inspect Threads\./);
844
+ expect(text).toContain("Talk to an Engineer");
845
+ expect(text).toContain("Sign up for Intelligence");
846
+ const ctaLabels = Array.from(
847
+ inspector.shadowRoot?.querySelectorAll<HTMLAnchorElement>("a") ?? [],
848
+ ).map((anchor) => anchor.textContent?.trim());
849
+ expect(ctaLabels).toEqual([
850
+ "Talk to an Engineer",
851
+ "Sign up for Intelligence",
852
+ ]);
853
+ expect(text).not.toContain("No threads yet");
854
+ expect(
855
+ fetchMock.mock.calls.some((call) => String(call[0]).includes("/threads")),
856
+ ).toBe(false);
857
+ });
858
+
859
+ it("adds ref and posthog distinct ID attribution to locked-state CTAs", async () => {
860
+ const { agent } = createMockAgent("alpha");
861
+ const harness = createHeaderMockCore(
862
+ { alpha: agent },
863
+ {},
864
+ { list: false },
865
+ false,
866
+ );
867
+
868
+ const inspector = new WebInspectorElement();
869
+ document.body.appendChild(inspector);
870
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
871
+ harness.emitAgentsChanged();
872
+
873
+ const internals = inspector as unknown as {
874
+ isOpen: boolean;
875
+ handleMenuSelect: (key: "threads") => void;
876
+ };
877
+ internals.isOpen = true;
878
+ internals.handleMenuSelect("threads");
879
+ await inspector.updateComplete;
880
+
881
+ const signup = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
882
+ 'a[href^="https://go.copilotkit.ai/intelligence-signup"]',
883
+ );
884
+ const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
885
+ 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
886
+ );
887
+
888
+ expect(signup).not.toBeNull();
889
+ expect(engineer).not.toBeNull();
890
+
891
+ const signupUrl = new URL(signup!.href);
892
+ expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector");
893
+ const distinctId = signupUrl.searchParams.get("posthog_distinct_id");
894
+ expect(distinctId).toMatch(/^[0-9a-f-]{36}$/);
895
+
896
+ const engineerUrl = new URL(engineer!.href);
897
+ expect(engineerUrl.origin).toBe("https://www.copilotkit.ai");
898
+ expect(engineerUrl.pathname).toBe("/talk-to-an-engineer");
899
+ expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
900
+ expect(engineerUrl.searchParams.get("posthog_distinct_id")).toBe(
901
+ distinctId,
902
+ );
903
+ });
904
+
905
+ it("tracks Threads tab clicks through the rendered inspector menu", async () => {
906
+ const { agent } = createMockAgent("alpha");
907
+ const harness = createHeaderMockCore(
908
+ { alpha: agent },
909
+ {},
910
+ { list: false },
911
+ false,
912
+ );
913
+
914
+ const inspector = new WebInspectorElement();
915
+ document.body.appendChild(inspector);
916
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
917
+ harness.emitAgentsChanged();
918
+
919
+ const internals = inspector as unknown as { isOpen: boolean };
920
+ internals.isOpen = true;
921
+ inspector.requestUpdate();
922
+ await inspector.updateComplete;
923
+
924
+ const threadsButton = Array.from(
925
+ inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
926
+ ).find((button) => button.textContent?.trim() === "Threads");
927
+ expect(threadsButton, "Threads menu button should render").toBeDefined();
928
+
929
+ threadsButton!.click();
930
+ await inspector.updateComplete;
931
+ await Promise.resolve();
932
+
933
+ const threadsTabClick = telemetryPosts().find(
934
+ (post) => post.event === "oss.inspector.threads_tab_clicked",
935
+ );
936
+ expect(threadsTabClick).toBeDefined();
937
+ expect(threadsTabClick!.properties).toMatchObject({
938
+ intelligence_status: "intelligence_not_enabled",
939
+ thread_service_status: "unavailable",
940
+ telemetry_disabled: false,
941
+ });
942
+ expect(threadsTabClick!.properties.distinct_id).toMatch(/^[0-9a-f-]{36}$/);
943
+ if (threadsTabClick!.properties.posthog_distinct_id !== undefined) {
944
+ expect(threadsTabClick!.properties.posthog_distinct_id).toBe(
945
+ threadsTabClick!.properties.distinct_id,
946
+ );
947
+ }
948
+ });
949
+
950
+ it("keeps the enabled empty Threads state when thread listing is available", async () => {
951
+ const { agent } = createMockAgent("alpha");
952
+ const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true);
953
+
954
+ const inspector = new WebInspectorElement();
955
+ document.body.appendChild(inspector);
956
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
957
+ harness.emitAgentsChanged();
958
+
959
+ const internals = inspector as unknown as {
960
+ isOpen: boolean;
961
+ handleMenuSelect: (key: "threads") => void;
962
+ };
963
+ internals.isOpen = true;
964
+ internals.handleMenuSelect("threads");
965
+ await inspector.updateComplete;
966
+
967
+ expect(inspector.shadowRoot?.textContent ?? "").toContain("No threads yet");
968
+ const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
969
+ 'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
970
+ );
971
+ expect(engineer?.href).toBe(
972
+ "https://www.copilotkit.ai/talk-to-an-engineer?ref=cpk-inspector-threads",
973
+ );
974
+ });
975
+ });