@copilotkit/runtime 1.56.4 → 1.56.5-canary.1777671752

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 (84) hide show
  1. package/dist/agent/converters/tanstack.cjs +121 -25
  2. package/dist/agent/converters/tanstack.cjs.map +1 -1
  3. package/dist/agent/converters/tanstack.d.cts.map +1 -1
  4. package/dist/agent/converters/tanstack.d.mts.map +1 -1
  5. package/dist/agent/converters/tanstack.mjs +121 -25
  6. package/dist/agent/converters/tanstack.mjs.map +1 -1
  7. package/dist/lib/runtime/agent-integrations/langgraph/agent.cjs +8 -1
  8. package/dist/lib/runtime/agent-integrations/langgraph/agent.cjs.map +1 -1
  9. package/dist/lib/runtime/agent-integrations/langgraph/agent.d.cts.map +1 -1
  10. package/dist/lib/runtime/agent-integrations/langgraph/agent.d.mts.map +1 -1
  11. package/dist/lib/runtime/agent-integrations/langgraph/agent.mjs +8 -1
  12. package/dist/lib/runtime/agent-integrations/langgraph/agent.mjs.map +1 -1
  13. package/dist/package.cjs +6 -6
  14. package/dist/package.mjs +6 -6
  15. package/dist/v2/index.d.cts +2 -2
  16. package/dist/v2/index.d.mts +2 -2
  17. package/dist/v2/runtime/core/fetch-handler.cjs +16 -0
  18. package/dist/v2/runtime/core/fetch-handler.cjs.map +1 -1
  19. package/dist/v2/runtime/core/fetch-handler.d.cts.map +1 -1
  20. package/dist/v2/runtime/core/fetch-handler.d.mts.map +1 -1
  21. package/dist/v2/runtime/core/fetch-handler.mjs +17 -1
  22. package/dist/v2/runtime/core/fetch-handler.mjs.map +1 -1
  23. package/dist/v2/runtime/core/fetch-router.cjs +18 -1
  24. package/dist/v2/runtime/core/fetch-router.cjs.map +1 -1
  25. package/dist/v2/runtime/core/fetch-router.mjs +18 -1
  26. package/dist/v2/runtime/core/fetch-router.mjs.map +1 -1
  27. package/dist/v2/runtime/core/hooks.cjs.map +1 -1
  28. package/dist/v2/runtime/core/hooks.d.cts +8 -0
  29. package/dist/v2/runtime/core/hooks.d.cts.map +1 -1
  30. package/dist/v2/runtime/core/hooks.d.mts +8 -0
  31. package/dist/v2/runtime/core/hooks.d.mts.map +1 -1
  32. package/dist/v2/runtime/core/hooks.mjs.map +1 -1
  33. package/dist/v2/runtime/endpoints/express.cjs +5 -5
  34. package/dist/v2/runtime/endpoints/express.cjs.map +1 -1
  35. package/dist/v2/runtime/endpoints/express.mjs +5 -5
  36. package/dist/v2/runtime/endpoints/express.mjs.map +1 -1
  37. package/dist/v2/runtime/handlers/handle-run.cjs +1 -0
  38. package/dist/v2/runtime/handlers/handle-run.cjs.map +1 -1
  39. package/dist/v2/runtime/handlers/handle-run.mjs +1 -0
  40. package/dist/v2/runtime/handlers/handle-run.mjs.map +1 -1
  41. package/dist/v2/runtime/handlers/intelligence/threads.cjs +124 -12
  42. package/dist/v2/runtime/handlers/intelligence/threads.cjs.map +1 -1
  43. package/dist/v2/runtime/handlers/intelligence/threads.mjs +122 -13
  44. package/dist/v2/runtime/handlers/intelligence/threads.mjs.map +1 -1
  45. package/dist/v2/runtime/index.d.cts +1 -1
  46. package/dist/v2/runtime/index.d.mts +1 -1
  47. package/dist/v2/runtime/intelligence-platform/client.cjs +30 -0
  48. package/dist/v2/runtime/intelligence-platform/client.cjs.map +1 -1
  49. package/dist/v2/runtime/intelligence-platform/client.d.cts +66 -0
  50. package/dist/v2/runtime/intelligence-platform/client.d.cts.map +1 -1
  51. package/dist/v2/runtime/intelligence-platform/client.d.mts +66 -0
  52. package/dist/v2/runtime/intelligence-platform/client.d.mts.map +1 -1
  53. package/dist/v2/runtime/intelligence-platform/client.mjs +30 -0
  54. package/dist/v2/runtime/intelligence-platform/client.mjs.map +1 -1
  55. package/dist/v2/runtime/runner/in-memory.cjs +94 -22
  56. package/dist/v2/runtime/runner/in-memory.cjs.map +1 -1
  57. package/dist/v2/runtime/runner/in-memory.d.cts +65 -2
  58. package/dist/v2/runtime/runner/in-memory.d.cts.map +1 -1
  59. package/dist/v2/runtime/runner/in-memory.d.mts +65 -2
  60. package/dist/v2/runtime/runner/in-memory.d.mts.map +1 -1
  61. package/dist/v2/runtime/runner/in-memory.mjs +94 -22
  62. package/dist/v2/runtime/runner/in-memory.mjs.map +1 -1
  63. package/dist/v2/runtime/runner/index.d.cts +1 -1
  64. package/dist/v2/runtime/runner/index.d.mts +1 -1
  65. package/package.json +7 -7
  66. package/src/agent/__tests__/agent-test-helpers.ts +31 -1
  67. package/src/agent/__tests__/converter-tanstack.test.ts +280 -0
  68. package/src/agent/converters/tanstack.ts +167 -10
  69. package/src/lib/runtime/agent-integrations/langgraph/agent.ts +8 -1
  70. package/src/v2/runtime/__tests__/express-fetch-bridge.test.ts +1 -1
  71. package/src/v2/runtime/__tests__/fetch-handler-validation.test.ts +68 -0
  72. package/src/v2/runtime/__tests__/fetch-router.test.ts +46 -0
  73. package/src/v2/runtime/__tests__/handle-run.test.ts +97 -1
  74. package/src/v2/runtime/__tests__/handle-threads.test.ts +493 -13
  75. package/src/v2/runtime/core/fetch-handler.ts +19 -0
  76. package/src/v2/runtime/core/fetch-router.ts +33 -1
  77. package/src/v2/runtime/core/hooks.ts +3 -0
  78. package/src/v2/runtime/endpoints/express.ts +9 -3
  79. package/src/v2/runtime/handlers/handle-run.ts +4 -0
  80. package/src/v2/runtime/handlers/handle-threads.ts +3 -0
  81. package/src/v2/runtime/handlers/intelligence/threads.ts +200 -41
  82. package/src/v2/runtime/intelligence-platform/client.ts +76 -0
  83. package/src/v2/runtime/runner/__tests__/in-memory-runner.test.ts +417 -3
  84. package/src/v2/runtime/runner/in-memory.ts +137 -51
