@llblab/pi-telegram 0.2.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.
@@ -0,0 +1,2982 @@
1
+ /**
2
+ * Regression tests for Telegram queue and runtime decision helpers
3
+ * Exercises queue ordering, mutation, dispatch planning, lifecycle plans, and model-switch guard behavior
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import test from "node:test";
11
+
12
+ import telegramExtension, { __telegramTestUtils } from "../index.ts";
13
+ import {
14
+ buildTelegramAgentStartPlan,
15
+ buildTelegramSessionShutdownState,
16
+ buildTelegramSessionStartState,
17
+ executeTelegramControlItemRuntime,
18
+ executeTelegramQueueDispatchPlan,
19
+ getNextTelegramToolExecutionCount,
20
+ shouldStartTelegramPolling,
21
+ } from "../lib/queue.ts";
22
+
23
+ async function waitForCondition(
24
+ predicate: () => boolean,
25
+ timeoutMs = 2000,
26
+ ): Promise<void> {
27
+ const deadline = Date.now() + timeoutMs;
28
+ while (Date.now() < deadline) {
29
+ if (predicate()) return;
30
+ await new Promise((resolve) => setTimeout(resolve, 10));
31
+ }
32
+ throw new Error("Timed out waiting for condition");
33
+ }
34
+
35
+ test("Control-lane items sort before priority and default prompt items", () => {
36
+ const queueItemType = undefined as
37
+ | Parameters<typeof __telegramTestUtils.compareTelegramQueueItems>[0]
38
+ | undefined;
39
+ const defaultPrompt: typeof queueItemType = {
40
+ kind: "prompt",
41
+ chatId: 1,
42
+ replyToMessageId: 1,
43
+ sourceMessageIds: [1],
44
+ queueOrder: 10,
45
+ queueLane: "default",
46
+ laneOrder: 10,
47
+ queuedAttachments: [],
48
+ content: [{ type: "text", text: "default" }],
49
+ historyText: "default",
50
+ statusSummary: "default",
51
+ };
52
+ const priorityPrompt: typeof queueItemType = {
53
+ kind: "prompt",
54
+ chatId: 1,
55
+ replyToMessageId: 2,
56
+ sourceMessageIds: [2],
57
+ queueOrder: 11,
58
+ queueLane: "priority",
59
+ laneOrder: 0,
60
+ queuedAttachments: [],
61
+ content: [{ type: "text", text: "priority" }],
62
+ historyText: "priority",
63
+ statusSummary: "priority",
64
+ };
65
+ const controlItem: typeof queueItemType = {
66
+ kind: "control",
67
+ controlType: "status",
68
+ chatId: 1,
69
+ replyToMessageId: 3,
70
+ queueOrder: 12,
71
+ queueLane: "control",
72
+ laneOrder: 0,
73
+ statusSummary: "control",
74
+ execute: async () => {},
75
+ };
76
+ const items = [defaultPrompt, controlItem, priorityPrompt].sort(
77
+ __telegramTestUtils.compareTelegramQueueItems,
78
+ );
79
+ assert.deepEqual(
80
+ items.map((item) => item?.statusSummary),
81
+ ["control", "priority", "default"],
82
+ );
83
+ });
84
+
85
+ test("Queue mutation helpers remove prompt items by Telegram message id", () => {
86
+ const queueItemType = undefined as
87
+ | Parameters<
88
+ typeof __telegramTestUtils.removeTelegramQueueItemsByMessageIds
89
+ >[0][number]
90
+ | undefined;
91
+ const promptItem: typeof queueItemType = {
92
+ kind: "prompt",
93
+ chatId: 1,
94
+ replyToMessageId: 1,
95
+ sourceMessageIds: [11, 12],
96
+ queueOrder: 1,
97
+ queueLane: "default",
98
+ laneOrder: 1,
99
+ queuedAttachments: [],
100
+ content: [{ type: "text", text: "prompt" }],
101
+ historyText: "prompt history",
102
+ statusSummary: "prompt",
103
+ };
104
+ const controlItem: typeof queueItemType = {
105
+ kind: "control",
106
+ controlType: "status",
107
+ chatId: 1,
108
+ replyToMessageId: 2,
109
+ queueOrder: 2,
110
+ queueLane: "control",
111
+ laneOrder: 0,
112
+ statusSummary: "control",
113
+ execute: async () => {},
114
+ };
115
+ const result = __telegramTestUtils.removeTelegramQueueItemsByMessageIds(
116
+ [promptItem, controlItem],
117
+ [12],
118
+ );
119
+ assert.equal(result.removedCount, 1);
120
+ assert.deepEqual(
121
+ result.items.map((item) => item.statusSummary),
122
+ ["control"],
123
+ );
124
+ });
125
+
126
+ test("Queue mutation helpers apply and clear prompt priority without touching control items", () => {
127
+ const queueItemType = undefined as
128
+ | Parameters<
129
+ typeof __telegramTestUtils.prioritizeTelegramQueuePrompt
130
+ >[0][number]
131
+ | undefined;
132
+ const promptItem: typeof queueItemType = {
133
+ kind: "prompt",
134
+ chatId: 1,
135
+ replyToMessageId: 1,
136
+ sourceMessageIds: [11],
137
+ queueOrder: 4,
138
+ queueLane: "default",
139
+ laneOrder: 4,
140
+ queuedAttachments: [],
141
+ content: [{ type: "text", text: "prompt" }],
142
+ historyText: "prompt history",
143
+ statusSummary: "prompt",
144
+ };
145
+ const controlItem: typeof queueItemType = {
146
+ kind: "control",
147
+ controlType: "status",
148
+ chatId: 1,
149
+ replyToMessageId: 2,
150
+ queueOrder: 5,
151
+ queueLane: "control",
152
+ laneOrder: 0,
153
+ statusSummary: "control",
154
+ execute: async () => {},
155
+ };
156
+ const prioritized = __telegramTestUtils.prioritizeTelegramQueuePrompt(
157
+ [promptItem, controlItem],
158
+ 11,
159
+ 0,
160
+ );
161
+ assert.equal(prioritized.changed, true);
162
+ assert.equal(prioritized.items[0]?.queueLane, "priority");
163
+ const cleared = __telegramTestUtils.clearTelegramQueuePromptPriority(
164
+ prioritized.items,
165
+ 11,
166
+ );
167
+ assert.equal(cleared.changed, true);
168
+ assert.equal(cleared.items[0]?.queueLane, "default");
169
+ assert.equal(cleared.items[1]?.queueLane, "control");
170
+ });
171
+
172
+ test("History partition keeps control items queued and extracts prompt items", () => {
173
+ const queueItemType = undefined as
174
+ | Parameters<
175
+ typeof __telegramTestUtils.partitionTelegramQueueItemsForHistory
176
+ >[0][number]
177
+ | undefined;
178
+ const promptItem: typeof queueItemType = {
179
+ kind: "prompt",
180
+ chatId: 1,
181
+ replyToMessageId: 1,
182
+ sourceMessageIds: [1],
183
+ queueOrder: 1,
184
+ queueLane: "default",
185
+ laneOrder: 1,
186
+ queuedAttachments: [],
187
+ content: [{ type: "text", text: "prompt" }],
188
+ historyText: "prompt history",
189
+ statusSummary: "prompt",
190
+ };
191
+ const controlItem: typeof queueItemType = {
192
+ kind: "control",
193
+ controlType: "status",
194
+ chatId: 1,
195
+ replyToMessageId: 2,
196
+ queueOrder: 2,
197
+ queueLane: "control",
198
+ laneOrder: 0,
199
+ statusSummary: "control",
200
+ execute: async () => {},
201
+ };
202
+ const result = __telegramTestUtils.partitionTelegramQueueItemsForHistory([
203
+ promptItem,
204
+ controlItem,
205
+ ]);
206
+ assert.deepEqual(
207
+ result.historyTurns.map((item) => item.statusSummary),
208
+ ["prompt"],
209
+ );
210
+ assert.deepEqual(
211
+ result.remainingItems.map((item) => item.statusSummary),
212
+ ["control"],
213
+ );
214
+ });
215
+
216
+ test("Dispatch planning returns the prompt item when dispatch is allowed", () => {
217
+ const queueItemType = undefined as
218
+ | Parameters<
219
+ typeof __telegramTestUtils.planNextTelegramQueueAction
220
+ >[0][number]
221
+ | undefined;
222
+ const controlItem: typeof queueItemType = {
223
+ kind: "control",
224
+ controlType: "status",
225
+ chatId: 1,
226
+ replyToMessageId: 1,
227
+ queueOrder: 1,
228
+ queueLane: "control",
229
+ laneOrder: 0,
230
+ statusSummary: "control",
231
+ execute: async () => {},
232
+ };
233
+ const promptItem: typeof queueItemType = {
234
+ kind: "prompt",
235
+ chatId: 1,
236
+ replyToMessageId: 2,
237
+ sourceMessageIds: [2],
238
+ queueOrder: 2,
239
+ queueLane: "default",
240
+ laneOrder: 2,
241
+ queuedAttachments: [],
242
+ content: [{ type: "text", text: "prompt" }],
243
+ historyText: "prompt history",
244
+ statusSummary: "prompt",
245
+ };
246
+ const result = __telegramTestUtils.planNextTelegramQueueAction(
247
+ [promptItem, controlItem],
248
+ true,
249
+ );
250
+ assert.equal(result.kind, "prompt");
251
+ assert.equal(
252
+ result.kind === "prompt" ? result.item.statusSummary : "",
253
+ "prompt",
254
+ );
255
+ assert.deepEqual(
256
+ result.remainingItems.map((item) => item.statusSummary),
257
+ ["prompt", "control"],
258
+ );
259
+ });
260
+
261
+ test("Dispatch planning runs control items before normal prompts", () => {
262
+ const queueItemType = undefined as
263
+ | Parameters<
264
+ typeof __telegramTestUtils.planNextTelegramQueueAction
265
+ >[0][number]
266
+ | undefined;
267
+ const controlItem: typeof queueItemType = {
268
+ kind: "control",
269
+ controlType: "status",
270
+ chatId: 1,
271
+ replyToMessageId: 1,
272
+ queueOrder: 1,
273
+ queueLane: "control",
274
+ laneOrder: 0,
275
+ statusSummary: "control",
276
+ execute: async () => {},
277
+ };
278
+ const promptItem: typeof queueItemType = {
279
+ kind: "prompt",
280
+ chatId: 1,
281
+ replyToMessageId: 2,
282
+ sourceMessageIds: [2],
283
+ queueOrder: 2,
284
+ queueLane: "default",
285
+ laneOrder: 2,
286
+ queuedAttachments: [],
287
+ content: [{ type: "text", text: "prompt" }],
288
+ historyText: "prompt history",
289
+ statusSummary: "prompt",
290
+ };
291
+ const result = __telegramTestUtils.planNextTelegramQueueAction(
292
+ [controlItem, promptItem],
293
+ true,
294
+ );
295
+ assert.equal(result.kind, "control");
296
+ assert.equal(
297
+ result.kind === "control" ? result.item.statusSummary : "",
298
+ "control",
299
+ );
300
+ assert.deepEqual(
301
+ result.remainingItems.map((item) => item.statusSummary),
302
+ ["prompt"],
303
+ );
304
+ });
305
+
306
+ test("Dispatch planning returns none when dispatch is blocked", () => {
307
+ const queueItemType = undefined as
308
+ | Parameters<
309
+ typeof __telegramTestUtils.planNextTelegramQueueAction
310
+ >[0][number]
311
+ | undefined;
312
+ const promptItem: typeof queueItemType = {
313
+ kind: "prompt",
314
+ chatId: 1,
315
+ replyToMessageId: 2,
316
+ sourceMessageIds: [2],
317
+ queueOrder: 2,
318
+ queueLane: "default",
319
+ laneOrder: 2,
320
+ queuedAttachments: [],
321
+ content: [{ type: "text", text: "prompt" }],
322
+ historyText: "prompt history",
323
+ statusSummary: "prompt",
324
+ };
325
+ const result = __telegramTestUtils.planNextTelegramQueueAction(
326
+ [promptItem],
327
+ false,
328
+ );
329
+ assert.equal(result.kind, "none");
330
+ assert.deepEqual(
331
+ result.remainingItems.map((item) => item.statusSummary),
332
+ ["prompt"],
333
+ );
334
+ });
335
+
336
+ test("Control-item dispatch sequencing hands off to the next prompt", () => {
337
+ const queueItemType = undefined as
338
+ | Parameters<
339
+ typeof __telegramTestUtils.planNextTelegramQueueAction
340
+ >[0][number]
341
+ | undefined;
342
+ const controlItem: typeof queueItemType = {
343
+ kind: "control",
344
+ controlType: "status",
345
+ chatId: 1,
346
+ replyToMessageId: 1,
347
+ queueOrder: 1,
348
+ queueLane: "control",
349
+ laneOrder: 0,
350
+ statusSummary: "control",
351
+ execute: async () => {},
352
+ };
353
+ const promptItem: typeof queueItemType = {
354
+ kind: "prompt",
355
+ chatId: 1,
356
+ replyToMessageId: 2,
357
+ sourceMessageIds: [2],
358
+ queueOrder: 2,
359
+ queueLane: "default",
360
+ laneOrder: 2,
361
+ queuedAttachments: [],
362
+ content: [{ type: "text", text: "prompt" }],
363
+ historyText: "prompt history",
364
+ statusSummary: "prompt",
365
+ };
366
+ const firstStep = __telegramTestUtils.planNextTelegramQueueAction(
367
+ [controlItem, promptItem],
368
+ true,
369
+ );
370
+ assert.equal(firstStep.kind, "control");
371
+ const secondStep = __telegramTestUtils.planNextTelegramQueueAction(
372
+ firstStep.remainingItems,
373
+ true,
374
+ );
375
+ assert.equal(secondStep.kind, "prompt");
376
+ assert.equal(
377
+ secondStep.kind === "prompt" ? secondStep.item.statusSummary : "",
378
+ "prompt",
379
+ );
380
+ });
381
+
382
+ test("Preserved abort leaves queued prompts waiting for explicit continuation", () => {
383
+ assert.equal(
384
+ __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({
385
+ hasTurn: true,
386
+ stopReason: "aborted",
387
+ preserveQueuedTurnsAsHistory: true,
388
+ }),
389
+ false,
390
+ );
391
+ const queueItemType = undefined as
392
+ | Parameters<
393
+ typeof __telegramTestUtils.planNextTelegramQueueAction
394
+ >[0][number]
395
+ | undefined;
396
+ const promptItem: typeof queueItemType = {
397
+ kind: "prompt",
398
+ chatId: 1,
399
+ replyToMessageId: 2,
400
+ sourceMessageIds: [2],
401
+ queueOrder: 2,
402
+ queueLane: "default",
403
+ laneOrder: 2,
404
+ queuedAttachments: [],
405
+ content: [{ type: "text", text: "prompt" }],
406
+ historyText: "prompt history",
407
+ statusSummary: "prompt",
408
+ };
409
+ const blockedDispatch = __telegramTestUtils.planNextTelegramQueueAction(
410
+ [promptItem],
411
+ __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({
412
+ hasTurn: true,
413
+ stopReason: "aborted",
414
+ preserveQueuedTurnsAsHistory: true,
415
+ }),
416
+ );
417
+ assert.equal(blockedDispatch.kind, "none");
418
+ assert.deepEqual(
419
+ blockedDispatch.remainingItems.map((item) => item.statusSummary),
420
+ ["prompt"],
421
+ );
422
+ });
423
+
424
+ test("Agent end dispatch policy resumes after success and error, but not preserved aborts", () => {
425
+ assert.equal(
426
+ __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({
427
+ hasTurn: false,
428
+ preserveQueuedTurnsAsHistory: false,
429
+ }),
430
+ true,
431
+ );
432
+ assert.equal(
433
+ __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({
434
+ hasTurn: true,
435
+ stopReason: "error",
436
+ preserveQueuedTurnsAsHistory: false,
437
+ }),
438
+ true,
439
+ );
440
+ assert.equal(
441
+ __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({
442
+ hasTurn: true,
443
+ stopReason: "aborted",
444
+ preserveQueuedTurnsAsHistory: false,
445
+ }),
446
+ true,
447
+ );
448
+ assert.equal(
449
+ __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({
450
+ hasTurn: true,
451
+ stopReason: "aborted",
452
+ preserveQueuedTurnsAsHistory: true,
453
+ }),
454
+ false,
455
+ );
456
+ });
457
+
458
+ test("Agent end plan classifies turn outcomes correctly", () => {
459
+ const noTurnPlan = __telegramTestUtils.buildTelegramAgentEndPlan({
460
+ hasTurn: false,
461
+ preserveQueuedTurnsAsHistory: false,
462
+ hasFinalText: false,
463
+ hasQueuedAttachments: false,
464
+ });
465
+ assert.equal(noTurnPlan.kind, "no-turn");
466
+ assert.equal(noTurnPlan.shouldDispatchNext, true);
467
+ const abortedPlan = __telegramTestUtils.buildTelegramAgentEndPlan({
468
+ hasTurn: true,
469
+ stopReason: "aborted",
470
+ preserveQueuedTurnsAsHistory: true,
471
+ hasFinalText: false,
472
+ hasQueuedAttachments: true,
473
+ });
474
+ assert.equal(abortedPlan.kind, "aborted");
475
+ assert.equal(abortedPlan.shouldClearPreview, true);
476
+ assert.equal(abortedPlan.shouldDispatchNext, false);
477
+ const errorPlan = __telegramTestUtils.buildTelegramAgentEndPlan({
478
+ hasTurn: true,
479
+ stopReason: "error",
480
+ preserveQueuedTurnsAsHistory: false,
481
+ hasFinalText: false,
482
+ hasQueuedAttachments: false,
483
+ });
484
+ assert.equal(errorPlan.kind, "error");
485
+ assert.equal(errorPlan.shouldSendErrorMessage, true);
486
+ const attachmentPlan = __telegramTestUtils.buildTelegramAgentEndPlan({
487
+ hasTurn: true,
488
+ preserveQueuedTurnsAsHistory: false,
489
+ hasFinalText: false,
490
+ hasQueuedAttachments: true,
491
+ });
492
+ assert.equal(attachmentPlan.kind, "attachments-only");
493
+ assert.equal(attachmentPlan.shouldSendAttachmentNotice, true);
494
+ const textPlan = __telegramTestUtils.buildTelegramAgentEndPlan({
495
+ hasTurn: true,
496
+ preserveQueuedTurnsAsHistory: false,
497
+ hasFinalText: true,
498
+ hasQueuedAttachments: false,
499
+ });
500
+ assert.equal(textPlan.kind, "text");
501
+ assert.equal(textPlan.shouldClearPreview, false);
502
+ });
503
+
504
+ test("Agent start plan consumes a dispatched prompt and resets transient flags", () => {
505
+ const queueItemType = undefined as
506
+ | Parameters<typeof buildTelegramAgentStartPlan>[0]["queuedItems"][number]
507
+ | undefined;
508
+ const promptItem: typeof queueItemType = {
509
+ kind: "prompt",
510
+ chatId: 1,
511
+ replyToMessageId: 2,
512
+ sourceMessageIds: [2],
513
+ queueOrder: 2,
514
+ queueLane: "default",
515
+ laneOrder: 2,
516
+ queuedAttachments: [],
517
+ content: [{ type: "text", text: "prompt" }],
518
+ historyText: "prompt history",
519
+ statusSummary: "prompt",
520
+ };
521
+ const plan = buildTelegramAgentStartPlan({
522
+ queuedItems: [promptItem],
523
+ hasPendingDispatch: true,
524
+ hasActiveTurn: false,
525
+ });
526
+ assert.equal(plan.activeTurn?.statusSummary, "prompt");
527
+ assert.equal(plan.shouldClearDispatchPending, true);
528
+ assert.equal(plan.shouldResetPendingModelSwitch, true);
529
+ assert.equal(plan.shouldResetToolExecutions, true);
530
+ assert.deepEqual(plan.remainingItems, []);
531
+ });
532
+
533
+ test("Tool execution count helper respects active-turn presence", () => {
534
+ assert.equal(
535
+ getNextTelegramToolExecutionCount({
536
+ hasActiveTurn: true,
537
+ currentCount: 0,
538
+ event: "start",
539
+ }),
540
+ 1,
541
+ );
542
+ assert.equal(
543
+ getNextTelegramToolExecutionCount({
544
+ hasActiveTurn: true,
545
+ currentCount: 1,
546
+ event: "end",
547
+ }),
548
+ 0,
549
+ );
550
+ assert.equal(
551
+ getNextTelegramToolExecutionCount({
552
+ hasActiveTurn: false,
553
+ currentCount: 3,
554
+ event: "end",
555
+ }),
556
+ 3,
557
+ );
558
+ });
559
+
560
+ test("Dispatch is allowed only when every guard is clear", () => {
561
+ assert.equal(
562
+ __telegramTestUtils.canDispatchTelegramTurnState({
563
+ compactionInProgress: false,
564
+ hasActiveTelegramTurn: false,
565
+ hasPendingTelegramDispatch: false,
566
+ isIdle: true,
567
+ hasPendingMessages: false,
568
+ }),
569
+ true,
570
+ );
571
+ });
572
+
573
+ test("Dispatch is blocked during compaction", () => {
574
+ assert.equal(
575
+ __telegramTestUtils.canDispatchTelegramTurnState({
576
+ compactionInProgress: true,
577
+ hasActiveTelegramTurn: false,
578
+ hasPendingTelegramDispatch: false,
579
+ isIdle: true,
580
+ hasPendingMessages: false,
581
+ }),
582
+ false,
583
+ );
584
+ });
585
+
586
+ test("Dispatch is blocked while a Telegram turn is active or pending", () => {
587
+ assert.equal(
588
+ __telegramTestUtils.canDispatchTelegramTurnState({
589
+ compactionInProgress: false,
590
+ hasActiveTelegramTurn: true,
591
+ hasPendingTelegramDispatch: false,
592
+ isIdle: true,
593
+ hasPendingMessages: false,
594
+ }),
595
+ false,
596
+ );
597
+ assert.equal(
598
+ __telegramTestUtils.canDispatchTelegramTurnState({
599
+ compactionInProgress: false,
600
+ hasActiveTelegramTurn: false,
601
+ hasPendingTelegramDispatch: true,
602
+ isIdle: true,
603
+ hasPendingMessages: false,
604
+ }),
605
+ false,
606
+ );
607
+ });
608
+
609
+ test("Dispatch is blocked when pi is busy or has pending messages", () => {
610
+ assert.equal(
611
+ __telegramTestUtils.canDispatchTelegramTurnState({
612
+ compactionInProgress: false,
613
+ hasActiveTelegramTurn: false,
614
+ hasPendingTelegramDispatch: false,
615
+ isIdle: false,
616
+ hasPendingMessages: false,
617
+ }),
618
+ false,
619
+ );
620
+ assert.equal(
621
+ __telegramTestUtils.canDispatchTelegramTurnState({
622
+ compactionInProgress: false,
623
+ hasActiveTelegramTurn: false,
624
+ hasPendingTelegramDispatch: false,
625
+ isIdle: true,
626
+ hasPendingMessages: true,
627
+ }),
628
+ false,
629
+ );
630
+ });
631
+
632
+ test("In-flight model switch is allowed only for active Telegram turns with abort support", () => {
633
+ assert.equal(
634
+ __telegramTestUtils.canRestartTelegramTurnForModelSwitch({
635
+ isIdle: false,
636
+ hasActiveTelegramTurn: true,
637
+ hasAbortHandler: true,
638
+ }),
639
+ true,
640
+ );
641
+ assert.equal(
642
+ __telegramTestUtils.canRestartTelegramTurnForModelSwitch({
643
+ isIdle: true,
644
+ hasActiveTelegramTurn: true,
645
+ hasAbortHandler: true,
646
+ }),
647
+ false,
648
+ );
649
+ assert.equal(
650
+ __telegramTestUtils.canRestartTelegramTurnForModelSwitch({
651
+ isIdle: false,
652
+ hasActiveTelegramTurn: false,
653
+ hasAbortHandler: true,
654
+ }),
655
+ false,
656
+ );
657
+ assert.equal(
658
+ __telegramTestUtils.canRestartTelegramTurnForModelSwitch({
659
+ isIdle: false,
660
+ hasActiveTelegramTurn: true,
661
+ hasAbortHandler: false,
662
+ }),
663
+ false,
664
+ );
665
+ });
666
+
667
+ test("Pending model switch abort waits until no tool executions remain", () => {
668
+ assert.equal(
669
+ __telegramTestUtils.shouldTriggerPendingTelegramModelSwitchAbort({
670
+ hasPendingModelSwitch: true,
671
+ hasActiveTelegramTurn: true,
672
+ hasAbortHandler: true,
673
+ activeToolExecutions: 0,
674
+ }),
675
+ true,
676
+ );
677
+ assert.equal(
678
+ __telegramTestUtils.shouldTriggerPendingTelegramModelSwitchAbort({
679
+ hasPendingModelSwitch: true,
680
+ hasActiveTelegramTurn: true,
681
+ hasAbortHandler: true,
682
+ activeToolExecutions: 1,
683
+ }),
684
+ false,
685
+ );
686
+ assert.equal(
687
+ __telegramTestUtils.shouldTriggerPendingTelegramModelSwitchAbort({
688
+ hasPendingModelSwitch: false,
689
+ hasActiveTelegramTurn: true,
690
+ hasAbortHandler: true,
691
+ activeToolExecutions: 0,
692
+ }),
693
+ false,
694
+ );
695
+ });
696
+
697
+ test("Model-switch continuation restart queues before abort when state is present", () => {
698
+ const events: string[] = [];
699
+ assert.equal(
700
+ __telegramTestUtils.restartTelegramModelSwitchContinuation({
701
+ activeTurn: { id: 1 },
702
+ abort: () => {
703
+ events.push("abort");
704
+ },
705
+ selection: { model: { provider: "openai", id: "gpt-5" } },
706
+ queueContinuation: (turn, selection) => {
707
+ events.push(`queue:${turn.id}:${selection.model.id}`);
708
+ },
709
+ }),
710
+ true,
711
+ );
712
+ assert.deepEqual(events, ["queue:1:gpt-5", "abort"]);
713
+ assert.equal(
714
+ __telegramTestUtils.restartTelegramModelSwitchContinuation({
715
+ activeTurn: undefined,
716
+ abort: () => {},
717
+ selection: { model: { provider: "openai", id: "gpt-5" } },
718
+ queueContinuation: () => {
719
+ events.push("unexpected");
720
+ },
721
+ }),
722
+ false,
723
+ );
724
+ });
725
+
726
+ test("Continuation prompt stays Telegram-scoped and resume-oriented", () => {
727
+ const text = __telegramTestUtils.buildTelegramModelSwitchContinuationText(
728
+ { provider: "openai", id: "gpt-5" },
729
+ "high",
730
+ );
731
+ assert.match(text, /^\[telegram\]/);
732
+ assert.match(text, /Continue the interrupted previous Telegram request/);
733
+ assert.match(text, /openai\/gpt-5/);
734
+ assert.match(text, /thinking level \(high\)/);
735
+ });
736
+
737
+ test("Control runtime runs the control item and always settles", async () => {
738
+ const events: string[] = [];
739
+ await executeTelegramControlItemRuntime(
740
+ {
741
+ kind: "control",
742
+ controlType: "status",
743
+ chatId: 1,
744
+ replyToMessageId: 2,
745
+ queueOrder: 1,
746
+ queueLane: "control",
747
+ laneOrder: 0,
748
+ statusSummary: "status",
749
+ execute: async () => {
750
+ events.push("execute");
751
+ },
752
+ },
753
+ {
754
+ ctx: {} as never,
755
+ sendTextReply: async () => {
756
+ events.push("reply");
757
+ return undefined;
758
+ },
759
+ onSettled: () => {
760
+ events.push("settled");
761
+ },
762
+ },
763
+ );
764
+ assert.deepEqual(events, ["execute", "settled"]);
765
+ });
766
+
767
+ test("Control runtime reports failures before settling", async () => {
768
+ const events: string[] = [];
769
+ await executeTelegramControlItemRuntime(
770
+ {
771
+ kind: "control",
772
+ controlType: "model",
773
+ chatId: 3,
774
+ replyToMessageId: 4,
775
+ queueOrder: 2,
776
+ queueLane: "control",
777
+ laneOrder: 1,
778
+ statusSummary: "model",
779
+ execute: async () => {
780
+ throw new Error("boom");
781
+ },
782
+ },
783
+ {
784
+ ctx: {} as never,
785
+ sendTextReply: async (_chatId, _replyToMessageId, text) => {
786
+ events.push(text);
787
+ return undefined;
788
+ },
789
+ onSettled: () => {
790
+ events.push("settled");
791
+ },
792
+ },
793
+ );
794
+ assert.deepEqual(events, ["Telegram control action failed: boom", "settled"]);
795
+ });
796
+
797
+ test("Dispatch runtime idles on none and executes control items directly", () => {
798
+ const events: string[] = [];
799
+ executeTelegramQueueDispatchPlan(
800
+ { kind: "none", remainingItems: [] },
801
+ {
802
+ executeControlItem: () => {
803
+ events.push("control");
804
+ },
805
+ onPromptDispatchStart: () => {
806
+ events.push("prompt-start");
807
+ },
808
+ sendUserMessage: () => {
809
+ events.push("prompt");
810
+ },
811
+ onPromptDispatchFailure: (message) => {
812
+ events.push(`error:${message}`);
813
+ },
814
+ onIdle: () => {
815
+ events.push("idle");
816
+ },
817
+ },
818
+ );
819
+ executeTelegramQueueDispatchPlan(
820
+ {
821
+ kind: "control",
822
+ item: {
823
+ kind: "control",
824
+ controlType: "status",
825
+ chatId: 1,
826
+ replyToMessageId: 1,
827
+ queueOrder: 1,
828
+ queueLane: "control",
829
+ laneOrder: 0,
830
+ statusSummary: "status",
831
+ execute: async () => {},
832
+ },
833
+ remainingItems: [],
834
+ },
835
+ {
836
+ executeControlItem: () => {
837
+ events.push("control");
838
+ },
839
+ onPromptDispatchStart: () => {
840
+ events.push("prompt-start");
841
+ },
842
+ sendUserMessage: () => {
843
+ events.push("prompt");
844
+ },
845
+ onPromptDispatchFailure: (message) => {
846
+ events.push(`error:${message}`);
847
+ },
848
+ onIdle: () => {
849
+ events.push("idle");
850
+ },
851
+ },
852
+ );
853
+ assert.deepEqual(events, ["idle", "control"]);
854
+ });
855
+
856
+ test("Dispatch runtime reports prompt dispatch failures after starting", () => {
857
+ const events: string[] = [];
858
+ executeTelegramQueueDispatchPlan(
859
+ {
860
+ kind: "prompt",
861
+ item: {
862
+ kind: "prompt",
863
+ chatId: 2,
864
+ replyToMessageId: 3,
865
+ sourceMessageIds: [3],
866
+ queueOrder: 2,
867
+ queueLane: "default",
868
+ laneOrder: 2,
869
+ queuedAttachments: [],
870
+ content: [{ type: "text", text: "prompt" }],
871
+ historyText: "prompt",
872
+ statusSummary: "prompt",
873
+ },
874
+ remainingItems: [],
875
+ },
876
+ {
877
+ executeControlItem: () => {
878
+ events.push("control");
879
+ },
880
+ onPromptDispatchStart: (chatId) => {
881
+ events.push(`start:${chatId}`);
882
+ },
883
+ sendUserMessage: () => {
884
+ throw new Error("boom");
885
+ },
886
+ onPromptDispatchFailure: (message) => {
887
+ events.push(`error:${message}`);
888
+ },
889
+ onIdle: () => {
890
+ events.push("idle");
891
+ },
892
+ },
893
+ );
894
+ assert.deepEqual(events, ["start:2", "error:boom"]);
895
+ });
896
+
897
+ test("Session runtime helper starts polling only when a bot token exists and polling is idle", () => {
898
+ assert.equal(
899
+ shouldStartTelegramPolling({
900
+ hasBotToken: true,
901
+ hasPollingPromise: false,
902
+ }),
903
+ true,
904
+ );
905
+ assert.equal(
906
+ shouldStartTelegramPolling({
907
+ hasBotToken: false,
908
+ hasPollingPromise: false,
909
+ }),
910
+ false,
911
+ );
912
+ assert.equal(
913
+ shouldStartTelegramPolling({
914
+ hasBotToken: true,
915
+ hasPollingPromise: true,
916
+ }),
917
+ false,
918
+ );
919
+ });
920
+
921
+ test("Session runtime helper resets session start state", () => {
922
+ const currentModel = { provider: "openai", id: "gpt-5" } as const;
923
+ const state = buildTelegramSessionStartState(currentModel as never);
924
+ assert.equal(state.currentTelegramModel, currentModel);
925
+ assert.equal(state.activeTelegramToolExecutions, 0);
926
+ assert.equal(state.nextQueuedTelegramItemOrder, 0);
927
+ assert.equal(state.nextQueuedTelegramControlOrder, 0);
928
+ assert.equal(state.telegramTurnDispatchPending, false);
929
+ assert.equal(state.compactionInProgress, false);
930
+ });
931
+
932
+ test("Session runtime helper clears shutdown state", () => {
933
+ const state = buildTelegramSessionShutdownState<string>();
934
+ assert.deepEqual(state.queuedTelegramItems, []);
935
+ assert.equal(state.nextQueuedTelegramItemOrder, 0);
936
+ assert.equal(state.nextQueuedTelegramControlOrder, 0);
937
+ assert.equal(state.nextPriorityReactionOrder, 0);
938
+ assert.equal(state.currentTelegramModel, undefined);
939
+ assert.equal(state.activeTelegramToolExecutions, 0);
940
+ assert.equal(state.telegramTurnDispatchPending, false);
941
+ assert.equal(state.compactionInProgress, false);
942
+ assert.equal(state.preserveQueuedTurnsAsHistory, false);
943
+ });
944
+
945
+ test("Extension runtime polls, pairs, and dispatches an inbound Telegram turn into pi", async () => {
946
+ const agentDir = join(homedir(), ".pi", "agent");
947
+ const configPath = join(agentDir, "telegram.json");
948
+ const previousConfig = await readFile(configPath, "utf8").catch(
949
+ () => undefined,
950
+ );
951
+ const handlers = new Map<
952
+ string,
953
+ (event: unknown, ctx: unknown) => Promise<unknown>
954
+ >();
955
+ const commands = new Map<
956
+ string,
957
+ { handler: (args: string, ctx: unknown) => Promise<void> }
958
+ >();
959
+ const sentMessages: Array<string | Array<{ type: string; text?: string }>> =
960
+ [];
961
+ let resolveDispatch:
962
+ | ((value: string | Array<{ type: string; text?: string }>) => void)
963
+ | undefined;
964
+ const dispatched = new Promise<
965
+ string | Array<{ type: string; text?: string }>
966
+ >((resolve) => {
967
+ resolveDispatch = resolve;
968
+ });
969
+ const pi = {
970
+ on: (
971
+ event: string,
972
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
973
+ ) => {
974
+ handlers.set(event, handler);
975
+ },
976
+ registerCommand: (
977
+ name: string,
978
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
979
+ ) => {
980
+ commands.set(name, definition);
981
+ },
982
+ registerTool: () => {},
983
+ sendUserMessage: (
984
+ content: string | Array<{ type: string; text?: string }>,
985
+ ) => {
986
+ sentMessages.push(content);
987
+ resolveDispatch?.(content);
988
+ },
989
+ getThinkingLevel: () => "medium",
990
+ } as never;
991
+ const originalFetch = globalThis.fetch;
992
+ let getUpdatesCalls = 0;
993
+ const apiCalls: string[] = [];
994
+ globalThis.fetch = async (input) => {
995
+ const url = typeof input === "string" ? input : input.toString();
996
+ const method = url.split("/").at(-1) ?? "";
997
+ apiCalls.push(method);
998
+ if (method === "deleteWebhook") {
999
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1000
+ }
1001
+ if (method === "getUpdates") {
1002
+ getUpdatesCalls += 1;
1003
+ if (getUpdatesCalls === 1) {
1004
+ return {
1005
+ json: async () => ({
1006
+ ok: true,
1007
+ result: [
1008
+ {
1009
+ _: "other",
1010
+ update_id: 1,
1011
+ message: {
1012
+ message_id: 42,
1013
+ chat: { id: 99, type: "private" },
1014
+ from: { id: 77, is_bot: false, first_name: "Test" },
1015
+ text: "hello from telegram",
1016
+ },
1017
+ },
1018
+ ],
1019
+ }),
1020
+ } as Response;
1021
+ }
1022
+ throw new DOMException("stop", "AbortError");
1023
+ }
1024
+ if (method === "sendMessage") {
1025
+ return {
1026
+ json: async () => ({ ok: true, result: { message_id: 100 } }),
1027
+ } as Response;
1028
+ }
1029
+ if (method === "sendChatAction") {
1030
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1031
+ }
1032
+ throw new Error(`Unexpected Telegram API method: ${method}`);
1033
+ };
1034
+ try {
1035
+ await mkdir(agentDir, { recursive: true });
1036
+ await writeFile(
1037
+ configPath,
1038
+ JSON.stringify({ botToken: "123:abc", lastUpdateId: 0 }, null, "\t") +
1039
+ "\n",
1040
+ "utf8",
1041
+ );
1042
+ telegramExtension(pi);
1043
+ const ctx = {
1044
+ hasUI: true,
1045
+ model: undefined,
1046
+ signal: undefined,
1047
+ ui: {
1048
+ theme: {
1049
+ fg: (_token: string, text: string) => text,
1050
+ },
1051
+ setStatus: () => {},
1052
+ notify: () => {},
1053
+ },
1054
+ isIdle: () => true,
1055
+ hasPendingMessages: () => false,
1056
+ abort: () => {},
1057
+ } as never;
1058
+ await handlers.get("session_start")?.({}, ctx);
1059
+ await commands.get("telegram-connect")?.handler("", ctx);
1060
+ const dispatchedContent = await dispatched;
1061
+ assert.equal(sentMessages.length, 1);
1062
+ assert.equal(Array.isArray(dispatchedContent), true);
1063
+ assert.equal(apiCalls.includes("sendMessage"), true);
1064
+ assert.equal(apiCalls.includes("sendChatAction"), true);
1065
+ const promptBlocks = dispatchedContent as Array<{
1066
+ type: string;
1067
+ text?: string;
1068
+ }>;
1069
+ assert.equal(promptBlocks[0]?.type, "text");
1070
+ assert.match(
1071
+ promptBlocks[0]?.text ?? "",
1072
+ /^\[telegram\] hello from telegram$/,
1073
+ );
1074
+ await handlers.get("session_shutdown")?.({}, ctx);
1075
+ } finally {
1076
+ globalThis.fetch = originalFetch;
1077
+ if (previousConfig === undefined) {
1078
+ await rm(configPath, { force: true });
1079
+ } else {
1080
+ await writeFile(configPath, previousConfig, "utf8");
1081
+ }
1082
+ }
1083
+ });
1084
+
1085
+ test("Extension runtime finalizes a drafted preview into the final Telegram reply on agent end", async () => {
1086
+ const agentDir = join(homedir(), ".pi", "agent");
1087
+ const configPath = join(agentDir, "telegram.json");
1088
+ const previousConfig = await readFile(configPath, "utf8").catch(
1089
+ () => undefined,
1090
+ );
1091
+ const handlers = new Map<
1092
+ string,
1093
+ (event: unknown, ctx: unknown) => Promise<unknown>
1094
+ >();
1095
+ const commands = new Map<
1096
+ string,
1097
+ { handler: (args: string, ctx: unknown) => Promise<void> }
1098
+ >();
1099
+ let resolveDispatch: (() => void) | undefined;
1100
+ const dispatched = new Promise<void>((resolve) => {
1101
+ resolveDispatch = resolve;
1102
+ });
1103
+ const draftTexts: string[] = [];
1104
+ const sentTexts: string[] = [];
1105
+ const pi = {
1106
+ on: (
1107
+ event: string,
1108
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
1109
+ ) => {
1110
+ handlers.set(event, handler);
1111
+ },
1112
+ registerCommand: (
1113
+ name: string,
1114
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
1115
+ ) => {
1116
+ commands.set(name, definition);
1117
+ },
1118
+ registerTool: () => {},
1119
+ sendUserMessage: () => {
1120
+ resolveDispatch?.();
1121
+ },
1122
+ getThinkingLevel: () => "medium",
1123
+ } as never;
1124
+ const originalFetch = globalThis.fetch;
1125
+ let getUpdatesCalls = 0;
1126
+ globalThis.fetch = async (input, init) => {
1127
+ const url = typeof input === "string" ? input : input.toString();
1128
+ const method = url.split("/").at(-1) ?? "";
1129
+ const body =
1130
+ typeof init?.body === "string"
1131
+ ? (JSON.parse(init.body) as Record<string, unknown>)
1132
+ : undefined;
1133
+ if (method === "deleteWebhook") {
1134
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1135
+ }
1136
+ if (method === "getUpdates") {
1137
+ getUpdatesCalls += 1;
1138
+ if (getUpdatesCalls === 1) {
1139
+ return {
1140
+ json: async () => ({
1141
+ ok: true,
1142
+ result: [
1143
+ {
1144
+ _: "other",
1145
+ update_id: 1,
1146
+ message: {
1147
+ message_id: 7,
1148
+ chat: { id: 99, type: "private" },
1149
+ from: { id: 77, is_bot: false, first_name: "Test" },
1150
+ text: "please answer",
1151
+ },
1152
+ },
1153
+ ],
1154
+ }),
1155
+ } as Response;
1156
+ }
1157
+ throw new DOMException("stop", "AbortError");
1158
+ }
1159
+ if (method === "sendMessageDraft") {
1160
+ draftTexts.push(String(body?.text ?? ""));
1161
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1162
+ }
1163
+ if (method === "sendMessage") {
1164
+ sentTexts.push(String(body?.text ?? ""));
1165
+ return {
1166
+ json: async () => ({
1167
+ ok: true,
1168
+ result: { message_id: 100 + sentTexts.length },
1169
+ }),
1170
+ } as Response;
1171
+ }
1172
+ if (method === "sendChatAction") {
1173
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1174
+ }
1175
+ if (method === "editMessageText") {
1176
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1177
+ }
1178
+ throw new Error(`Unexpected Telegram API method: ${method}`);
1179
+ };
1180
+ try {
1181
+ await mkdir(agentDir, { recursive: true });
1182
+ await writeFile(
1183
+ configPath,
1184
+ JSON.stringify(
1185
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
1186
+ null,
1187
+ "\t",
1188
+ ) + "\n",
1189
+ "utf8",
1190
+ );
1191
+ telegramExtension(pi);
1192
+ const ctx = {
1193
+ hasUI: true,
1194
+ model: undefined,
1195
+ signal: undefined,
1196
+ ui: {
1197
+ theme: {
1198
+ fg: (_token: string, text: string) => text,
1199
+ },
1200
+ setStatus: () => {},
1201
+ notify: () => {},
1202
+ },
1203
+ isIdle: () => true,
1204
+ hasPendingMessages: () => false,
1205
+ abort: () => {},
1206
+ } as never;
1207
+ await handlers.get("session_start")?.({}, ctx);
1208
+ await commands.get("telegram-connect")?.handler("", ctx);
1209
+ await dispatched;
1210
+ await handlers.get("agent_start")?.({}, ctx);
1211
+ await handlers.get("message_update")?.(
1212
+ {
1213
+ message: {
1214
+ role: "assistant",
1215
+ content: [{ type: "text", text: "Draft **preview**" }],
1216
+ },
1217
+ },
1218
+ ctx,
1219
+ );
1220
+ await new Promise((resolve) => setTimeout(resolve, 850));
1221
+ await handlers.get("agent_end")?.(
1222
+ {
1223
+ messages: [
1224
+ {
1225
+ role: "assistant",
1226
+ content: [{ type: "text", text: "Final **answer**" }],
1227
+ },
1228
+ ],
1229
+ },
1230
+ ctx,
1231
+ );
1232
+ assert.deepEqual(draftTexts, ["Draft preview", "Final answer", ""]);
1233
+ assert.equal(sentTexts.length, 1);
1234
+ assert.match(sentTexts[0] ?? "", /Final <b>answer<\/b>/);
1235
+ await handlers.get("session_shutdown")?.({}, ctx);
1236
+ } finally {
1237
+ globalThis.fetch = originalFetch;
1238
+ if (previousConfig === undefined) {
1239
+ await rm(configPath, { force: true });
1240
+ } else {
1241
+ await writeFile(configPath, previousConfig, "utf8");
1242
+ }
1243
+ }
1244
+ });
1245
+
1246
+ test("Extension runtime carries queued follow-ups into history after an aborted turn", async () => {
1247
+ const agentDir = join(homedir(), ".pi", "agent");
1248
+ const configPath = join(agentDir, "telegram.json");
1249
+ const previousConfig = await readFile(configPath, "utf8").catch(
1250
+ () => undefined,
1251
+ );
1252
+ const handlers = new Map<
1253
+ string,
1254
+ (event: unknown, ctx: unknown) => Promise<unknown>
1255
+ >();
1256
+ const commands = new Map<
1257
+ string,
1258
+ { handler: (args: string, ctx: unknown) => Promise<void> }
1259
+ >();
1260
+ const sentMessages: Array<string | Array<{ type: string; text?: string }>> =
1261
+ [];
1262
+ let firstDispatchResolved = false;
1263
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
1264
+ let thirdUpdatesResolve: ((value: Response) => void) | undefined;
1265
+ let fourthUpdatesResolve: ((value: Response) => void) | undefined;
1266
+ const secondUpdates = new Promise<Response>((resolve) => {
1267
+ secondUpdatesResolve = resolve;
1268
+ });
1269
+ const thirdUpdates = new Promise<Response>((resolve) => {
1270
+ thirdUpdatesResolve = resolve;
1271
+ });
1272
+ const fourthUpdates = new Promise<Response>((resolve) => {
1273
+ fourthUpdatesResolve = resolve;
1274
+ });
1275
+ const pi = {
1276
+ on: (
1277
+ event: string,
1278
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
1279
+ ) => {
1280
+ handlers.set(event, handler);
1281
+ },
1282
+ registerCommand: (
1283
+ name: string,
1284
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
1285
+ ) => {
1286
+ commands.set(name, definition);
1287
+ },
1288
+ registerTool: () => {},
1289
+ sendUserMessage: (
1290
+ content: string | Array<{ type: string; text?: string }>,
1291
+ ) => {
1292
+ sentMessages.push(content);
1293
+ firstDispatchResolved = true;
1294
+ },
1295
+ getThinkingLevel: () => "medium",
1296
+ } as never;
1297
+ const originalFetch = globalThis.fetch;
1298
+ let getUpdatesCalls = 0;
1299
+ const sendTexts: string[] = [];
1300
+ globalThis.fetch = async (input, init) => {
1301
+ const url = typeof input === "string" ? input : input.toString();
1302
+ const method = url.split("/").at(-1) ?? "";
1303
+ const body =
1304
+ typeof init?.body === "string"
1305
+ ? (JSON.parse(init.body) as Record<string, unknown>)
1306
+ : undefined;
1307
+ if (method === "deleteWebhook") {
1308
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1309
+ }
1310
+ if (method === "getUpdates") {
1311
+ getUpdatesCalls += 1;
1312
+ if (getUpdatesCalls === 1) {
1313
+ return {
1314
+ json: async () => ({
1315
+ ok: true,
1316
+ result: [
1317
+ {
1318
+ _: "other",
1319
+ update_id: 1,
1320
+ message: {
1321
+ message_id: 10,
1322
+ chat: { id: 99, type: "private" },
1323
+ from: { id: 77, is_bot: false, first_name: "Test" },
1324
+ text: "first request",
1325
+ },
1326
+ },
1327
+ ],
1328
+ }),
1329
+ } as Response;
1330
+ }
1331
+ if (getUpdatesCalls === 2) return secondUpdates;
1332
+ if (getUpdatesCalls === 3) return thirdUpdates;
1333
+ if (getUpdatesCalls === 4) return fourthUpdates;
1334
+ throw new DOMException("stop", "AbortError");
1335
+ }
1336
+ if (method === "sendMessage") {
1337
+ sendTexts.push(String(body?.text ?? ""));
1338
+ return {
1339
+ json: async () => ({
1340
+ ok: true,
1341
+ result: { message_id: 100 + sendTexts.length },
1342
+ }),
1343
+ } as Response;
1344
+ }
1345
+ if (method === "sendChatAction") {
1346
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1347
+ }
1348
+ throw new Error(`Unexpected Telegram API method: ${method}`);
1349
+ };
1350
+ try {
1351
+ await mkdir(agentDir, { recursive: true });
1352
+ await writeFile(
1353
+ configPath,
1354
+ JSON.stringify(
1355
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
1356
+ null,
1357
+ "\t",
1358
+ ) + "\n",
1359
+ "utf8",
1360
+ );
1361
+ telegramExtension(pi);
1362
+ const baseCtx = {
1363
+ hasUI: true,
1364
+ model: undefined,
1365
+ signal: undefined,
1366
+ ui: {
1367
+ theme: {
1368
+ fg: (_token: string, text: string) => text,
1369
+ },
1370
+ setStatus: () => {},
1371
+ notify: () => {},
1372
+ },
1373
+ hasPendingMessages: () => false,
1374
+ };
1375
+ const idleCtx = {
1376
+ ...baseCtx,
1377
+ isIdle: () => true,
1378
+ abort: () => {},
1379
+ } as never;
1380
+ let aborted = false;
1381
+ const activeCtx = {
1382
+ ...baseCtx,
1383
+ isIdle: () => false,
1384
+ abort: () => {
1385
+ aborted = true;
1386
+ },
1387
+ } as never;
1388
+ await handlers.get("session_start")?.({}, idleCtx);
1389
+ await commands.get("telegram-connect")?.handler("", idleCtx);
1390
+ await waitForCondition(() => firstDispatchResolved);
1391
+ await handlers.get("agent_start")?.({}, activeCtx);
1392
+ secondUpdatesResolve?.({
1393
+ json: async () => ({
1394
+ ok: true,
1395
+ result: [
1396
+ {
1397
+ _: "other",
1398
+ update_id: 2,
1399
+ message: {
1400
+ message_id: 11,
1401
+ chat: { id: 99, type: "private" },
1402
+ from: { id: 77, is_bot: false, first_name: "Test" },
1403
+ text: "follow up",
1404
+ },
1405
+ },
1406
+ ],
1407
+ }),
1408
+ } as Response);
1409
+ await waitForCondition(() => getUpdatesCalls >= 3);
1410
+ thirdUpdatesResolve?.({
1411
+ json: async () => ({
1412
+ ok: true,
1413
+ result: [
1414
+ {
1415
+ _: "other",
1416
+ update_id: 3,
1417
+ message: {
1418
+ message_id: 12,
1419
+ chat: { id: 99, type: "private" },
1420
+ from: { id: 77, is_bot: false, first_name: "Test" },
1421
+ text: "/stop",
1422
+ },
1423
+ },
1424
+ ],
1425
+ }),
1426
+ } as Response);
1427
+ await waitForCondition(() => aborted);
1428
+ await handlers.get("agent_end")?.(
1429
+ {
1430
+ messages: [
1431
+ {
1432
+ role: "assistant",
1433
+ stopReason: "aborted",
1434
+ content: [{ type: "text", text: "" }],
1435
+ },
1436
+ ],
1437
+ },
1438
+ idleCtx,
1439
+ );
1440
+ const dispatchCountBeforeNextTurn = sentMessages.length;
1441
+ fourthUpdatesResolve?.({
1442
+ json: async () => ({
1443
+ ok: true,
1444
+ result: [
1445
+ {
1446
+ _: "other",
1447
+ update_id: 4,
1448
+ message: {
1449
+ message_id: 13,
1450
+ chat: { id: 99, type: "private" },
1451
+ from: { id: 77, is_bot: false, first_name: "Test" },
1452
+ text: "new request",
1453
+ },
1454
+ },
1455
+ ],
1456
+ }),
1457
+ } as Response);
1458
+ await waitForCondition(
1459
+ () => sentMessages.length === dispatchCountBeforeNextTurn + 1,
1460
+ );
1461
+ const promptBlocks = sentMessages.at(-1) as Array<{
1462
+ type: string;
1463
+ text?: string;
1464
+ }>;
1465
+ const promptText = promptBlocks[0]?.text ?? "";
1466
+ assert.match(promptText, /^\[telegram\]/);
1467
+ assert.match(
1468
+ promptText,
1469
+ /Earlier Telegram messages arrived after an aborted turn/,
1470
+ );
1471
+ assert.match(promptText, /1\. follow up/);
1472
+ assert.match(promptText, /Current Telegram message:\nnew request/);
1473
+ assert.equal(sendTexts.includes("Aborted current turn."), true);
1474
+ await handlers.get("session_shutdown")?.({}, idleCtx);
1475
+ } finally {
1476
+ globalThis.fetch = originalFetch;
1477
+ if (previousConfig === undefined) {
1478
+ await rm(configPath, { force: true });
1479
+ } else {
1480
+ await writeFile(configPath, previousConfig, "utf8");
1481
+ }
1482
+ }
1483
+ });
1484
+
1485
+ test("Extension runtime runs queued status control before the next queued prompt after agent end", async () => {
1486
+ const agentDir = join(homedir(), ".pi", "agent");
1487
+ const configPath = join(agentDir, "telegram.json");
1488
+ const previousConfig = await readFile(configPath, "utf8").catch(
1489
+ () => undefined,
1490
+ );
1491
+ const handlers = new Map<
1492
+ string,
1493
+ (event: unknown, ctx: unknown) => Promise<unknown>
1494
+ >();
1495
+ const commands = new Map<
1496
+ string,
1497
+ { handler: (args: string, ctx: unknown) => Promise<void> }
1498
+ >();
1499
+ const runtimeEvents: string[] = [];
1500
+ let firstDispatchResolved = false;
1501
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
1502
+ let thirdUpdatesResolve: ((value: Response) => void) | undefined;
1503
+ const secondUpdates = new Promise<Response>((resolve) => {
1504
+ secondUpdatesResolve = resolve;
1505
+ });
1506
+ const thirdUpdates = new Promise<Response>((resolve) => {
1507
+ thirdUpdatesResolve = resolve;
1508
+ });
1509
+ const pi = {
1510
+ on: (
1511
+ event: string,
1512
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
1513
+ ) => {
1514
+ handlers.set(event, handler);
1515
+ },
1516
+ registerCommand: (
1517
+ name: string,
1518
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
1519
+ ) => {
1520
+ commands.set(name, definition);
1521
+ },
1522
+ registerTool: () => {},
1523
+ sendUserMessage: (
1524
+ content: string | Array<{ type: string; text?: string }>,
1525
+ ) => {
1526
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
1527
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
1528
+ firstDispatchResolved = true;
1529
+ },
1530
+ getThinkingLevel: () => "medium",
1531
+ } as never;
1532
+ const originalFetch = globalThis.fetch;
1533
+ let getUpdatesCalls = 0;
1534
+ globalThis.fetch = async (input, init) => {
1535
+ const url = typeof input === "string" ? input : input.toString();
1536
+ const method = url.split("/").at(-1) ?? "";
1537
+ const body =
1538
+ typeof init?.body === "string"
1539
+ ? (JSON.parse(init.body) as Record<string, unknown>)
1540
+ : undefined;
1541
+ if (method === "deleteWebhook") {
1542
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1543
+ }
1544
+ if (method === "getUpdates") {
1545
+ getUpdatesCalls += 1;
1546
+ if (getUpdatesCalls === 1) {
1547
+ return {
1548
+ json: async () => ({
1549
+ ok: true,
1550
+ result: [
1551
+ {
1552
+ _: "other",
1553
+ update_id: 1,
1554
+ message: {
1555
+ message_id: 20,
1556
+ chat: { id: 99, type: "private" },
1557
+ from: { id: 77, is_bot: false, first_name: "Test" },
1558
+ text: "first request",
1559
+ },
1560
+ },
1561
+ ],
1562
+ }),
1563
+ } as Response;
1564
+ }
1565
+ if (getUpdatesCalls === 2) return secondUpdates;
1566
+ if (getUpdatesCalls === 3) return thirdUpdates;
1567
+ throw new DOMException("stop", "AbortError");
1568
+ }
1569
+ if (method === "sendMessage") {
1570
+ runtimeEvents.push(`send:${String(body?.text ?? "")}`);
1571
+ return {
1572
+ json: async () => ({
1573
+ ok: true,
1574
+ result: { message_id: 100 + runtimeEvents.length },
1575
+ }),
1576
+ } as Response;
1577
+ }
1578
+ if (method === "sendChatAction") {
1579
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1580
+ }
1581
+ throw new Error(`Unexpected Telegram API method: ${method}`);
1582
+ };
1583
+ try {
1584
+ await mkdir(agentDir, { recursive: true });
1585
+ await writeFile(
1586
+ configPath,
1587
+ JSON.stringify(
1588
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
1589
+ null,
1590
+ "\t",
1591
+ ) + "\n",
1592
+ "utf8",
1593
+ );
1594
+ telegramExtension(pi);
1595
+ const baseCtx = {
1596
+ hasUI: true,
1597
+ cwd: process.cwd(),
1598
+ model: undefined,
1599
+ signal: undefined,
1600
+ ui: {
1601
+ theme: {
1602
+ fg: (_token: string, text: string) => text,
1603
+ },
1604
+ setStatus: () => {},
1605
+ notify: () => {},
1606
+ },
1607
+ sessionManager: {
1608
+ getEntries: () => [],
1609
+ },
1610
+ modelRegistry: {
1611
+ refresh: () => {},
1612
+ getAvailable: () => [],
1613
+ isUsingOAuth: () => false,
1614
+ },
1615
+ getContextUsage: () => undefined,
1616
+ hasPendingMessages: () => false,
1617
+ abort: () => {},
1618
+ };
1619
+ const idleCtx = {
1620
+ ...baseCtx,
1621
+ isIdle: () => true,
1622
+ } as never;
1623
+ const activeCtx = {
1624
+ ...baseCtx,
1625
+ isIdle: () => false,
1626
+ } as never;
1627
+ await handlers.get("session_start")?.({}, idleCtx);
1628
+ await commands.get("telegram-connect")?.handler("", idleCtx);
1629
+ await waitForCondition(() => firstDispatchResolved);
1630
+ await handlers.get("agent_start")?.({}, activeCtx);
1631
+ secondUpdatesResolve?.({
1632
+ json: async () => ({
1633
+ ok: true,
1634
+ result: [
1635
+ {
1636
+ _: "other",
1637
+ update_id: 2,
1638
+ message: {
1639
+ message_id: 21,
1640
+ chat: { id: 99, type: "private" },
1641
+ from: { id: 77, is_bot: false, first_name: "Test" },
1642
+ text: "/status",
1643
+ },
1644
+ },
1645
+ ],
1646
+ }),
1647
+ } as Response);
1648
+ await waitForCondition(() => getUpdatesCalls >= 3);
1649
+ thirdUpdatesResolve?.({
1650
+ json: async () => ({
1651
+ ok: true,
1652
+ result: [
1653
+ {
1654
+ _: "other",
1655
+ update_id: 3,
1656
+ message: {
1657
+ message_id: 22,
1658
+ chat: { id: 99, type: "private" },
1659
+ from: { id: 77, is_bot: false, first_name: "Test" },
1660
+ text: "follow up after status",
1661
+ },
1662
+ },
1663
+ ],
1664
+ }),
1665
+ } as Response);
1666
+ await waitForCondition(() => runtimeEvents.length >= 1);
1667
+ await handlers.get("agent_end")?.(
1668
+ {
1669
+ messages: [
1670
+ {
1671
+ role: "assistant",
1672
+ content: [{ type: "text", text: "" }],
1673
+ },
1674
+ ],
1675
+ },
1676
+ idleCtx,
1677
+ );
1678
+ await waitForCondition(() => runtimeEvents.length >= 3);
1679
+ assert.equal(runtimeEvents[0], "dispatch:[telegram] first request");
1680
+ assert.match(runtimeEvents[1] ?? "", /^send:<b>Context:<\/b>/);
1681
+ assert.equal(
1682
+ runtimeEvents[2],
1683
+ "dispatch:[telegram] follow up after status",
1684
+ );
1685
+ await handlers.get("session_shutdown")?.({}, idleCtx);
1686
+ } finally {
1687
+ globalThis.fetch = originalFetch;
1688
+ if (previousConfig === undefined) {
1689
+ await rm(configPath, { force: true });
1690
+ } else {
1691
+ await writeFile(configPath, previousConfig, "utf8");
1692
+ }
1693
+ }
1694
+ });
1695
+
1696
+ test("Extension runtime runs queued model control before the next queued prompt after agent end", async () => {
1697
+ const agentDir = join(homedir(), ".pi", "agent");
1698
+ const configPath = join(agentDir, "telegram.json");
1699
+ const previousConfig = await readFile(configPath, "utf8").catch(
1700
+ () => undefined,
1701
+ );
1702
+ const handlers = new Map<
1703
+ string,
1704
+ (event: unknown, ctx: unknown) => Promise<unknown>
1705
+ >();
1706
+ const commands = new Map<
1707
+ string,
1708
+ { handler: (args: string, ctx: unknown) => Promise<void> }
1709
+ >();
1710
+ const runtimeEvents: string[] = [];
1711
+ const modelA = {
1712
+ provider: "openai",
1713
+ id: "gpt-a",
1714
+ reasoning: true,
1715
+ } as const;
1716
+ const modelB = {
1717
+ provider: "anthropic",
1718
+ id: "claude-b",
1719
+ reasoning: false,
1720
+ } as const;
1721
+ let firstDispatchResolved = false;
1722
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
1723
+ let thirdUpdatesResolve: ((value: Response) => void) | undefined;
1724
+ const secondUpdates = new Promise<Response>((resolve) => {
1725
+ secondUpdatesResolve = resolve;
1726
+ });
1727
+ const thirdUpdates = new Promise<Response>((resolve) => {
1728
+ thirdUpdatesResolve = resolve;
1729
+ });
1730
+ const pi = {
1731
+ on: (
1732
+ event: string,
1733
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
1734
+ ) => {
1735
+ handlers.set(event, handler);
1736
+ },
1737
+ registerCommand: (
1738
+ name: string,
1739
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
1740
+ ) => {
1741
+ commands.set(name, definition);
1742
+ },
1743
+ registerTool: () => {},
1744
+ sendUserMessage: (
1745
+ content: string | Array<{ type: string; text?: string }>,
1746
+ ) => {
1747
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
1748
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
1749
+ firstDispatchResolved = true;
1750
+ },
1751
+ getThinkingLevel: () => "medium",
1752
+ } as never;
1753
+ const originalFetch = globalThis.fetch;
1754
+ let getUpdatesCalls = 0;
1755
+ globalThis.fetch = async (input, init) => {
1756
+ const url = typeof input === "string" ? input : input.toString();
1757
+ const method = url.split("/").at(-1) ?? "";
1758
+ const body =
1759
+ typeof init?.body === "string"
1760
+ ? (JSON.parse(init.body) as Record<string, unknown>)
1761
+ : undefined;
1762
+ if (method === "deleteWebhook") {
1763
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1764
+ }
1765
+ if (method === "getUpdates") {
1766
+ getUpdatesCalls += 1;
1767
+ if (getUpdatesCalls === 1) {
1768
+ return {
1769
+ json: async () => ({
1770
+ ok: true,
1771
+ result: [
1772
+ {
1773
+ _: "other",
1774
+ update_id: 1,
1775
+ message: {
1776
+ message_id: 23,
1777
+ chat: { id: 99, type: "private" },
1778
+ from: { id: 77, is_bot: false, first_name: "Test" },
1779
+ text: "first request",
1780
+ },
1781
+ },
1782
+ ],
1783
+ }),
1784
+ } as Response;
1785
+ }
1786
+ if (getUpdatesCalls === 2) return secondUpdates;
1787
+ if (getUpdatesCalls === 3) return thirdUpdates;
1788
+ throw new DOMException("stop", "AbortError");
1789
+ }
1790
+ if (method === "sendMessage") {
1791
+ runtimeEvents.push(`send:${String(body?.text ?? "")}`);
1792
+ return {
1793
+ json: async () => ({
1794
+ ok: true,
1795
+ result: { message_id: 100 + runtimeEvents.length },
1796
+ }),
1797
+ } as Response;
1798
+ }
1799
+ if (method === "sendChatAction") {
1800
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1801
+ }
1802
+ throw new Error(`Unexpected Telegram API method: ${method}`);
1803
+ };
1804
+ try {
1805
+ await mkdir(agentDir, { recursive: true });
1806
+ await writeFile(
1807
+ configPath,
1808
+ JSON.stringify(
1809
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
1810
+ null,
1811
+ "\t",
1812
+ ) + "\n",
1813
+ "utf8",
1814
+ );
1815
+ telegramExtension(pi);
1816
+ const baseCtx = {
1817
+ hasUI: true,
1818
+ cwd: process.cwd(),
1819
+ model: modelA,
1820
+ signal: undefined,
1821
+ ui: {
1822
+ theme: {
1823
+ fg: (_token: string, text: string) => text,
1824
+ },
1825
+ setStatus: () => {},
1826
+ notify: () => {},
1827
+ },
1828
+ sessionManager: {
1829
+ getEntries: () => [],
1830
+ },
1831
+ modelRegistry: {
1832
+ refresh: () => {},
1833
+ getAvailable: () => [modelA, modelB],
1834
+ isUsingOAuth: () => false,
1835
+ },
1836
+ getContextUsage: () => undefined,
1837
+ hasPendingMessages: () => false,
1838
+ abort: () => {},
1839
+ };
1840
+ const idleCtx = {
1841
+ ...baseCtx,
1842
+ isIdle: () => true,
1843
+ } as never;
1844
+ const activeCtx = {
1845
+ ...baseCtx,
1846
+ isIdle: () => false,
1847
+ } as never;
1848
+ await handlers.get("session_start")?.({}, idleCtx);
1849
+ await commands.get("telegram-connect")?.handler("", idleCtx);
1850
+ await waitForCondition(() => firstDispatchResolved);
1851
+ await handlers.get("agent_start")?.({}, activeCtx);
1852
+ secondUpdatesResolve?.({
1853
+ json: async () => ({
1854
+ ok: true,
1855
+ result: [
1856
+ {
1857
+ _: "other",
1858
+ update_id: 2,
1859
+ message: {
1860
+ message_id: 24,
1861
+ chat: { id: 99, type: "private" },
1862
+ from: { id: 77, is_bot: false, first_name: "Test" },
1863
+ text: "/model",
1864
+ },
1865
+ },
1866
+ ],
1867
+ }),
1868
+ } as Response);
1869
+ await waitForCondition(() => getUpdatesCalls >= 3);
1870
+ thirdUpdatesResolve?.({
1871
+ json: async () => ({
1872
+ ok: true,
1873
+ result: [
1874
+ {
1875
+ _: "other",
1876
+ update_id: 3,
1877
+ message: {
1878
+ message_id: 25,
1879
+ chat: { id: 99, type: "private" },
1880
+ from: { id: 77, is_bot: false, first_name: "Test" },
1881
+ text: "follow up after model",
1882
+ },
1883
+ },
1884
+ ],
1885
+ }),
1886
+ } as Response);
1887
+ await waitForCondition(() => runtimeEvents.length >= 1);
1888
+ await handlers.get("agent_end")?.(
1889
+ {
1890
+ messages: [
1891
+ {
1892
+ role: "assistant",
1893
+ content: [{ type: "text", text: "" }],
1894
+ },
1895
+ ],
1896
+ },
1897
+ idleCtx,
1898
+ );
1899
+ await waitForCondition(() => runtimeEvents.length >= 3);
1900
+ assert.equal(runtimeEvents[0], "dispatch:[telegram] first request");
1901
+ assert.equal(runtimeEvents[1], "send:<b>Choose a model:</b>");
1902
+ assert.equal(runtimeEvents[2], "dispatch:[telegram] follow up after model");
1903
+ await handlers.get("session_shutdown")?.({}, idleCtx);
1904
+ } finally {
1905
+ globalThis.fetch = originalFetch;
1906
+ if (previousConfig === undefined) {
1907
+ await rm(configPath, { force: true });
1908
+ } else {
1909
+ await writeFile(configPath, previousConfig, "utf8");
1910
+ }
1911
+ }
1912
+ });
1913
+
1914
+ test("Extension runtime keeps queued turns blocked until compaction completes", async () => {
1915
+ const agentDir = join(homedir(), ".pi", "agent");
1916
+ const configPath = join(agentDir, "telegram.json");
1917
+ const previousConfig = await readFile(configPath, "utf8").catch(
1918
+ () => undefined,
1919
+ );
1920
+ const handlers = new Map<
1921
+ string,
1922
+ (event: unknown, ctx: unknown) => Promise<unknown>
1923
+ >();
1924
+ const commands = new Map<
1925
+ string,
1926
+ { handler: (args: string, ctx: unknown) => Promise<void> }
1927
+ >();
1928
+ const runtimeEvents: string[] = [];
1929
+ let compactHooks:
1930
+ | {
1931
+ onComplete: () => void;
1932
+ onError: (error: unknown) => void;
1933
+ }
1934
+ | undefined;
1935
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
1936
+ const secondUpdates = new Promise<Response>((resolve) => {
1937
+ secondUpdatesResolve = resolve;
1938
+ });
1939
+ const pi = {
1940
+ on: (
1941
+ event: string,
1942
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
1943
+ ) => {
1944
+ handlers.set(event, handler);
1945
+ },
1946
+ registerCommand: (
1947
+ name: string,
1948
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
1949
+ ) => {
1950
+ commands.set(name, definition);
1951
+ },
1952
+ registerTool: () => {},
1953
+ sendUserMessage: (
1954
+ content: string | Array<{ type: string; text?: string }>,
1955
+ ) => {
1956
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
1957
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
1958
+ },
1959
+ getThinkingLevel: () => "medium",
1960
+ } as never;
1961
+ const originalFetch = globalThis.fetch;
1962
+ let getUpdatesCalls = 0;
1963
+ globalThis.fetch = async (input, init) => {
1964
+ const url = typeof input === "string" ? input : input.toString();
1965
+ const method = url.split("/").at(-1) ?? "";
1966
+ const body =
1967
+ typeof init?.body === "string"
1968
+ ? (JSON.parse(init.body) as Record<string, unknown>)
1969
+ : undefined;
1970
+ if (method === "deleteWebhook") {
1971
+ return { json: async () => ({ ok: true, result: true }) } as Response;
1972
+ }
1973
+ if (method === "getUpdates") {
1974
+ getUpdatesCalls += 1;
1975
+ if (getUpdatesCalls === 1) {
1976
+ return {
1977
+ json: async () => ({
1978
+ ok: true,
1979
+ result: [
1980
+ {
1981
+ _: "other",
1982
+ update_id: 1,
1983
+ message: {
1984
+ message_id: 30,
1985
+ chat: { id: 99, type: "private" },
1986
+ from: { id: 77, is_bot: false, first_name: "Test" },
1987
+ text: "/compact",
1988
+ },
1989
+ },
1990
+ ],
1991
+ }),
1992
+ } as Response;
1993
+ }
1994
+ if (getUpdatesCalls === 2) {
1995
+ return secondUpdates;
1996
+ }
1997
+ throw new DOMException("stop", "AbortError");
1998
+ }
1999
+ if (method === "sendMessage") {
2000
+ runtimeEvents.push(`send:${String(body?.text ?? "")}`);
2001
+ return {
2002
+ json: async () => ({
2003
+ ok: true,
2004
+ result: { message_id: 100 + runtimeEvents.length },
2005
+ }),
2006
+ } as Response;
2007
+ }
2008
+ throw new Error(`Unexpected Telegram API method: ${method}`);
2009
+ };
2010
+ try {
2011
+ await mkdir(agentDir, { recursive: true });
2012
+ await writeFile(
2013
+ configPath,
2014
+ JSON.stringify(
2015
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
2016
+ null,
2017
+ "\t",
2018
+ ) + "\n",
2019
+ "utf8",
2020
+ );
2021
+ telegramExtension(pi);
2022
+ const ctx = {
2023
+ hasUI: true,
2024
+ model: undefined,
2025
+ signal: undefined,
2026
+ ui: {
2027
+ theme: {
2028
+ fg: (_token: string, text: string) => text,
2029
+ },
2030
+ setStatus: () => {},
2031
+ notify: () => {},
2032
+ },
2033
+ isIdle: () => true,
2034
+ hasPendingMessages: () => false,
2035
+ abort: () => {},
2036
+ compact: (hooks: {
2037
+ onComplete: () => void;
2038
+ onError: (error: unknown) => void;
2039
+ }) => {
2040
+ compactHooks = hooks;
2041
+ runtimeEvents.push("compact:start");
2042
+ },
2043
+ } as never;
2044
+ await handlers.get("session_start")?.({}, ctx);
2045
+ await commands.get("telegram-connect")?.handler("", ctx);
2046
+ await waitForCondition(() => runtimeEvents.includes("compact:start"));
2047
+ assert.equal(runtimeEvents.includes("send:Compaction started."), true);
2048
+ secondUpdatesResolve?.({
2049
+ json: async () => ({
2050
+ ok: true,
2051
+ result: [
2052
+ {
2053
+ _: "other",
2054
+ update_id: 2,
2055
+ message: {
2056
+ message_id: 31,
2057
+ chat: { id: 99, type: "private" },
2058
+ from: { id: 77, is_bot: false, first_name: "Test" },
2059
+ text: "follow up after compaction",
2060
+ },
2061
+ },
2062
+ ],
2063
+ }),
2064
+ } as Response);
2065
+ await waitForCondition(() => getUpdatesCalls >= 3);
2066
+ assert.equal(
2067
+ runtimeEvents.some(
2068
+ (event) => event === "dispatch:[telegram] follow up after compaction",
2069
+ ),
2070
+ false,
2071
+ );
2072
+ compactHooks?.onComplete();
2073
+ await waitForCondition(() =>
2074
+ runtimeEvents.includes("dispatch:[telegram] follow up after compaction"),
2075
+ );
2076
+ await waitForCondition(() =>
2077
+ runtimeEvents.includes("send:Compaction completed."),
2078
+ );
2079
+ await handlers.get("session_shutdown")?.({}, ctx);
2080
+ } finally {
2081
+ globalThis.fetch = originalFetch;
2082
+ if (previousConfig === undefined) {
2083
+ await rm(configPath, { force: true });
2084
+ } else {
2085
+ await writeFile(configPath, previousConfig, "utf8");
2086
+ }
2087
+ }
2088
+ });
2089
+
2090
+ test("Extension runtime coalesces media-group updates into one delayed dispatch", async () => {
2091
+ const agentDir = join(homedir(), ".pi", "agent");
2092
+ const configPath = join(agentDir, "telegram.json");
2093
+ const previousConfig = await readFile(configPath, "utf8").catch(
2094
+ () => undefined,
2095
+ );
2096
+ const handlers = new Map<
2097
+ string,
2098
+ (event: unknown, ctx: unknown) => Promise<unknown>
2099
+ >();
2100
+ const commands = new Map<
2101
+ string,
2102
+ { handler: (args: string, ctx: unknown) => Promise<void> }
2103
+ >();
2104
+ const runtimeEvents: string[] = [];
2105
+ const pi = {
2106
+ on: (
2107
+ event: string,
2108
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
2109
+ ) => {
2110
+ handlers.set(event, handler);
2111
+ },
2112
+ registerCommand: (
2113
+ name: string,
2114
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
2115
+ ) => {
2116
+ commands.set(name, definition);
2117
+ },
2118
+ registerTool: () => {},
2119
+ sendUserMessage: (
2120
+ content: string | Array<{ type: string; text?: string }>,
2121
+ ) => {
2122
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
2123
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
2124
+ },
2125
+ getThinkingLevel: () => "medium",
2126
+ } as never;
2127
+ const originalFetch = globalThis.fetch;
2128
+ let getUpdatesCalls = 0;
2129
+ globalThis.fetch = async (input) => {
2130
+ const url = typeof input === "string" ? input : input.toString();
2131
+ const method = url.split("/").at(-1) ?? "";
2132
+ if (method === "deleteWebhook") {
2133
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2134
+ }
2135
+ if (method === "getUpdates") {
2136
+ getUpdatesCalls += 1;
2137
+ if (getUpdatesCalls === 1) {
2138
+ return {
2139
+ json: async () => ({
2140
+ ok: true,
2141
+ result: [
2142
+ {
2143
+ _: "other",
2144
+ update_id: 1,
2145
+ message: {
2146
+ message_id: 40,
2147
+ media_group_id: "album-1",
2148
+ chat: { id: 99, type: "private" },
2149
+ from: { id: 77, is_bot: false, first_name: "Test" },
2150
+ caption: "first caption",
2151
+ },
2152
+ },
2153
+ {
2154
+ _: "other",
2155
+ update_id: 2,
2156
+ message: {
2157
+ message_id: 41,
2158
+ media_group_id: "album-1",
2159
+ chat: { id: 99, type: "private" },
2160
+ from: { id: 77, is_bot: false, first_name: "Test" },
2161
+ caption: "second caption",
2162
+ },
2163
+ },
2164
+ ],
2165
+ }),
2166
+ } as Response;
2167
+ }
2168
+ throw new DOMException("stop", "AbortError");
2169
+ }
2170
+ throw new Error(`Unexpected Telegram API method: ${method}`);
2171
+ };
2172
+ try {
2173
+ await mkdir(agentDir, { recursive: true });
2174
+ await writeFile(
2175
+ configPath,
2176
+ JSON.stringify(
2177
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
2178
+ null,
2179
+ "\t",
2180
+ ) + "\n",
2181
+ "utf8",
2182
+ );
2183
+ telegramExtension(pi);
2184
+ const ctx = {
2185
+ hasUI: true,
2186
+ model: undefined,
2187
+ signal: undefined,
2188
+ ui: {
2189
+ theme: {
2190
+ fg: (_token: string, text: string) => text,
2191
+ },
2192
+ setStatus: () => {},
2193
+ notify: () => {},
2194
+ },
2195
+ isIdle: () => true,
2196
+ hasPendingMessages: () => false,
2197
+ abort: () => {},
2198
+ } as never;
2199
+ await handlers.get("session_start")?.({}, ctx);
2200
+ await commands.get("telegram-connect")?.handler("", ctx);
2201
+ await new Promise((resolve) => setTimeout(resolve, 300));
2202
+ assert.equal(runtimeEvents.length, 0);
2203
+ await waitForCondition(() => runtimeEvents.length === 1, 2500);
2204
+ assert.equal(
2205
+ runtimeEvents[0],
2206
+ "dispatch:[telegram] first caption\n\nsecond caption",
2207
+ );
2208
+ await handlers.get("session_shutdown")?.({}, ctx);
2209
+ } finally {
2210
+ globalThis.fetch = originalFetch;
2211
+ if (previousConfig === undefined) {
2212
+ await rm(configPath, { force: true });
2213
+ } else {
2214
+ await writeFile(configPath, previousConfig, "utf8");
2215
+ }
2216
+ }
2217
+ });
2218
+
2219
+ test("Extension runtime applies reaction priority and removal before the next dispatch", async () => {
2220
+ const agentDir = join(homedir(), ".pi", "agent");
2221
+ const configPath = join(agentDir, "telegram.json");
2222
+ const previousConfig = await readFile(configPath, "utf8").catch(
2223
+ () => undefined,
2224
+ );
2225
+ const handlers = new Map<
2226
+ string,
2227
+ (event: unknown, ctx: unknown) => Promise<unknown>
2228
+ >();
2229
+ const commands = new Map<
2230
+ string,
2231
+ { handler: (args: string, ctx: unknown) => Promise<void> }
2232
+ >();
2233
+ const runtimeEvents: string[] = [];
2234
+ let firstDispatchResolved = false;
2235
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
2236
+ let thirdUpdatesResolve: ((value: Response) => void) | undefined;
2237
+ let fourthUpdatesResolve: ((value: Response) => void) | undefined;
2238
+ let fifthUpdatesResolve: ((value: Response) => void) | undefined;
2239
+ const secondUpdates = new Promise<Response>((resolve) => {
2240
+ secondUpdatesResolve = resolve;
2241
+ });
2242
+ const thirdUpdates = new Promise<Response>((resolve) => {
2243
+ thirdUpdatesResolve = resolve;
2244
+ });
2245
+ const fourthUpdates = new Promise<Response>((resolve) => {
2246
+ fourthUpdatesResolve = resolve;
2247
+ });
2248
+ const fifthUpdates = new Promise<Response>((resolve) => {
2249
+ fifthUpdatesResolve = resolve;
2250
+ });
2251
+ const pi = {
2252
+ on: (
2253
+ event: string,
2254
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
2255
+ ) => {
2256
+ handlers.set(event, handler);
2257
+ },
2258
+ registerCommand: (
2259
+ name: string,
2260
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
2261
+ ) => {
2262
+ commands.set(name, definition);
2263
+ },
2264
+ registerTool: () => {},
2265
+ sendUserMessage: (
2266
+ content: string | Array<{ type: string; text?: string }>,
2267
+ ) => {
2268
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
2269
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
2270
+ firstDispatchResolved = true;
2271
+ },
2272
+ getThinkingLevel: () => "medium",
2273
+ } as never;
2274
+ const originalFetch = globalThis.fetch;
2275
+ let getUpdatesCalls = 0;
2276
+ globalThis.fetch = async (input) => {
2277
+ const url = typeof input === "string" ? input : input.toString();
2278
+ const method = url.split("/").at(-1) ?? "";
2279
+ if (method === "deleteWebhook") {
2280
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2281
+ }
2282
+ if (method === "getUpdates") {
2283
+ getUpdatesCalls += 1;
2284
+ if (getUpdatesCalls === 1) {
2285
+ return {
2286
+ json: async () => ({
2287
+ ok: true,
2288
+ result: [
2289
+ {
2290
+ _: "other",
2291
+ update_id: 1,
2292
+ message: {
2293
+ message_id: 30,
2294
+ chat: { id: 99, type: "private" },
2295
+ from: { id: 77, is_bot: false, first_name: "Test" },
2296
+ text: "first request",
2297
+ },
2298
+ },
2299
+ ],
2300
+ }),
2301
+ } as Response;
2302
+ }
2303
+ if (getUpdatesCalls === 2) return secondUpdates;
2304
+ if (getUpdatesCalls === 3) return thirdUpdates;
2305
+ if (getUpdatesCalls === 4) return fourthUpdates;
2306
+ if (getUpdatesCalls === 5) return fifthUpdates;
2307
+ throw new DOMException("stop", "AbortError");
2308
+ }
2309
+ if (method === "sendChatAction") {
2310
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2311
+ }
2312
+ throw new Error(`Unexpected Telegram API method: ${method}`);
2313
+ };
2314
+ try {
2315
+ await mkdir(agentDir, { recursive: true });
2316
+ await writeFile(
2317
+ configPath,
2318
+ JSON.stringify(
2319
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
2320
+ null,
2321
+ "\t",
2322
+ ) + "\n",
2323
+ "utf8",
2324
+ );
2325
+ telegramExtension(pi);
2326
+ const baseCtx = {
2327
+ hasUI: true,
2328
+ model: undefined,
2329
+ signal: undefined,
2330
+ ui: {
2331
+ theme: {
2332
+ fg: (_token: string, text: string) => text,
2333
+ },
2334
+ setStatus: () => {},
2335
+ notify: () => {},
2336
+ },
2337
+ hasPendingMessages: () => false,
2338
+ abort: () => {},
2339
+ };
2340
+ const idleCtx = {
2341
+ ...baseCtx,
2342
+ isIdle: () => true,
2343
+ } as never;
2344
+ const activeCtx = {
2345
+ ...baseCtx,
2346
+ isIdle: () => false,
2347
+ } as never;
2348
+ await handlers.get("session_start")?.({}, idleCtx);
2349
+ await commands.get("telegram-connect")?.handler("", idleCtx);
2350
+ await waitForCondition(() => firstDispatchResolved);
2351
+ await handlers.get("agent_start")?.({}, activeCtx);
2352
+ secondUpdatesResolve?.({
2353
+ json: async () => ({
2354
+ ok: true,
2355
+ result: [
2356
+ {
2357
+ _: "other",
2358
+ update_id: 2,
2359
+ message: {
2360
+ message_id: 31,
2361
+ chat: { id: 99, type: "private" },
2362
+ from: { id: 77, is_bot: false, first_name: "Test" },
2363
+ text: "older waiting",
2364
+ },
2365
+ },
2366
+ ],
2367
+ }),
2368
+ } as Response);
2369
+ await waitForCondition(() => getUpdatesCalls >= 3);
2370
+ thirdUpdatesResolve?.({
2371
+ json: async () => ({
2372
+ ok: true,
2373
+ result: [
2374
+ {
2375
+ _: "other",
2376
+ update_id: 3,
2377
+ message: {
2378
+ message_id: 32,
2379
+ chat: { id: 99, type: "private" },
2380
+ from: { id: 77, is_bot: false, first_name: "Test" },
2381
+ text: "newer waiting",
2382
+ },
2383
+ },
2384
+ ],
2385
+ }),
2386
+ } as Response);
2387
+ await waitForCondition(() => getUpdatesCalls >= 4);
2388
+ fourthUpdatesResolve?.({
2389
+ json: async () => ({
2390
+ ok: true,
2391
+ result: [
2392
+ {
2393
+ _: "other",
2394
+ update_id: 4,
2395
+ message_reaction: {
2396
+ chat: { id: 99, type: "private" },
2397
+ message_id: 32,
2398
+ user: { id: 77, is_bot: false, first_name: "Test" },
2399
+ old_reaction: [],
2400
+ new_reaction: [{ type: "emoji", emoji: "👍" }],
2401
+ date: 1,
2402
+ },
2403
+ },
2404
+ ],
2405
+ }),
2406
+ } as Response);
2407
+ await waitForCondition(() => getUpdatesCalls >= 5);
2408
+ fifthUpdatesResolve?.({
2409
+ json: async () => ({
2410
+ ok: true,
2411
+ result: [
2412
+ {
2413
+ _: "other",
2414
+ update_id: 5,
2415
+ message_reaction: {
2416
+ chat: { id: 99, type: "private" },
2417
+ message_id: 31,
2418
+ user: { id: 77, is_bot: false, first_name: "Test" },
2419
+ old_reaction: [],
2420
+ new_reaction: [{ type: "emoji", emoji: "👎" }],
2421
+ date: 2,
2422
+ },
2423
+ },
2424
+ ],
2425
+ }),
2426
+ } as Response);
2427
+ await waitForCondition(() => getUpdatesCalls >= 6);
2428
+ await handlers.get("agent_end")?.(
2429
+ {
2430
+ messages: [
2431
+ {
2432
+ role: "assistant",
2433
+ content: [{ type: "text", text: "" }],
2434
+ },
2435
+ ],
2436
+ },
2437
+ idleCtx,
2438
+ );
2439
+ await waitForCondition(() => runtimeEvents.length === 2);
2440
+ assert.equal(runtimeEvents[0], "dispatch:[telegram] first request");
2441
+ assert.equal(runtimeEvents[1], "dispatch:[telegram] newer waiting");
2442
+ await handlers.get("agent_start")?.({}, activeCtx);
2443
+ await handlers.get("agent_end")?.(
2444
+ {
2445
+ messages: [
2446
+ {
2447
+ role: "assistant",
2448
+ content: [{ type: "text", text: "" }],
2449
+ },
2450
+ ],
2451
+ },
2452
+ idleCtx,
2453
+ );
2454
+ await new Promise((resolve) => setTimeout(resolve, 50));
2455
+ assert.deepEqual(runtimeEvents, [
2456
+ "dispatch:[telegram] first request",
2457
+ "dispatch:[telegram] newer waiting",
2458
+ ]);
2459
+ await handlers.get("session_shutdown")?.({}, idleCtx);
2460
+ } finally {
2461
+ globalThis.fetch = originalFetch;
2462
+ if (previousConfig === undefined) {
2463
+ await rm(configPath, { force: true });
2464
+ } else {
2465
+ await writeFile(configPath, previousConfig, "utf8");
2466
+ }
2467
+ }
2468
+ });
2469
+
2470
+ test("Extension runtime switches model in flight and dispatches a continuation turn after abort", async () => {
2471
+ const agentDir = join(homedir(), ".pi", "agent");
2472
+ const configPath = join(agentDir, "telegram.json");
2473
+ const previousConfig = await readFile(configPath, "utf8").catch(
2474
+ () => undefined,
2475
+ );
2476
+ const handlers = new Map<
2477
+ string,
2478
+ (event: unknown, ctx: unknown) => Promise<unknown>
2479
+ >();
2480
+ const commands = new Map<
2481
+ string,
2482
+ { handler: (args: string, ctx: unknown) => Promise<void> }
2483
+ >();
2484
+ const runtimeEvents: string[] = [];
2485
+ const modelA = {
2486
+ provider: "openai",
2487
+ id: "gpt-a",
2488
+ reasoning: true,
2489
+ } as const;
2490
+ const modelB = {
2491
+ provider: "anthropic",
2492
+ id: "claude-b",
2493
+ reasoning: false,
2494
+ } as const;
2495
+ let idle = true;
2496
+ let aborted = false;
2497
+ const setModels: Array<string> = [];
2498
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
2499
+ let thirdUpdatesResolve: ((value: Response) => void) | undefined;
2500
+ const secondUpdates = new Promise<Response>((resolve) => {
2501
+ secondUpdatesResolve = resolve;
2502
+ });
2503
+ const thirdUpdates = new Promise<Response>((resolve) => {
2504
+ thirdUpdatesResolve = resolve;
2505
+ });
2506
+ const pi = {
2507
+ on: (
2508
+ event: string,
2509
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
2510
+ ) => {
2511
+ handlers.set(event, handler);
2512
+ },
2513
+ registerCommand: (
2514
+ name: string,
2515
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
2516
+ ) => {
2517
+ commands.set(name, definition);
2518
+ },
2519
+ registerTool: () => {},
2520
+ sendUserMessage: (
2521
+ content: string | Array<{ type: string; text?: string }>,
2522
+ ) => {
2523
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
2524
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
2525
+ },
2526
+ getThinkingLevel: () => "medium",
2527
+ setModel: async (model: { provider: string; id: string }) => {
2528
+ setModels.push(`${model.provider}/${model.id}`);
2529
+ return true;
2530
+ },
2531
+ setThinkingLevel: () => {},
2532
+ } as never;
2533
+ const originalFetch = globalThis.fetch;
2534
+ let getUpdatesCalls = 0;
2535
+ let nextMessageId = 100;
2536
+ const callbackAnswers: string[] = [];
2537
+ globalThis.fetch = async (input, init) => {
2538
+ const url = typeof input === "string" ? input : input.toString();
2539
+ const method = url.split("/").at(-1) ?? "";
2540
+ const body =
2541
+ typeof init?.body === "string"
2542
+ ? (JSON.parse(init.body) as Record<string, unknown>)
2543
+ : undefined;
2544
+ if (method === "deleteWebhook") {
2545
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2546
+ }
2547
+ if (method === "getUpdates") {
2548
+ getUpdatesCalls += 1;
2549
+ if (getUpdatesCalls === 1) {
2550
+ return {
2551
+ json: async () => ({
2552
+ ok: true,
2553
+ result: [
2554
+ {
2555
+ _: "other",
2556
+ update_id: 1,
2557
+ message: {
2558
+ message_id: 40,
2559
+ chat: { id: 99, type: "private" },
2560
+ from: { id: 77, is_bot: false, first_name: "Test" },
2561
+ text: "/model",
2562
+ },
2563
+ },
2564
+ ],
2565
+ }),
2566
+ } as Response;
2567
+ }
2568
+ if (getUpdatesCalls === 2) return secondUpdates;
2569
+ if (getUpdatesCalls === 3) return thirdUpdates;
2570
+ throw new DOMException("stop", "AbortError");
2571
+ }
2572
+ if (method === "sendMessage") {
2573
+ runtimeEvents.push(`send:${String(body?.text ?? "")}`);
2574
+ return {
2575
+ json: async () => ({
2576
+ ok: true,
2577
+ result: { message_id: nextMessageId++ },
2578
+ }),
2579
+ } as Response;
2580
+ }
2581
+ if (method === "editMessageText") {
2582
+ runtimeEvents.push(`edit:${String(body?.text ?? "")}`);
2583
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2584
+ }
2585
+ if (method === "answerCallbackQuery") {
2586
+ callbackAnswers.push(String(body?.text ?? ""));
2587
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2588
+ }
2589
+ if (method === "sendChatAction") {
2590
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2591
+ }
2592
+ throw new Error(`Unexpected Telegram API method: ${method}`);
2593
+ };
2594
+ try {
2595
+ await mkdir(agentDir, { recursive: true });
2596
+ await writeFile(
2597
+ configPath,
2598
+ JSON.stringify(
2599
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
2600
+ null,
2601
+ "\t",
2602
+ ) + "\n",
2603
+ "utf8",
2604
+ );
2605
+ telegramExtension(pi);
2606
+ const ctx = {
2607
+ hasUI: true,
2608
+ cwd: process.cwd(),
2609
+ model: modelA,
2610
+ signal: undefined,
2611
+ ui: {
2612
+ theme: {
2613
+ fg: (_token: string, text: string) => text,
2614
+ },
2615
+ setStatus: () => {},
2616
+ notify: () => {},
2617
+ },
2618
+ sessionManager: {
2619
+ getEntries: () => [],
2620
+ },
2621
+ modelRegistry: {
2622
+ refresh: () => {},
2623
+ getAvailable: () => [modelA, modelB],
2624
+ isUsingOAuth: () => false,
2625
+ },
2626
+ getContextUsage: () => undefined,
2627
+ hasPendingMessages: () => false,
2628
+ isIdle: () => idle,
2629
+ abort: () => {
2630
+ aborted = true;
2631
+ },
2632
+ } as never;
2633
+ await handlers.get("session_start")?.({}, ctx);
2634
+ await commands.get("telegram-connect")?.handler("", ctx);
2635
+ await waitForCondition(() =>
2636
+ runtimeEvents.some((event) => event === "send:<b>Choose a model:</b>"),
2637
+ );
2638
+ secondUpdatesResolve?.({
2639
+ json: async () => ({
2640
+ ok: true,
2641
+ result: [
2642
+ {
2643
+ _: "other",
2644
+ update_id: 2,
2645
+ message: {
2646
+ message_id: 41,
2647
+ chat: { id: 99, type: "private" },
2648
+ from: { id: 77, is_bot: false, first_name: "Test" },
2649
+ text: "first request",
2650
+ },
2651
+ },
2652
+ ],
2653
+ }),
2654
+ } as Response);
2655
+ await waitForCondition(() =>
2656
+ runtimeEvents.some(
2657
+ (event) => event === "dispatch:[telegram] first request",
2658
+ ),
2659
+ );
2660
+ idle = false;
2661
+ await handlers.get("agent_start")?.({}, ctx);
2662
+ thirdUpdatesResolve?.({
2663
+ json: async () => ({
2664
+ ok: true,
2665
+ result: [
2666
+ {
2667
+ _: "other",
2668
+ update_id: 3,
2669
+ callback_query: {
2670
+ id: "cb-1",
2671
+ from: { id: 77, is_bot: false, first_name: "Test" },
2672
+ data: "model:pick:1",
2673
+ message: {
2674
+ message_id: 100,
2675
+ chat: { id: 99, type: "private" },
2676
+ },
2677
+ },
2678
+ },
2679
+ ],
2680
+ }),
2681
+ } as Response);
2682
+ await waitForCondition(() => aborted);
2683
+ assert.deepEqual(setModels, ["anthropic/claude-b"]);
2684
+ assert.equal(
2685
+ callbackAnswers.includes("Switching to claude-b and continuing…"),
2686
+ true,
2687
+ );
2688
+ idle = true;
2689
+ await handlers.get("agent_end")?.(
2690
+ {
2691
+ messages: [
2692
+ {
2693
+ role: "assistant",
2694
+ stopReason: "aborted",
2695
+ content: [{ type: "text", text: "" }],
2696
+ },
2697
+ ],
2698
+ },
2699
+ ctx,
2700
+ );
2701
+ await waitForCondition(() =>
2702
+ runtimeEvents.some((event) =>
2703
+ event.includes(
2704
+ "Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)",
2705
+ ),
2706
+ ),
2707
+ );
2708
+ assert.equal(
2709
+ runtimeEvents.includes("dispatch:[telegram] first request"),
2710
+ true,
2711
+ );
2712
+ assert.equal(
2713
+ runtimeEvents.some((event) =>
2714
+ event.includes(
2715
+ "dispatch:[telegram] Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)",
2716
+ ),
2717
+ ),
2718
+ true,
2719
+ );
2720
+ await handlers.get("session_shutdown")?.({}, ctx);
2721
+ } finally {
2722
+ globalThis.fetch = originalFetch;
2723
+ if (previousConfig === undefined) {
2724
+ await rm(configPath, { force: true });
2725
+ } else {
2726
+ await writeFile(configPath, previousConfig, "utf8");
2727
+ }
2728
+ }
2729
+ });
2730
+
2731
+ test("Extension runtime delays model-switch abort until the active tool finishes", async () => {
2732
+ const agentDir = join(homedir(), ".pi", "agent");
2733
+ const configPath = join(agentDir, "telegram.json");
2734
+ const previousConfig = await readFile(configPath, "utf8").catch(
2735
+ () => undefined,
2736
+ );
2737
+ const handlers = new Map<
2738
+ string,
2739
+ (event: unknown, ctx: unknown) => Promise<unknown>
2740
+ >();
2741
+ const commands = new Map<
2742
+ string,
2743
+ { handler: (args: string, ctx: unknown) => Promise<void> }
2744
+ >();
2745
+ const runtimeEvents: string[] = [];
2746
+ const modelA = {
2747
+ provider: "openai",
2748
+ id: "gpt-a",
2749
+ reasoning: true,
2750
+ } as const;
2751
+ const modelB = {
2752
+ provider: "anthropic",
2753
+ id: "claude-b",
2754
+ reasoning: false,
2755
+ } as const;
2756
+ let idle = true;
2757
+ let aborted = false;
2758
+ const setModels: Array<string> = [];
2759
+ let secondUpdatesResolve: ((value: Response) => void) | undefined;
2760
+ let thirdUpdatesResolve: ((value: Response) => void) | undefined;
2761
+ const secondUpdates = new Promise<Response>((resolve) => {
2762
+ secondUpdatesResolve = resolve;
2763
+ });
2764
+ const thirdUpdates = new Promise<Response>((resolve) => {
2765
+ thirdUpdatesResolve = resolve;
2766
+ });
2767
+ const pi = {
2768
+ on: (
2769
+ event: string,
2770
+ handler: (event: unknown, ctx: unknown) => Promise<unknown>,
2771
+ ) => {
2772
+ handlers.set(event, handler);
2773
+ },
2774
+ registerCommand: (
2775
+ name: string,
2776
+ definition: { handler: (args: string, ctx: unknown) => Promise<void> },
2777
+ ) => {
2778
+ commands.set(name, definition);
2779
+ },
2780
+ registerTool: () => {},
2781
+ sendUserMessage: (
2782
+ content: string | Array<{ type: string; text?: string }>,
2783
+ ) => {
2784
+ const promptBlocks = content as Array<{ type: string; text?: string }>;
2785
+ runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`);
2786
+ },
2787
+ getThinkingLevel: () => "medium",
2788
+ setModel: async (model: { provider: string; id: string }) => {
2789
+ setModels.push(`${model.provider}/${model.id}`);
2790
+ return true;
2791
+ },
2792
+ setThinkingLevel: () => {},
2793
+ } as never;
2794
+ const originalFetch = globalThis.fetch;
2795
+ let getUpdatesCalls = 0;
2796
+ let nextMessageId = 100;
2797
+ const callbackAnswers: string[] = [];
2798
+ globalThis.fetch = async (input, init) => {
2799
+ const url = typeof input === "string" ? input : input.toString();
2800
+ const method = url.split("/").at(-1) ?? "";
2801
+ const body =
2802
+ typeof init?.body === "string"
2803
+ ? (JSON.parse(init.body) as Record<string, unknown>)
2804
+ : undefined;
2805
+ if (method === "deleteWebhook") {
2806
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2807
+ }
2808
+ if (method === "getUpdates") {
2809
+ getUpdatesCalls += 1;
2810
+ if (getUpdatesCalls === 1) {
2811
+ return {
2812
+ json: async () => ({
2813
+ ok: true,
2814
+ result: [
2815
+ {
2816
+ _: "other",
2817
+ update_id: 1,
2818
+ message: {
2819
+ message_id: 50,
2820
+ chat: { id: 99, type: "private" },
2821
+ from: { id: 77, is_bot: false, first_name: "Test" },
2822
+ text: "/model",
2823
+ },
2824
+ },
2825
+ ],
2826
+ }),
2827
+ } as Response;
2828
+ }
2829
+ if (getUpdatesCalls === 2) return secondUpdates;
2830
+ if (getUpdatesCalls === 3) return thirdUpdates;
2831
+ throw new DOMException("stop", "AbortError");
2832
+ }
2833
+ if (method === "sendMessage") {
2834
+ runtimeEvents.push(`send:${String(body?.text ?? "")}`);
2835
+ return {
2836
+ json: async () => ({
2837
+ ok: true,
2838
+ result: { message_id: nextMessageId++ },
2839
+ }),
2840
+ } as Response;
2841
+ }
2842
+ if (method === "editMessageText") {
2843
+ runtimeEvents.push(`edit:${String(body?.text ?? "")}`);
2844
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2845
+ }
2846
+ if (method === "answerCallbackQuery") {
2847
+ callbackAnswers.push(String(body?.text ?? ""));
2848
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2849
+ }
2850
+ if (method === "sendChatAction") {
2851
+ return { json: async () => ({ ok: true, result: true }) } as Response;
2852
+ }
2853
+ throw new Error(`Unexpected Telegram API method: ${method}`);
2854
+ };
2855
+ try {
2856
+ await mkdir(agentDir, { recursive: true });
2857
+ await writeFile(
2858
+ configPath,
2859
+ JSON.stringify(
2860
+ { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
2861
+ null,
2862
+ "\t",
2863
+ ) + "\n",
2864
+ "utf8",
2865
+ );
2866
+ telegramExtension(pi);
2867
+ const ctx = {
2868
+ hasUI: true,
2869
+ cwd: process.cwd(),
2870
+ model: modelA,
2871
+ signal: undefined,
2872
+ ui: {
2873
+ theme: {
2874
+ fg: (_token: string, text: string) => text,
2875
+ },
2876
+ setStatus: () => {},
2877
+ notify: () => {},
2878
+ },
2879
+ sessionManager: {
2880
+ getEntries: () => [],
2881
+ },
2882
+ modelRegistry: {
2883
+ refresh: () => {},
2884
+ getAvailable: () => [modelA, modelB],
2885
+ isUsingOAuth: () => false,
2886
+ },
2887
+ getContextUsage: () => undefined,
2888
+ hasPendingMessages: () => false,
2889
+ isIdle: () => idle,
2890
+ abort: () => {
2891
+ aborted = true;
2892
+ },
2893
+ } as never;
2894
+ await handlers.get("session_start")?.({}, ctx);
2895
+ await commands.get("telegram-connect")?.handler("", ctx);
2896
+ await waitForCondition(() =>
2897
+ runtimeEvents.some((event) => event === "send:<b>Choose a model:</b>"),
2898
+ );
2899
+ secondUpdatesResolve?.({
2900
+ json: async () => ({
2901
+ ok: true,
2902
+ result: [
2903
+ {
2904
+ _: "other",
2905
+ update_id: 2,
2906
+ message: {
2907
+ message_id: 51,
2908
+ chat: { id: 99, type: "private" },
2909
+ from: { id: 77, is_bot: false, first_name: "Test" },
2910
+ text: "first request",
2911
+ },
2912
+ },
2913
+ ],
2914
+ }),
2915
+ } as Response);
2916
+ await waitForCondition(() =>
2917
+ runtimeEvents.some(
2918
+ (event) => event === "dispatch:[telegram] first request",
2919
+ ),
2920
+ );
2921
+ idle = false;
2922
+ await handlers.get("agent_start")?.({}, ctx);
2923
+ await handlers.get("tool_execution_start")?.({}, ctx);
2924
+ thirdUpdatesResolve?.({
2925
+ json: async () => ({
2926
+ ok: true,
2927
+ result: [
2928
+ {
2929
+ _: "other",
2930
+ update_id: 3,
2931
+ callback_query: {
2932
+ id: "cb-2",
2933
+ from: { id: 77, is_bot: false, first_name: "Test" },
2934
+ data: "model:pick:1",
2935
+ message: {
2936
+ message_id: 100,
2937
+ chat: { id: 99, type: "private" },
2938
+ },
2939
+ },
2940
+ },
2941
+ ],
2942
+ }),
2943
+ } as Response);
2944
+ await waitForCondition(() =>
2945
+ callbackAnswers.includes(
2946
+ "Switched to claude-b. Restarting after the current tool finishes…",
2947
+ ),
2948
+ );
2949
+ assert.deepEqual(setModels, ["anthropic/claude-b"]);
2950
+ assert.equal(aborted, false);
2951
+ await handlers.get("tool_execution_end")?.({}, ctx);
2952
+ await waitForCondition(() => aborted);
2953
+ idle = true;
2954
+ await handlers.get("agent_end")?.(
2955
+ {
2956
+ messages: [
2957
+ {
2958
+ role: "assistant",
2959
+ stopReason: "aborted",
2960
+ content: [{ type: "text", text: "" }],
2961
+ },
2962
+ ],
2963
+ },
2964
+ ctx,
2965
+ );
2966
+ await waitForCondition(() =>
2967
+ runtimeEvents.some((event) =>
2968
+ event.includes(
2969
+ "dispatch:[telegram] Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)",
2970
+ ),
2971
+ ),
2972
+ );
2973
+ await handlers.get("session_shutdown")?.({}, ctx);
2974
+ } finally {
2975
+ globalThis.fetch = originalFetch;
2976
+ if (previousConfig === undefined) {
2977
+ await rm(configPath, { force: true });
2978
+ } else {
2979
+ await writeFile(configPath, previousConfig, "utf8");
2980
+ }
2981
+ }
2982
+ });