@copilotkit/channels-core 0.4.1-canary.parallel1 → 0.5.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 (47) hide show
  1. package/README.md +7 -8
  2. package/dist/canonical-run-loop.test.d.ts +2 -0
  3. package/dist/canonical-run-loop.test.d.ts.map +1 -0
  4. package/dist/canonical-run-loop.test.js +453 -0
  5. package/dist/codec.d.ts +2 -3
  6. package/dist/codec.d.ts.map +1 -1
  7. package/dist/create-channel.d.ts +110 -4
  8. package/dist/create-channel.d.ts.map +1 -1
  9. package/dist/create-channel.js +258 -92
  10. package/dist/create-channel.test.js +552 -29
  11. package/dist/delivery-error.d.ts +17 -0
  12. package/dist/delivery-error.d.ts.map +1 -0
  13. package/dist/delivery-error.js +22 -0
  14. package/dist/index.d.ts +6 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/managed-v1-await-choice-guard.test.d.ts +2 -0
  18. package/dist/managed-v1-await-choice-guard.test.d.ts.map +1 -0
  19. package/dist/managed-v1-await-choice-guard.test.js +52 -0
  20. package/dist/platform-adapter.d.ts +74 -11
  21. package/dist/platform-adapter.d.ts.map +1 -1
  22. package/dist/run-loop.d.ts +25 -6
  23. package/dist/run-loop.d.ts.map +1 -1
  24. package/dist/run-loop.js +289 -39
  25. package/dist/run-loop.test.js +37 -0
  26. package/dist/sanitize-agent-events.d.ts +24 -0
  27. package/dist/sanitize-agent-events.d.ts.map +1 -0
  28. package/dist/sanitize-agent-events.js +88 -0
  29. package/dist/sanitize-agent-events.test.d.ts +2 -0
  30. package/dist/sanitize-agent-events.test.d.ts.map +1 -0
  31. package/dist/sanitize-agent-events.test.js +194 -0
  32. package/dist/source-platform.test.d.ts +2 -0
  33. package/dist/source-platform.test.d.ts.map +1 -0
  34. package/dist/source-platform.test.js +149 -0
  35. package/dist/testing/fake-adapter.d.ts +8 -1
  36. package/dist/testing/fake-adapter.d.ts.map +1 -1
  37. package/dist/testing/fake-adapter.js +30 -1
  38. package/dist/testing/fake-agent.d.ts +5 -0
  39. package/dist/testing/fake-agent.d.ts.map +1 -1
  40. package/dist/testing/fake-agent.js +12 -0
  41. package/dist/thread-promise-contract.test.d.ts +2 -0
  42. package/dist/thread-promise-contract.test.d.ts.map +1 -0
  43. package/dist/thread-promise-contract.test.js +37 -0
  44. package/dist/thread.d.ts +5 -0
  45. package/dist/thread.d.ts.map +1 -1
  46. package/dist/thread.js +338 -243
  47. package/package.json +4 -4
@@ -52,17 +52,147 @@ function collectText(nodes) {
52
52
  }
53
53
  return out;
54
54
  }
55
- /** Capture user messages injected into a fake agent without changing its behavior. */
55
+ /**
56
+ * Apply `patch` to `agent` and, recursively, to every clone descended from it.
57
+ *
58
+ * `createChannel` isolates every turn via `clone()`, so the instance a turn
59
+ * actually runs on is never the one the test configured. A spy installed only on
60
+ * the configured agent would observe nothing.
61
+ */
62
+ function patchAgentAndClones(agent, patch) {
63
+ const wrap = (target) => {
64
+ patch(target);
65
+ const origClone = target.clone.bind(target);
66
+ target.clone = () => {
67
+ const cloned = origClone();
68
+ wrap(cloned);
69
+ return cloned;
70
+ };
71
+ };
72
+ wrap(agent);
73
+ }
74
+ /** Capture user messages injected into a fake agent (and every clone of it). */
56
75
  function captureAddedMessages(agent) {
57
76
  const added = [];
58
- const addMessage = agent.addMessage.bind(agent);
59
- agent.addMessage = (message) => {
60
- added.push(message);
61
- return addMessage(message);
62
- };
77
+ patchAgentAndClones(agent, (target) => {
78
+ const addMessage = target.addMessage.bind(target);
79
+ target.addMessage = (message) => {
80
+ added.push(message);
81
+ return addMessage(message);
82
+ };
83
+ });
63
84
  return added;
64
85
  }
