talon-agent 1.9.0 → 1.9.2

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.
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Functional tests for tool schemas + the Telegram action handler.
3
+ *
4
+ * Covers two wiring layers that have historically broken silently:
5
+ *
6
+ * 1. Tool definition → bridge call
7
+ * `tool.execute(parsedParams, bridge)` must call the bridge
8
+ * with the correct action name and the correct params shape.
9
+ * Catches: action-name typos, missing param forwarding, and
10
+ * multiplexed dispatch (e.g. `send` → 13 different bridge
11
+ * actions depending on `type`).
12
+ *
13
+ * 2. Bridge → Telegram Bot API
14
+ * `createTelegramActionHandler(...)` translates a bridge
15
+ * action body into the right grammy `bot.api.*` call with
16
+ * the right arguments. Catches: drift between the bridge
17
+ * action name produced by a tool and the case label in the
18
+ * handler switch, or wrong arg ordering.
19
+ *
20
+ * No real bot, no real network, no spawned processes. Pure
21
+ * in-process round trips with vi.fn() spies.
22
+ */
23
+
24
+ import { describe, it, expect, vi, beforeEach } from "vitest";
25
+ import { z } from "zod";
26
+ import { ALL_TOOLS } from "../core/tools/index.js";
27
+ import type { ToolDefinition } from "../core/tools/types.js";
28
+
29
+ // ── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ function getTool(name: string): ToolDefinition {
32
+ const tool = ALL_TOOLS.find((t) => t.name === name);
33
+ if (!tool) throw new Error(`tool ${name} not found in ALL_TOOLS`);
34
+ return tool;
35
+ }
36
+
37
+ /** Run the tool's zod schema over `raw` and return the parsed/coerced output. */
38
+ function parseSchema(
39
+ tool: ToolDefinition,
40
+ raw: Record<string, unknown>,
41
+ ): Record<string, unknown> {
42
+ const obj = z.object(tool.schema as Record<string, z.ZodTypeAny>);
43
+ return obj.parse(raw) as Record<string, unknown>;
44
+ }
45
+
46
+ /** Build a vi-mocked bridge that records every call and returns ok. */
47
+ function makeBridge() {
48
+ return vi.fn(async (_action: string, _params: Record<string, unknown>) => ({
49
+ ok: true,
50
+ }));
51
+ }
52
+
53
+ // ════════════════════════════════════════════════════════════════════════════
54
+ // Part A — Tool definition → bridge call
55
+ // ════════════════════════════════════════════════════════════════════════════
56
+
57
+ describe("Tool → bridge round-trip", () => {
58
+ // ── Single-action tools (1:1 with a bridge action name) ──────────────────
59
+ describe("single-action telegram tools", () => {
60
+ const cases: Array<{
61
+ tool: string;
62
+ params: Record<string, unknown>;
63
+ bridgeAction: string;
64
+ expectedParams?: Record<string, unknown>;
65
+ }> = [
66
+ {
67
+ tool: "react",
68
+ params: { message_id: 2081, emoji: "❤️" },
69
+ bridgeAction: "react",
70
+ expectedParams: { message_id: 2081, emoji: "❤️" },
71
+ },
72
+ {
73
+ tool: "edit_message",
74
+ params: { message_id: 2081, text: "edited" },
75
+ bridgeAction: "edit_message",
76
+ expectedParams: { message_id: 2081, text: "edited" },
77
+ },
78
+ {
79
+ tool: "delete_message",
80
+ params: { message_id: 2081 },
81
+ bridgeAction: "delete_message",
82
+ expectedParams: { message_id: 2081 },
83
+ },
84
+ {
85
+ tool: "forward_message",
86
+ params: { message_id: 2081 },
87
+ bridgeAction: "forward_message",
88
+ },
89
+ {
90
+ tool: "pin_message",
91
+ params: { message_id: 2081 },
92
+ bridgeAction: "pin_message",
93
+ },
94
+ {
95
+ tool: "unpin_message",
96
+ params: { message_id: 2081 },
97
+ bridgeAction: "unpin_message",
98
+ },
99
+ {
100
+ tool: "stop_poll",
101
+ params: { message_id: 2081 },
102
+ bridgeAction: "stop_poll",
103
+ },
104
+ {
105
+ tool: "get_member_info",
106
+ params: { user_id: 352042062 },
107
+ bridgeAction: "get_member_info",
108
+ },
109
+ {
110
+ tool: "get_message_by_id",
111
+ params: { message_id: 2081 },
112
+ bridgeAction: "get_message_by_id",
113
+ },
114
+ {
115
+ tool: "download_media",
116
+ params: { message_id: 2081 },
117
+ bridgeAction: "download_media",
118
+ },
119
+ ];
120
+
121
+ for (const c of cases) {
122
+ it(`${c.tool} → bridge("${c.bridgeAction}")`, async () => {
123
+ const tool = getTool(c.tool);
124
+ const bridge = makeBridge();
125
+ const parsed = parseSchema(tool, c.params);
126
+
127
+ await tool.execute(parsed, bridge);
128
+
129
+ expect(bridge).toHaveBeenCalledTimes(1);
130
+ const [action, params] = bridge.mock.calls[0]!;
131
+ expect(action).toBe(c.bridgeAction);
132
+ if (c.expectedParams) {
133
+ expect(params).toEqual(c.expectedParams);
134
+ }
135
+ });
136
+ }
137
+ });
138
+
139
+ // ── send tool dispatches to many bridge actions ──────────────────────────
140
+ describe("send tool dispatches by type", () => {
141
+ const cases: Array<{
142
+ label: string;
143
+ params: Record<string, unknown>;
144
+ bridgeAction: string;
145
+ paramShape?: (p: Record<string, unknown>) => void;
146
+ }> = [
147
+ {
148
+ label: "text",
149
+ params: { type: "text", text: "hello" },
150
+ bridgeAction: "send_message",
151
+ paramShape: (p) => {
152
+ expect(p.text).toBe("hello");
153
+ },
154
+ },
155
+ {
156
+ label: "text with reply_to",
157
+ params: { type: "text", text: "ok", reply_to: 2081 },
158
+ bridgeAction: "send_message",
159
+ paramShape: (p) => {
160
+ expect(p.reply_to_message_id).toBe(2081);
161
+ },
162
+ },
163
+ {
164
+ label: "text with buttons",
165
+ params: {
166
+ type: "text",
167
+ text: "choose",
168
+ buttons: [[{ text: "A", callback_data: "a" }]],
169
+ },
170
+ bridgeAction: "send_message_with_buttons",
171
+ paramShape: (p) => {
172
+ expect((p.rows as unknown[]).length).toBe(1);
173
+ },
174
+ },
175
+ {
176
+ label: "delayed text → schedule_message",
177
+ params: { type: "text", text: "later", delay_seconds: 60 },
178
+ bridgeAction: "schedule_message",
179
+ paramShape: (p) => {
180
+ expect(p.delay_seconds).toBe(60);
181
+ },
182
+ },
183
+ {
184
+ label: "photo",
185
+ params: { type: "photo", file_path: "/tmp/x.jpg", caption: "hi" },
186
+ bridgeAction: "send_photo",
187
+ },
188
+ {
189
+ label: "file",
190
+ params: { type: "file", file_path: "/tmp/x.pdf" },
191
+ bridgeAction: "send_file",
192
+ },
193
+ {
194
+ label: "video",
195
+ params: { type: "video", file_path: "/tmp/x.mp4" },
196
+ bridgeAction: "send_video",
197
+ },
198
+ {
199
+ label: "voice",
200
+ params: { type: "voice", file_path: "/tmp/x.ogg" },
201
+ bridgeAction: "send_voice",
202
+ },
203
+ {
204
+ label: "audio",
205
+ params: {
206
+ type: "audio",
207
+ file_path: "/tmp/song.mp3",
208
+ title: "T",
209
+ performer: "P",
210
+ },
211
+ bridgeAction: "send_audio",
212
+ paramShape: (p) => {
213
+ expect(p.title).toBe("T");
214
+ expect(p.performer).toBe("P");
215
+ },
216
+ },
217
+ {
218
+ label: "animation",
219
+ params: { type: "animation", file_path: "/tmp/x.gif" },
220
+ bridgeAction: "send_animation",
221
+ },
222
+ {
223
+ label: "sticker",
224
+ params: { type: "sticker", file_id: "CAACAgI..." },
225
+ bridgeAction: "send_sticker",
226
+ },
227
+ {
228
+ label: "poll",
229
+ params: {
230
+ type: "poll",
231
+ question: "Best?",
232
+ options: ["A", "B"],
233
+ },
234
+ bridgeAction: "send_poll",
235
+ paramShape: (p) => {
236
+ expect(p.question).toBe("Best?");
237
+ expect(p.type).toBe("regular");
238
+ },
239
+ },
240
+ {
241
+ label: "poll with correct_option_id → quiz",
242
+ params: {
243
+ type: "poll",
244
+ question: "Q?",
245
+ options: ["A", "B"],
246
+ correct_option_id: 1,
247
+ },
248
+ bridgeAction: "send_poll",
249
+ paramShape: (p) => {
250
+ expect(p.type).toBe("quiz");
251
+ },
252
+ },
253
+ {
254
+ label: "location",
255
+ params: { type: "location", latitude: 53.15, longitude: -6.07 },
256
+ bridgeAction: "send_location",
257
+ },
258
+ {
259
+ label: "contact",
260
+ params: {
261
+ type: "contact",
262
+ phone_number: "+1234",
263
+ first_name: "Test",
264
+ },
265
+ bridgeAction: "send_contact",
266
+ },
267
+ {
268
+ label: "dice",
269
+ params: { type: "dice" },
270
+ bridgeAction: "send_dice",
271
+ },
272
+ ];
273
+
274
+ for (const c of cases) {
275
+ it(`send(${c.label}) → bridge("${c.bridgeAction}")`, async () => {
276
+ const tool = getTool("send");
277
+ const bridge = makeBridge();
278
+ const parsed = parseSchema(tool, c.params);
279
+
280
+ await tool.execute(parsed, bridge);
281
+
282
+ expect(bridge).toHaveBeenCalledTimes(1);
283
+ const [action, params] = bridge.mock.calls[0]!;
284
+ expect(action).toBe(c.bridgeAction);
285
+ if (c.paramShape) c.paramShape(params as Record<string, unknown>);
286
+ });
287
+ }
288
+ });
289
+
290
+ // ── Coercion goes end-to-end through execute() ───────────────────────────
291
+ describe("ID coercion survives the schema → execute pipeline", () => {
292
+ it("react with stringified message_id arrives at bridge as a number", async () => {
293
+ const tool = getTool("react");
294
+ const bridge = makeBridge();
295
+ const parsed = parseSchema(tool, {
296
+ message_id: "2081",
297
+ emoji: "❤️",
298
+ });
299
+
300
+ await tool.execute(parsed, bridge);
301
+
302
+ const [, params] = bridge.mock.calls[0]!;
303
+ expect((params as { message_id: unknown }).message_id).toBe(2081);
304
+ expect(typeof (params as { message_id: unknown }).message_id).toBe(
305
+ "number",
306
+ );
307
+ });
308
+
309
+ it("send.reply_to with stringified value survives to send_message", async () => {
310
+ const tool = getTool("send");
311
+ const bridge = makeBridge();
312
+ const parsed = parseSchema(tool, {
313
+ type: "text",
314
+ text: "hi",
315
+ reply_to: "2081",
316
+ });
317
+
318
+ await tool.execute(parsed, bridge);
319
+
320
+ const [, params] = bridge.mock.calls[0]!;
321
+ expect(
322
+ (params as { reply_to_message_id: unknown }).reply_to_message_id,
323
+ ).toBe(2081);
324
+ });
325
+
326
+ it("get_member_info with stringified user_id arrives as number", async () => {
327
+ const tool = getTool("get_member_info");
328
+ const bridge = makeBridge();
329
+ const parsed = parseSchema(tool, { user_id: "352042062" });
330
+
331
+ await tool.execute(parsed, bridge);
332
+
333
+ const [, params] = bridge.mock.calls[0]!;
334
+ expect((params as { user_id: unknown }).user_id).toBe(352042062);
335
+ });
336
+ });
337
+ });
338
+
339
+ // ════════════════════════════════════════════════════════════════════════════
340
+ // Part B — Telegram action handler → grammy Bot API
341
+ // ════════════════════════════════════════════════════════════════════════════
342
+
343
+ import {
344
+ createTelegramActionHandler,
345
+ // re-export for test side-only — handler depends on the InputFile constructor
346
+ } from "../frontend/telegram/actions.js";
347
+ import type { Bot } from "grammy";
348
+ import type { Gateway } from "../core/gateway.js";
349
+
350
+ interface BotApiSpy {
351
+ setMessageReaction: ReturnType<typeof vi.fn>;
352
+ editMessageText: ReturnType<typeof vi.fn>;
353
+ deleteMessage: ReturnType<typeof vi.fn>;
354
+ pinChatMessage: ReturnType<typeof vi.fn>;
355
+ unpinChatMessage: ReturnType<typeof vi.fn>;
356
+ forwardMessage: ReturnType<typeof vi.fn>;
357
+ copyMessage: ReturnType<typeof vi.fn>;
358
+ sendMessage: ReturnType<typeof vi.fn>;
359
+ sendChatAction: ReturnType<typeof vi.fn>;
360
+ }
361
+
362
+ function makeBotSpy(): { bot: Bot; api: BotApiSpy } {
363
+ const api: BotApiSpy = {
364
+ setMessageReaction: vi.fn(async () => true),
365
+ editMessageText: vi.fn(async () => ({ message_id: 1 })),
366
+ deleteMessage: vi.fn(async () => true),
367
+ pinChatMessage: vi.fn(async () => true),
368
+ unpinChatMessage: vi.fn(async () => true),
369
+ forwardMessage: vi.fn(async () => ({ message_id: 999 })),
370
+ copyMessage: vi.fn(async () => ({ message_id: 1000 })),
371
+ sendMessage: vi.fn(async () => ({ message_id: 1001 })),
372
+ sendChatAction: vi.fn(async () => true),
373
+ };
374
+ const bot = { api } as unknown as Bot;
375
+ return { bot, api };
376
+ }
377
+
378
+ function makeGateway(): Gateway {
379
+ return {
380
+ incrementMessages: vi.fn(),
381
+ incrementErrors: vi.fn(),
382
+ incrementRetries: vi.fn(),
383
+ incrementSuccess: vi.fn(),
384
+ } as unknown as Gateway;
385
+ }
386
+
387
+ class StubInputFile {
388
+ // Match the grammy InputFile shape just enough for `new InputFileClass(...)`.
389
+ data: unknown;
390
+ filename: string;
391
+ constructor(data: unknown, filename: string) {
392
+ this.data = data;
393
+ this.filename = filename;
394
+ }
395
+ }
396
+
397
+ describe("createTelegramActionHandler", () => {
398
+ let bot: Bot;
399
+ let api: BotApiSpy;
400
+ let gateway: Gateway;
401
+ let handler: ReturnType<typeof createTelegramActionHandler>;
402
+ const chatId = 12345;
403
+
404
+ beforeEach(() => {
405
+ const spy = makeBotSpy();
406
+ bot = spy.bot;
407
+ api = spy.api;
408
+ gateway = makeGateway();
409
+ handler = createTelegramActionHandler(
410
+ bot,
411
+ StubInputFile as unknown as typeof import("grammy").InputFile,
412
+ "fake-token",
413
+ gateway,
414
+ );
415
+ });
416
+
417
+ it("react → bot.api.setMessageReaction with chatId, message_id, emoji", async () => {
418
+ const result = await handler(
419
+ { action: "react", message_id: 2081, emoji: "❤️" },
420
+ chatId,
421
+ );
422
+
423
+ expect(api.setMessageReaction).toHaveBeenCalledTimes(1);
424
+ expect(api.setMessageReaction).toHaveBeenCalledWith(chatId, 2081, [
425
+ { type: "emoji", emoji: "❤️" },
426
+ ]);
427
+ expect(result).toEqual({ ok: true });
428
+ });
429
+
430
+ it("react with stringified message_id (post-coercion) still calls bot.api correctly", async () => {
431
+ // Even if something upstream skipped coercion and delivered a string,
432
+ // the handler does Number(body.message_id) and recovers.
433
+ await handler({ action: "react", message_id: "2081", emoji: "🔥" }, chatId);
434
+
435
+ expect(api.setMessageReaction).toHaveBeenCalledWith(chatId, 2081, [
436
+ { type: "emoji", emoji: "🔥" },
437
+ ]);
438
+ });
439
+
440
+ it("react falls back to 👍 if custom emoji rejected, and reports ok", async () => {
441
+ const seen: string[] = [];
442
+ api.setMessageReaction.mockImplementation(
443
+ async (
444
+ _chatId: number,
445
+ _msgId: number,
446
+ reactions: Array<{ type: string; emoji: string }>,
447
+ ) => {
448
+ seen.push(reactions[0]!.emoji);
449
+ if (reactions[0]!.emoji === "🦄") {
450
+ throw new Error("REACTION_INVALID");
451
+ }
452
+ return true;
453
+ },
454
+ );
455
+
456
+ const result = await handler(
457
+ { action: "react", message_id: 1, emoji: "🦄" },
458
+ chatId,
459
+ );
460
+
461
+ expect(api.setMessageReaction).toHaveBeenCalledTimes(2);
462
+ expect(seen).toEqual(["🦄", "👍"]);
463
+ expect(result).toEqual({ ok: true });
464
+ });
465
+
466
+ it("delete_message → bot.api.deleteMessage(chatId, message_id)", async () => {
467
+ const result = await handler(
468
+ { action: "delete_message", message_id: 2081 },
469
+ chatId,
470
+ );
471
+
472
+ expect(api.deleteMessage).toHaveBeenCalledWith(chatId, 2081);
473
+ expect(result).toEqual({ ok: true });
474
+ });
475
+
476
+ it("pin_message → bot.api.pinChatMessage(chatId, message_id)", async () => {
477
+ await handler({ action: "pin_message", message_id: 2081 }, chatId);
478
+ expect(api.pinChatMessage).toHaveBeenCalledWith(chatId, 2081);
479
+ });
480
+
481
+ it("unpin_message → bot.api.unpinChatMessage(chatId, message_id?)", async () => {
482
+ await handler({ action: "unpin_message", message_id: 2081 }, chatId);
483
+ expect(api.unpinChatMessage).toHaveBeenCalledWith(chatId, 2081);
484
+
485
+ api.unpinChatMessage.mockClear();
486
+ await handler({ action: "unpin_message" }, chatId);
487
+ expect(api.unpinChatMessage).toHaveBeenCalledWith(chatId, undefined);
488
+ });
489
+
490
+ it("forward_message → bot.api.forwardMessage(chatId, chatId, message_id)", async () => {
491
+ const result = await handler(
492
+ { action: "forward_message", message_id: 2081 },
493
+ chatId,
494
+ );
495
+
496
+ expect(api.forwardMessage).toHaveBeenCalledWith(chatId, chatId, 2081);
497
+ expect(result).toEqual({ ok: true, message_id: 999 });
498
+ });
499
+
500
+ it("forward_message rejects cross-chat targets", async () => {
501
+ const result = await handler(
502
+ { action: "forward_message", message_id: 2081, to_chat_id: 99999 },
503
+ chatId,
504
+ );
505
+
506
+ expect(result).toEqual({
507
+ ok: false,
508
+ error: "Cross-chat forwarding not allowed.",
509
+ });
510
+ expect(api.forwardMessage).not.toHaveBeenCalled();
511
+ });
512
+
513
+ it("copy_message → bot.api.copyMessage(chatId, chatId, message_id)", async () => {
514
+ const result = await handler(
515
+ { action: "copy_message", message_id: 2081 },
516
+ chatId,
517
+ );
518
+
519
+ expect(api.copyMessage).toHaveBeenCalledWith(chatId, chatId, 2081);
520
+ expect(result).toEqual({ ok: true, message_id: 1000 });
521
+ });
522
+
523
+ it("edit_message → bot.api.editMessageText(chatId, message_id, html, opts)", async () => {
524
+ await handler(
525
+ { action: "edit_message", message_id: 2081, text: "**bold**" },
526
+ chatId,
527
+ );
528
+
529
+ expect(api.editMessageText).toHaveBeenCalledTimes(1);
530
+ const call = api.editMessageText.mock.calls[0]!;
531
+ expect(call[0]).toBe(chatId);
532
+ expect(call[1]).toBe(2081);
533
+ expect(typeof call[2]).toBe("string");
534
+ expect(call[3]).toEqual({ parse_mode: "HTML" });
535
+ });
536
+
537
+ it("edit_message rejects text > TELEGRAM_MAX_TEXT (4096)", async () => {
538
+ const longText = "x".repeat(5000);
539
+ const result = await handler(
540
+ { action: "edit_message", message_id: 2081, text: longText },
541
+ chatId,
542
+ );
543
+
544
+ expect(result).toEqual({
545
+ ok: false,
546
+ error: "Text too long (max 4096)",
547
+ });
548
+ expect(api.editMessageText).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it("send_chat_action → bot.api.sendChatAction(chatId, action_str)", async () => {
552
+ await handler(
553
+ { action: "send_chat_action", chat_action: "typing" },
554
+ chatId,
555
+ );
556
+ expect(api.sendChatAction).toHaveBeenCalledWith(chatId, "typing");
557
+ });
558
+
559
+ it("send_message → withRetry → bot.api.sendMessage with HTML and reply_to", async () => {
560
+ const result = await handler(
561
+ {
562
+ action: "send_message",
563
+ text: "hello",
564
+ reply_to_message_id: 2081,
565
+ },
566
+ chatId,
567
+ );
568
+
569
+ expect(api.sendMessage).toHaveBeenCalledTimes(1);
570
+ expect(gateway.incrementMessages).toHaveBeenCalledWith(chatId);
571
+ const call = api.sendMessage.mock.calls[0]!;
572
+ expect(call[0]).toBe(chatId);
573
+ expect(typeof call[1]).toBe("string");
574
+ expect(call[2]).toMatchObject({
575
+ reply_parameters: { message_id: 2081 },
576
+ parse_mode: "HTML",
577
+ });
578
+ expect(result).toEqual({ ok: true, message_id: 1001 });
579
+ });
580
+
581
+ it("schedule_message returns a schedule id and keeps a timer", async () => {
582
+ vi.useFakeTimers();
583
+ try {
584
+ const result = (await handler(
585
+ {
586
+ action: "schedule_message",
587
+ text: "later",
588
+ delay_seconds: 5,
589
+ },
590
+ chatId,
591
+ )) as { ok: true; schedule_id: string; delay_seconds: number };
592
+
593
+ expect(result.ok).toBe(true);
594
+ expect(result.delay_seconds).toBe(5);
595
+ expect(typeof result.schedule_id).toBe("string");
596
+
597
+ // Cancel before it fires so we don't leak a real send.
598
+ const cancel = await handler(
599
+ { action: "cancel_scheduled", schedule_id: result.schedule_id },
600
+ chatId,
601
+ );
602
+ expect(cancel).toEqual({ ok: true, cancelled: true });
603
+ } finally {
604
+ vi.useRealTimers();
605
+ }
606
+ });
607
+
608
+ it("cancel_scheduled with unknown id returns ok:false", async () => {
609
+ const result = await handler(
610
+ { action: "cancel_scheduled", schedule_id: "nonexistent" },
611
+ chatId,
612
+ );
613
+ expect(result).toEqual({ ok: false, error: "Schedule not found" });
614
+ });
615
+ });