@@ -8,6 +8,13 @@ import {
8
8
  ToolCallEndEvent,
9
9
  ToolCallStartEvent,
10
10
  ToolCallResultEvent,
11
+ StateSnapshotEvent,
12
+ StateDeltaEvent,
13
+ ReasoningStartEvent,
14
+ ReasoningMessageStartEvent,
15
+ ReasoningMessageContentEvent,
16
+ ReasoningMessageEndEvent,
17
+ ReasoningEndEvent,
11
18
  } from "@ag-ui/client";
12
19
  import { randomUUID } from "@copilotkit/shared";
13
20
 
@@ -229,6 +236,44 @@ export async function* convertTanStackStream(
229
236
  abortSignal: AbortSignal,
230
237
  ): AsyncGenerator<BaseEvent> {
231
238
  const messageId = randomUUID();
239
+ const toolNamesById = new Map<string, string>();
240
+ // Track the reasoning lifecycle at two granularities so closeReasoningIfOpen
241
+ // emits exactly the events still owed. A single boolean conflates the run
242
+ // (REASONING_START → REASONING_END) with the message
243
+ // (REASONING_MESSAGE_START → REASONING_MESSAGE_END) and produces a duplicate
244
+ // REASONING_MESSAGE_END when upstream emits MSG_END but not END before
245
+ // text/tools resume.
246
+ let reasoningRunOpen = false;
247
+ let reasoningMessageOpen = false;
248
+ let reasoningMessageId = randomUUID();
249
+
250
+ function* closeReasoningIfOpen(): Generator<BaseEvent> {
251
+ if (reasoningMessageOpen) {
252
+ reasoningMessageOpen = false;
253
+ const msgEnd: ReasoningMessageEndEvent = {
254
+ type: EventType.REASONING_MESSAGE_END,
255
+ messageId: reasoningMessageId,
256
+ };
257
+ yield msgEnd;
258
+ }
259
+ if (reasoningRunOpen) {
260
+ reasoningRunOpen = false;
261
+ const end: ReasoningEndEvent = {
262
+ type: EventType.REASONING_END,
263
+ messageId: reasoningMessageId,
264
+ };
265
+ yield end;
266
+ }
267
+ }
268
+
269
+ // TanStack's chat() engine runs a multi-turn agent loop: after the model
270
+ // returns tool calls, the engine tries to execute them and re-prompt. This
271
+ // produces a second round of TOOL_CALL_START / TOOL_CALL_END events that
272
+ // duplicate the ones from the first streaming pass. The CopilotKit runtime
273
+ // handles tool execution externally (via the frontend SDK), so we must stop
274
+ // converting events once the TanStack adapter signals the first turn is
275
+ // complete with RUN_FINISHED.
276
+ let runFinished = false;
232
277
 
233
278
  for await (const chunk of stream) {
234
279
  if (abortSignal.aborted) break;
@@ -236,7 +281,17 @@ export async function* convertTanStackStream(
236
281
  const raw = chunk as Record<string, unknown>;
237
282
  const type = raw.type as string;
238
283
 
239
- if (type === "TEXT_MESSAGE_CONTENT" && raw.delta) {
284
+ // Stop converting after the first RUN_FINISHED — any subsequent events
285
+ // come from TanStack's internal tool-execution loop and would produce
286
+ // duplicate TOOL_CALL_END events that violate the ag-ui verify middleware.
287
+ if (type === "RUN_FINISHED") {
288
+ runFinished = true;
289
+ continue;
290
+ }
291
+ if (runFinished) continue;
292
+
293
+ if (type === "TEXT_MESSAGE_CONTENT" && raw.delta != null) {
294
+ yield* closeReasoningIfOpen();
240
295
  const textEvent: TextMessageChunkEvent = {
241
296
  type: EventType.TEXT_MESSAGE_CHUNK,
242
297
  role: "assistant",
@@ -245,6 +300,8 @@ export async function* convertTanStackStream(
245
300
  };
246
301
  yield textEvent;
247
302
  } else if (type === "TOOL_CALL_START") {
303
+ yield* closeReasoningIfOpen();
304
+ toolNamesById.set(raw.toolCallId as string, raw.toolCallName as string);
248
305
  const startEvent: ToolCallStartEvent = {
249
306
  type: EventType.TOOL_CALL_START,
250
307
  parentMessageId: messageId,
@@ -253,6 +310,7 @@ export async function* convertTanStackStream(
253
310
  };
254
311
  yield startEvent;
255
312
  } else if (type === "TOOL_CALL_ARGS") {
313
+ yield* closeReasoningIfOpen();
256
314
  const argsEvent: ToolCallArgsEvent = {
257
315
  type: EventType.TOOL_CALL_ARGS,
258
316
  toolCallId: raw.toolCallId as string,
@@ -260,35 +318,134 @@ export async function* convertTanStackStream(
260
318
  };
261
319
  yield argsEvent;
262
320
  } else if (type === "TOOL_CALL_END") {
321
+ yield* closeReasoningIfOpen();
263
322
  const endEvent: ToolCallEndEvent = {
264
323
  type: EventType.TOOL_CALL_END,
265
324
  toolCallId: raw.toolCallId as string,
266
325
  };
267
326
  yield endEvent;
268
327
  } else if (type === "TOOL_CALL_RESULT") {
328
+ yield* closeReasoningIfOpen();
329
+ const toolCallId = raw.toolCallId as string;
330
+ const toolName = toolNamesById.get(toolCallId);
331
+ // Accept the payload from either `content` (canonical TanStack shape)
332
+ // or `result` (alternate shape used by some adapters / tests). Both
333
+ // state-tool detection and the final TOOL_CALL_RESULT serialization
334
+ // must read the same field, otherwise STATE_SNAPSHOT/STATE_DELTA can
335
+ // be silently dropped when upstream uses `result`.
336
+ const rawPayload = raw.content ?? raw.result;
337
+
338
+ const parsedContent =
339
+ typeof rawPayload === "string" ? safeParse(rawPayload) : rawPayload;
340
+
341
+ if (
342
+ toolName === "AGUISendStateSnapshot" &&
343
+ parsedContent &&
344
+ typeof parsedContent === "object" &&
345
+ "snapshot" in parsedContent
346
+ ) {
347
+ const stateSnapshotEvent: StateSnapshotEvent = {
348
+ type: EventType.STATE_SNAPSHOT,
349
+ snapshot: (parsedContent as Record<string, unknown>).snapshot,
350
+ };
351
+ yield stateSnapshotEvent;
352
+ }
353
+
354
+ if (
355
+ toolName === "AGUISendStateDelta" &&
356
+ parsedContent &&
357
+ typeof parsedContent === "object" &&
358
+ "delta" in parsedContent
359
+ ) {
360
+ const stateDeltaEvent: StateDeltaEvent = {
361
+ type: EventType.STATE_DELTA,
362
+ delta: (parsedContent as Record<string, unknown>).delta as never,
363
+ };
364
+ yield stateDeltaEvent;
365
+ }
366
+
269
367
  let serializedContent: string;
270
- if (typeof raw.content === "string") {
271
- serializedContent = raw.content;
368
+ if (typeof rawPayload === "string") {
369
+ serializedContent = rawPayload;
272
370
  } else {
273
371
  try {
274
- serializedContent = JSON.stringify(raw.content ?? raw.result ?? null);
372
+ serializedContent = JSON.stringify(rawPayload ?? null);
275
373
  } catch {
276
374
  serializedContent = "[Unserializable tool result]";
277
375
  }
278
376
  }
377
+
279
378
  const resultEvent: ToolCallResultEvent = {
280
379
  type: EventType.TOOL_CALL_RESULT,
281
380
  role: "tool",
282
381
  messageId: randomUUID(),
283
- toolCallId: raw.toolCallId as string,
382
+ toolCallId,
284
383
  content: serializedContent,
285
384
  };
286
385
  yield resultEvent;
386
+ toolNamesById.delete(toolCallId);
387
+ } else if (type === "REASONING_START") {
388
+ // If a prior reasoning run is still open (no REASONING_END before this
389
+ // new START), close it cleanly first so MSG_END / END pair correctly.
390
+ yield* closeReasoningIfOpen();
391
+ reasoningRunOpen = true;
392
+ reasoningMessageId = (raw.messageId as string) ?? randomUUID();
393
+ const startEvt: ReasoningStartEvent = {
394
+ type: EventType.REASONING_START,
395
+ messageId: reasoningMessageId,
396
+ };
397
+ yield startEvt;
398
+ } else if (type === "REASONING_MESSAGE_START") {
399
+ reasoningMessageOpen = true;
400
+ const evt: ReasoningMessageStartEvent = {
401
+ type: EventType.REASONING_MESSAGE_START,
402
+ messageId: reasoningMessageId,
403
+ role: "reasoning",
404
+ };
405
+ yield evt;
406
+ } else if (type === "REASONING_MESSAGE_CONTENT") {
407
+ const evt: ReasoningMessageContentEvent = {
408
+ type: EventType.REASONING_MESSAGE_CONTENT,
409
+ messageId: reasoningMessageId,
410
+ delta: raw.delta as string,
411
+ };
412
+ yield evt;
413
+ } else if (type === "REASONING_MESSAGE_END") {
414
+ reasoningMessageOpen = false;
415
+ const evt: ReasoningMessageEndEvent = {
416
+ type: EventType.REASONING_MESSAGE_END,
417
+ messageId: reasoningMessageId,
418
+ };
419
+ yield evt;
420
+ } else if (type === "REASONING_END") {
421
+ // If upstream sends REASONING_END while a message is still open, emit
422
+ // the missing REASONING_MESSAGE_END FIRST so the closing pair stays in
423
+ // order (MSG_END before END). Otherwise the next non-reasoning chunk
424
+ // would trigger closeReasoningIfOpen and emit MSG_END after END.
425
+ if (reasoningMessageOpen) {
426
+ reasoningMessageOpen = false;
427
+ const msgEnd: ReasoningMessageEndEvent = {
428
+ type: EventType.REASONING_MESSAGE_END,
429
+ messageId: reasoningMessageId,
430
+ };
431
+ yield msgEnd;
432
+ }
433
+ reasoningRunOpen = false;
434
+ const evt: ReasoningEndEvent = {
435
+ type: EventType.REASONING_END,
436
+ messageId: reasoningMessageId,
437
+ };
438
+ yield evt;
287
439
  }
288
- // Unhandled chunk types are silently ignored.
289
- // Known gaps: STATE_SNAPSHOT, STATE_DELTA, and REASONING events are not
290
- // converted from TanStack streams. Shared state and reasoning will not
291
- // surface when using the TanStack backend. Use the AI SDK backend if these
292
- // features are required.
440
+ }
441
+
442
+ yield* closeReasoningIfOpen();
443
+ }
444
+
445
+ function safeParse(value: string): unknown {
446
+ try {
447
+ return JSON.parse(value);
448
+ } catch {
449
+ return value;
293
450
  }
294
451
  }
@@ -154,7 +154,14 @@ export class LangGraphAgent extends AGUILangGraphAgent {
154
154
 
155
155
  // @ts-ignore
156
156
  run(input: RunAgentInput): Observable<BaseEvent> {
157
- return super.run(input).pipe(
157
+ const enrichedInput = {
158
+ ...input,
159
+ forwardedProps: {
160
+ ...input.forwardedProps,
161
+ streamSubgraphs: input.forwardedProps?.streamSubgraphs ?? true,
162
+ },
163
+ };
164
+ return super.run(enrichedInput).pipe(
158
165
  map((processedEvent) => {
159
166
  // Turn raw event into emit state snapshot from tool call event
160
167
  if (processedEvent.type === EventType.RAW) {
@@ -23,7 +23,7 @@ function createApp(
23
23
  app.use(express.json());
24
24
  }
25
25
 
26
- app.all("*", (req, res) => nodeHandler(req, res));
26
+ app.all(/.*/, (req, res) => nodeHandler(req, res));
27
27
  return app;
28
28
  }
29
29
 
@@ -370,3 +370,71 @@ describe("fetch-handler validation — multi-route edge cases", () => {
370
370
  expect(body.message).toBeUndefined();
371
371
  });
372
372
  });
373
+
374
+ /* ------------------------------------------------------------------------------------------------
375
+ * Multi-route: HTTP method enforcement on per-thread GET endpoints
376
+ *
377
+ * /threads/:threadId/events and /threads/:threadId/state are read-only and
378
+ * must reject anything other than GET with 405 + Allow: GET. These tests
379
+ * pin that contract so a future refactor cannot quietly downgrade it.
380
+ * --------------------------------------------------------------------------------------------- */
381
+
382
+ describe("fetch-handler validation — GET-only enforcement on threads read endpoints", () => {
383
+ const runtime = createRuntime();
384
+ const handler = createCopilotRuntimeHandler({
385
+ runtime,
386
+ basePath: "/api",
387
+ });
388
+
389
+ const expectMethodNotAllowed = async (
390
+ response: Response,
391
+ expectedAllow: string,
392
+ ) => {
393
+ expect(response.status).toBe(405);
394
+ expect(response.headers.get("Allow")).toBe(expectedAllow);
395
+ };
396
+
397
+ for (const method of ["POST", "PATCH", "DELETE"]) {
398
+ it(`returns 405 with Allow: GET for ${method} /threads/:id/events`, async () => {
399
+ const response = await handler(
400
+ new Request("http://localhost/api/threads/thread-1/events", {
401
+ method,
402
+ // PATCH/POST without a body or content-type would otherwise hit
403
+ // a different validation branch; this exercises pure method check.
404
+ headers: { "Content-Type": "application/json" },
405
+ }),
406
+ );
407
+ await expectMethodNotAllowed(response, "GET");
408
+ });
409
+
410
+ it(`returns 405 with Allow: GET for ${method} /threads/:id/state`, async () => {
411
+ const response = await handler(
412
+ new Request("http://localhost/api/threads/thread-1/state", {
413
+ method,
414
+ headers: { "Content-Type": "application/json" },
415
+ }),
416
+ );
417
+ await expectMethodNotAllowed(response, "GET");
418
+ });
419
+ }
420
+
421
+ it("accepts GET on /threads/:id/events", async () => {
422
+ const response = await handler(
423
+ new Request("http://localhost/api/threads/thread-1/events", {
424
+ method: "GET",
425
+ }),
426
+ );
427
+ // The route may 422 (no Intelligence configured) or 200 — either way it
428
+ // is NOT a 405, which is the contract we are pinning here.
429
+ expect(response.status).not.toBe(405);
430
+ });
431
+
432
+ it("accepts GET on /threads/:id/state", async () => {
433
+ const response = await handler(
434
+ new Request("http://localhost/api/threads/thread-1/state", {
435
+ method: "GET",
436
+ }),
437
+ );
438
+ expect(response.status).not.toBe(405);
439
+ });
440
+ });
@@ -90,6 +90,52 @@ describe("fetch-router", () => {
90
90
  });
91
91
  });
92
92
 
93
+ it("matches GET /threads/:threadId/events", () => {
94
+ const result = matchRoute(
95
+ "/api/copilotkit/threads/thread-abc/events",
96
+ basePath,
97
+ );
98
+ expect(result).toEqual({
99
+ method: "threads/events",
100
+ threadId: "thread-abc",
101
+ });
102
+ });
103
+
104
+ it("matches GET /threads/:threadId/events with URL-encoded threadId", () => {
105
+ const result = matchRoute(
106
+ "/api/copilotkit/threads/thread%2F123/events",
107
+ basePath,
108
+ );
109
+ expect(result).toEqual({
110
+ method: "threads/events",
111
+ threadId: "thread/123",
112
+ });
113
+ });
114
+
115
+ it("matches GET /threads/:threadId/state", () => {
116
+ const result = matchRoute(
117
+ "/api/copilotkit/threads/thread-abc/state",
118
+ basePath,
119
+ );
120
+ expect(result).toEqual({
121
+ method: "threads/state",
122
+ threadId: "thread-abc",
123
+ });
124
+ });
125
+
126
+ it("matches POST /threads/clear (and does not collide with threads/update)", () => {
127
+ // Critical: the threads/update route also matches /threads/:threadId,
128
+ // so we must verify that "/threads/clear" never falls through to that
129
+ // arm with threadId="clear". The router has explicit guards (the
130
+ // segment[len-1] !== "clear" check) — this test pins them.
131
+ const result = matchRoute("/api/copilotkit/threads/clear", basePath);
132
+ expect(result).toEqual({ method: "threads/clear" });
133
+ expect(result).not.toEqual({
134
+ method: "threads/update",
135
+ threadId: "clear",
136
+ });
137
+ });
138
+
93
139
  it("handles URL-encoded threadId in thread routes", () => {
94
140
  const result = matchRoute(
95
141
  "/api/copilotkit/threads/thread%2F123",
@@ -1,10 +1,17 @@
1
1
  import { Observable } from "rxjs";
2
2
  import { describe, it, expect, vi } from "vitest";
3
- import { AbstractAgent, BaseEvent, EventType, HttpAgent } from "@ag-ui/client";
3
+ import {
4
+ AbstractAgent,
5
+ BaseEvent,
6
+ EventType,
7
+ HttpAgent,
8
+ RunAgentInput,
9
+ } from "@ag-ui/client";
4
10
  import { A2UIMiddleware } from "@ag-ui/a2ui-middleware";
5
11
  import { handleRunAgent } from "../handlers/handle-run";
6
12
  import { CopilotRuntime } from "../core/runtime";
7
13
  import { IntelligenceAgentRunner } from "../runner/intelligence";
14
+ import { InMemoryAgentRunner } from "../runner/in-memory";
8
15
 
9
16
  describe("handleRunAgent", () => {
10
17
  const createMockRuntime = (
@@ -1289,4 +1296,93 @@ describe("handleRunAgent", () => {
1289
1296
  }
1290
1297
  });
1291
1298
  });
1299
+
1300
+ describe("agentId tagging on cloned agents", () => {
1301
+ /**
1302
+ * Pins handle-run.ts:40 — `agent.agentId = agentId` is set on the clone
1303
+ * BEFORE the agent reaches the runner. Without it, InMemoryAgentRunner
1304
+ * falls back to "default" when stamping historic runs, and listThreads
1305
+ * returns rows with the wrong agentId. This breaks the agentId filter
1306
+ * in `GET /threads?agentId=...` for the local-dev fallback.
1307
+ *
1308
+ * This test runs the full flow through InMemoryAgentRunner with an
1309
+ * AbstractAgent whose own `agentId` field is undefined (matches the
1310
+ * shape after `clone()` returns a fresh instance), and asserts the
1311
+ * runner records the registry key, NOT "default".
1312
+ */
1313
+ class TaggingTestAgent extends AbstractAgent {
1314
+ async runAgent(
1315
+ _input: RunAgentInput,
1316
+ options: { onEvent: (event: { event: BaseEvent }) => void },
1317
+ ): Promise<void> {
1318
+ // Emit a single TEXT_MESSAGE_END event so the run produces at least
1319
+ // one event and gets persisted to historicRuns. RUN_STARTED /
1320
+ // RUN_FINISHED are appended by the runner itself.
1321
+ options.onEvent({
1322
+ event: {
1323
+ type: EventType.TEXT_MESSAGE_END,
1324
+ messageId: "msg-1",
1325
+ } as BaseEvent,
1326
+ });
1327
+ }
1328
+
1329
+ clone(): AbstractAgent {
1330
+ // The fresh clone has NO agentId — the only way the runner can know
1331
+ // the registry key is if handle-run.ts:40 stamps it before the run.
1332
+ return new TaggingTestAgent();
1333
+ }
1334
+ }
1335
+
1336
+ const createRunRequestForAgent = (agentId: string, threadId: string) =>
1337
+ new Request(`https://example.com/agent/${agentId}/run`, {
1338
+ method: "POST",
1339
+ headers: { "Content-Type": "application/json" },
1340
+ body: JSON.stringify({
1341
+ threadId,
1342
+ runId: `run-${threadId}`,
1343
+ state: {},
1344
+ messages: [],
1345
+ tools: [],
1346
+ context: [],
1347
+ forwardedProps: {},
1348
+ }),
1349
+ });
1350
+
1351
+ it("propagates the registry agentId onto historic runs (NOT 'default')", async () => {
1352
+ const runner = new InMemoryAgentRunner();
1353
+ const agent = new TaggingTestAgent();
1354
+ const runtime = new CopilotRuntime({
1355
+ agents: { tagged: agent },
1356
+ runner,
1357
+ });
1358
+
1359
+ // Use a unique threadId so this test does not collide with other
1360
+ // tests that share the InMemoryAgentRunner GLOBAL_STORE.
1361
+ const threadId = `thread-tagged-${Date.now()}-${Math.random()}`;
1362
+
1363
+ const response = await handleRunAgent({
1364
+ runtime,
1365
+ request: createRunRequestForAgent("tagged", threadId),
1366
+ agentId: "tagged",
1367
+ });
1368
+ expect(response.status).toBe(200);
1369
+
1370
+ // Drain the SSE stream so the underlying observable run completes —
1371
+ // historicRuns is only populated AFTER the run finalizes.
1372
+ const reader = response.body!.getReader();
1373
+ while (true) {
1374
+ const { done } = await reader.read();
1375
+ if (done) break;
1376
+ }
1377
+
1378
+ const threads = runner.listThreads();
1379
+ const thisThread = threads.find((t) => t.id === threadId);
1380
+ expect(thisThread).toBeDefined();
1381
+ expect(thisThread!.agentId).toBe("tagged");
1382
+ // Negative assertion locks the regression: a future change that drops
1383
+ // the `agent.agentId = agentId` line in handle-run will surface as
1384
+ // "default" here, not as a missing thread.
1385
+ expect(thisThread!.agentId).not.toBe("default");
1386
+ });
1387
+ });
1292
1388
  });