@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.
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/runtime/core/fetch-handler.cjs +16 -0
- package/dist/v2/runtime/core/fetch-handler.cjs.map +1 -1
- package/dist/v2/runtime/core/fetch-handler.d.cts.map +1 -1
- package/dist/v2/runtime/core/fetch-handler.d.mts.map +1 -1
- package/dist/v2/runtime/core/fetch-handler.mjs +17 -1
- package/dist/v2/runtime/core/fetch-handler.mjs.map +1 -1
- package/dist/v2/runtime/core/fetch-router.cjs +18 -1
- package/dist/v2/runtime/core/fetch-router.cjs.map +1 -1
- package/dist/v2/runtime/core/fetch-router.mjs +18 -1
- package/dist/v2/runtime/core/fetch-router.mjs.map +1 -1
- package/dist/v2/runtime/core/hooks.cjs.map +1 -1
- package/dist/v2/runtime/core/hooks.d.cts +8 -0
- package/dist/v2/runtime/core/hooks.d.cts.map +1 -1
- package/dist/v2/runtime/core/hooks.d.mts +8 -0
- package/dist/v2/runtime/core/hooks.d.mts.map +1 -1
- package/dist/v2/runtime/core/hooks.mjs.map +1 -1
- package/dist/v2/runtime/handlers/handle-run.cjs +1 -0
- package/dist/v2/runtime/handlers/handle-run.cjs.map +1 -1
- package/dist/v2/runtime/handlers/handle-run.mjs +1 -0
- package/dist/v2/runtime/handlers/handle-run.mjs.map +1 -1
- package/dist/v2/runtime/handlers/intelligence/threads.cjs +124 -12
- package/dist/v2/runtime/handlers/intelligence/threads.cjs.map +1 -1
- package/dist/v2/runtime/handlers/intelligence/threads.mjs +122 -13
- package/dist/v2/runtime/handlers/intelligence/threads.mjs.map +1 -1
- package/dist/v2/runtime/index.d.cts +1 -1
- package/dist/v2/runtime/index.d.mts +1 -1
- package/dist/v2/runtime/intelligence-platform/client.cjs +30 -0
- package/dist/v2/runtime/intelligence-platform/client.cjs.map +1 -1
- package/dist/v2/runtime/intelligence-platform/client.d.cts +66 -0
- package/dist/v2/runtime/intelligence-platform/client.d.cts.map +1 -1
- package/dist/v2/runtime/intelligence-platform/client.d.mts +66 -0
- package/dist/v2/runtime/intelligence-platform/client.d.mts.map +1 -1
- package/dist/v2/runtime/intelligence-platform/client.mjs +30 -0
- package/dist/v2/runtime/intelligence-platform/client.mjs.map +1 -1
- package/dist/v2/runtime/runner/in-memory.cjs +94 -22
- package/dist/v2/runtime/runner/in-memory.cjs.map +1 -1
- package/dist/v2/runtime/runner/in-memory.d.cts +65 -2
- package/dist/v2/runtime/runner/in-memory.d.cts.map +1 -1
- package/dist/v2/runtime/runner/in-memory.d.mts +65 -2
- package/dist/v2/runtime/runner/in-memory.d.mts.map +1 -1
- package/dist/v2/runtime/runner/in-memory.mjs +94 -22
- package/dist/v2/runtime/runner/in-memory.mjs.map +1 -1
- package/dist/v2/runtime/runner/index.d.cts +1 -1
- package/dist/v2/runtime/runner/index.d.mts +1 -1
- package/package.json +2 -2
- package/src/v2/runtime/__tests__/fetch-handler-validation.test.ts +68 -0
- package/src/v2/runtime/__tests__/fetch-router.test.ts +46 -0
- package/src/v2/runtime/__tests__/handle-run.test.ts +97 -1
- package/src/v2/runtime/__tests__/handle-threads.test.ts +493 -13
- package/src/v2/runtime/core/fetch-handler.ts +19 -0
- package/src/v2/runtime/core/fetch-router.ts +33 -1
- package/src/v2/runtime/core/hooks.ts +3 -0
- package/src/v2/runtime/handlers/handle-run.ts +4 -0
- package/src/v2/runtime/handlers/handle-threads.ts +3 -0
- package/src/v2/runtime/handlers/intelligence/threads.ts +200 -41
- package/src/v2/runtime/intelligence-platform/client.ts +76 -0
- package/src/v2/runtime/runner/__tests__/in-memory-runner.test.ts +417 -3
- package/src/v2/runtime/runner/in-memory.ts +137 -51
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
2
|
import { InMemoryAgentRunner } from "../in-memory";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import type { InMemoryThread } from "../in-memory";
|
|
4
|
+
import type {
|
|
5
5
|
BaseEvent,
|
|
6
|
-
EventType,
|
|
7
6
|
Message,
|
|
8
7
|
RunAgentInput,
|
|
9
8
|
RunErrorEvent,
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
TextMessageStartEvent,
|
|
14
13
|
ToolCallResultEvent,
|
|
15
14
|
} from "@ag-ui/client";
|
|
15
|
+
import { AbstractAgent, EventType } from "@ag-ui/client";
|
|
16
16
|
import { EMPTY, firstValueFrom } from "rxjs";
|
|
17
17
|
import { toArray } from "rxjs/operators";
|
|
18
18
|
|
|
@@ -94,6 +94,7 @@ describe("InMemoryAgentRunner", () => {
|
|
|
94
94
|
|
|
95
95
|
beforeEach(() => {
|
|
96
96
|
runner = new InMemoryAgentRunner();
|
|
97
|
+
runner.clearThreads();
|
|
97
98
|
});
|
|
98
99
|
|
|
99
100
|
describe("RunStarted payload", () => {
|
|
@@ -336,6 +337,12 @@ describe("InMemoryAgentRunner", () => {
|
|
|
336
337
|
|
|
337
338
|
expect(errorEvent).toBeDefined();
|
|
338
339
|
expect(errorEvent!.message).toBe("HTTP 401: Unauthorized");
|
|
340
|
+
// RUN_ERROR must be the terminal event — the runner must not also emit
|
|
341
|
+
// RUN_FINISHED on the failure path, and nothing should follow the error.
|
|
342
|
+
expect(events[events.length - 1].type).toBe(EventType.RUN_ERROR);
|
|
343
|
+
expect(
|
|
344
|
+
events.filter((e) => e.type === EventType.RUN_FINISHED),
|
|
345
|
+
).toHaveLength(0);
|
|
339
346
|
});
|
|
340
347
|
|
|
341
348
|
it("propagates non-HTTP error messages into the RUN_ERROR event", async () => {
|
|
@@ -361,3 +368,410 @@ describe("InMemoryAgentRunner", () => {
|
|
|
361
368
|
});
|
|
362
369
|
});
|
|
363
370
|
});
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Agent that populates this.messages after a run — needed to test the
|
|
374
|
+
// listThreads / getThreadMessages fallback which reads agent.messages.
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
class MessagePopulatingTestAgent extends AbstractAgent {
|
|
377
|
+
constructor(
|
|
378
|
+
// Accept undefined so `clone()` can forward `this.agentId` losslessly.
|
|
379
|
+
// `AbstractAgent.agentId` is optional (`AgentConfig.agentId?: string`),
|
|
380
|
+
// so coercing undefined to "" would silently turn "no agent id" into
|
|
381
|
+
// "empty agent id" — a different state.
|
|
382
|
+
agentId: string | undefined,
|
|
383
|
+
private readonly inputMessages: Message[],
|
|
384
|
+
private readonly generatedMessages: Message[],
|
|
385
|
+
) {
|
|
386
|
+
super({ agentId });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Override runAgent to simulate what a real agent does: populate this.messages
|
|
390
|
+
// with the full conversation (input + generated) then call the subscriber callbacks.
|
|
391
|
+
// Aligns with TestAgent above — `onEvent` is required so the in-memory runner
|
|
392
|
+
// contract (always supply an event sink) is exercised exactly the same way.
|
|
393
|
+
// `onNewMessage` is declared optional to match TestAgent and the actual
|
|
394
|
+
// runner call site, which always passes it. Without the declaration the
|
|
395
|
+
// mock's options shape silently drifts from production and a regression
|
|
396
|
+
// that starts depending on `onNewMessage` here would compile cleanly.
|
|
397
|
+
async runAgent(
|
|
398
|
+
input: RunAgentInput,
|
|
399
|
+
options: {
|
|
400
|
+
onEvent: (params: { event: BaseEvent }) => void;
|
|
401
|
+
onNewMessage?: (args: { message: Message }) => void;
|
|
402
|
+
onRunStartedEvent?: () => void;
|
|
403
|
+
},
|
|
404
|
+
): Promise<{ result: unknown; newMessages: Message[] }> {
|
|
405
|
+
const runStarted: RunStartedEvent = {
|
|
406
|
+
type: EventType.RUN_STARTED,
|
|
407
|
+
threadId: input.threadId,
|
|
408
|
+
runId: input.runId,
|
|
409
|
+
};
|
|
410
|
+
options.onEvent({ event: runStarted });
|
|
411
|
+
options.onRunStartedEvent?.();
|
|
412
|
+
|
|
413
|
+
for (const msg of this.generatedMessages) {
|
|
414
|
+
const start = {
|
|
415
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
416
|
+
messageId: msg.id,
|
|
417
|
+
role: (msg as { role: string }).role,
|
|
418
|
+
} as TextMessageStartEvent;
|
|
419
|
+
const content = {
|
|
420
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
421
|
+
messageId: msg.id,
|
|
422
|
+
delta: (msg as { content?: string }).content ?? "",
|
|
423
|
+
} as TextMessageContentEvent;
|
|
424
|
+
const end = {
|
|
425
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
426
|
+
messageId: msg.id,
|
|
427
|
+
} as TextMessageEndEvent;
|
|
428
|
+
options.onEvent({ event: start });
|
|
429
|
+
options.onEvent({ event: content });
|
|
430
|
+
options.onEvent({ event: end });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Populate this.messages — this is what real AbstractAgent.runAgent does
|
|
434
|
+
this.messages = [...this.inputMessages, ...this.generatedMessages];
|
|
435
|
+
return { result: undefined, newMessages: this.generatedMessages };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
clone(): AbstractAgent {
|
|
439
|
+
return new MessagePopulatingTestAgent(
|
|
440
|
+
this.agentId,
|
|
441
|
+
this.inputMessages,
|
|
442
|
+
this.generatedMessages,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
protected run(): ReturnType<AbstractAgent["run"]> {
|
|
447
|
+
return EMPTY;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Mirror `TestAgent` and `ThrowingAgent` — `AbstractAgent.connect()` would
|
|
451
|
+
// otherwise inherit production behavior that may try to open a transport.
|
|
452
|
+
// Returning EMPTY keeps clones inert in tests.
|
|
453
|
+
protected connect(): ReturnType<AbstractAgent["connect"]> {
|
|
454
|
+
return EMPTY;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
describe("InMemoryAgentRunner — listThreads / getThreadMessages", () => {
|
|
459
|
+
let runner: InMemoryAgentRunner;
|
|
460
|
+
|
|
461
|
+
const userMessage: Message = { id: "u1", role: "user", content: "Hello" };
|
|
462
|
+
const assistantMessage: Message = {
|
|
463
|
+
id: "a1",
|
|
464
|
+
role: "assistant",
|
|
465
|
+
content: "Hi there!",
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
beforeEach(async () => {
|
|
469
|
+
runner = new InMemoryAgentRunner();
|
|
470
|
+
// Reset the module-level GLOBAL_STORE singleton so tests don't leak into each other
|
|
471
|
+
runner.clearThreads();
|
|
472
|
+
|
|
473
|
+
// Run a single turn on a unique thread so each test starts fresh
|
|
474
|
+
const agent = new MessagePopulatingTestAgent(
|
|
475
|
+
"test-agent",
|
|
476
|
+
[userMessage],
|
|
477
|
+
[assistantMessage],
|
|
478
|
+
);
|
|
479
|
+
await firstValueFrom(
|
|
480
|
+
runner
|
|
481
|
+
.run({
|
|
482
|
+
threadId: "list-threads-thread-1",
|
|
483
|
+
agent,
|
|
484
|
+
input: {
|
|
485
|
+
threadId: "list-threads-thread-1",
|
|
486
|
+
runId: "run-lt-1",
|
|
487
|
+
messages: [userMessage],
|
|
488
|
+
state: {},
|
|
489
|
+
tools: [],
|
|
490
|
+
context: [],
|
|
491
|
+
},
|
|
492
|
+
})
|
|
493
|
+
.pipe(toArray()),
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe("listThreads", () => {
|
|
498
|
+
it("returns a summary for each completed thread", () => {
|
|
499
|
+
const threads = runner.listThreads();
|
|
500
|
+
const thread = threads.find(
|
|
501
|
+
(t: InMemoryThread) => t.id === "list-threads-thread-1",
|
|
502
|
+
);
|
|
503
|
+
expect(thread).toBeDefined();
|
|
504
|
+
expect(thread!.agentId).toBe("test-agent");
|
|
505
|
+
expect(thread!.name).toBeNull();
|
|
506
|
+
expect(thread!.archived).toBe(false);
|
|
507
|
+
expect(thread!.createdAt).toBeTruthy();
|
|
508
|
+
expect(thread!.updatedAt).toBeTruthy();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("returns threads sorted most-recently-updated first", async () => {
|
|
512
|
+
// Run a second thread after a delay long enough that timer-resolution
|
|
513
|
+
// jitter on slow CI runners cannot collapse the two timestamps. 20ms
|
|
514
|
+
// sits comfortably above typical setTimeout granularity (~4ms in Node)
|
|
515
|
+
// and the file-system timestamp resolution we observed flakes around.
|
|
516
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
517
|
+
const agent2 = new MessagePopulatingTestAgent(
|
|
518
|
+
"test-agent",
|
|
519
|
+
[userMessage],
|
|
520
|
+
[assistantMessage],
|
|
521
|
+
);
|
|
522
|
+
await firstValueFrom(
|
|
523
|
+
runner
|
|
524
|
+
.run({
|
|
525
|
+
threadId: "list-threads-thread-2",
|
|
526
|
+
agent: agent2,
|
|
527
|
+
input: {
|
|
528
|
+
threadId: "list-threads-thread-2",
|
|
529
|
+
runId: "run-lt-2",
|
|
530
|
+
messages: [userMessage],
|
|
531
|
+
state: {},
|
|
532
|
+
tools: [],
|
|
533
|
+
context: [],
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
.pipe(toArray()),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const threads = runner.listThreads();
|
|
540
|
+
const ids = threads.map((t: InMemoryThread) => t.id);
|
|
541
|
+
const idx1 = ids.indexOf("list-threads-thread-1");
|
|
542
|
+
const idx2 = ids.indexOf("list-threads-thread-2");
|
|
543
|
+
// thread-2 is more recent, should appear before thread-1
|
|
544
|
+
expect(idx2).toBeLessThan(idx1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("returns an empty array when no threads have been run", () => {
|
|
548
|
+
const freshRunner = new InMemoryAgentRunner();
|
|
549
|
+
freshRunner.clearThreads();
|
|
550
|
+
expect(freshRunner.listThreads()).toEqual([]);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe("getThreadMessages", () => {
|
|
555
|
+
it("returns all messages for a completed thread", () => {
|
|
556
|
+
const messages = runner.getThreadMessages("list-threads-thread-1");
|
|
557
|
+
expect(messages).toHaveLength(2);
|
|
558
|
+
const roles = messages.map((m) => (m as { role: string }).role);
|
|
559
|
+
expect(roles).toContain("user");
|
|
560
|
+
expect(roles).toContain("assistant");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("includes message content", () => {
|
|
564
|
+
const messages = runner.getThreadMessages("list-threads-thread-1");
|
|
565
|
+
const user = messages.find(
|
|
566
|
+
(m) => (m as { role: string }).role === "user",
|
|
567
|
+
) as {
|
|
568
|
+
content?: string;
|
|
569
|
+
};
|
|
570
|
+
const assistant = messages.find(
|
|
571
|
+
(m) => (m as { role: string }).role === "assistant",
|
|
572
|
+
) as { content?: string };
|
|
573
|
+
expect(user?.content).toBe("Hello");
|
|
574
|
+
expect(assistant?.content).toBe("Hi there!");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("returns an empty array for an unknown threadId", () => {
|
|
578
|
+
const messages = runner.getThreadMessages("nonexistent-thread-xyz");
|
|
579
|
+
expect(messages).toEqual([]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("reflects the most recent run's full message history", async () => {
|
|
583
|
+
const followUp: Message = {
|
|
584
|
+
id: "u2",
|
|
585
|
+
role: "user",
|
|
586
|
+
content: "Follow up",
|
|
587
|
+
};
|
|
588
|
+
const followUpReply: Message = {
|
|
589
|
+
id: "a2",
|
|
590
|
+
role: "assistant",
|
|
591
|
+
content: "Sure!",
|
|
592
|
+
};
|
|
593
|
+
const agent2 = new MessagePopulatingTestAgent(
|
|
594
|
+
"test-agent",
|
|
595
|
+
[userMessage, assistantMessage, followUp],
|
|
596
|
+
[followUpReply],
|
|
597
|
+
);
|
|
598
|
+
await firstValueFrom(
|
|
599
|
+
runner
|
|
600
|
+
.run({
|
|
601
|
+
threadId: "list-threads-thread-1",
|
|
602
|
+
agent: agent2,
|
|
603
|
+
input: {
|
|
604
|
+
threadId: "list-threads-thread-1",
|
|
605
|
+
runId: "run-lt-turn-2",
|
|
606
|
+
messages: [userMessage, assistantMessage, followUp],
|
|
607
|
+
state: {},
|
|
608
|
+
tools: [],
|
|
609
|
+
context: [],
|
|
610
|
+
},
|
|
611
|
+
})
|
|
612
|
+
.pipe(toArray()),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const messages = runner.getThreadMessages("list-threads-thread-1");
|
|
616
|
+
// Should have all 4 messages from the second run's snapshot
|
|
617
|
+
expect(messages).toHaveLength(4);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe("getThreadEvents", () => {
|
|
622
|
+
it("returns stored events for a completed thread", () => {
|
|
623
|
+
const events = runner.getThreadEvents("list-threads-thread-1");
|
|
624
|
+
// The beforeEach runs a single turn. The MessagePopulatingTestAgent
|
|
625
|
+
// emits RUN_STARTED + a TEXT_MESSAGE triple for the assistant reply
|
|
626
|
+
// and never emits a terminal event itself.
|
|
627
|
+
expect(events.length).toBeGreaterThan(0);
|
|
628
|
+
const types = events.map((e) => e.type);
|
|
629
|
+
expect(types).toContain(EventType.RUN_STARTED);
|
|
630
|
+
// Content events must be present so the inspector can replay full
|
|
631
|
+
// thread history — guard against a regression that strips them
|
|
632
|
+
// during compaction.
|
|
633
|
+
expect(types).toContain(EventType.TEXT_MESSAGE_START);
|
|
634
|
+
expect(types).toContain(EventType.TEXT_MESSAGE_CONTENT);
|
|
635
|
+
expect(types).toContain(EventType.TEXT_MESSAGE_END);
|
|
636
|
+
// finalizeRunEvents mutates the events array to append a synthetic
|
|
637
|
+
// terminal event when the agent does not emit one itself: a
|
|
638
|
+
// RUN_ERROR with code INCOMPLETE_STREAM. Asserting this explicitly
|
|
639
|
+
// guards against a regression where the synthetic event is dropped
|
|
640
|
+
// (the inspector would render an in-progress thread forever) or
|
|
641
|
+
// where the code is silently changed to something inspectors don't
|
|
642
|
+
// recognise.
|
|
643
|
+
const terminal = events.find(
|
|
644
|
+
(e): e is BaseEvent & { code?: string } =>
|
|
645
|
+
e.type === EventType.RUN_ERROR,
|
|
646
|
+
);
|
|
647
|
+
expect(terminal).toBeDefined();
|
|
648
|
+
expect((terminal as { code?: string }).code).toBe("INCOMPLETE_STREAM");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("returns an empty array for an unknown threadId", () => {
|
|
652
|
+
expect(runner.getThreadEvents("nonexistent-thread-xyz")).toEqual([]);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("flattens events across multiple historic runs", async () => {
|
|
656
|
+
const followUp: Message = {
|
|
657
|
+
id: "u2",
|
|
658
|
+
role: "user",
|
|
659
|
+
content: "Follow up",
|
|
660
|
+
};
|
|
661
|
+
const agent2 = new MessagePopulatingTestAgent(
|
|
662
|
+
"test-agent",
|
|
663
|
+
[userMessage, assistantMessage, followUp],
|
|
664
|
+
[{ id: "a2", role: "assistant", content: "Sure!" }],
|
|
665
|
+
);
|
|
666
|
+
await firstValueFrom(
|
|
667
|
+
runner
|
|
668
|
+
.run({
|
|
669
|
+
threadId: "list-threads-thread-1",
|
|
670
|
+
agent: agent2,
|
|
671
|
+
input: {
|
|
672
|
+
threadId: "list-threads-thread-1",
|
|
673
|
+
runId: "run-lt-turn-2",
|
|
674
|
+
messages: [userMessage, assistantMessage, followUp],
|
|
675
|
+
state: {},
|
|
676
|
+
tools: [],
|
|
677
|
+
context: [],
|
|
678
|
+
},
|
|
679
|
+
})
|
|
680
|
+
.pipe(toArray()),
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
const events = runner.getThreadEvents("list-threads-thread-1");
|
|
684
|
+
const runStartedCount = events.filter(
|
|
685
|
+
(e) => e.type === EventType.RUN_STARTED,
|
|
686
|
+
).length;
|
|
687
|
+
// Two runs means two RUN_STARTED events survive compaction.
|
|
688
|
+
expect(runStartedCount).toBe(2);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("getThreadState", () => {
|
|
693
|
+
it("returns null when the thread has never emitted a state snapshot", () => {
|
|
694
|
+
// The beforeEach agent doesn't emit STATE_SNAPSHOT events.
|
|
695
|
+
expect(runner.getThreadState("list-threads-thread-1")).toBeNull();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("returns null for an unknown threadId", () => {
|
|
699
|
+
expect(runner.getThreadState("nonexistent-thread-xyz")).toBeNull();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("returns the last STATE_SNAPSHOT payload after a run", async () => {
|
|
703
|
+
const snapshot = { counter: 7, name: "alpha" };
|
|
704
|
+
const stateAgent = new TestAgent(
|
|
705
|
+
[
|
|
706
|
+
{
|
|
707
|
+
type: EventType.STATE_SNAPSHOT,
|
|
708
|
+
snapshot,
|
|
709
|
+
} as BaseEvent,
|
|
710
|
+
],
|
|
711
|
+
true,
|
|
712
|
+
);
|
|
713
|
+
await firstValueFrom(
|
|
714
|
+
runner
|
|
715
|
+
.run({
|
|
716
|
+
threadId: "thread-with-state",
|
|
717
|
+
agent: stateAgent,
|
|
718
|
+
input: {
|
|
719
|
+
threadId: "thread-with-state",
|
|
720
|
+
runId: "run-state-1",
|
|
721
|
+
messages: [],
|
|
722
|
+
state: {},
|
|
723
|
+
tools: [],
|
|
724
|
+
context: [],
|
|
725
|
+
},
|
|
726
|
+
})
|
|
727
|
+
.pipe(toArray()),
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
expect(runner.getThreadState("thread-with-state")).toEqual(snapshot);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("returns the most recent snapshot across multiple runs", async () => {
|
|
734
|
+
const first = { step: 1 };
|
|
735
|
+
const second = { step: 2 };
|
|
736
|
+
|
|
737
|
+
const run = async (threadId: string, runId: string, snapshot: object) => {
|
|
738
|
+
const agent = new TestAgent(
|
|
739
|
+
[{ type: EventType.STATE_SNAPSHOT, snapshot } as BaseEvent],
|
|
740
|
+
true,
|
|
741
|
+
);
|
|
742
|
+
await firstValueFrom(
|
|
743
|
+
runner
|
|
744
|
+
.run({
|
|
745
|
+
threadId,
|
|
746
|
+
agent,
|
|
747
|
+
input: {
|
|
748
|
+
threadId,
|
|
749
|
+
runId,
|
|
750
|
+
messages: [],
|
|
751
|
+
state: {},
|
|
752
|
+
tools: [],
|
|
753
|
+
context: [],
|
|
754
|
+
},
|
|
755
|
+
})
|
|
756
|
+
.pipe(toArray()),
|
|
757
|
+
);
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
await run("thread-multi-state", "run-a", first);
|
|
761
|
+
await run("thread-multi-state", "run-b", second);
|
|
762
|
+
|
|
763
|
+
expect(runner.getThreadState("thread-multi-state")).toEqual(second);
|
|
764
|
+
|
|
765
|
+
// Cross-thread isolation: a snapshot on a different thread must not
|
|
766
|
+
// bleed into the original thread's state. This guards against any
|
|
767
|
+
// accidental "last-write-wins" leak in the per-thread state store.
|
|
768
|
+
const otherThreadSnapshot = { step: 999 };
|
|
769
|
+
await run("thread-other", "run-other", otherThreadSnapshot);
|
|
770
|
+
|
|
771
|
+
expect(runner.getThreadState("thread-other")).toEqual(
|
|
772
|
+
otherThreadSnapshot,
|
|
773
|
+
);
|
|
774
|
+
expect(runner.getThreadState("thread-multi-state")).toEqual(second);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
AbstractAgent,
|
|
11
11
|
BaseEvent,
|
|
12
12
|
EventType,
|
|
13
|
-
|
|
13
|
+
Message,
|
|
14
14
|
RunStartedEvent,
|
|
15
|
+
StateSnapshotEvent,
|
|
15
16
|
compactEvents,
|
|
16
17
|
} from "@ag-ui/client";
|
|
17
18
|
import { finalizeRunEvents } from "@copilotkit/shared";
|
|
@@ -19,11 +20,34 @@ import { finalizeRunEvents } from "@copilotkit/shared";
|
|
|
19
20
|
interface HistoricRun {
|
|
20
21
|
threadId: string;
|
|
21
22
|
runId: string;
|
|
23
|
+
/** ID of the agent that executed this run. */
|
|
24
|
+
agentId: string;
|
|
22
25
|
parentRunId: string | null;
|
|
23
26
|
events: BaseEvent[];
|
|
27
|
+
/**
|
|
28
|
+
* Snapshot of all messages (input + generated) at the end of this run.
|
|
29
|
+
* Used by the local thread-messages fallback endpoint.
|
|
30
|
+
*/
|
|
31
|
+
messages: Message[];
|
|
24
32
|
createdAt: number;
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Lightweight thread summary returned by {@link InMemoryAgentRunner.listThreads}.
|
|
37
|
+
* Shape matches the Intelligence platform's ThreadRecord so the same HTTP
|
|
38
|
+
* response envelope can be used for both backends.
|
|
39
|
+
*/
|
|
40
|
+
export interface InMemoryThread {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string | null;
|
|
43
|
+
agentId: string;
|
|
44
|
+
organizationId: ""; // always empty in in-memory mode
|
|
45
|
+
createdById: ""; // always empty in in-memory mode
|
|
46
|
+
archived: false; // always false in in-memory mode
|
|
47
|
+
createdAt: string;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
class InMemoryEventStore {
|
|
28
52
|
constructor(public threadId: string) {}
|
|
29
53
|
|
|
@@ -52,50 +76,7 @@ class InMemoryEventStore {
|
|
|
52
76
|
currentEvents: BaseEvent[] | null = null;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
|
-
|
|
56
|
-
const GLOBAL_STORE_KEY = Symbol.for("@copilotkit/runtime/in-memory-store");
|
|
57
|
-
|
|
58
|
-
interface GlobalStoreData {
|
|
59
|
-
stores: Map<string, InMemoryEventStore>;
|
|
60
|
-
historicRunsBackup: Map<string, HistoricRun[]>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function getGlobalStore(): Map<string, InMemoryEventStore> {
|
|
64
|
-
const globalAny = globalThis as unknown as Record<symbol, GlobalStoreData>;
|
|
65
|
-
|
|
66
|
-
if (!globalAny[GLOBAL_STORE_KEY]) {
|
|
67
|
-
globalAny[GLOBAL_STORE_KEY] = {
|
|
68
|
-
stores: new Map<string, InMemoryEventStore>(),
|
|
69
|
-
historicRunsBackup: new Map<string, HistoricRun[]>(),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const data = globalAny[GLOBAL_STORE_KEY];
|
|
74
|
-
|
|
75
|
-
// Restore historic runs from backup after hot reload
|
|
76
|
-
// (when stores map is empty but backup has data)
|
|
77
|
-
if (data.stores.size === 0 && data.historicRunsBackup.size > 0) {
|
|
78
|
-
for (const [threadId, historicRuns] of data.historicRunsBackup) {
|
|
79
|
-
const store = new InMemoryEventStore(threadId);
|
|
80
|
-
store.historicRuns = historicRuns;
|
|
81
|
-
data.stores.set(threadId, store);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return data.stores;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function backupHistoricRuns(
|
|
89
|
-
threadId: string,
|
|
90
|
-
historicRuns: HistoricRun[],
|
|
91
|
-
): void {
|
|
92
|
-
const globalAny = globalThis as unknown as Record<symbol, GlobalStoreData>;
|
|
93
|
-
if (globalAny[GLOBAL_STORE_KEY]) {
|
|
94
|
-
globalAny[GLOBAL_STORE_KEY].historicRunsBackup.set(threadId, historicRuns);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const GLOBAL_STORE = getGlobalStore();
|
|
79
|
+
const GLOBAL_STORE = new Map<string, InMemoryEventStore>();
|
|
99
80
|
|
|
100
81
|
export class InMemoryAgentRunner extends AgentRunner {
|
|
101
82
|
run(request: AgentRunnerRunRequest): Observable<BaseEvent> {
|
|
@@ -215,13 +196,15 @@ export class InMemoryAgentRunner extends AgentRunner {
|
|
|
215
196
|
store.historicRuns.push({
|
|
216
197
|
threadId: request.threadId,
|
|
217
198
|
runId: store.currentRunId,
|
|
199
|
+
agentId: request.agent.agentId ?? "default",
|
|
218
200
|
parentRunId,
|
|
219
201
|
events: compactedEvents,
|
|
202
|
+
// Snapshot all messages (input + generated) for the thread-messages endpoint
|
|
203
|
+
messages: Array.isArray(request.agent.messages)
|
|
204
|
+
? [...request.agent.messages]
|
|
205
|
+
: [],
|
|
220
206
|
createdAt: Date.now(),
|
|
221
207
|
});
|
|
222
|
-
|
|
223
|
-
// Backup for hot reload survival
|
|
224
|
-
backupHistoricRuns(request.threadId, store.historicRuns);
|
|
225
208
|
}
|
|
226
209
|
|
|
227
210
|
// Complete the run
|
|
@@ -252,13 +235,14 @@ export class InMemoryAgentRunner extends AgentRunner {
|
|
|
252
235
|
store.historicRuns.push({
|
|
253
236
|
threadId: request.threadId,
|
|
254
237
|
runId: store.currentRunId,
|
|
238
|
+
agentId: request.agent.agentId ?? "default",
|
|
255
239
|
parentRunId,
|
|
256
240
|
events: compactedEvents,
|
|
241
|
+
messages: Array.isArray(request.agent.messages)
|
|
242
|
+
? [...request.agent.messages]
|
|
243
|
+
: [],
|
|
257
244
|
createdAt: Date.now(),
|
|
258
245
|
});
|
|
259
|
-
|
|
260
|
-
// Backup for hot reload survival
|
|
261
|
-
backupHistoricRuns(request.threadId, store.historicRuns);
|
|
262
246
|
}
|
|
263
247
|
|
|
264
248
|
// Complete the run
|
|
@@ -378,4 +362,106 @@ export class InMemoryAgentRunner extends AgentRunner {
|
|
|
378
362
|
return Promise.resolve(false);
|
|
379
363
|
}
|
|
380
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Returns a summary of every thread that has been run through this runner.
|
|
368
|
+
*
|
|
369
|
+
* This powers the local-dev fallback for `GET /threads` when the Intelligence
|
|
370
|
+
* platform is not configured. Each entry mirrors the shape of a platform
|
|
371
|
+
* `ThreadRecord` so the HTTP handler can use the same response envelope.
|
|
372
|
+
*/
|
|
373
|
+
listThreads(): InMemoryThread[] {
|
|
374
|
+
const threads: InMemoryThread[] = [];
|
|
375
|
+
for (const [threadId, store] of GLOBAL_STORE) {
|
|
376
|
+
if (store.historicRuns.length === 0) continue;
|
|
377
|
+
const firstRun = store.historicRuns[0]!;
|
|
378
|
+
const lastRun = store.historicRuns[store.historicRuns.length - 1]!;
|
|
379
|
+
threads.push({
|
|
380
|
+
id: threadId,
|
|
381
|
+
name: null,
|
|
382
|
+
agentId: lastRun.agentId,
|
|
383
|
+
organizationId: "",
|
|
384
|
+
createdById: "",
|
|
385
|
+
archived: false,
|
|
386
|
+
createdAt: new Date(firstRun.createdAt).toISOString(),
|
|
387
|
+
updatedAt: new Date(lastRun.createdAt).toISOString(),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// Most recently updated first
|
|
391
|
+
return threads.sort(
|
|
392
|
+
(a, b) =>
|
|
393
|
+
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Returns all messages for a thread, using the snapshot captured at the end
|
|
399
|
+
* of the most recent run.
|
|
400
|
+
*
|
|
401
|
+
* This powers the local-dev fallback for `GET /threads/:threadId/messages`
|
|
402
|
+
* when the Intelligence platform is not configured. The returned `Message[]`
|
|
403
|
+
* objects come directly from the ag-ui agent, so their shape is compatible
|
|
404
|
+
* with the Intelligence platform's `ThreadMessage` type.
|
|
405
|
+
*/
|
|
406
|
+
getThreadMessages(threadId: string): Message[] {
|
|
407
|
+
const store = GLOBAL_STORE.get(threadId);
|
|
408
|
+
if (!store || store.historicRuns.length === 0) return [];
|
|
409
|
+
// The last run's snapshot has the complete conversation history
|
|
410
|
+
return store.historicRuns[store.historicRuns.length - 1]!.messages;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Returns all AG-UI events for a thread, compacted across historic runs.
|
|
415
|
+
*
|
|
416
|
+
* Powers the local-dev fallback for `GET /threads/:threadId/events` when the
|
|
417
|
+
* Intelligence platform is not configured. The compaction logic matches
|
|
418
|
+
* the connection-replay path in {@link connect}, so the stream a
|
|
419
|
+
* late-joining inspector sees matches what this method returns.
|
|
420
|
+
*/
|
|
421
|
+
getThreadEvents(threadId: string): BaseEvent[] {
|
|
422
|
+
const store = GLOBAL_STORE.get(threadId);
|
|
423
|
+
if (!store || store.historicRuns.length === 0) return [];
|
|
424
|
+
const all: BaseEvent[] = [];
|
|
425
|
+
for (const run of store.historicRuns) all.push(...run.events);
|
|
426
|
+
return compactEvents(all);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Returns the agent state snapshot for a thread.
|
|
431
|
+
*
|
|
432
|
+
* Derived from the last `STATE_SNAPSHOT` in the compacted event stream. The
|
|
433
|
+
* AG-UI `compactEvents` helper consolidates STATE_DELTA events and produces
|
|
434
|
+
* a single trailing STATE_SNAPSHOT when state changes exist, so this is a
|
|
435
|
+
* faithful view of state at the end of the most recent run.
|
|
436
|
+
*
|
|
437
|
+
* Returns `null` when the thread has never emitted a STATE_SNAPSHOT.
|
|
438
|
+
*/
|
|
439
|
+
getThreadState(threadId: string): Record<string, unknown> | null {
|
|
440
|
+
const events = this.getThreadEvents(threadId);
|
|
441
|
+
// Walk backwards — the last snapshot wins.
|
|
442
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
443
|
+
const event = events[i]!;
|
|
444
|
+
if (event.type === EventType.STATE_SNAPSHOT) {
|
|
445
|
+
const snapshot = (event as StateSnapshotEvent).snapshot;
|
|
446
|
+
if (snapshot && typeof snapshot === "object") {
|
|
447
|
+
return snapshot as Record<string, unknown>;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Clears all in-memory thread history.
|
|
457
|
+
*
|
|
458
|
+
* Powers the local-dev fallback for `POST /threads/clear`, letting consumers
|
|
459
|
+
* (e.g. the demo's Clear button) reset to an empty thread list without
|
|
460
|
+
* restarting the runtime. Intentionally not exposed on the Intelligence
|
|
461
|
+
* platform path: there, thread history lives in a real database and must
|
|
462
|
+
* not be wiped this way.
|
|
463
|
+
*/
|
|
464
|
+
clearThreads(): void {
|
|
465
|
+
GLOBAL_STORE.clear();
|
|
466
|
+
}
|
|
381
467
|
}
|