@copilotkit/channels 0.1.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +204 -0
  3. package/dist/action-registry.d.ts +59 -0
  4. package/dist/action-registry.d.ts.map +1 -0
  5. package/dist/action-registry.js +217 -0
  6. package/dist/action-registry.test.d.ts +2 -0
  7. package/dist/action-registry.test.d.ts.map +1 -0
  8. package/dist/action-registry.test.js +115 -0
  9. package/dist/action-store.d.ts +21 -0
  10. package/dist/action-store.d.ts.map +1 -0
  11. package/dist/action-store.js +23 -0
  12. package/dist/action-store.test.d.ts +2 -0
  13. package/dist/action-store.test.d.ts.map +1 -0
  14. package/dist/action-store.test.js +27 -0
  15. package/dist/codec.d.ts +25 -0
  16. package/dist/codec.d.ts.map +1 -0
  17. package/dist/codec.js +1 -0
  18. package/dist/commands.d.ts +73 -0
  19. package/dist/commands.d.ts.map +1 -0
  20. package/dist/commands.js +38 -0
  21. package/dist/commands.test.d.ts +2 -0
  22. package/dist/commands.test.d.ts.map +1 -0
  23. package/dist/commands.test.js +23 -0
  24. package/dist/create-bot.d.ts +171 -0
  25. package/dist/create-bot.d.ts.map +1 -0
  26. package/dist/create-bot.foundations.test.d.ts +2 -0
  27. package/dist/create-bot.foundations.test.d.ts.map +1 -0
  28. package/dist/create-bot.foundations.test.js +147 -0
  29. package/dist/create-bot.js +576 -0
  30. package/dist/create-bot.test.d.ts +2 -0
  31. package/dist/create-bot.test.d.ts.map +1 -0
  32. package/dist/create-bot.test.js +936 -0
  33. package/dist/index.d.ts +26 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +32 -0
  36. package/dist/mint-id.d.ts +3 -0
  37. package/dist/mint-id.d.ts.map +1 -0
  38. package/dist/mint-id.js +20 -0
  39. package/dist/mint-id.test.d.ts +2 -0
  40. package/dist/mint-id.test.d.ts.map +1 -0
  41. package/dist/mint-id.test.js +16 -0
  42. package/dist/modal-submit.test.d.ts +2 -0
  43. package/dist/modal-submit.test.d.ts.map +1 -0
  44. package/dist/modal-submit.test.js +68 -0
  45. package/dist/platform-adapter.d.ts +312 -0
  46. package/dist/platform-adapter.d.ts.map +1 -0
  47. package/dist/platform-adapter.js +1 -0
  48. package/dist/platform-adapter.test.d.ts +2 -0
  49. package/dist/platform-adapter.test.d.ts.map +1 -0
  50. package/dist/platform-adapter.test.js +40 -0
  51. package/dist/reactions.test.d.ts +2 -0
  52. package/dist/reactions.test.d.ts.map +1 -0
  53. package/dist/reactions.test.js +197 -0
  54. package/dist/run-loop.d.ts +41 -0
  55. package/dist/run-loop.d.ts.map +1 -0
  56. package/dist/run-loop.js +102 -0
  57. package/dist/run-loop.test.d.ts +2 -0
  58. package/dist/run-loop.test.d.ts.map +1 -0
  59. package/dist/run-loop.test.js +125 -0
  60. package/dist/standard-schema.d.ts +52 -0
  61. package/dist/standard-schema.d.ts.map +1 -0
  62. package/dist/standard-schema.js +66 -0
  63. package/dist/state/duration.d.ts +3 -0
  64. package/dist/state/duration.d.ts.map +1 -0
  65. package/dist/state/duration.js +16 -0
  66. package/dist/state/duration.test.d.ts +2 -0
  67. package/dist/state/duration.test.d.ts.map +1 -0
  68. package/dist/state/duration.test.js +13 -0
  69. package/dist/state/kv-action-store.d.ts +7 -0
  70. package/dist/state/kv-action-store.d.ts.map +1 -0
  71. package/dist/state/kv-action-store.js +9 -0
  72. package/dist/state/kv-action-store.test.d.ts +2 -0
  73. package/dist/state/kv-action-store.test.d.ts.map +1 -0
  74. package/dist/state/kv-action-store.test.js +21 -0
  75. package/dist/state/memory-store.d.ts +41 -0
  76. package/dist/state/memory-store.d.ts.map +1 -0
  77. package/dist/state/memory-store.js +110 -0
  78. package/dist/state/memory-store.test.d.ts +2 -0
  79. package/dist/state/memory-store.test.d.ts.map +1 -0
  80. package/dist/state/memory-store.test.js +22 -0
  81. package/dist/state/state-conversation-store.d.ts +14 -0
  82. package/dist/state/state-conversation-store.d.ts.map +1 -0
  83. package/dist/state/state-conversation-store.js +22 -0
  84. package/dist/state/state-conversation-store.test.d.ts +2 -0
  85. package/dist/state/state-conversation-store.test.d.ts.map +1 -0
  86. package/dist/state/state-conversation-store.test.js +58 -0
  87. package/dist/state/state-store.d.ts +63 -0
  88. package/dist/state/state-store.d.ts.map +1 -0
  89. package/dist/state/state-store.js +1 -0
  90. package/dist/telemetry/bot-telemetry.d.ts +31 -0
  91. package/dist/telemetry/bot-telemetry.d.ts.map +1 -0
  92. package/dist/telemetry/bot-telemetry.js +70 -0
  93. package/dist/telemetry/bot-telemetry.test.d.ts +2 -0
  94. package/dist/telemetry/bot-telemetry.test.d.ts.map +1 -0
  95. package/dist/telemetry/bot-telemetry.test.js +56 -0
  96. package/dist/telemetry/create-bot-telemetry.test.d.ts +2 -0
  97. package/dist/telemetry/create-bot-telemetry.test.d.ts.map +1 -0
  98. package/dist/telemetry/create-bot-telemetry.test.js +71 -0
  99. package/dist/telemetry/e2e-telemetry.test.d.ts +2 -0
  100. package/dist/telemetry/e2e-telemetry.test.d.ts.map +1 -0
  101. package/dist/telemetry/e2e-telemetry.test.js +56 -0
  102. package/dist/telemetry/events-catalog.test.d.ts +2 -0
  103. package/dist/telemetry/events-catalog.test.d.ts.map +1 -0
  104. package/dist/telemetry/events-catalog.test.js +8 -0
  105. package/dist/telemetry/install-id.d.ts +8 -0
  106. package/dist/telemetry/install-id.d.ts.map +1 -0
  107. package/dist/telemetry/install-id.js +43 -0
  108. package/dist/telemetry/install-id.test.d.ts +2 -0
  109. package/dist/telemetry/install-id.test.d.ts.map +1 -0
  110. package/dist/telemetry/install-id.test.js +43 -0
  111. package/dist/telemetry/platform-allowlist-coverage.test.d.ts +2 -0
  112. package/dist/telemetry/platform-allowlist-coverage.test.d.ts.map +1 -0
  113. package/dist/telemetry/platform-allowlist-coverage.test.js +45 -0
  114. package/dist/telemetry/sanitize-error.d.ts +3 -0
  115. package/dist/telemetry/sanitize-error.d.ts.map +1 -0
  116. package/dist/telemetry/sanitize-error.js +31 -0
  117. package/dist/telemetry/sanitize-error.test.d.ts +2 -0
  118. package/dist/telemetry/sanitize-error.test.d.ts.map +1 -0
  119. package/dist/telemetry/sanitize-error.test.js +33 -0
  120. package/dist/testing/fake-adapter.d.ts +137 -0
  121. package/dist/testing/fake-adapter.d.ts.map +1 -0
  122. package/dist/testing/fake-adapter.js +273 -0
  123. package/dist/testing/fake-agent.d.ts +28 -0
  124. package/dist/testing/fake-agent.d.ts.map +1 -0
  125. package/dist/testing/fake-agent.js +36 -0
  126. package/dist/testing/state-store-conformance.d.ts +4 -0
  127. package/dist/testing/state-store-conformance.d.ts.map +1 -0
  128. package/dist/testing/state-store-conformance.js +117 -0
  129. package/dist/thread-capabilities.test.d.ts +2 -0
  130. package/dist/thread-capabilities.test.d.ts.map +1 -0
  131. package/dist/thread-capabilities.test.js +77 -0
  132. package/dist/thread-ephemeral.test.d.ts +2 -0
  133. package/dist/thread-ephemeral.test.d.ts.map +1 -0
  134. package/dist/thread-ephemeral.test.js +45 -0
  135. package/dist/thread-reactions.test.d.ts +2 -0
  136. package/dist/thread-reactions.test.d.ts.map +1 -0
  137. package/dist/thread-reactions.test.js +38 -0
  138. package/dist/thread.d.ts +152 -0
  139. package/dist/thread.d.ts.map +1 -0
  140. package/dist/thread.js +329 -0
  141. package/dist/thread.test.d.ts +2 -0
  142. package/dist/thread.test.d.ts.map +1 -0
  143. package/dist/thread.test.js +83 -0
  144. package/dist/tools.d.ts +67 -0
  145. package/dist/tools.d.ts.map +1 -0
  146. package/dist/tools.js +38 -0
  147. package/dist/tools.test.d.ts +2 -0
  148. package/dist/tools.test.d.ts.map +1 -0
  149. package/dist/tools.test.js +31 -0
  150. package/dist/transcripts.d.ts +52 -0
  151. package/dist/transcripts.d.ts.map +1 -0
  152. package/dist/transcripts.js +67 -0
  153. package/dist/transcripts.test.d.ts +2 -0
  154. package/dist/transcripts.test.d.ts.map +1 -0
  155. package/dist/transcripts.test.js +108 -0
  156. package/package.json +72 -0
