@copilotkit/runtime 1.55.2-next.1 → 1.55.3-canary.1776215089

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 (107) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/agent/converters/tanstack.cjs.map +1 -1
  3. package/dist/agent/converters/tanstack.d.cts +6 -19
  4. package/dist/agent/converters/tanstack.d.cts.map +1 -1
  5. package/dist/agent/converters/tanstack.d.mts +6 -19
  6. package/dist/agent/converters/tanstack.d.mts.map +1 -1
  7. package/dist/agent/converters/tanstack.mjs.map +1 -1
  8. package/dist/agent/index.cjs +14 -0
  9. package/dist/agent/index.cjs.map +1 -1
  10. package/dist/agent/index.d.cts +12 -1
  11. package/dist/agent/index.d.cts.map +1 -1
  12. package/dist/agent/index.d.mts +12 -1
  13. package/dist/agent/index.d.mts.map +1 -1
  14. package/dist/agent/index.mjs +14 -0
  15. package/dist/agent/index.mjs.map +1 -1
  16. package/dist/index.d.cts +2 -1
  17. package/dist/index.d.mts +2 -1
  18. package/dist/lib/index.d.cts +1 -0
  19. package/dist/lib/index.d.cts.map +1 -1
  20. package/dist/lib/index.d.mts +1 -0
  21. package/dist/lib/index.d.mts.map +1 -1
  22. package/dist/lib/runtime/copilot-runtime.cjs.map +1 -1
  23. package/dist/lib/runtime/copilot-runtime.d.cts +3 -3
  24. package/dist/lib/runtime/copilot-runtime.d.cts.map +1 -1
  25. package/dist/lib/runtime/copilot-runtime.d.mts +3 -3
  26. package/dist/lib/runtime/copilot-runtime.d.mts.map +1 -1
  27. package/dist/lib/runtime/copilot-runtime.mjs.map +1 -1
  28. package/dist/package.cjs +1 -1
  29. package/dist/package.mjs +1 -1
  30. package/dist/v2/index.cjs +1 -0
  31. package/dist/v2/index.d.cts +3 -3
  32. package/dist/v2/index.d.mts +3 -3
  33. package/dist/v2/index.mjs +2 -2
  34. package/dist/v2/runtime/core/fetch-handler.cjs +43 -3
  35. package/dist/v2/runtime/core/fetch-handler.cjs.map +1 -1
  36. package/dist/v2/runtime/core/fetch-handler.d.cts.map +1 -1
  37. package/dist/v2/runtime/core/fetch-handler.d.mts.map +1 -1
  38. package/dist/v2/runtime/core/fetch-handler.mjs +43 -3
  39. package/dist/v2/runtime/core/fetch-handler.mjs.map +1 -1
  40. package/dist/v2/runtime/core/fetch-router.cjs +26 -0
  41. package/dist/v2/runtime/core/fetch-router.cjs.map +1 -1
  42. package/dist/v2/runtime/core/fetch-router.mjs +26 -0
  43. package/dist/v2/runtime/core/fetch-router.mjs.map +1 -1
  44. package/dist/v2/runtime/core/hooks.cjs.map +1 -1
  45. package/dist/v2/runtime/core/hooks.d.cts +13 -0
  46. package/dist/v2/runtime/core/hooks.d.cts.map +1 -1
  47. package/dist/v2/runtime/core/hooks.d.mts +13 -0
  48. package/dist/v2/runtime/core/hooks.d.mts.map +1 -1
  49. package/dist/v2/runtime/core/hooks.mjs.map +1 -1
  50. package/dist/v2/runtime/core/runtime.cjs +13 -0
  51. package/dist/v2/runtime/core/runtime.cjs.map +1 -1
  52. package/dist/v2/runtime/core/runtime.d.cts +43 -3
  53. package/dist/v2/runtime/core/runtime.d.cts.map +1 -1
  54. package/dist/v2/runtime/core/runtime.d.mts +43 -3
  55. package/dist/v2/runtime/core/runtime.d.mts.map +1 -1
  56. package/dist/v2/runtime/core/runtime.mjs +13 -1
  57. package/dist/v2/runtime/core/runtime.mjs.map +1 -1
  58. package/dist/v2/runtime/handlers/get-runtime-info.cjs +18 -10
  59. package/dist/v2/runtime/handlers/get-runtime-info.cjs.map +1 -1
  60. package/dist/v2/runtime/handlers/get-runtime-info.mjs +19 -11
  61. package/dist/v2/runtime/handlers/get-runtime-info.mjs.map +1 -1
  62. package/dist/v2/runtime/handlers/handle-connect.cjs +1 -1
  63. package/dist/v2/runtime/handlers/handle-connect.cjs.map +1 -1
  64. package/dist/v2/runtime/handlers/handle-connect.mjs +1 -1
  65. package/dist/v2/runtime/handlers/handle-connect.mjs.map +1 -1
  66. package/dist/v2/runtime/handlers/handle-run.cjs +1 -1
  67. package/dist/v2/runtime/handlers/handle-run.cjs.map +1 -1
  68. package/dist/v2/runtime/handlers/handle-run.mjs +1 -1
  69. package/dist/v2/runtime/handlers/handle-run.mjs.map +1 -1
  70. package/dist/v2/runtime/handlers/handle-stop.cjs +2 -1
  71. package/dist/v2/runtime/handlers/handle-stop.cjs.map +1 -1
  72. package/dist/v2/runtime/handlers/handle-stop.mjs +2 -1
  73. package/dist/v2/runtime/handlers/handle-stop.mjs.map +1 -1
  74. package/dist/v2/runtime/handlers/intelligence/thread-names.cjs +1 -1
  75. package/dist/v2/runtime/handlers/intelligence/thread-names.cjs.map +1 -1
  76. package/dist/v2/runtime/handlers/intelligence/thread-names.mjs +1 -1
  77. package/dist/v2/runtime/handlers/intelligence/thread-names.mjs.map +1 -1
  78. package/dist/v2/runtime/handlers/intelligence/threads.cjs +179 -0
  79. package/dist/v2/runtime/handlers/intelligence/threads.cjs.map +1 -0
  80. package/dist/v2/runtime/handlers/intelligence/threads.mjs +173 -0
  81. package/dist/v2/runtime/handlers/intelligence/threads.mjs.map +1 -0
  82. package/dist/v2/runtime/handlers/shared/agent-utils.cjs +3 -2
  83. package/dist/v2/runtime/handlers/shared/agent-utils.cjs.map +1 -1
  84. package/dist/v2/runtime/handlers/shared/agent-utils.mjs +3 -2
  85. package/dist/v2/runtime/handlers/shared/agent-utils.mjs.map +1 -1
  86. package/dist/v2/runtime/index.d.cts +1 -1
  87. package/dist/v2/runtime/index.d.mts +1 -1
  88. package/package.json +2 -2
  89. package/src/agent/__tests__/capabilities.test.ts +81 -0
  90. package/src/agent/converters/tanstack.ts +15 -7
  91. package/src/agent/index.ts +33 -0
  92. package/src/lib/runtime/copilot-runtime.ts +6 -1
  93. package/src/v2/runtime/__tests__/agents-factory.test.ts +136 -0
  94. package/src/v2/runtime/__tests__/fetch-router.test.ts +76 -0
  95. package/src/v2/runtime/__tests__/get-runtime-info.test.ts +134 -1
  96. package/src/v2/runtime/core/fetch-handler.ts +55 -4
  97. package/src/v2/runtime/core/fetch-router.ts +48 -0
  98. package/src/v2/runtime/core/hooks.ts +6 -1
  99. package/src/v2/runtime/core/runtime.ts +63 -2
  100. package/src/v2/runtime/handlers/get-runtime-info.ts +33 -8
  101. package/src/v2/runtime/handlers/handle-connect.ts +1 -1
  102. package/src/v2/runtime/handlers/handle-run.ts +1 -1
  103. package/src/v2/runtime/handlers/handle-stop.ts +2 -1
  104. package/src/v2/runtime/handlers/handle-threads.ts +1 -0
  105. package/src/v2/runtime/handlers/intelligence/thread-names.ts +1 -1
  106. package/src/v2/runtime/handlers/intelligence/threads.ts +28 -0
  107. package/src/v2/runtime/handlers/shared/agent-utils.ts +3 -2