86
+ /** Sum `runAgent` calls across the configured agent and every clone of it. */
87
+ function trackRunAgentCalls(agent) {
88
+ let total = 0;
89
+ patchAgentAndClones(agent, (target) => {
90
+ const orig = target.runAgent.bind(target);
91
+ target.runAgent = async (parameters, subscriber) => {
92
+ total += 1;
93
+ return orig(parameters, subscriber);
94
+ };
95
+ });
96
+ return { total: () => total };
97
+ }
98
+ /**
99
+ * Capture the agent instances handed to the conversation store — i.e. the ones
100
+ * that actually run, post-isolation — rather than the configured prototype.
101
+ */
102
+ function captureSessionAgents(fake) {
103
+ const agents = [];
104
+ const orig = fake.conversationStore.getOrCreate.bind(fake.conversationStore);
105
+ fake.conversationStore.getOrCreate = async (key, target, makeAgent) => {
106
+ const session = await orig(key, target, makeAgent);
107
+ agents.push(session.agent);
108
+ return session;
109
+ };
110
+ return { agents };
111
+ }
65
112
  describe("createChannel", () => {
113
+ it("rejects activation when a message-capable adapter has no eligible handler", async () => {
114
+ const fake = new FakeAdapter({ messageEvents: true });
115
+ const channel = createChannel({ name: "support", adapters: [fake] });
116
+ await expect(channel.ɵruntime.start()).rejects.toThrow('channel "support" must register onMention or onMessage');
117
+ expect(fake.started).toBe(false);
118
+ });
119
+ it("routes an explicit mention only to onMention", async () => {
120
+ const fake = new FakeAdapter();
121
+ const channel = createChannel({ adapters: [fake] });
122
+ const mentions = vi.fn();
123
+ const messages = vi.fn();
124
+ channel.onMention(mentions);
125
+ channel.onMessage(messages);
126
+ await channel.ɵruntime.start();
127
+ await fake.getSink().onTurn({
128
+ conversationKey: "c1",
129
+ replyTarget: {},
130
+ userText: "hello",
131
+ platform: "fake",
132
+ operation: {
133
+ kind: "created",
134
+ logicalMessageId: "message-1",
135
+ revisionId: "revision-1",
136
+ mentioned: true,
137
+ },
138
+ });
139
+ expect(mentions).toHaveBeenCalledOnce();
140
+ expect(messages).not.toHaveBeenCalled();
141
+ });
142
+ it("falls an explicit mention back to onMessage", async () => {
143
+ const fake = new FakeAdapter();
144
+ const channel = createChannel({ adapters: [fake] });
145
+ const messages = vi.fn();
146
+ channel.onMessage(messages);
147
+ await channel.ɵruntime.start();
148
+ await fake.getSink().onTurn({
149
+ conversationKey: "c1",
150
+ replyTarget: {},
151
+ userText: "hello",
152
+ platform: "fake",
153
+ operation: {
154
+ kind: "updated",
155
+ logicalMessageId: "message-1",
156
+ revisionId: "revision-2",
157
+ mentioned: true,
158
+ },
159
+ });
160
+ expect(messages).toHaveBeenCalledOnce();
161
+ expect(messages).toHaveBeenCalledWith({
162
+ thread: expect.anything(),
163
+ message: expect.objectContaining({
164
+ operation: {
165
+ kind: "updated",
166
+ logicalMessageId: "message-1",
167
+ revisionId: "revision-2",
168
+ mentioned: true,
169
+ },
170
+ }),
171
+ });
172
+ });
173
+ it("routes an ordinary message only to onMessage", async () => {
174
+ const fake = new FakeAdapter();
175
+ const channel = createChannel({ adapters: [fake] });
176
+ const mentions = vi.fn();
177
+ const messages = vi.fn();
178
+ channel.onMention(mentions);
179
+ channel.onMessage(messages);
180
+ await channel.ɵruntime.start();
181
+ await fake.getSink().onTurn({
182
+ conversationKey: "c1",
183
+ replyTarget: {},
184
+ userText: "",
185
+ platform: "fake",
186
+ operation: {
187
+ kind: "deleted",
188
+ logicalMessageId: "message-1",
189
+ revisionId: "revision-3",
190
+ mentioned: false,
191
+ },
192
+ });
193
+ expect(messages).toHaveBeenCalledOnce();
194
+ expect(mentions).not.toHaveBeenCalled();
195
+ });
66
196
  it("routes a mention to a handler that posts UI", async () => {
67
197
  const fake = new FakeAdapter();
68
198
  const agent = new FakeAgent();
@@ -112,6 +242,89 @@ describe("createChannel", () => {
112
242
  content: "Say my name",
113
243
  });
114
244
  });
245
+ it("injects the inbound turn only into the first managed run in one handler", async () => {
246
+ const fake = new FakeAdapter();
247
+ Object.defineProperty(fake, "injectInboundTurnOnce", { value: true });
248
+ const agent = new FakeAgent();
249
+ const added = captureAddedMessages(agent);
250
+ const runs = trackRunAgentCalls(agent);
251
+ const channel = createChannel({ adapters: [fake], agent: () => agent });
252
+ channel.onMention(async ({ thread }) => {
253
+ await thread.runAgent();
254
+ await thread.runAgent();
255
+ });
256
+ await channel.ɵruntime.start();
257
+ await fake.getSink().onTurn({
258
+ conversationKey: "c1",
259
+ replyTarget: {},
260
+ userText: "Use this once",
261
+ platform: "fake",
262
+ });
263
+ // Each `thread.runAgent()` resolves its own isolated instance, so count runs
264
+ // across the configured agent and its clones rather than on one object.
265
+ expect(runs.total()).toBe(2);
266
+ expect(added).toEqual([
267
+ expect.objectContaining({
268
+ role: "user",
269
+ content: "Use this once",
270
+ }),
271
+ ]);
272
+ });
273
+ it("wraps every local tool iteration in one managed lifecycle", async () => {
274
+ const fake = new FakeAdapter();
275
+ const canonicalToolEnds = [];
276
+ const lifecycleCalls = [];
277
+ fake.runAgentLifecycle = async (args) => {
278
+ lifecycleCalls.push(1);
279
+ const subscriber = {
280
+ onToolCallEndEvent({ event }) {
281
+ canonicalToolEnds.push(event.toolCallId);
282
+ },
283
+ };
284
+ return args.execute(subscriber);
285
+ };
286
+ const agent = new FakeAgent([
287
+ (subscriber) => {
288
+ subscriber.onToolCallEndEvent?.({
289
+ event: { toolCallId: "tool-1" },
290
+ toolCallName: "echo",
291
+ toolCallArgs: { value: "hello" },
292
+ });
293
+ },
294
+ () => undefined,
295
+ ]);
296
+ const sessionAgents = captureSessionAgents(fake);
297
+ const channel = createChannel({
298
+ adapters: [fake],
299
+ agent: () => agent,
300
+ tools: [
301
+ {
302
+ name: "echo",
303
+ description: "Return the input",
304
+ parameters: z.object({ value: z.string() }),
305
+ handler: ({ value }) => value,
306
+ },
307
+ ],
308
+ });
309
+ channel.onMention(async ({ thread }) => {
310
+ await thread.runAgent();
311
+ });
312
+ await channel.ɵruntime.start();
313
+ await fake.getSink().onTurn({
314
+ conversationKey: "c1",
315
+ replyTarget: {},
316
+ userText: "Run the tool",
317
+ platform: "fake",
318
+ });
319
+ expect(lifecycleCalls).toHaveLength(1);
320
+ // One `thread.runAgent()` → one isolated instance, and the tool loop iterates
321
+ // twice on that instance. Assert on it, not on the configured prototype.
322
+ expect(sessionAgents.agents).toHaveLength(1);
323
+ const ran = sessionAgents.agents[0];
324
+ expect(ran.runAgentCalls).toBe(2);
325
+ expect(canonicalToolEnds).toEqual(["tool-1"]);
326
+ expect(ran.messages.some((message) => message.role === "tool" && message.toolCallId === "tool-1")).toBe(true);
327
+ });
115
328
  it("does not duplicate an inbound message seeded by the conversation store", async () => {
116
329
  const fake = new FakeAdapter();
117
330
  const agent = new FakeAgent();
@@ -147,6 +360,37 @@ describe("createChannel", () => {
147
360
  content: "Say my name",
148
361
  });
149
362
  });
363
+ it("does not duplicate an explicitly repeated inbound prompt seeded by the conversation store", async () => {
364
+ const fake = new FakeAdapter();
365
+ const agent = new FakeAgent();
366
+ const added = captureAddedMessages(agent);
367
+ const getOrCreate = fake.conversationStore.getOrCreate.bind(fake.conversationStore);
368
+ Object.defineProperty(fake.conversationStore, "seedsInboundTurn", {
369
+ value: true,
370
+ });
371
+ fake.conversationStore.getOrCreate = async (...args) => {
372
+ const session = await getOrCreate(...args);
373
+ session.agent.addMessage({
374
+ id: "inbound",
375
+ role: "user",
376
+ content: "Say my name",
377
+ });
378
+ return session;
379
+ };
380
+ const channel = createChannel({ adapters: [fake], agent: () => agent });
381
+ channel.onMention(async ({ thread, message }) => {
382
+ await thread.runAgent({ prompt: message.text });
383
+ });
384
+ await channel.ɵruntime.start();
385
+ await fake.getSink().onTurn({
386
+ conversationKey: "c1",
387
+ replyTarget: {},
388
+ userText: "Say my name",
389
+ platform: "fake",
390
+ });
391
+ expect(added).toHaveLength(1);
392
+ expect(added[0]).toMatchObject({ id: "inbound" });
393
+ });
150
394
  it("defaults runAgent prompt to inbound multimodal content parts", async () => {
151
395
  const fake = new FakeAdapter();
152
396
  const agent = new FakeAgent();
@@ -265,18 +509,21 @@ describe("createChannel", () => {
265
509
  it("merges per-turn runAgent context with the channel-level context", async () => {
266
510
  const fake = new FakeAdapter();
267
511
  const agent = new FakeAgent();
268
- // Capture the context/tools passed to the agent's first runAgent call.
512
+ // Capture the context/tools passed to the first runAgent call on whichever
513
+ // isolated instance ends up running.
269
514
  let seenContext;
270
515
  let seenTools;
271
- const origRunAgent = agent.runAgent.bind(agent);
272
- agent.runAgent = async (parameters, subscriber) => {
273
- if (seenContext === undefined) {
274
- seenContext = parameters
275
- ?.context;
276
- seenTools = parameters?.tools;
277
- }
278
- return origRunAgent(parameters, subscriber);
279
- };
516
+ patchAgentAndClones(agent, (target) => {
517
+ const origRunAgent = target.runAgent.bind(target);
518
+ target.runAgent = async (parameters, subscriber) => {
519
+ if (seenContext === undefined) {
520
+ seenContext = parameters
521
+ ?.context;
522
+ seenTools = parameters?.tools;
523
+ }
524
+ return origRunAgent(parameters, subscriber);
525
+ };
526
+ });
280
527
  const channel = createChannel({
281
528
  adapters: [fake],
282
529
  agent: () => agent,
@@ -431,6 +678,278 @@ describe("createChannel", () => {
431
678
  // Both turns' handlers should have run.
432
679
  expect(runs).toBe(2);
433
680
  });
681
+ it("default concurrency is parallel: overlapping same-conversation turns both run", async () => {
682
+ const state = new MemoryStore();
683
+ let runs = 0;
684
+ let release;
685
+ const gate = new Promise((r) => (release = r));
686
+ const fake = new FakeAdapter();
687
+ const channel = createChannel({
688
+ adapters: [fake],
689
+ agent: () => new FakeAgent(),
690
+ store: { adapter: state },
691
+ });
692
+ channel.onMention(async () => {
693
+ runs++;
694
+ await gate;
695
+ });
696
+ await channel.ɵruntime.start();
697
+ const sink = fake.getSink();
698
+ const turn = {
699
+ conversationKey: "c1",
700
+ replyTarget: {},
701
+ userText: "hi",
702
+ platform: "fake",
703
+ };
704
+ const p1 = sink.onTurn({ ...turn, eventId: "E-a" });
705
+ const p2 = sink.onTurn({ ...turn, eventId: "E-b" });
706
+ // Both handlers must have started before either finishes (parallel).
707
+ await vi.waitFor(() => expect(runs).toBe(2));
708
+ release();
709
+ await Promise.all([p1, p2]);
710
+ expect(runs).toBe(2);
711
+ });
712
+ it("concurrency: serial queues the second turn until the first finishes", async () => {
713
+ const state = new MemoryStore();
714
+ const order = [];
715
+ let release1;
716
+ const gate1 = new Promise((r) => (release1 = r));
717
+ const fake = new FakeAdapter();
718
+ const channel = createChannel({
719
+ adapters: [fake],
720
+ agent: () => new FakeAgent(),
721
+ store: { adapter: state, concurrency: "serial" },
722
+ });
723
+ channel.onMention(async ({ message }) => {
724
+ order.push(`start:${message.eventId}`);
725
+ if (message.eventId === "E1")
726
+ await gate1;
727
+ order.push(`end:${message.eventId}`);
728
+ });
729
+ await channel.ɵruntime.start();
730
+ const sink = fake.getSink();
731
+ const base = {
732
+ conversationKey: "c1",
733
+ replyTarget: {},
734
+ userText: "hi",
735
+ platform: "fake",
736
+ };
737
+ const p1 = sink.onTurn({ ...base, eventId: "E1" });
738
+ const p2 = sink.onTurn({ ...base, eventId: "E2" });
739
+ await vi.waitFor(() => expect(order).toContain("start:E1"));
740
+ // Second must not start while first is gated.
741
+ expect(order).toEqual(["start:E1"]);
742
+ release1();
743
+ await Promise.all([p1, p2]);
744
+ expect(order).toEqual(["start:E1", "end:E1", "start:E2", "end:E2"]);
745
+ });
746
+ it("concurrency: drop discards the overlapping turn", async () => {
747
+ const state = new MemoryStore();
748
+ let runs = 0;
749
+ let release;
750
+ const gate = new Promise((r) => (release = r));
751
+ const fake = new FakeAdapter();
752
+ const channel = createChannel({
753
+ adapters: [fake],
754
+ agent: () => new FakeAgent(),
755
+ store: { adapter: state, concurrency: "drop" },
756
+ });
757
+ channel.onMention(async () => {
758
+ runs++;
759
+ await gate;
760
+ });
761
+ await channel.ɵruntime.start();
762
+ const sink = fake.getSink();
763
+ const turn = {
764
+ conversationKey: "c1",
765
+ replyTarget: {},
766
+ userText: "hi",
767
+ platform: "fake",
768
+ };
769
+ const p1 = sink.onTurn({ ...turn, eventId: "E1" });
770
+ const p2 = sink.onTurn({ ...turn, eventId: "E2" });
771
+ release();
772
+ await Promise.all([p1, p2]);
773
+ expect(runs).toBe(1);
774
+ });
775
+ it("singleton agent is cloned per run (distinct instances)", async () => {
776
+ const state = new MemoryStore();
777
+ const prototype = new FakeAgent();
778
+ const fake = new FakeAdapter();
779
+ const { agents: seen } = captureSessionAgents(fake);
780
+ const channel = createChannel({
781
+ adapters: [fake],
782
+ agent: prototype, // singleton, not factory
783
+ store: { adapter: state },
784
+ });
785
+ channel.onMention(async ({ thread }) => {
786
+ await thread.runAgent({ prompt: "hi" });
787
+ });
788
+ await channel.ɵruntime.start();
789
+ const sink = fake.getSink();
790
+ await Promise.all([
791
+ sink.onTurn({
792
+ conversationKey: "c1",
793
+ replyTarget: {},
794
+ userText: "a",
795
+ platform: "fake",
796
+ eventId: "E1",
797
+ }),
798
+ sink.onTurn({
799
+ conversationKey: "c1",
800
+ replyTarget: {},
801
+ userText: "b",
802
+ platform: "fake",
803
+ eventId: "E2",
804
+ }),
805
+ ]);
806
+ expect(seen.length).toBe(2);
807
+ expect(seen[0]).not.toBe(prototype);
808
+ expect(seen[1]).not.toBe(prototype);
809
+ expect(seen[0]).not.toBe(seen[1]);
810
+ });
811
+ it("singleton agent whose clone returns itself fails loud", async () => {
812
+ const state = new MemoryStore();
813
+ const bad = new FakeAgent();
814
+ bad.clone = () => bad;
815
+ const fake = new FakeAdapter();
816
+ const channel = createChannel({
817
+ adapters: [fake],
818
+ agent: bad,
819
+ store: { adapter: state },
820
+ });
821
+ channel.onMention(async ({ thread }) => {
822
+ await thread.runAgent({ prompt: "hi" });
823
+ });
824
+ await channel.ɵruntime.start();
825
+ const sink = fake.getSink();
826
+ await expect(sink.onTurn({
827
+ conversationKey: "c1",
828
+ replyTarget: {},
829
+ userText: "hi",
830
+ platform: "fake",
831
+ eventId: "E1",
832
+ })).rejects.toThrow(/clone\(\) must return a distinct instance/);
833
+ });
834
+ it("factory returning a shared instance isolates each turn from the others", async () => {
835
+ const state = new MemoryStore();
836
+ const shared = new FakeAgent();
837
+ const fake = new FakeAdapter();
838
+ const { agents: seen } = captureSessionAgents(fake);
839
+ // The shape this exists for: a factory that hands back the same object on
840
+ // every call. Turn concurrency is parallel by default, so without isolation
841
+ // both turns would run on `shared` and append into its one `messages` array.
842
+ const channel = createChannel({
843
+ adapters: [fake],
844
+ agent: (threadId) => {
845
+ shared.threadId = threadId;
846
+ return shared;
847
+ },
848
+ store: { adapter: state },
849
+ });
850
+ // What each run believes it was asked, read while both turns are in flight.
851
+ const askedPerRun = [];
852
+ patchAgentAndClones(shared, (target) => {
853
+ const orig = target.runAgent.bind(target);
854
+ target.runAgent = async (parameters, subscriber) => {
855
+ // Hold the run open so the turns genuinely overlap, then read back. On a
856
+ // shared instance the other turn's user message has landed by now.
857
+ await new Promise((r) => setTimeout(r, 20));
858
+ askedPerRun.push(target.messages
859
+ .filter((m) => m.role === "user")
860
+ .map((m) => String(m.content))
861
+ .join("+"));
862
+ return orig(parameters, subscriber);
863
+ };
864
+ });
865
+ channel.onMention(async ({ thread }) => {
866
+ await thread.runAgent();
867
+ });
868
+ await channel.ɵruntime.start();
869
+ const sink = fake.getSink();
870
+ await Promise.all([
871
+ sink.onTurn({
872
+ conversationKey: "c1",
873
+ replyTarget: {},
874
+ userText: "first",
875
+ platform: "fake",
876
+ eventId: "E1",
877
+ }),
878
+ sink.onTurn({
879
+ conversationKey: "c1",
880
+ replyTarget: {},
881
+ userText: "second",
882
+ platform: "fake",
883
+ eventId: "E2",
884
+ }),
885
+ ]);
886
+ // Assert the symptom before the mechanism, so a regression reports the
887
+ // user-visible defect rather than an object-identity puzzle: without
888
+ // isolation both runs read "first+second" off the one shared `messages`
889
+ // array, so each turn is prompted with the other user's question too.
890
+ // Completion order between the turns isn't guaranteed — compare as a set.
891
+ expect(askedPerRun.sort()).toEqual(["first", "second"]);
892
+ // Mechanism: two distinct clones, neither of them the configured object.
893
+ expect(seen).toHaveLength(2);
894
+ expect(seen[0]).not.toBe(shared);
895
+ expect(seen[1]).not.toBe(shared);
896
+ expect(seen[0]).not.toBe(seen[1]);
897
+ // Nothing ever runs on the configured object, so it stays pristine.
898
+ expect(shared.messages).toEqual([]);
899
+ });
900
+ it("agent whose clone() drops subclass state fails loud", async () => {
901
+ const state = new MemoryStore();
902
+ // Inherits `FakeAgent.clone()`, which builds a plain `FakeAgent` and so
903
+ // cannot carry this field — the same shape as a subclass inheriting
904
+ // `AbstractAgent.prototype.clone()`, which copies a fixed field list.
905
+ class StatefulAgent extends FakeAgent {
906
+ authClient = { token: "secret" };
907
+ }
908
+ const agent = new StatefulAgent();
909
+ const fake = new FakeAdapter();
910
+ const channel = createChannel({
911
+ adapters: [fake],
912
+ agent: () => agent,
913
+ store: { adapter: state },
914
+ });
915
+ channel.onMention(async ({ thread }) => {
916
+ await thread.runAgent({ prompt: "hi" });
917
+ });
918
+ await channel.ɵruntime.start();
919
+ const sink = fake.getSink();
920
+ await expect(sink.onTurn({
921
+ conversationKey: "c1",
922
+ replyTarget: {},
923
+ userText: "hi",
924
+ platform: "fake",
925
+ eventId: "E1",
926
+ })).rejects.toThrow(/StatefulAgent\.clone\(\) dropped authClient/);
927
+ });
928
+ it("does not fail loud when clone() drops an instance-patched method", async () => {
929
+ const state = new MemoryStore();
930
+ const agent = new FakeAgent();
931
+ // Spies and instrumentation assign methods on the instance. `FakeAgent.clone()`
932
+ // does not carry them, but the prototype method survives, so the clone still
933
+ // behaves correctly and this must not be treated as dropped state.
934
+ agent.runAgent = async (parameters, subscriber) => FakeAgent.prototype.runAgent.call(agent, parameters, subscriber);
935
+ const fake = new FakeAdapter();
936
+ const channel = createChannel({
937
+ adapters: [fake],
938
+ agent: () => agent,
939
+ store: { adapter: state },
940
+ });
941
+ channel.onMention(async ({ thread }) => {
942
+ await thread.runAgent({ prompt: "hi" });
943
+ });
944
+ await channel.ɵruntime.start();
945
+ await expect(fake.getSink().onTurn({
946
+ conversationKey: "c1",
947
+ replyTarget: {},
948
+ userText: "hi",
949
+ platform: "fake",
950
+ eventId: "E1",
951
+ })).resolves.not.toThrow();
952
+ });
434
953
  it("dedupes turns by eventId", async () => {
435
954
  const state = new MemoryStore();
436
955
  let runs = 0;
@@ -551,19 +1070,23 @@ describe("createChannel", () => {
551
1070
  // have the fake produce an assistant message with text on agent.messages
552
1071
  // (mirroring how run-loop expects assistant replies to land there).
553
1072
  let seenContext;
554
- const origRunAgent = agent.runAgent.bind(agent);
555
- agent.runAgent = async (parameters, subscriber) => {
556
- if (seenContext === undefined) {
557
- seenContext = parameters
558
- ?.context;
559
- }
560
- agent.addMessage({
561
- id: globalThis.crypto.randomUUID(),
562
- role: "assistant",
563
- content: "the assistant reply",
564
- });
565
- return origRunAgent(parameters, subscriber);
566
- };
1073
+ patchAgentAndClones(agent, (target) => {
1074
+ const origRunAgent = target.runAgent.bind(target);
1075
+ target.runAgent = async (parameters, subscriber) => {
1076
+ if (seenContext === undefined) {
1077
+ seenContext = parameters
1078
+ ?.context;
1079
+ }
1080
+ // Add to the instance that is running, so the reply lands on the
1081
+ // messages the run loop reads back.
1082
+ target.addMessage({
1083
+ id: globalThis.crypto.randomUUID(),
1084
+ role: "assistant",
1085
+ content: "the assistant reply",
1086
+ });
1087
+ return origRunAgent(parameters, subscriber);
1088
+ };
1089
+ });
567
1090
  channel.onMention(async ({ thread }) => {
568
1091
  await thread.runAgent({ transcript: true });
569
1092
  });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A provider delivery has already reached a terminal outcome.
3
+ *
4
+ * Tool handlers must not convert this error into model-visible tool output:
5
+ * continuing the agent would let later events render through a closed delivery.
6
+ */
7
+ declare const CHANNEL_DELIVERY_TERMINATED: unique symbol;
8
+ export declare class ChannelDeliveryTerminatedError extends Error {
9
+ readonly [CHANNEL_DELIVERY_TERMINATED] = true;
10
+ constructor(message: string, options?: ErrorOptions);
11
+ }
12
+ /**
13
+ * Recognize terminal delivery errors across duplicated package installations.
14
+ */
15
+ export declare function isChannelDeliveryTerminatedError(error: unknown): error is ChannelDeliveryTerminatedError;
16
+ export {};
17
+ //# sourceMappingURL=delivery-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery-error.d.ts","sourceRoot":"","sources":["../src/delivery-error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,QAAA,MAAM,2BAA2B,eAEhC,CAAC;AAEF,qBAAa,8BAA+B,SAAQ,KAAK;IACvD,QAAQ,CAAC,CAAC,2BAA2B,CAAC,QAAQ;gBAElC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAIpD;AAED;;GAEG;AACH,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,8BAA8B,CAQzC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A provider delivery has already reached a terminal outcome.
3
+ *
4
+ * Tool handlers must not convert this error into model-visible tool output:
5
+ * continuing the agent would let later events render through a closed delivery.
6
+ */
7
+ const CHANNEL_DELIVERY_TERMINATED = Symbol.for("copilotkit.channels.deliveryTerminated");
8
+ export class ChannelDeliveryTerminatedError extends Error {
9
+ [CHANNEL_DELIVERY_TERMINATED] = true;
10
+ constructor(message, options) {
11
+ super(message, options);
12
+ this.name = "ChannelDeliveryTerminatedError";
13
+ }
14
+ }
15
+ /**
16
+ * Recognize terminal delivery errors across duplicated package installations.
17
+ */
18
+ export function isChannelDeliveryTerminatedError(error) {
19
+ return (typeof error === "object" &&
20
+ error !== null &&
21
+ error[CHANNEL_DELIVERY_TERMINATED] === true);
22
+ }