@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.
- package/README.md +7 -8
- package/dist/canonical-run-loop.test.d.ts +2 -0
- package/dist/canonical-run-loop.test.d.ts.map +1 -0
- package/dist/canonical-run-loop.test.js +453 -0
- package/dist/codec.d.ts +2 -3
- package/dist/codec.d.ts.map +1 -1
- package/dist/create-channel.d.ts +110 -4
- package/dist/create-channel.d.ts.map +1 -1
- package/dist/create-channel.js +258 -92
- package/dist/create-channel.test.js +552 -29
- package/dist/delivery-error.d.ts +17 -0
- package/dist/delivery-error.d.ts.map +1 -0
- package/dist/delivery-error.js +22 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/managed-v1-await-choice-guard.test.d.ts +2 -0
- package/dist/managed-v1-await-choice-guard.test.d.ts.map +1 -0
- package/dist/managed-v1-await-choice-guard.test.js +52 -0
- package/dist/platform-adapter.d.ts +74 -11
- package/dist/platform-adapter.d.ts.map +1 -1
- package/dist/run-loop.d.ts +25 -6
- package/dist/run-loop.d.ts.map +1 -1
- package/dist/run-loop.js +289 -39
- package/dist/run-loop.test.js +37 -0
- package/dist/sanitize-agent-events.d.ts +24 -0
- package/dist/sanitize-agent-events.d.ts.map +1 -0
- package/dist/sanitize-agent-events.js +88 -0
- package/dist/sanitize-agent-events.test.d.ts +2 -0
- package/dist/sanitize-agent-events.test.d.ts.map +1 -0
- package/dist/sanitize-agent-events.test.js +194 -0
- package/dist/source-platform.test.d.ts +2 -0
- package/dist/source-platform.test.d.ts.map +1 -0
- package/dist/source-platform.test.js +149 -0
- package/dist/testing/fake-adapter.d.ts +8 -1
- package/dist/testing/fake-adapter.d.ts.map +1 -1
- package/dist/testing/fake-adapter.js +30 -1
- package/dist/testing/fake-agent.d.ts +5 -0
- package/dist/testing/fake-agent.d.ts.map +1 -1
- package/dist/testing/fake-agent.js +12 -0
- package/dist/thread-promise-contract.test.d.ts +2 -0
- package/dist/thread-promise-contract.test.d.ts.map +1 -0
- package/dist/thread-promise-contract.test.js +37 -0
- package/dist/thread.d.ts +5 -0
- package/dist/thread.d.ts.map +1 -1
- package/dist/thread.js +338 -243
- package/package.json +4 -4
|
@@ -52,17 +52,147 @@ function collectText(nodes) {
|
|
|
52
52
|
}
|
|
53
53
|
return out;
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
seenContext
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
seenContext
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|