@@ -11,24 +11,32 @@ import {
11
11
  } from "@ag-ui/client";
12
12
  import { randomUUID } from "@copilotkit/shared";
13
13
 
14
+ type ContentPartSource =
15
+ | { type: "data"; value: string; mimeType: string }
16
+ | { type: "url"; value: string; mimeType?: string };
17
+
14
18
  /**
15
19
  * A TanStack AI content part (text, image, audio, video, or document).
16
20
  */
17
21
  export type TanStackContentPart =
18
22
  | { type: "text"; content: string }
19
- | {
20
- type: "image" | "audio" | "video" | "document";
21
- source:
22
- | { type: "data"; value: string; mimeType: string }
23
- | { type: "url"; value: string; mimeType?: string };
24
- };
23
+ | { type: "image"; source: ContentPartSource }
24
+ | { type: "audio"; source: ContentPartSource }
25
+ | { type: "video"; source: ContentPartSource }
26
+ | { type: "document"; source: ContentPartSource };
25
27
 
26
28
  /**
27
29
  * Message format expected by TanStack AI's `chat()`.
30
+ *
31
+ * Content is typed as `any[]` for the multimodal case so messages are directly
32
+ * passable to any adapter without casts — different adapters constrain which
33
+ * modalities they accept (e.g. OpenAI only allows text + image).
34
+ * Use `TanStackContentPart` to inspect individual parts if needed.
28
35
  */
