@copilotkit/runtime 1.56.5 → 1.57.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.
Files changed (62) hide show
  1. package/dist/package.cjs +1 -1
  2. package/dist/package.mjs +1 -1
  3. package/dist/v2/index.d.cts +2 -2
  4. package/dist/v2/index.d.mts +2 -2
  5. package/dist/v2/runtime/core/fetch-handler.cjs +16 -0
  6. package/dist/v2/runtime/core/fetch-handler.cjs.map +1 -1
  7. package/dist/v2/runtime/core/fetch-handler.d.cts.map +1 -1
  8. package/dist/v2/runtime/core/fetch-handler.d.mts.map +1 -1
  9. package/dist/v2/runtime/core/fetch-handler.mjs +17 -1
  10. package/dist/v2/runtime/core/fetch-handler.mjs.map +1 -1
  11. package/dist/v2/runtime/core/fetch-router.cjs +18 -1
  12. package/dist/v2/runtime/core/fetch-router.cjs.map +1 -1
  13. package/dist/v2/runtime/core/fetch-router.mjs +18 -1
  14. package/dist/v2/runtime/core/fetch-router.mjs.map +1 -1
  15. package/dist/v2/runtime/core/hooks.cjs.map +1 -1
  16. package/dist/v2/runtime/core/hooks.d.cts +8 -0
  17. package/dist/v2/runtime/core/hooks.d.cts.map +1 -1
  18. package/dist/v2/runtime/core/hooks.d.mts +8 -0
  19. package/dist/v2/runtime/core/hooks.d.mts.map +1 -1
  20. package/dist/v2/runtime/core/hooks.mjs.map +1 -1
  21. package/dist/v2/runtime/handlers/handle-run.cjs +1 -0
  22. package/dist/v2/runtime/handlers/handle-run.cjs.map +1 -1
  23. package/dist/v2/runtime/handlers/handle-run.mjs +1 -0
  24. package/dist/v2/runtime/handlers/handle-run.mjs.map +1 -1
  25. package/dist/v2/runtime/handlers/intelligence/threads.cjs +124 -12
  26. package/dist/v2/runtime/handlers/intelligence/threads.cjs.map +1 -1
  27. package/dist/v2/runtime/handlers/intelligence/threads.mjs +122 -13
  28. package/dist/v2/runtime/handlers/intelligence/threads.mjs.map +1 -1
  29. package/dist/v2/runtime/index.d.cts +1 -1
  30. package/dist/v2/runtime/index.d.mts +1 -1
  31. package/dist/v2/runtime/intelligence-platform/client.cjs +30 -0
  32. package/dist/v2/runtime/intelligence-platform/client.cjs.map +1 -1
  33. package/dist/v2/runtime/intelligence-platform/client.d.cts +66 -0
  34. package/dist/v2/runtime/intelligence-platform/client.d.cts.map +1 -1
  35. package/dist/v2/runtime/intelligence-platform/client.d.mts +66 -0
  36. package/dist/v2/runtime/intelligence-platform/client.d.mts.map +1 -1
  37. package/dist/v2/runtime/intelligence-platform/client.mjs +30 -0
  38. package/dist/v2/runtime/intelligence-platform/client.mjs.map +1 -1
  39. package/dist/v2/runtime/runner/in-memory.cjs +94 -22
  40. package/dist/v2/runtime/runner/in-memory.cjs.map +1 -1
  41. package/dist/v2/runtime/runner/in-memory.d.cts +65 -2
  42. package/dist/v2/runtime/runner/in-memory.d.cts.map +1 -1
  43. package/dist/v2/runtime/runner/in-memory.d.mts +65 -2
  44. package/dist/v2/runtime/runner/in-memory.d.mts.map +1 -1
  45. package/dist/v2/runtime/runner/in-memory.mjs +94 -22
  46. package/dist/v2/runtime/runner/in-memory.mjs.map +1 -1
  47. package/dist/v2/runtime/runner/index.d.cts +1 -1
  48. package/dist/v2/runtime/runner/index.d.mts +1 -1
  49. package/package.json +2 -2
  50. package/src/v2/runtime/__tests__/fetch-handler-validation.test.ts +68 -0
  51. package/src/v2/runtime/__tests__/fetch-router.test.ts +46 -0
  52. package/src/v2/runtime/__tests__/handle-run.test.ts +97 -1
  53. package/src/v2/runtime/__tests__/handle-threads.test.ts +493 -13
  54. package/src/v2/runtime/core/fetch-handler.ts +19 -0
  55. package/src/v2/runtime/core/fetch-router.ts +33 -1
  56. package/src/v2/runtime/core/hooks.ts +3 -0
  57. package/src/v2/runtime/handlers/handle-run.ts +4 -0
  58. package/src/v2/runtime/handlers/handle-threads.ts +3 -0
  59. package/src/v2/runtime/handlers/intelligence/threads.ts +200 -41
  60. package/src/v2/runtime/intelligence-platform/client.ts +76 -0
  61. package/src/v2/runtime/runner/__tests__/in-memory-runner.test.ts +417 -3
  62. package/src/v2/runtime/runner/in-memory.ts +137 -51