@@ -0,0 +1,936 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { z } from "zod";
3
+ import { createBot } from "./create-bot.js";
4
+ import { defineBotCommand } from "./commands.js";
5
+ import { FakeAdapter } from "./testing/fake-adapter.js";
6
+ import { FakeAgent } from "./testing/fake-agent.js";
7
+ import { MemoryStore } from "./state/memory-store.js";
8
+ import { Section, Actions, Button } from "@copilotkit/channels-ui";
9
+ const tick = () => new Promise((r) => setTimeout(r, 0));
10
+ /**
11
+ * Compile-time guards for the handler generics (validated by check-types/build,
12
+ * never executed). `onInterrupt<T>` types `payload`; `onInteraction<T>` types
13
+ * `ctx.action.value`.
14
+ */
15
+ const __handlerTypeGuards = () => {
16
+ const bot = createBot({ adapters: [new FakeAdapter()] });
17
+ bot.onInterrupt("ask", ({ payload }) => {
18
+ payload.question.toUpperCase();
19
+ // @ts-expect-error 'missing' is not on the payload type
20
+ payload.missing;
21
+ });
22
+ bot.onInteraction("next", (ctx) => {
23
+ ctx.action.value?.page.toFixed(0);
24
+ // @ts-expect-error 'nope' is not on the action value type
25
+ ctx.action.value?.nope;
26
+ });
27
+ };
28
+ void __handlerTypeGuards;
29
+ /** Recursively find the first node of a given type in an IR tree. */
30
+ function findNode(nodes, type) {
31
+ for (const n of nodes) {
32
+ if (n.type === type)
33
+ return n;
34
+ const children = n.props.children;
35
+ if (Array.isArray(children)) {
36
+ const found = findNode(children, type);
37
+ if (found)
38
+ return found;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ /** Concatenate all text node values in an IR tree. */
44
+ function collectText(nodes) {
45
+ let out = "";
46
+ for (const n of nodes) {
47
+ if (n.type === "text" && typeof n.props.value === "string")
48
+ out += n.props.value;
49
+ const children = n.props.children;
50
+ if (Array.isArray(children))
51
+ out += collectText(children);
52
+ }
53
+ return out;
54
+ }
55
+ describe("createBot", () => {
56
+ it("routes a mention to a handler that posts UI", async () => {
57
+ const fake = new FakeAdapter();
58
+ const agent = new FakeAgent();
59
+ const bot = createBot({ adapters: [fake], agent: () => agent });
60
+ bot.onMention(async ({ thread }) => {
61
+ await thread.post(Section({ children: "hi" }));
62
+ });
63
+ await bot.start();
64
+ fake.emitTurn({ userText: "yo", conversationKey: "c1" });
65
+ await tick();
66
+ expect(fake.posted.length).toBe(1);
67
+ const ir = fake.posted[0];
68
+ expect(findNode(ir, "section")).toBeDefined();
69
+ expect(collectText(ir)).toBe("hi");
70
+ });
71
+ it("calls renderer.finish() once after a turn's run-loop resolves", async () => {
72
+ const fake = new FakeAdapter();
73
+ const agent = new FakeAgent();
74
+ const bot = createBot({ adapters: [fake], agent: () => agent });
75
+ bot.onMention(async ({ thread }) => {
76
+ await thread.runAgent();
77
+ });
78
+ await bot.start();
79
+ fake.emitTurn({ userText: "yo", conversationKey: "c1" });
80
+ await tick();
81
+ const renderer = fake.lastRunRenderer;
82
+ expect(renderer.finishCalls).toBe(1);
83
+ });
84
+ it("delivers a turn's contentParts as the runAgent prompt to agent.addMessage", async () => {
85
+ const fake = new FakeAdapter();
86
+ const agent = new FakeAgent();
87
+ // Capture what the framework injects as the user message.
88
+ const added = [];
89
+ const origAddMessage = agent.addMessage.bind(agent);
90
+ agent.addMessage = (m) => {
91
+ added.push(m);
92
+ return origAddMessage(m);
93
+ };
94
+ const bot = createBot({ adapters: [fake], agent: () => agent });
95
+ const parts = [
96
+ { type: "text", text: "look" },
97
+ {
98
+ type: "image",
99
+ source: { type: "data", value: "QUJD", mimeType: "image/png" },
100
+ },
101
+ ];
102
+ bot.onMention(async ({ thread, message }) => {
103
+ // The example mirrors this: prefer multimodal parts over plain text.
104
+ await thread.runAgent({
105
+ prompt: message.contentParts && message.contentParts.length > 0
106
+ ? message.contentParts
107
+ : message.text,
108
+ });
109
+ });
110
+ await bot.start();
111
+ fake.emitTurn({
112
+ userText: "look",
113
+ conversationKey: "c1",
114
+ contentParts: parts,
115
+ });
116
+ await tick();
117
+ expect(added).toHaveLength(1);
118
+ const msg = added[0];
119
+ expect(msg.role).toBe("user");
120
+ // The multimodal parts array survives the string-typed `content` cast.
121
+ expect(msg.content).toEqual(parts);
122
+ });
123
+ it("dispatches a bound onClick handler on interaction", async () => {
124
+ const fake = new FakeAdapter();
125
+ const agent = new FakeAgent();
126
+ const bot = createBot({ adapters: [fake], agent: () => agent });
127
+ let clicked = false;
128
+ bot.onMention(async ({ thread }) => {
129
+ await thread.post(Actions({
130
+ children: [
131
+ Button({
132
+ value: { ok: 1 },
133
+ onClick: () => {
134
+ clicked = true;
135
+ },
136
+ children: "Go",
137
+ }),
138
+ ],
139
+ }));
140
+ });
141
+ await bot.start();
142
+ fake.emitTurn({ userText: "yo", conversationKey: "c1" });
143
+ await tick();
144
+ const button = findNode(fake.posted[0], "button");
145
+ const id = button.props.onClick.id;
146
+ expect(typeof id).toBe("string");
147
+ fake.emitInteraction({ id, conversationKey: "c1", value: { ok: 1 } });
148
+ await tick();
149
+ expect(clicked).toBe(true);
150
+ });
151
+ it("resolves a HITL awaitChoice with the element value when the event carries none (Telegram)", async () => {
152
+ const fake = new FakeAdapter();
153
+ const agent = new FakeAgent();
154
+ const bot = createBot({ adapters: [fake], agent: () => agent });
155
+ let chosen;
156
+ bot.onMention(async ({ thread }) => {
157
+ chosen = await thread.awaitChoice(Actions({
158
+ children: [
159
+ Button({
160
+ value: { confirmed: true },
161
+ onClick: () => { },
162
+ children: "Create",
163
+ }),
164
+ ],
165
+ }));
166
+ });
167
+ await bot.start();
168
+ fake.emitTurn({ userText: "create a thing", conversationKey: "c1" });
169
+ await tick();
170
+ const button = findNode(fake.posted[0], "button");
171
+ const id = button.props.onClick.id;
172
+ // Telegram can't carry the button value in callback_data, so the event has
173
+ // no `value`. The waiter must still resolve with the button's value, which
174
+ // the registry recovers from the rendered element.
175
+ fake.emitInteraction({ id, conversationKey: "c1" });
176
+ await tick();
177
+ expect(chosen).toEqual({ confirmed: true });
178
+ });
179
+ it("merges per-turn runAgent context with the bot-level context", async () => {
180
+ const fake = new FakeAdapter();
181
+ const agent = new FakeAgent();
182
+ // Capture the context/tools passed to the agent's first runAgent call.
183
+ let seenContext;
184
+ let seenTools;
185
+ const origRunAgent = agent.runAgent.bind(agent);
186
+ agent.runAgent = async (parameters, subscriber) => {
187
+ if (seenContext === undefined) {
188
+ seenContext = parameters
189
+ ?.context;
190
+ seenTools = parameters?.tools;
191
+ }
192
+ return origRunAgent(parameters, subscriber);
193
+ };
194
+ const bot = createBot({
195
+ adapters: [fake],
196
+ agent: () => agent,
197
+ context: [{ description: "bot-level", value: "always here" }],
198
+ });
199
+ bot.onMention(async ({ thread }) => {
200
+ await thread.runAgent({
201
+ context: [{ description: "who", value: "user U1" }],
202
+ });
203
+ });
204
+ await bot.start();
205
+ fake.emitTurn({ userText: "go", conversationKey: "c1" });
206
+ await tick();
207
+ expect(seenContext).toEqual([
208
+ { description: "bot-level", value: "always here" },
209
+ { description: "who", value: "user U1" },
210
+ ]);
211
+ expect(seenTools).toEqual([]);
212
+ });
213
+ it("thread.postFile returns a capability-gated error when the adapter can't upload", async () => {
214
+ const fake = new FakeAdapter();
215
+ const agent = new FakeAgent();
216
+ const bot = createBot({ adapters: [fake], agent: () => agent });
217
+ let result;
218
+ bot.onMention(async ({ thread }) => {
219
+ result = await thread.postFile({
220
+ bytes: new Uint8Array([1, 2, 3]),
221
+ filename: "x.png",
222
+ });
223
+ });
224
+ await bot.start();
225
+ fake.emitTurn({ userText: "hi", conversationKey: "c1" });
226
+ await tick();
227
+ expect(result).toEqual({
228
+ ok: false,
229
+ error: "fake does not support file upload",
230
+ });
231
+ });
232
+ it("thread.getMessages and thread.lookupUser surface the adapter's data", async () => {
233
+ const fake = new FakeAdapter();
234
+ fake.messages = [
235
+ { user: { id: "u1", name: "Ada" }, text: "hi", ts: "1", isBot: false },
236
+ ];
237
+ fake.user = { id: "u1", name: "Ada" };
238
+ const agent = new FakeAgent();
239
+ const bot = createBot({ adapters: [fake], agent: () => agent });
240
+ let history;
241
+ let resolved;
242
+ bot.onMention(async ({ thread }) => {
243
+ history = await thread.getMessages();
244
+ resolved = await thread.lookupUser("Ada");
245
+ });
246
+ await bot.start();
247
+ fake.emitTurn({ userText: "hi", conversationKey: "c1" });
248
+ await tick();
249
+ expect(history).toEqual([
250
+ { user: { id: "u1", name: "Ada" }, text: "hi", ts: "1", isBot: false },
251
+ ]);
252
+ expect(resolved).toEqual({ id: "u1", name: "Ada" });
253
+ });
254
+ it("resolves awaitChoice when a matching interaction arrives", async () => {
255
+ const fake = new FakeAdapter();
256
+ const agent = new FakeAgent();
257
+ const bot = createBot({ adapters: [fake], agent: () => agent });
258
+ let choicePromise;
259
+ bot.onMention(async ({ thread }) => {
260
+ choicePromise = thread.awaitChoice(Actions({
261
+ children: [
262
+ Button({
263
+ value: { confirmed: true },
264
+ onClick: () => { },
265
+ children: "Confirm",
266
+ }),
267
+ ],
268
+ }));
269
+ });
270
+ await bot.start();
271
+ fake.emitTurn({ userText: "decide", conversationKey: "c1" });
272
+ await tick();
273
+ const button = findNode(fake.posted[0], "button");
274
+ const id = button.props.onClick.id;
275
+ fake.emitInteraction({
276
+ id,
277
+ conversationKey: "c1",
278
+ value: { confirmed: true },
279
+ });
280
+ await tick();
281
+ expect(choicePromise).toBeDefined();
282
+ await expect(choicePromise).resolves.toEqual({ confirmed: true });
283
+ });
284
+ it("drops an overlapping turn on the same conversation (onLockConflict: drop)", async () => {
285
+ const state = new MemoryStore();
286
+ let runs = 0;
287
+ let release;
288
+ const gate = new Promise((r) => (release = r));
289
+ const fake = new FakeAdapter();
290
+ const agent = new FakeAgent();
291
+ const bot = createBot({
292
+ adapters: [fake],
293
+ agent: () => agent,
294
+ store: { adapter: state, onLockConflict: "drop" },
295
+ });
296
+ bot.onMention(async () => {
297
+ runs++;
298
+ await gate;
299
+ });
300
+ await bot.start();
301
+ const sink = fake.getSink();
302
+ const turn = {
303
+ conversationKey: "c1",
304
+ replyTarget: {},
305
+ userText: "hi",
306
+ platform: "fake",
307
+ };
308
+ // Fire two overlapping onTurn calls — second arrives while first holds the lock.
309
+ const p1 = sink.onTurn(turn);
310
+ const p2 = sink.onTurn(turn);
311
+ release();
312
+ await Promise.all([p1, p2]);
313
+ // Only the first turn's handler should have run; the second was dropped.
314
+ expect(runs).toBe(1);
315
+ });
316
+ it("runs both handlers when onLockConflict is force", async () => {
317
+ const state = new MemoryStore();
318
+ let runs = 0;
319
+ let release;
320
+ const gate = new Promise((r) => (release = r));
321
+ const fake = new FakeAdapter();
322
+ const agent = new FakeAgent();
323
+ const bot = createBot({
324
+ adapters: [fake],
325
+ agent: () => agent,
326
+ store: { adapter: state, onLockConflict: "force" },
327
+ });
328
+ bot.onMention(async () => {
329
+ runs++;
330
+ await gate;
331
+ });
332
+ await bot.start();
333
+ const sink = fake.getSink();
334
+ const turn = {
335
+ conversationKey: "c1",
336
+ replyTarget: {},
337
+ userText: "hi",
338
+ platform: "fake",
339
+ };
340
+ // Fire two overlapping onTurn calls — second forces through the lock.
341
+ const p1 = sink.onTurn(turn);
342
+ const p2 = sink.onTurn(turn);
343
+ release();
344
+ await Promise.all([p1, p2]);
345
+ // Both turns' handlers should have run.
346
+ expect(runs).toBe(2);
347
+ });
348
+ it("dedupes turns by eventId", async () => {
349
+ const state = new MemoryStore();
350
+ let runs = 0;
351
+ const fake = new FakeAdapter();
352
+ const agent = new FakeAgent();
353
+ const bot = createBot({
354
+ adapters: [fake],
355
+ agent: () => agent,
356
+ store: { adapter: state },
357
+ });
358
+ bot.onMention(async () => {
359
+ runs++;
360
+ });
361
+ await bot.start();
362
+ const sink = fake.getSink();
363
+ const base = {
364
+ conversationKey: "c",
365
+ replyTarget: {},
366
+ userText: "x",
367
+ platform: "fake",
368
+ eventId: "E1",
369
+ };
370
+ // Same eventId delivered twice → handler runs only once.
371
+ await sink.onTurn(base);
372
+ await sink.onTurn({ ...base });
373
+ expect(runs).toBe(1);
374
+ // Different eventId → handler runs again.
375
+ await sink.onTurn({ ...base, eventId: "E2" });
376
+ expect(runs).toBe(2);
377
+ });
378
+ it("throws when identity is set without transcripts", () => {
379
+ const fake = new FakeAdapter();
380
+ expect(() => createBot({
381
+ adapters: [fake],
382
+ store: { identity: () => "key" },
383
+ })).toThrow("createBot: `identity` and `transcripts` must be configured together.");
384
+ });
385
+ it("throws when transcripts is set without identity", () => {
386
+ const fake = new FakeAdapter();
387
+ expect(() => createBot({
388
+ adapters: [fake],
389
+ store: { transcripts: { maxPerUser: 100 } },
390
+ })).toThrow("createBot: `identity` and `transcripts` must be configured together.");
391
+ });
392
+ it("stamps message.userKey when identity resolves a key", async () => {
393
+ const state = new MemoryStore();
394
+ const fake = new FakeAdapter();
395
+ const agent = new FakeAgent();
396
+ const bot = createBot({
397
+ adapters: [fake],
398
+ agent: () => agent,
399
+ store: {
400
+ adapter: state,
401
+ identity: () => "user@example.com",
402
+ transcripts: {},
403
+ },
404
+ });
405
+ let capturedKey;
406
+ bot.onMention(async ({ message }) => {
407
+ capturedKey = message.userKey;
408
+ });
409
+ await bot.start();
410
+ const sink = fake.getSink();
411
+ await sink.onTurn({
412
+ conversationKey: "c1",
413
+ replyTarget: {},
414
+ userText: "hello",
415
+ platform: "fake",
416
+ user: { id: "u1" },
417
+ });
418
+ expect(capturedKey).toBe("user@example.com");
419
+ });
420
+ it("bot.transcripts.append/list round-trips with MemoryStore", async () => {
421
+ const state = new MemoryStore();
422
+ const fake = new FakeAdapter();
423
+ const agent = new FakeAgent();
424
+ const bot = createBot({
425
+ adapters: [fake],
426
+ agent: () => agent,
427
+ store: {
428
+ adapter: state,
429
+ identity: () => "alice@example.com",
430
+ transcripts: { maxPerUser: 50 },
431
+ },
432
+ });
433
+ await bot.start();
434
+ const sink = fake.getSink();
435
+ // Drive a turn so identity is resolved and we can verify transcripts exist
436
+ const thread = { platform: "fake", conversationKey: "c1" };
437
+ // Directly append via bot.transcripts
438
+ await bot.transcripts.append(thread, { role: "user", text: "hi there" }, {
439
+ userKey: "alice@example.com",
440
+ });
441
+ await bot.transcripts.append(thread, { role: "assistant", text: "hello!" }, { userKey: "alice@example.com" });
442
+ const entries = await bot.transcripts.list({
443
+ userKey: "alice@example.com",
444
+ });
445
+ expect(entries).toHaveLength(2);
446
+ expect(entries[0].role).toBe("user");
447
+ expect(entries[0].text).toBe("hi there");
448
+ expect(entries[1].role).toBe("assistant");
449
+ expect(entries[1].text).toBe("hello!");
450
+ });
451
+ it("runAgent({ transcript: true }) injects prior history and captures the reply", async () => {
452
+ const state = new MemoryStore();
453
+ const fake = new FakeAdapter();
454
+ const agent = new FakeAgent();
455
+ const bot = createBot({
456
+ adapters: [fake],
457
+ agent: () => agent,
458
+ store: {
459
+ adapter: state,
460
+ identity: () => "u@x.com",
461
+ transcripts: {},
462
+ },
463
+ });
464
+ // Capture the context the agent receives on its first runAgent call, and
465
+ // have the fake produce an assistant message with text on agent.messages
466
+ // (mirroring how run-loop expects assistant replies to land there).
467
+ let seenContext;
468
+ const origRunAgent = agent.runAgent.bind(agent);
469
+ agent.runAgent = async (parameters, subscriber) => {
470
+ if (seenContext === undefined) {
471
+ seenContext = parameters
472
+ ?.context;
473
+ }
474
+ agent.addMessage({
475
+ id: globalThis.crypto.randomUUID(),
476
+ role: "assistant",
477
+ content: "the assistant reply",
478
+ });
479
+ return origRunAgent(parameters, subscriber);
480
+ };
481
+ bot.onMention(async ({ thread }) => {
482
+ await thread.runAgent({ transcript: true });
483
+ });
484
+ await bot.start();
485
+ // Seed one prior cross-platform entry (different platform label) so we can
486
+ // assert it shows up in the injected context. Seeded post-start: transcripts
487
+ // are only available once the backend is resolved in start().
488
+ await bot.transcripts.append({ platform: "discord", conversationKey: "other" }, { role: "user", text: "remembered from discord" }, { userKey: "u@x.com" });
489
+ const sink = fake.getSink();
490
+ await sink.onTurn({
491
+ conversationKey: "c1",
492
+ replyTarget: {},
493
+ userText: "hello from fake",
494
+ platform: "fake",
495
+ user: { id: "u1" },
496
+ });
497
+ // Append side: both the user turn and the assistant reply are recorded,
498
+ // oldest-first (after the seeded discord entry).
499
+ const entries = await bot.transcripts.list({ userKey: "u@x.com" });
500
+ const fakeEntries = entries.filter((e) => e.platform === "fake");
501
+ expect(fakeEntries).toHaveLength(2);
502
+ expect(fakeEntries[0].role).toBe("user");
503
+ expect(fakeEntries[0].text).toBe("hello from fake");
504
+ expect(fakeEntries[1].role).toBe("assistant");
505
+ expect(fakeEntries[1].text).toBe("the assistant reply");
506
+ // Injection side: the agent's context includes an entry whose value carries
507
+ // the prior discord message text.
508
+ const ctx = seenContext;
509
+ const injected = ctx.find((c) => c.value.includes("remembered from discord"));
510
+ expect(injected).toBeDefined();
511
+ expect(injected.value).toContain("[discord] user: remembered from discord");
512
+ });
513
+ it("typesafe state: setState validates against store.state schema and round-trips", async () => {
514
+ const fake = new FakeAdapter();
515
+ const agent = new FakeAgent();
516
+ const bot = createBot({
517
+ adapters: [fake],
518
+ agent: () => agent,
519
+ store: {
520
+ adapter: new MemoryStore(),
521
+ state: z.object({ step: z.string() }),
522
+ },
523
+ });
524
+ let roundTripped;
525
+ let rejected = false;
526
+ bot.onMention(async ({ thread }) => {
527
+ // Typed to { step: string } via the configured schema.
528
+ await thread.setState({ step: "x" });
529
+ roundTripped = await thread.state();
530
+ // An invalid value (wrong shape) must reject at runtime.
531
+ try {
532
+ await thread.setState({ bad: 1 });
533
+ }
534
+ catch {
535
+ rejected = true;
536
+ }
537
+ });
538
+ await bot.start();
539
+ fake.emitTurn({ userText: "go", conversationKey: "c1" });
540
+ await tick();
541
+ expect(roundTripped).toEqual({ step: "x" });
542
+ expect(rejected).toBe(true);
543
+ });
544
+ });
545
+ describe("createBot lock and dedup edge cases", () => {
546
+ it("releases the lock after the handler throws", async () => {
547
+ const state = new MemoryStore();
548
+ let release;
549
+ const gate = new Promise((r) => (release = r));
550
+ const fake = new FakeAdapter();
551
+ // Use a separate bot so the throwing handler is isolated.
552
+ const bot1 = createBot({
553
+ adapters: [fake],
554
+ store: { adapter: state, onLockConflict: "drop", lockTtl: 5000 },
555
+ });
556
+ let runs = 0;
557
+ bot1.onMention(async () => {
558
+ runs++;
559
+ await gate;
560
+ throw new Error("boom");
561
+ });
562
+ await bot1.start();
563
+ const sink = fake.getSink();
564
+ const turn = {
565
+ conversationKey: "c1",
566
+ replyTarget: {},
567
+ userText: "hi",
568
+ platform: "fake",
569
+ };
570
+ // Turn 1 throws after gate releases.
571
+ const p1 = Promise.resolve(sink.onTurn(turn)).catch(() => { });
572
+ release();
573
+ await p1;
574
+ // Lock must be free — verify by acquiring it directly.
575
+ const tok = await state.lock.acquire("turn:c1");
576
+ expect(tok).not.toBeNull();
577
+ if (tok)
578
+ await state.lock.release("turn:c1", tok.token);
579
+ expect(runs).toBe(1);
580
+ });
581
+ it("onLockConflict callback drop: second turn dropped and callback receives correct args", async () => {
582
+ const state = new MemoryStore();
583
+ let runs = 0;
584
+ let release;
585
+ const gate = new Promise((r) => (release = r));
586
+ let callbackConversationKey;
587
+ let callbackMessageText;
588
+ const fake = new FakeAdapter();
589
+ const bot = createBot({
590
+ adapters: [fake],
591
+ store: {
592
+ adapter: state,
593
+ onLockConflict: (conversationKey, message) => {
594
+ callbackConversationKey = conversationKey;
595
+ callbackMessageText = message.text;
596
+ return "drop";
597
+ },
598
+ },
599
+ });
600
+ bot.onMention(async () => {
601
+ runs++;
602
+ await gate;
603
+ });
604
+ await bot.start();
605
+ const sink = fake.getSink();
606
+ const turn = {
607
+ conversationKey: "c1",
608
+ replyTarget: {},
609
+ userText: "hello",
610
+ platform: "fake",
611
+ };
612
+ const p1 = sink.onTurn(turn);
613
+ const p2 = sink.onTurn(turn);
614
+ release();
615
+ await Promise.all([p1, p2]);
616
+ expect(runs).toBe(1);
617
+ expect(callbackConversationKey).toBe("c1");
618
+ expect(callbackMessageText).toBe("hello");
619
+ });
620
+ it("onLockConflict callback returning Promise<force>: both turns run", async () => {
621
+ const state = new MemoryStore();
622
+ let runs = 0;
623
+ let release;
624
+ const gate = new Promise((r) => (release = r));
625
+ const fake = new FakeAdapter();
626
+ const bot = createBot({
627
+ adapters: [fake],
628
+ store: {
629
+ adapter: state,
630
+ onLockConflict: () => Promise.resolve("force"),
631
+ },
632
+ });
633
+ bot.onMention(async () => {
634
+ runs++;
635
+ await gate;
636
+ });
637
+ await bot.start();
638
+ const sink = fake.getSink();
639
+ const turn = {
640
+ conversationKey: "c1",
641
+ replyTarget: {},
642
+ userText: "hi",
643
+ platform: "fake",
644
+ };
645
+ const p1 = sink.onTurn(turn);
646
+ const p2 = sink.onTurn(turn);
647
+ release();
648
+ await Promise.all([p1, p2]);
649
+ expect(runs).toBe(2);
650
+ });
651
+ it("identity throws: handler still runs and userKey is undefined", async () => {
652
+ const state = new MemoryStore();
653
+ const fake = new FakeAdapter();
654
+ const bot = createBot({
655
+ adapters: [fake],
656
+ store: {
657
+ adapter: state,
658
+ identity: () => {
659
+ throw new Error("x");
660
+ },
661
+ transcripts: {},
662
+ },
663
+ });
664
+ let capturedUserKey = "SENTINEL";
665
+ bot.onMention(async ({ message }) => {
666
+ capturedUserKey = message.userKey;
667
+ });
668
+ await bot.start();
669
+ const sink = fake.getSink();
670
+ await sink.onTurn({
671
+ conversationKey: "c1",
672
+ replyTarget: {},
673
+ userText: "hi",
674
+ platform: "fake",
675
+ });
676
+ expect(capturedUserKey).toBeUndefined();
677
+ });
678
+ it("identity returns null: userKey is undefined", async () => {
679
+ const state = new MemoryStore();
680
+ const fake = new FakeAdapter();
681
+ const bot = createBot({
682
+ adapters: [fake],
683
+ store: { adapter: state, identity: () => null, transcripts: {} },
684
+ });
685
+ let capturedUserKey = "SENTINEL";
686
+ bot.onMention(async ({ message }) => {
687
+ capturedUserKey = message.userKey;
688
+ });
689
+ await bot.start();
690
+ const sink = fake.getSink();
691
+ await sink.onTurn({
692
+ conversationKey: "c1",
693
+ replyTarget: {},
694
+ userText: "hi",
695
+ platform: "fake",
696
+ });
697
+ expect(capturedUserKey).toBeUndefined();
698
+ });
699
+ it("dedup+lock ordering: deduped turn never takes the lock", async () => {
700
+ const state = new MemoryStore();
701
+ let runs = 0;
702
+ const fake = new FakeAdapter();
703
+ const bot = createBot({
704
+ adapters: [fake],
705
+ store: { adapter: state, onLockConflict: "drop" },
706
+ });
707
+ bot.onMention(async () => {
708
+ runs++;
709
+ });
710
+ await bot.start();
711
+ const sink = fake.getSink();
712
+ const base = {
713
+ conversationKey: "c1",
714
+ replyTarget: {},
715
+ userText: "x",
716
+ platform: "fake",
717
+ eventId: "E1",
718
+ };
719
+ await sink.onTurn(base);
720
+ await sink.onTurn({ ...base }); // same eventId — should be deduped before lock
721
+ expect(runs).toBe(1);
722
+ // Lock must still be acquirable (deduped turn never held it).
723
+ const tok = await state.lock.acquire("turn:c1");
724
+ expect(tok).not.toBeNull();
725
+ if (tok)
726
+ await state.lock.release("turn:c1", tok.token);
727
+ });
728
+ it("dedup store error is swallowed: handler still runs", async () => {
729
+ const state = new MemoryStore();
730
+ // Wrap state.dedup.seen to throw.
731
+ const origSeen = state.dedup.seen.bind(state.dedup);
732
+ let firstCall = true;
733
+ state.dedup.seen = async (key, ttlMs) => {
734
+ if (firstCall) {
735
+ firstCall = false;
736
+ throw new Error("dedup store exploded");
737
+ }
738
+ return origSeen(key, ttlMs);
739
+ };
740
+ let runs = 0;
741
+ const fake = new FakeAdapter();
742
+ const bot = createBot({ adapters: [fake], store: { adapter: state } });
743
+ bot.onMention(async () => {
744
+ runs++;
745
+ });
746
+ await bot.start();
747
+ const sink = fake.getSink();
748
+ await sink.onTurn({
749
+ conversationKey: "c1",
750
+ replyTarget: {},
751
+ userText: "hi",
752
+ platform: "fake",
753
+ eventId: "E1",
754
+ });
755
+ // Handler must still run despite the dedup store error.
756
+ expect(runs).toBe(1);
757
+ });
758
+ it("a turn dropped on lock-conflict does not burn its eventId — a redelivery is processed", async () => {
759
+ const state = new MemoryStore();
760
+ let runs = 0;
761
+ let releaseGate;
762
+ const gate = new Promise((r) => (releaseGate = r));
763
+ const fake = new FakeAdapter();
764
+ const bot = createBot({
765
+ adapters: [fake],
766
+ store: { adapter: state, onLockConflict: "drop" },
767
+ });
768
+ bot.onMention(async () => {
769
+ runs++;
770
+ await gate;
771
+ });
772
+ await bot.start();
773
+ const sink = fake.getSink();
774
+ const turnA = {
775
+ conversationKey: "c1",
776
+ replyTarget: {},
777
+ userText: "first",
778
+ platform: "fake",
779
+ eventId: "E1",
780
+ };
781
+ const turnB = {
782
+ conversationKey: "c1",
783
+ replyTarget: {},
784
+ userText: "second",
785
+ platform: "fake",
786
+ eventId: "E2",
787
+ };
788
+ // Turn A acquires the lock and blocks on the gate.
789
+ const p1 = sink.onTurn(turnA);
790
+ // Turn B arrives while A holds the lock — dropped (onLockConflict: "drop").
791
+ const p2 = sink.onTurn(turnB);
792
+ // B is dropped before reaching dedup, so E2 must NOT be burned.
793
+ await p2;
794
+ // Release A so it finishes and releases the lock.
795
+ releaseGate();
796
+ await p1;
797
+ // Now redeliver turn B with the same eventId "E2" — must be processed.
798
+ await sink.onTurn(turnB);
799
+ // A ran once; B's redelivery ran once = 2 total.
800
+ expect(runs).toBe(2);
801
+ });
802
+ it("a genuine duplicate delivery is still deduped (processed once)", async () => {
803
+ const state = new MemoryStore();
804
+ let runs = 0;
805
+ const fake = new FakeAdapter();
806
+ const bot = createBot({
807
+ adapters: [fake],
808
+ store: { adapter: state },
809
+ });
810
+ bot.onMention(async () => {
811
+ runs++;
812
+ });
813
+ await bot.start();
814
+ const sink = fake.getSink();
815
+ const turn = {
816
+ conversationKey: "c2",
817
+ replyTarget: {},
818
+ userText: "hello",
819
+ platform: "fake",
820
+ eventId: "D1",
821
+ };
822
+ // First delivery: processes successfully and marks E D1 seen.
823
+ await sink.onTurn(turn);
824
+ expect(runs).toBe(1);
825
+ // Second delivery of the same eventId: must be deduped — handler does NOT run again.
826
+ await sink.onTurn({ ...turn });
827
+ expect(runs).toBe(1);
828
+ });
829
+ });
830
+ describe("createBot slash commands", () => {
831
+ it("routes a command to its handler with the raw text", async () => {
832
+ const fake = new FakeAdapter();
833
+ const bot = createBot({ adapters: [fake] });
834
+ let seen;
835
+ bot.onCommand("triage", ({ command, text }) => {
836
+ seen = { command, text };
837
+ });
838
+ await bot.start();
839
+ await fake.emitCommand({ command: "/Triage", text: "db is down" });
840
+ expect(seen).toEqual({ command: "triage", text: "db is down" });
841
+ });
842
+ it("ignores a command with no registered handler", async () => {
843
+ const fake = new FakeAdapter();
844
+ const bot = createBot({ adapters: [fake] });
845
+ let fired = false;
846
+ bot.onCommand("triage", () => {
847
+ fired = true;
848
+ });
849
+ await bot.start();
850
+ await fake.emitCommand({ command: "unknown", text: "x" });
851
+ expect(fired).toBe(false);
852
+ });
853
+ it("parses rawOptions through the command's schema into ctx.options", async () => {
854
+ let captured;
855
+ const fake = new FakeAdapter();
856
+ const bot = createBot({
857
+ adapters: [fake],
858
+ commands: [
859
+ defineBotCommand({
860
+ name: "book",
861
+ options: z.object({ seat: z.string() }),
862
+ handler: ({ options }) => {
863
+ captured = options; // typed { seat: string }
864
+ },
865
+ }),
866
+ ],
867
+ });
868
+ await bot.start();
869
+ await fake.emitCommand({
870
+ command: "book",
871
+ text: "raw",
872
+ rawOptions: { seat: "12A" },
873
+ });
874
+ expect(captured).toEqual({ seat: "12A" });
875
+ });
876
+ it("hands declared commands to adapters that implement registerCommands", async () => {
877
+ const fake = new FakeAdapter();
878
+ const bot = createBot({ adapters: [fake] });
879
+ bot.onCommand("triage", () => { });
880
+ bot.onCommand("status", () => { });
881
+ await bot.start();
882
+ expect(fake.registeredCommands?.map((c) => c.name).sort()).toEqual([
883
+ "status",
884
+ "triage",
885
+ ]);
886
+ });
887
+ it("start() resolves and keeps healthy adapters when one adapter's start() rejects", async () => {
888
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
889
+ try {
890
+ const bad = new FakeAdapter({ platform: "telegram", failStart: true });
891
+ const good = new FakeAdapter({ platform: "slack" });
892
+ const bot = createBot({ adapters: [bad, good] });
893
+ await expect(bot.start()).resolves.toBeUndefined();
894
+ expect(good.started).toBe(true);
895
+ expect(errSpy.mock.calls.some((c) => String(c[0]).includes("telegram"))).toBe(true);
896
+ }
897
+ finally {
898
+ errSpy.mockRestore();
899
+ }
900
+ });
901
+ it("start() resolves and the healthy adapter still receives commands when another adapter's registerCommands() rejects", async () => {
902
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
903
+ try {
904
+ const bad = new FakeAdapter({
905
+ platform: "telegram",
906
+ failRegisterCommands: true,
907
+ });
908
+ const good = new FakeAdapter({ platform: "slack" });
909
+ const bot = createBot({ adapters: [bad, good] });
910
+ bot.onCommand("triage", () => { });
911
+ await expect(bot.start()).resolves.toBeUndefined();
912
+ expect(good.started).toBe(true);
913
+ expect(good.registeredCommands?.map((c) => c.name)).toEqual(["triage"]);
914
+ expect(errSpy.mock.calls.some((c) => String(c[0]).includes("telegram"))).toBe(true);
915
+ }
916
+ finally {
917
+ errSpy.mockRestore();
918
+ }
919
+ });
920
+ it("stop() resolves and stops healthy adapters when one adapter's stop() rejects", async () => {
921
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
922
+ try {
923
+ const bad = new FakeAdapter({ platform: "telegram", failStop: true });
924
+ const good = new FakeAdapter({ platform: "slack" });
925
+ const stopSpy = vi.spyOn(good, "stop");
926
+ const bot = createBot({ adapters: [bad, good] });
927
+ await bot.start();
928
+ await expect(bot.stop()).resolves.toBeUndefined();
929
+ expect(stopSpy).toHaveBeenCalled();
930
+ expect(errSpy.mock.calls.some((c) => String(c[0]).includes("telegram"))).toBe(true);
931
+ }
932
+ finally {
933
+ errSpy.mockRestore();
934
+ }
935
+ });
936
+ });