29
36
  export interface TanStackChatMessage {
30
37
  role: "user" | "assistant" | "tool";
31
- content: string | null | TanStackContentPart[];
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ content: string | null | any[];
32
40
  name?: string;
33
41
  toolCalls?: Array<{
34
42
  id: string;
@@ -20,6 +20,7 @@ import {
20
20
  StateSnapshotEvent,
21
21
  StateDeltaEvent,
22
22
  } from "@ag-ui/client";
23
+ import type { AgentCapabilities } from "@ag-ui/core";
23
24
  import {
24
25
  streamText,
25
26
  LanguageModel,
@@ -817,6 +818,15 @@ export interface BuiltInAgentClassicConfig {
817
818
  * Example: `{ openai: { reasoningEffort: "high" } }`
818
819
  */
819
820
  providerOptions?: Record<string, any>;
821
+ /**
822
+ * Explicit agent capabilities. **Shallow-merged** at the category level on
823
+ * top of auto-inferred defaults — providing a category (e.g. `tools`)
824
+ * replaces that entire category, not individual fields within it.
825
+ *
826
+ * For example, `{ tools: { supported: true } }` will drop the inferred
827
+ * `clientProvided` value. Include all fields for any category you override.
828
+ */
829
+ capabilities?: Partial<AgentCapabilities>;
820
830
  }
821
831
 
822
832
  /**
@@ -854,6 +864,29 @@ export class BuiltInAgent extends AbstractAgent {
854
864
  return this.config?.overridableProperties?.includes(property) ?? false;
855
865
  }
856
866
 
867
+ async getCapabilities(): Promise<AgentCapabilities> {
868
+ const inferred: AgentCapabilities = {
869
+ tools: {
870
+ supported: true,
871
+ clientProvided: true,
872
+ },
873
+ transport: {
874
+ streaming: true,
875
+ },
876
+ };
877
+
878
+ if (!this.config.capabilities) {
879
+ return inferred;
880
+ }
881
+
882
+ // Shallow merge at the category level — explicit overrides replace
883
+ // entire categories when provided, inferred defaults fill the rest.
884
+ return {
885
+ ...inferred,
886
+ ...this.config.capabilities,
887
+ };
888
+ }
889
+
857
890
  run(input: RunAgentInput): Observable<BaseEvent> {
858
891
  if (isFactoryConfig(this.config)) {
859
892
  return this.runFactory(input, this.config);
@@ -35,8 +35,13 @@ import {
35
35
  type CopilotRuntimeOptions,
36
36
  type CopilotRuntimeOptions as CopilotRuntimeOptionsVNext,
37
37
  type AgentRunner,
38
+ type AgentsConfig,
39
+ type AgentsFactory,
40
+ type AgentFactoryContext,
38
41
  InMemoryAgentRunner,
39
42
  } from "../../v2/runtime";
43
+
44
+ export type { AgentsConfig, AgentsFactory, AgentFactoryContext };
40
45
  import { TelemetryAgentRunner } from "./telemetry-agent-runner";
41
46
  import telemetry from "../telemetry-client";
42
47
 
@@ -318,7 +323,7 @@ interface CopilotRuntimeConstructorParams<T extends Parameter[] | [] = []>
318
323
  * – the `MaybePromise<NonEmptyRecord<T>>` constraint in `CopilotRuntimeOptionsVNext`
319
324
  * – the `Record<string, AbstractAgent>` constraint in `both
320
325
  */
321
- agents?: MaybePromise<NonEmptyRecord<Record<string, AbstractAgent>>>;
326
+ agents?: AgentsConfig;
322
327
  }
323
328
 
324
329
  /**
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { HttpAgent } from "@ag-ui/client";
3
+ import {
4
+ resolveAgents,
5
+ type AgentsConfig,
6
+ type AgentFactoryContext,
7
+ } from "../core/runtime";
8
+ import { handleRunAgent } from "../handlers/handle-run";
9
+ import { handleGetRuntimeInfo } from "../handlers/get-runtime-info";
10
+ import { CopilotRuntime } from "../core/runtime";
11
+
12
+ function createMockAgent(name = "test") {
13
+ return new HttpAgent({ url: `https://example.com/${name}` });
14
+ }
15
+
16
+ function createMockRuntime(agents: AgentsConfig) {
17
+ return {
18
+ agents,
19
+ transcriptionService: undefined,
20
+ beforeRequestMiddleware: undefined,
21
+ afterRequestMiddleware: undefined,
22
+ runner: { stop: vi.fn().mockResolvedValue(true) },
23
+ mode: "sse",
24
+ } as unknown as CopilotRuntime;
25
+ }
26
+
27
+ function createMockRequest(headers?: Record<string, string>) {
28
+ return new Request("https://example.com/agent/test/run", {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json", ...headers },
31
+ body: JSON.stringify({ threadId: "thread-1", messages: [], state: {} }),
32
+ });
33
+ }
34
+
35
+ describe("resolveAgents", () => {
36
+ it("resolves a static record", async () => {
37
+ const agents = { default: createMockAgent() };
38
+ const result = await resolveAgents(agents);
39
+ expect(result).toBe(agents);
40
+ });
41
+
42
+ it("resolves a Promise", async () => {
43
+ const agents = { default: createMockAgent() };
44
+ const result = await resolveAgents(Promise.resolve(agents));
45
+ expect(result).toEqual(agents);
46
+ });
47
+
48
+ it("calls a factory function with request context", async () => {
49
+ const agent = createMockAgent();
50
+ const factory = vi.fn().mockReturnValue({ default: agent });
51
+ const request = createMockRequest({ "x-tenant-id": "tenant-123" });
52
+
53
+ const result = await resolveAgents(factory, request);
54
+
55
+ expect(factory).toHaveBeenCalledTimes(1);
56
+ expect(factory).toHaveBeenCalledWith({ request });
57
+ expect(result).toEqual({ default: agent });
58
+ });
59
+
60
+ it("calls an async factory function", async () => {
61
+ const agent = createMockAgent();
62
+ const factory = vi.fn().mockResolvedValue({ default: agent });
63
+ const request = createMockRequest();
64
+
65
+ const result = await resolveAgents(factory, request);
66
+ expect(factory).toHaveBeenCalledTimes(1);
67
+ expect(result).toEqual({ default: agent });
68
+ });
69
+
70
+ it("throws when factory is used without a request", async () => {
71
+ const factory = vi.fn().mockReturnValue({ default: createMockAgent() });
72
+
73
+ await expect(resolveAgents(factory)).rejects.toThrow(
74
+ "Agent factory function requires a request context",
75
+ );
76
+ expect(factory).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("factory can read request headers for per-tenant resolution", async () => {
80
+ const tenantAgentA = createMockAgent("tenant-a");
81
+ const tenantAgentB = createMockAgent("tenant-b");
82
+
83
+ const factory = ({ request }: AgentFactoryContext) => {
84
+ const tenantId = request.headers.get("x-tenant-id");
85
+ if (tenantId === "a") return { default: tenantAgentA };
86
+ return { default: tenantAgentB };
87
+ };
88
+
89
+ const requestA = createMockRequest({ "x-tenant-id": "a" });
90
+ const requestB = createMockRequest({ "x-tenant-id": "b" });
91
+
92
+ const resultA = await resolveAgents(factory, requestA);
93
+ expect(resultA.default).toBe(tenantAgentA);
94
+
95
+ const resultB = await resolveAgents(factory, requestB);
96
+ expect(resultB.default).toBe(tenantAgentB);
97
+ });
98
+ });
99
+
100
+ describe("handleRunAgent with agents factory", () => {
101
+ it("returns 404 when factory returns no matching agent", async () => {
102
+ const factory = vi.fn().mockReturnValue({
103
+ other: createMockAgent("other"),
104
+ });
105
+ const runtime = createMockRuntime(factory);
106
+ const request = createMockRequest();
107
+
108
+ const response = await handleRunAgent({
109
+ runtime,
110
+ request,
111
+ agentId: "nonexistent",
112
+ });
113
+
114
+ expect(response.status).toBe(404);
115
+ expect(factory).toHaveBeenCalledWith({ request });
116
+ });
117
+ });
118
+
119
+ describe("handleGetRuntimeInfo with agents factory", () => {
120
+ it("resolves factory and lists agents", async () => {
121
+ const factory = vi.fn().mockReturnValue({
122
+ support: createMockAgent("support"),
123
+ technical: createMockAgent("technical"),
124
+ });
125
+ const runtime = createMockRuntime(factory);
126
+ const request = createMockRequest();
127
+
128
+ const response = await handleGetRuntimeInfo({ runtime, request });
129
+
130
+ expect(response.status).toBe(200);
131
+ const body = await response.json();
132
+ expect(Object.keys(body.agents)).toContain("support");
133
+ expect(Object.keys(body.agents)).toContain("technical");
134
+ expect(factory).toHaveBeenCalledWith({ request });
135
+ });
136
+ });
@@ -50,6 +50,57 @@ describe("fetch-router", () => {
50
50
  expect(result).toBeNull();
51
51
  });
52
52
 
53
+ it("matches GET /threads", () => {
54
+ const result = matchRoute("/api/copilotkit/threads", basePath);
55
+ expect(result).toEqual({ method: "threads/list" });
56
+ });
57
+
58
+ it("matches POST /threads/subscribe", () => {
59
+ const result = matchRoute("/api/copilotkit/threads/subscribe", basePath);
60
+ expect(result).toEqual({ method: "threads/subscribe" });
61
+ });
62
+
63
+ it("matches PATCH /threads/:threadId", () => {
64
+ const result = matchRoute("/api/copilotkit/threads/thread-abc", basePath);
65
+ expect(result).toEqual({
66
+ method: "threads/update",
67
+ threadId: "thread-abc",
68
+ });
69
+ });
70
+
71
+ it("matches POST /threads/:threadId/archive", () => {
72
+ const result = matchRoute(
73
+ "/api/copilotkit/threads/thread-abc/archive",
74
+ basePath,
75
+ );
76
+ expect(result).toEqual({
77
+ method: "threads/archive",
78
+ threadId: "thread-abc",
79
+ });
80
+ });
81
+
82
+ it("matches GET /threads/:threadId/messages", () => {
83
+ const result = matchRoute(
84
+ "/api/copilotkit/threads/thread-abc/messages",
85
+ basePath,
86
+ );
87
+ expect(result).toEqual({
88
+ method: "threads/messages",
89
+ threadId: "thread-abc",
90
+ });
91
+ });
92
+
93
+ it("handles URL-encoded threadId in thread routes", () => {
94
+ const result = matchRoute(
95
+ "/api/copilotkit/threads/thread%2F123",
96
+ basePath,
97
+ );
98
+ expect(result).toEqual({
99
+ method: "threads/update",
100
+ threadId: "thread/123",
101
+ });
102
+ });
103
+
53
104
  it("returns null when basePath is a prefix but not a segment boundary", () => {
54
105
  const result = matchRoute("/api/copilotkitextra/info", basePath);
55
106
  expect(result).toBeNull();
@@ -124,6 +175,31 @@ describe("fetch-router", () => {
124
175
  expect(result).toBeNull();
125
176
  });
126
177
 
178
+ it("matches /threads suffix", () => {
179
+ const result = matchRoute("/anything/threads");
180
+ expect(result).toEqual({ method: "threads/list" });
181
+ });
182
+
183
+ it("matches /threads/subscribe suffix", () => {
184
+ const result = matchRoute("/anything/threads/subscribe");
185
+ expect(result).toEqual({ method: "threads/subscribe" });
186
+ });
187
+
188
+ it("matches /threads/:threadId suffix", () => {
189
+ const result = matchRoute("/anything/threads/t1");
190
+ expect(result).toEqual({ method: "threads/update", threadId: "t1" });
191
+ });
192
+
193
+ it("matches /threads/:threadId/archive suffix", () => {
194
+ const result = matchRoute("/anything/threads/t1/archive");
195
+ expect(result).toEqual({ method: "threads/archive", threadId: "t1" });
196
+ });
197
+
198
+ it("matches /threads/:threadId/messages suffix", () => {
199
+ const result = matchRoute("/anything/threads/t1/messages");
200
+ expect(result).toEqual({ method: "threads/messages", threadId: "t1" });
201
+ });
202
+
127
203
  it("works with deeply nested mount prefix", () => {
128
204
  const result = matchRoute("/api/v2/copilotkit/agent/a1/run");
129
205
  expect(result).toEqual({ method: "agent/run", agentId: "a1" });
@@ -1,7 +1,7 @@
1
1
  import { handleGetRuntimeInfo } from "../handlers/get-runtime-info";
2
2
  import { CopilotRuntime } from "../core/runtime";
3
3
  import { TranscriptionService } from "../transcription-service/transcription-service";
4
- import { describe, it, expect } from "vitest";
4
+ import { describe, it, expect, vi } from "vitest";
5
5
  import type { AbstractAgent } from "@ag-ui/client";
6
6
 
7
7
  // Mock transcription service
@@ -117,6 +117,139 @@ describe("handleGetRuntimeInfo", () => {
117
117
  expect(data.a2uiEnabled).toBe(true);
118
118
  });
119
119
 
120
+ it("should include capabilities when agent implements getCapabilities", async () => {
121
+ const mockCapabilities = {
122
+ tools: { supported: true, clientProvided: true },
123
+ transport: { streaming: true },
124
+ };
125
+
126
+ const mockAgent = {
127
+ description: "Capable agent",
128
+ constructor: { name: "CapableAgent" },
129
+ getCapabilities: async () => mockCapabilities,
130
+ };
131
+
132
+ const runtime = new CopilotRuntime({
133
+ agents: {
134
+ capableAgent: mockAgent as unknown as AbstractAgent,
135
+ },
136
+ });
137
+
138
+ const response = await handleGetRuntimeInfo({
139
+ runtime,
140
+ request: mockRequest,
141
+ });
142
+
143
+ expect(response.status).toBe(200);
144
+
145
+ const data = await response.json();
146
+ expect(data.agents.capableAgent.capabilities).toEqual(mockCapabilities);
147
+ });
148
+
149
+ it("should omit capabilities when agent does not implement getCapabilities", async () => {
150
+ const mockAgent = {
151
+ description: "Basic agent",
152
+ constructor: { name: "BasicAgent" },
153
+ };
154
+
155
+ const runtime = new CopilotRuntime({
156
+ agents: {
157
+ basicAgent: mockAgent as unknown as AbstractAgent,
158
+ },
159
+ });
160
+
161
+ const response = await handleGetRuntimeInfo({
162
+ runtime,
163
+ request: mockRequest,
164
+ });
165
+
166
+ expect(response.status).toBe(200);
167
+
168
+ const data = await response.json();
169
+ expect(data.agents.basicAgent.capabilities).toBeUndefined();
170
+ });
171
+
172
+ it("should include empty capabilities object when getCapabilities returns {}", async () => {
173
+ const mockAgent = {
174
+ description: "Empty caps agent",
175
+ constructor: { name: "EmptyCapsAgent" },
176
+ getCapabilities: async () => ({}),
177
+ };
178
+
179
+ const runtime = new CopilotRuntime({
180
+ agents: {
181
+ emptyAgent: mockAgent as unknown as AbstractAgent,
182
+ },
183
+ });
184
+
185
+ const response = await handleGetRuntimeInfo({
186
+ runtime,
187
+ request: mockRequest,
188
+ });
189
+
190
+ expect(response.status).toBe(200);
191
+
192
+ const data = await response.json();
193
+ // {} is truthy, so it should be included in the response
194
+ expect(data.agents.emptyAgent.capabilities).toEqual({});
195
+ });
196
+
197
+ it("should isolate per-agent getCapabilities failures and log a warning", async () => {
198
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
199
+
200
+ const failingAgent = {
201
+ description: "Failing agent",
202
+ constructor: { name: "FailingAgent" },
203
+ getCapabilities: async () => {
204
+ throw new Error("capability fetch failed");
205
+ },
206
+ };
207
+
208
+ const healthyAgent = {
209
+ description: "Healthy agent",
210
+ constructor: { name: "HealthyAgent" },
211
+ getCapabilities: async () => ({
212
+ tools: { supported: true },
213
+ }),
214
+ };
215
+
216
+ const runtime = new CopilotRuntime({
217
+ agents: {
218
+ failing: failingAgent as unknown as AbstractAgent,
219
+ healthy: healthyAgent as unknown as AbstractAgent,
220
+ },
221
+ });
222
+
223
+ const response = await handleGetRuntimeInfo({
224
+ runtime,
225
+ request: mockRequest,
226
+ });
227
+
228
+ expect(response.status).toBe(200);
229
+
230
+ const data = await response.json();
231
+ // Failing agent should still appear but without capabilities
232
+ expect(data.agents.failing).toEqual({
233
+ name: "failing",
234
+ description: "Failing agent",
235
+ className: "FailingAgent",
236
+ });
237
+ expect(data.agents.failing.capabilities).toBeUndefined();
238
+
239
+ // Healthy agent should have its capabilities
240
+ expect(data.agents.healthy.capabilities).toEqual({
241
+ tools: { supported: true },
242
+ });
243
+
244
+ // Error should be logged, not silently swallowed
245
+ expect(warnSpy).toHaveBeenCalledWith(
246
+ 'Failed to fetch capabilities for agent "failing":',
247
+ "capability fetch failed",
248
+ );
249
+
250
+ warnSpy.mockRestore();
251
+ });
252
+
120
253
  it("should return 500 error when runtime.agents throws an error", async () => {
121
254
  const runtime = {
122
255
  get agents(): Record<string, AbstractAgent> {
@@ -46,6 +46,14 @@ import { handleConnectAgent } from "../handlers/handle-connect";
46
46
  import { handleStopAgent } from "../handlers/handle-stop";
47
47
  import { handleGetRuntimeInfo } from "../handlers/get-runtime-info";
48
48
  import { handleTranscribe } from "../handlers/handle-transcribe";
49
+ import {
50
+ handleListThreads,
51
+ handleSubscribeToThreads,
52
+ handleUpdateThread,
53
+ handleArchiveThread,
54
+ handleDeleteThread,
55
+ handleGetThreadMessages,
56
+ } from "../handlers/handle-threads";
49
57
  import {
50
58
  parseMethodCall,
51
59
  createJsonRequest,
@@ -306,6 +314,31 @@ function dispatchRoute(
306
314
  return handleGetRuntimeInfo({ runtime, request });
307
315
  case "transcribe":
308
316
  return handleTranscribe({ runtime, request });
317
+ case "threads/list":
318
+ return handleListThreads({ runtime, request });
319
+ case "threads/subscribe":
320
+ return handleSubscribeToThreads({ runtime, request });
321
+ case "threads/update":
322
+ if (request.method.toUpperCase() === "DELETE") {
323
+ return handleDeleteThread({
324
+ runtime,
325
+ request,
326
+ threadId: route.threadId,
327
+ });
328
+ }
329
+ return handleUpdateThread({ runtime, request, threadId: route.threadId });
330
+ case "threads/archive":
331
+ return handleArchiveThread({
332
+ runtime,
333
+ request,
334
+ threadId: route.threadId,
335
+ });
336
+ case "threads/messages":
337
+ return handleGetThreadMessages({
338
+ runtime,
339
+ request,
340
+ threadId: route.threadId,
341
+ });
309
342
  }
310
343
  }
311
344
 
@@ -376,10 +409,28 @@ function validateHttpMethod(
376
409
  route: RouteInfo,
377
410
  ): Response | null {
378
411
  const method = httpMethod.toUpperCase();
379
- if (route.method === "info" && method === "GET") return null;
380
- if (route.method !== "info" && method === "POST") return null;
381
- const allowed = route.method === "info" ? "GET" : "POST";
382
- return jsonResponse({ error: "Method not allowed" }, 405, { Allow: allowed });
412
+
413
+ switch (route.method) {
414
+ case "info":
415
+ case "threads/list":
416
+ case "threads/messages":
417
+ if (method === "GET") return null;
418
+ return jsonResponse({ error: "Method not allowed" }, 405, {
419
+ Allow: "GET",
420
+ });
421
+
422
+ case "threads/update":
423
+ if (method === "PATCH" || method === "DELETE") return null;
424
+ return jsonResponse({ error: "Method not allowed" }, 405, {
425
+ Allow: "PATCH, DELETE",
426
+ });
427
+
428
+ default:
429
+ if (method === "POST") return null;
430
+ return jsonResponse({ error: "Method not allowed" }, 405, {
431
+ Allow: "POST",
432
+ });
433
+ }
383
434
  }
384
435
 
385
436
  /* ------------------------------------------------------------------------------------------------
@@ -108,5 +108,53 @@ function matchSegments(path: string): RouteInfo | null {
108
108
  return { method: "agent/stop", agentId, threadId };
109
109
  }
110
110
 
111
+ // /threads/subscribe (2 segments)
112
+ if (
113
+ len >= 2 &&
114
+ segments[len - 2] === "threads" &&
115
+ segments[len - 1] === "subscribe"
116
+ ) {
117
+ return { method: "threads/subscribe" };
118
+ }
119
+
120
+ // /threads/:threadId/messages (3 segments)
121
+ if (
122
+ len >= 3 &&
123
+ segments[len - 3] === "threads" &&
124
+ segments[len - 1] === "messages"
125
+ ) {
126
+ const threadId = safeDecodeURIComponent(segments[len - 2]!);
127
+ if (!threadId) return null;
128
+ return { method: "threads/messages", threadId };
129
+ }
130
+
131
+ // /threads/:threadId/archive (3 segments)
132
+ if (
133
+ len >= 3 &&
134
+ segments[len - 3] === "threads" &&
135
+ segments[len - 1] === "archive"
136
+ ) {
137
+ const threadId = safeDecodeURIComponent(segments[len - 2]!);
138
+ if (!threadId) return null;
139
+ return { method: "threads/archive", threadId };
140
+ }
141
+
142
+ // /threads/:threadId (2 segments) — update or delete
143
+ if (
144
+ len >= 2 &&
145
+ segments[len - 2] === "threads" &&
146
+ segments[len - 1] !== "subscribe"
147
+ ) {
148
+ const threadId = safeDecodeURIComponent(segments[len - 1]!);
149
+ if (!threadId) return null;
150
+ // Disambiguated by HTTP method in the handler
151
+ return { method: "threads/update", threadId };
152
+ }
153
+
154
+ // /threads (1 segment) — list
155
+ if (len >= 1 && segments[len - 1] === "threads") {
156
+ return { method: "threads/list" };
157
+ }
158
+
111
159
  return null;
112
160
  }
@@ -38,7 +38,12 @@ export type RouteInfo =
38
38
  | { method: "agent/connect"; agentId: string }
39
39
  | { method: "agent/stop"; agentId: string; threadId: string }
40
40
  | { method: "info" }
41
- | { method: "transcribe" };
41
+ | { method: "transcribe" }
42
+ | { method: "threads/list" }
43
+ | { method: "threads/subscribe" }
44
+ | { method: "threads/update"; threadId: string }
45
+ | { method: "threads/archive"; threadId: string }
46
+ | { method: "threads/messages"; threadId: string };
42
47
 
43
48
  /* ------------------------------------------------------------------------------------------------
44
49
  * Hook contexts