@@ -2,12 +2,17 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import {
4
4
  handleArchiveThread,
5
+ handleClearThreads,
5
6
  handleDeleteThread,
7
+ handleGetThreadEvents,
8
+ handleGetThreadMessages,
9
+ handleGetThreadState,
6
10
  handleListThreads,
7
11
  handleSubscribeToThreads,
8
12
  handleUpdateThread,
9
13
  } from "../handlers/handle-threads";
10
14
  import { CopilotRuntime } from "../core/runtime";
15
+ import { InMemoryAgentRunner } from "../runner/in-memory";
11
16
 
12
17
  describe("thread handlers", () => {
13
18
  const createIdentifyUser = () =>
@@ -47,7 +52,7 @@ describe("thread handlers", () => {
47
52
  body: JSON.stringify(body),
48
53
  });
49
54
 
50
- it("returns 422 when intelligence is not configured for listThreads", async () => {
55
+ it("returns empty thread list when intelligence is not configured for listThreads", async () => {
51
56
  const runtime = new CopilotRuntime({ agents: {} });
52
57
 
53
58
  const response = await handleListThreads({
@@ -55,10 +60,10 @@ describe("thread handlers", () => {
55
60
  request: new Request("https://example.com/threads?agentId=agent-1"),
56
61
  });
57
62
 
58
- expect(response.status).toBe(422);
63
+ expect(response.status).toBe(200);
59
64
  await expect(response.json()).resolves.toEqual({
60
- error:
61
- "Missing CopilotKitIntelligence configuration. Thread operations require a CopilotKitIntelligence instance to be provided in CopilotRuntime options.",
65
+ threads: [],
66
+ nextCursor: null,
62
67
  });
63
68
  });
64
69
 
@@ -143,6 +148,16 @@ describe("thread handlers", () => {
143
148
 
144
149
  expect(response.status).toBe(500);
145
150
  expect(intelligence.ɵsubscribeToThreads).not.toHaveBeenCalled();
151
+ // The handler must log the auth failure so an operator looking at
152
+ // the runtime logs sees why the request 500ed. Asserting the
153
+ // operation name ("identifying intelligence user") catches a
154
+ // regression that swaps the diagnostic for a generic placeholder.
155
+ // The throw originates inside `resolveIntelligenceUser`, which
156
+ // logs and returns 500 before `subscribeToThreads` is reached.
157
+ expect(errorSpy).toHaveBeenCalledWith(
158
+ expect.stringContaining("Error identifying intelligence user"),
159
+ expect.any(Error),
160
+ );
146
161
  } finally {
147
162
  errorSpy.mockRestore();
148
163
  }
@@ -357,37 +372,247 @@ describe("thread handlers", () => {
357
372
 
358
373
  it("returns 422 when intelligence is not configured for thread mutations", async () => {
359
374
  const runtime = new CopilotRuntime({ agents: {} });
360
- const mutationRequest = new Request(
361
- "https://example.com/threads/thread-1",
362
- {
363
- method: "POST",
375
+ const buildRequest = (method: "PATCH" | "POST" | "DELETE") =>
376
+ new Request("https://example.com/threads/thread-1", {
377
+ method,
364
378
  headers: { "Content-Type": "application/json" },
365
379
  body: JSON.stringify({ userId: "user-1", agentId: "agent-1" }),
366
- },
367
- );
380
+ });
368
381
 
369
382
  const updateResponse = await handleUpdateThread({
370
383
  runtime,
371
- request: mutationRequest.clone(),
384
+ request: buildRequest("PATCH"),
372
385
  threadId: "thread-1",
373
386
  });
374
387
  expect(updateResponse.status).toBe(422);
375
388
 
376
389
  const archiveResponse = await handleArchiveThread({
377
390
  runtime,
378
- request: mutationRequest.clone(),
391
+ request: buildRequest("POST"),
379
392
  threadId: "thread-1",
380
393
  });
381
394
  expect(archiveResponse.status).toBe(422);
382
395
 
396
+ // Use the real DELETE method here — Request.clone() preserves the
397
+ // method of the original, so re-using a POST clone for the delete
398
+ // path silently exercises the wrong verb.
383
399
  const deleteResponse = await handleDeleteThread({
384
400
  runtime,
385
- request: mutationRequest.clone(),
401
+ request: buildRequest("DELETE"),
386
402
  threadId: "thread-1",
387
403
  });
388
404
  expect(deleteResponse.status).toBe(422);
389
405
  });
390
406
 
407
+ describe("handleClearThreads", () => {
408
+ // handleClearThreads is intentionally synchronous — it has no I/O on
409
+ // either branch (in-memory map mutation or platform no-op), so it
410
+ // returns a plain Response rather than a Promise. The other handlers
411
+ // in this suite are awaited because they either parse JSON or hit
412
+ // the Intelligence platform; this one does neither.
413
+ it("clears in-memory threads and returns 204 for InMemoryAgentRunner", () => {
414
+ const runner = new InMemoryAgentRunner();
415
+ const clearThreadsSpy = vi.spyOn(runner, "clearThreads");
416
+ const runtime = new CopilotRuntime({ agents: {}, runner });
417
+
418
+ const response = handleClearThreads({
419
+ runtime,
420
+ request: new Request("https://example.com/threads"),
421
+ });
422
+
423
+ // Lock the synchronous contract: a regression that starts awaiting
424
+ // I/O inside this handler must update the call sites that rely on
425
+ // the synchronous return shape. Asserting `not.toBeInstanceOf(Promise)`
426
+ // catches that drift at runtime.
427
+ expect(response).not.toBeInstanceOf(Promise);
428
+ expect(response.status).toBe(204);
429
+ expect(clearThreadsSpy).toHaveBeenCalledTimes(1);
430
+ });
431
+
432
+ it("returns 204 without touching state when intelligence runtime is configured", () => {
433
+ const intelligence = { listThreads: vi.fn() };
434
+ const runtime = createIntelligenceRuntime({ intelligence });
435
+
436
+ const response = handleClearThreads({
437
+ runtime,
438
+ request: new Request("https://example.com/threads"),
439
+ });
440
+
441
+ // Same synchronous-contract guard as the in-memory branch above.
442
+ expect(response).not.toBeInstanceOf(Promise);
443
+ expect(response.status).toBe(204);
444
+ expect(intelligence.listThreads).not.toHaveBeenCalled();
445
+ });
446
+ });
447
+
448
+ describe("handleGetThreadMessages", () => {
449
+ it("returns messages from the in-memory runner for a known thread", async () => {
450
+ const runner = new InMemoryAgentRunner();
451
+ vi.spyOn(runner, "getThreadMessages").mockReturnValue([
452
+ { id: "m1", role: "user", content: "hello" } as never,
453
+ { id: "m2", role: "assistant", content: "hi there" } as never,
454
+ ]);
455
+ const runtime = new CopilotRuntime({ agents: {}, runner });
456
+
457
+ const response = await handleGetThreadMessages({
458
+ runtime,
459
+ request: new Request("https://example.com/threads/thread-1/messages"),
460
+ threadId: "thread-1",
461
+ });
462
+
463
+ expect(response.status).toBe(200);
464
+ const body = await response.json();
465
+ expect(body.messages).toHaveLength(2);
466
+ expect(body.messages[0]).toMatchObject({
467
+ id: "m1",
468
+ role: "user",
469
+ content: "hello",
470
+ });
471
+ expect(body.messages[1]).toMatchObject({
472
+ id: "m2",
473
+ role: "assistant",
474
+ content: "hi there",
475
+ });
476
+ });
477
+
478
+ it("returns empty messages for an unknown threadId", async () => {
479
+ const runtime = new CopilotRuntime({ agents: {} });
480
+
481
+ const response = await handleGetThreadMessages({
482
+ runtime,
483
+ request: new Request(
484
+ "https://example.com/threads/nonexistent/messages",
485
+ ),
486
+ threadId: "nonexistent",
487
+ });
488
+
489
+ expect(response.status).toBe(200);
490
+ const body = await response.json();
491
+ expect(body.messages).toEqual([]);
492
+ });
493
+
494
+ it("delegates to intelligence.getThreadMessages when intelligence is configured", async () => {
495
+ const intelligence = {
496
+ getThreadMessages: vi
497
+ .fn()
498
+ .mockResolvedValue({ messages: [{ id: "m1" }] }),
499
+ };
500
+ const identifyUser = createIdentifyUser();
501
+ const runtime = createIntelligenceRuntime({ intelligence, identifyUser });
502
+
503
+ const response = await handleGetThreadMessages({
504
+ runtime,
505
+ request: new Request("https://example.com/threads/thread-1/messages"),
506
+ threadId: "thread-1",
507
+ });
508
+
509
+ expect(response.status).toBe(200);
510
+ // The handler must propagate the platform's response body verbatim —
511
+ // assert it explicitly so a regression that swaps in a stubbed body
512
+ // (e.g. `{ messages: [] }`) is caught.
513
+ const body = await response.json();
514
+ expect(body.messages).toEqual([{ id: "m1" }]);
515
+ expect(intelligence.getThreadMessages).toHaveBeenCalledWith({
516
+ threadId: "thread-1",
517
+ });
518
+ expect(identifyUser).toHaveBeenCalledTimes(1);
519
+ expect(identifyUser).toHaveBeenCalledWith(
520
+ expect.objectContaining({ url: expect.stringContaining("thread-1") }),
521
+ );
522
+ });
523
+
524
+ it("returns 500 when identifyUser throws for getThreadMessages", async () => {
525
+ const intelligence = {
526
+ getThreadMessages: vi.fn(),
527
+ };
528
+ const runtime = createIntelligenceRuntime({
529
+ intelligence,
530
+ identifyUser: vi.fn().mockRejectedValue(new Error("auth failed")),
531
+ });
532
+ // resolveIntelligenceUser logs via console.error on rejection — silence
533
+ // it for the duration of this test so the suite output stays clean,
534
+ // matching the pattern used in the subscribe-throw test above.
535
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
536
+
537
+ try {
538
+ const response = await handleGetThreadMessages({
539
+ runtime,
540
+ request: new Request("https://example.com/threads/thread-1/messages"),
541
+ threadId: "thread-1",
542
+ });
543
+
544
+ expect(response.status).toBe(500);
545
+ expect(intelligence.getThreadMessages).not.toHaveBeenCalled();
546
+ } finally {
547
+ errorSpy.mockRestore();
548
+ }
549
+ });
550
+
551
+ it("returns 422 when neither in-memory nor intelligence is configured", async () => {
552
+ // A CopilotRuntime with no runner defaults to InMemoryAgentRunner,
553
+ // so simulate a non-InMemory, non-intelligence setup via a custom runner stub.
554
+ // Use the intelligence path but omit intelligence config.
555
+ const runtime = createIntelligenceRuntime({ intelligence: undefined });
556
+
557
+ const response = await handleGetThreadMessages({
558
+ runtime,
559
+ request: new Request("https://example.com/threads/thread-1/messages"),
560
+ threadId: "thread-1",
561
+ });
562
+
563
+ expect(response.status).toBe(422);
564
+ });
565
+
566
+ it("maps tool-call and tool-result messages from the in-memory runner without as-never casts", async () => {
567
+ const runner = new InMemoryAgentRunner();
568
+ const messages = [
569
+ {
570
+ id: "m1",
571
+ role: "assistant" as const,
572
+ toolCalls: [
573
+ {
574
+ id: "tc-1",
575
+ type: "function" as const,
576
+ function: { name: "get_weather", arguments: '{"city":"Paris"}' },
577
+ },
578
+ ],
579
+ },
580
+ {
581
+ id: "m2",
582
+ role: "tool" as const,
583
+ toolCallId: "tc-1",
584
+ content: '{"temp":18}',
585
+ },
586
+ ];
587
+ vi.spyOn(runner, "getThreadMessages").mockReturnValue(messages);
588
+ const runtime = new CopilotRuntime({ agents: {}, runner });
589
+
590
+ const response = await handleGetThreadMessages({
591
+ runtime,
592
+ request: new Request("https://example.com/threads/thread-1/messages"),
593
+ threadId: "thread-1",
594
+ });
595
+
596
+ expect(response.status).toBe(200);
597
+ const body = await response.json();
598
+ expect(body.messages).toHaveLength(2);
599
+
600
+ const assistantMsg = body.messages[0];
601
+ expect(assistantMsg.role).toBe("assistant");
602
+ expect(assistantMsg.toolCalls).toHaveLength(1);
603
+ expect(assistantMsg.toolCalls[0]).toMatchObject({
604
+ id: "tc-1",
605
+ name: "get_weather",
606
+ args: '{"city":"Paris"}',
607
+ });
608
+
609
+ const toolResultMsg = body.messages[1];
610
+ expect(toolResultMsg.role).toBe("tool");
611
+ expect(toolResultMsg.toolCallId).toBe("tc-1");
612
+ expect(toolResultMsg.content).toBe('{"temp":18}');
613
+ });
614
+ });
615
+
391
616
  it("returns 422 when intelligence is not configured for thread subscription", async () => {
392
617
  const runtime = new CopilotRuntime({ agents: {} });
393
618
 
@@ -447,4 +672,259 @@ describe("thread handlers", () => {
447
672
  agentId: "agent-1",
448
673
  });
449
674
  });
675
+
676
+ describe("handleGetThreadEvents", () => {
677
+ it("returns events from the in-memory runner for a known thread", async () => {
678
+ const runner = new InMemoryAgentRunner();
679
+ const fakeEvents = [
680
+ { type: "RUN_STARTED", runId: "r1", threadId: "thread-1" },
681
+ {
682
+ type: "TEXT_MESSAGE_START",
683
+ messageId: "m1",
684
+ role: "assistant",
685
+ },
686
+ ];
687
+ vi.spyOn(runner, "getThreadEvents").mockReturnValue(fakeEvents as never);
688
+ const runtime = new CopilotRuntime({ agents: {}, runner });
689
+
690
+ const response = await handleGetThreadEvents({
691
+ runtime,
692
+ request: new Request("https://example.com/threads/thread-1/events"),
693
+ threadId: "thread-1",
694
+ });
695
+
696
+ expect(response.status).toBe(200);
697
+ const body = await response.json();
698
+ expect(body.events).toHaveLength(2);
699
+ expect(body.events[0]).toMatchObject({ type: "RUN_STARTED" });
700
+ });
701
+
702
+ it("returns empty events for an unknown threadId via the in-memory runner", async () => {
703
+ const runtime = new CopilotRuntime({ agents: {} });
704
+
705
+ const response = await handleGetThreadEvents({
706
+ runtime,
707
+ request: new Request("https://example.com/threads/nonexistent/events"),
708
+ threadId: "nonexistent",
709
+ });
710
+
711
+ expect(response.status).toBe(200);
712
+ const body = await response.json();
713
+ expect(body.events).toEqual([]);
714
+ });
715
+
716
+ it("delegates to intelligence.getThreadEvents when intelligence is configured", async () => {
717
+ // Mirrors the platform's `_inspect/threads/:id/events` response shape
718
+ // (Intelligence PR #144). The handler strips the platform-internal
719
+ // `decodeErrorRowIds` and `truncated` fields before returning, so the
720
+ // wire shape stays `{ events }` to match the in-memory branch.
721
+ const platformEvents = [
722
+ { type: "RUN_STARTED", threadId: "thread-1", runId: "run-a" },
723
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "hello" },
724
+ ];
725
+ const intelligence = {
726
+ getThreadEvents: vi.fn().mockResolvedValue({
727
+ events: platformEvents,
728
+ decodeErrorRowIds: [],
729
+ truncated: false,
730
+ }),
731
+ };
732
+ const identifyUser = createIdentifyUser();
733
+ const runtime = createIntelligenceRuntime({ intelligence, identifyUser });
734
+
735
+ const response = await handleGetThreadEvents({
736
+ runtime,
737
+ request: new Request("https://example.com/threads/thread-1/events"),
738
+ threadId: "thread-1",
739
+ });
740
+
741
+ expect(response.status).toBe(200);
742
+ expect(intelligence.getThreadEvents).toHaveBeenCalledWith({
743
+ threadId: "thread-1",
744
+ });
745
+ expect(identifyUser).toHaveBeenCalledTimes(1);
746
+ const body = await response.json();
747
+ expect(body).toEqual({ events: platformEvents });
748
+ });
749
+
750
+ it("returns 500 when intelligence.getThreadEvents throws", async () => {
751
+ const intelligence = {
752
+ getThreadEvents: vi
753
+ .fn()
754
+ .mockRejectedValue(new Error("platform unavailable")),
755
+ };
756
+ const runtime = createIntelligenceRuntime({ intelligence });
757
+
758
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
759
+ try {
760
+ const response = await handleGetThreadEvents({
761
+ runtime,
762
+ request: new Request("https://example.com/threads/thread-1/events"),
763
+ threadId: "thread-1",
764
+ });
765
+
766
+ expect(response.status).toBe(500);
767
+ expect(intelligence.getThreadEvents).toHaveBeenCalledTimes(1);
768
+ } finally {
769
+ errorSpy.mockRestore();
770
+ }
771
+ });
772
+
773
+ it("returns 500 when the runner throws", async () => {
774
+ const runner = new InMemoryAgentRunner();
775
+ vi.spyOn(runner, "getThreadEvents").mockImplementation(() => {
776
+ throw new Error("boom");
777
+ });
778
+ const runtime = new CopilotRuntime({ agents: {}, runner });
779
+
780
+ const response = await handleGetThreadEvents({
781
+ runtime,
782
+ request: new Request("https://example.com/threads/thread-1/events"),
783
+ threadId: "thread-1",
784
+ });
785
+
786
+ expect(response.status).toBe(500);
787
+ });
788
+ });
789
+
790
+ describe("handleGetThreadState", () => {
791
+ it("returns the state from the in-memory runner", async () => {
792
+ const runner = new InMemoryAgentRunner();
793
+ const snapshot = { counter: 3, label: "alpha" };
794
+ vi.spyOn(runner, "getThreadState").mockReturnValue(snapshot as never);
795
+ const runtime = new CopilotRuntime({ agents: {}, runner });
796
+
797
+ const response = await handleGetThreadState({
798
+ runtime,
799
+ request: new Request("https://example.com/threads/thread-1/state"),
800
+ threadId: "thread-1",
801
+ });
802
+
803
+ expect(response.status).toBe(200);
804
+ const body = await response.json();
805
+ expect(body.state).toEqual(snapshot);
806
+ });
807
+
808
+ it("returns state:null when the runner has no snapshot for the thread", async () => {
809
+ const runtime = new CopilotRuntime({ agents: {} });
810
+
811
+ const response = await handleGetThreadState({
812
+ runtime,
813
+ request: new Request("https://example.com/threads/nonexistent/state"),
814
+ threadId: "nonexistent",
815
+ });
816
+
817
+ expect(response.status).toBe(200);
818
+ const body = await response.json();
819
+ expect(body.state).toBeNull();
820
+ });
821
+
822
+ it("delegates to intelligence.getThreadState and returns the snapshot when intelligence is configured", async () => {
823
+ // Platform returns a discriminated `ThreadStateResult` (Intelligence
824
+ // PR #144). The `snapshot` arm carries the folded current state; the
825
+ // handler flattens it to `{ state }` so the inspector consumes the
826
+ // same shape as the in-memory branch.
827
+ const snapshot = { counter: 7, label: "intel" };
828
+ const intelligence = {
829
+ getThreadState: vi.fn().mockResolvedValue({
830
+ kind: "snapshot",
831
+ state: snapshot,
832
+ skippedDeltas: 0,
833
+ }),
834
+ };
835
+ const identifyUser = createIdentifyUser();
836
+ const runtime = createIntelligenceRuntime({ intelligence, identifyUser });
837
+
838
+ const response = await handleGetThreadState({
839
+ runtime,
840
+ request: new Request("https://example.com/threads/thread-1/state"),
841
+ threadId: "thread-1",
842
+ });
843
+
844
+ expect(response.status).toBe(200);
845
+ expect(intelligence.getThreadState).toHaveBeenCalledWith({
846
+ threadId: "thread-1",
847
+ });
848
+ expect(identifyUser).toHaveBeenCalledTimes(1);
849
+ const body = await response.json();
850
+ expect(body.state).toEqual(snapshot);
851
+ });
852
+
853
+ it("returns state:null for the no-snapshot kind from intelligence", async () => {
854
+ const intelligence = {
855
+ getThreadState: vi.fn().mockResolvedValue({ kind: "no-snapshot" }),
856
+ };
857
+ const runtime = createIntelligenceRuntime({ intelligence });
858
+
859
+ const response = await handleGetThreadState({
860
+ runtime,
861
+ request: new Request("https://example.com/threads/thread-1/state"),
862
+ threadId: "thread-1",
863
+ });
864
+
865
+ expect(response.status).toBe(200);
866
+ const body = await response.json();
867
+ expect(body.state).toBeNull();
868
+ });
869
+
870
+ it("returns state:null for the snapshot-decode-error kind from intelligence", async () => {
871
+ // The platform logs the underlying decode failure server-side; from
872
+ // the inspector's perspective, "no readable state" is the same UX as
873
+ // "no snapshot yet."
874
+ const intelligence = {
875
+ getThreadState: vi
876
+ .fn()
877
+ .mockResolvedValue({ kind: "snapshot-decode-error" }),
878
+ };
879
+ const runtime = createIntelligenceRuntime({ intelligence });
880
+
881
+ const response = await handleGetThreadState({
882
+ runtime,
883
+ request: new Request("https://example.com/threads/thread-1/state"),
884
+ threadId: "thread-1",
885
+ });
886
+
887
+ expect(response.status).toBe(200);
888
+ const body = await response.json();
889
+ expect(body.state).toBeNull();
890
+ });
891
+
892
+ it("returns 500 when intelligence.getThreadState throws", async () => {
893
+ const intelligence = {
894
+ getThreadState: vi
895
+ .fn()
896
+ .mockRejectedValue(new Error("platform unavailable")),
897
+ };
898
+ const runtime = createIntelligenceRuntime({ intelligence });
899
+
900
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
901
+ try {
902
+ const response = await handleGetThreadState({
903
+ runtime,
904
+ request: new Request("https://example.com/threads/thread-1/state"),
905
+ threadId: "thread-1",
906
+ });
907
+
908
+ expect(response.status).toBe(500);
909
+ } finally {
910
+ errorSpy.mockRestore();
911
+ }
912
+ });
913
+
914
+ it("returns 500 when the runner throws", async () => {
915
+ const runner = new InMemoryAgentRunner();
916
+ vi.spyOn(runner, "getThreadState").mockImplementation(() => {
917
+ throw new Error("boom");
918
+ });
919
+ const runtime = new CopilotRuntime({ agents: {}, runner });
920
+
921
+ const response = await handleGetThreadState({
922
+ runtime,
923
+ request: new Request("https://example.com/threads/thread-1/state"),
924
+ threadId: "thread-1",
925
+ });
926
+
927
+ expect(response.status).toBe(500);
928
+ });
929
+ });
450
930
  });
@@ -48,12 +48,15 @@ import { handleGetRuntimeInfo } from "../handlers/get-runtime-info";
48
48
  import { handleTranscribe } from "../handlers/handle-transcribe";
49
49
  import { handleDebugEvents } from "../handlers/handle-debug-events";
50
50
  import {
51
+ handleClearThreads,
51
52
  handleListThreads,
52
53
  handleSubscribeToThreads,
53
54
  handleUpdateThread,
54
55
  handleArchiveThread,
55
56
  handleDeleteThread,
56
57
  handleGetThreadMessages,
58
+ handleGetThreadEvents,
59
+ handleGetThreadState,
57
60
  } from "../handlers/handle-threads";
58
61
  import {
59
62
  parseMethodCall,
@@ -318,6 +321,8 @@ function dispatchRoute(
318
321
  return handleGetRuntimeInfo({ runtime, request });
319
322
  case "transcribe":
320
323
  return handleTranscribe({ runtime, request });
324
+ case "threads/clear":
325
+ return Promise.resolve(handleClearThreads({ runtime, request }));
321
326
  case "threads/list":
322
327
  return handleListThreads({ runtime, request });
323
328
  case "threads/subscribe":
@@ -343,6 +348,18 @@ function dispatchRoute(
343
348
  request,
344
349
  threadId: route.threadId,
345
350
  });
351
+ case "threads/events":
352
+ return handleGetThreadEvents({
353
+ runtime,
354
+ request,
355
+ threadId: route.threadId,
356
+ });
357
+ case "threads/state":
358
+ return handleGetThreadState({
359
+ runtime,
360
+ request,
361
+ threadId: route.threadId,
362
+ });
346
363
  case "cpk-debug-events":
347
364
  return Promise.resolve(handleDebugEvents({ runtime, request }));
348
365
  }
@@ -420,6 +437,8 @@ function validateHttpMethod(
420
437
  case "info":
421
438
  case "threads/list":
422
439
  case "threads/messages":
440
+ case "threads/events":
441
+ case "threads/state":
423
442
  case "cpk-debug-events":
424
443
  if (method === "GET") return null;
425
444
  return jsonResponse({ error: "Method not allowed" }, 405, {
@@ -139,6 +139,28 @@ function matchSegments(path: string): RouteInfo | null {
139
139
  return { method: "threads/messages", threadId };
140
140
  }
141
141
 
142
+ // /threads/:threadId/events (3 segments)
143
+ if (
144
+ len >= 3 &&
145
+ segments[len - 3] === "threads" &&
146
+ segments[len - 1] === "events"
147
+ ) {
148
+ const threadId = safeDecodeURIComponent(segments[len - 2]!);
149
+ if (!threadId) return null;
150
+ return { method: "threads/events", threadId };
151
+ }
152
+
153
+ // /threads/:threadId/state (3 segments)
154
+ if (
155
+ len >= 3 &&
156
+ segments[len - 3] === "threads" &&
157
+ segments[len - 1] === "state"
158
+ ) {
159
+ const threadId = safeDecodeURIComponent(segments[len - 2]!);
160
+ if (!threadId) return null;
161
+ return { method: "threads/state", threadId };
162
+ }
163
+
142
164
  // /threads/:threadId/archive (3 segments)
143
165
  if (
144
166
  len >= 3 &&
@@ -150,11 +172,21 @@ function matchSegments(path: string): RouteInfo | null {
150
172
  return { method: "threads/archive", threadId };
151
173
  }
152
174
 
175
+ // /threads/clear (2 segments) — wipe in-memory thread history
176
+ if (
177
+ len >= 2 &&
178
+ segments[len - 2] === "threads" &&
179
+ segments[len - 1] === "clear"
180
+ ) {
181
+ return { method: "threads/clear" };
182
+ }
183
+
153
184
  // /threads/:threadId (2 segments) — update or delete
154
185
  if (
155
186
  len >= 2 &&
156
187
  segments[len - 2] === "threads" &&
157
- segments[len - 1] !== "subscribe"
188
+ segments[len - 1] !== "subscribe" &&
189
+ segments[len - 1] !== "clear"
158
190
  ) {
159
191
  const threadId = safeDecodeURIComponent(segments[len - 1]!);
160
192
  if (!threadId) return null;
@@ -44,6 +44,9 @@ export type RouteInfo =
44
44
  | { method: "threads/update"; threadId: string }
45
45
  | { method: "threads/archive"; threadId: string }
46
46
  | { method: "threads/messages"; threadId: string }
47
+ | { method: "threads/events"; threadId: string }
48
+ | { method: "threads/state"; threadId: string }
49
+ | { method: "threads/clear" }
47
50
  | { method: "cpk-debug-events" };
48
51
 
49
52
  /* ------------------------------------------------------------------------------------------------