@llblab/pi-telegram 0.51.0 → 0.51.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.
Files changed (46) hide show
  1. package/AGENTS.md +4 -3
  2. package/BACKLOG.md +3 -8
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +1 -1
  5. package/dist/lib/bindings.d.ts +2 -0
  6. package/dist/lib/bindings.js +3 -4
  7. package/dist/lib/bus-api.d.ts +0 -1
  8. package/dist/lib/bus-api.js +0 -3
  9. package/dist/lib/bus-follower.d.ts +3 -1
  10. package/dist/lib/bus-follower.js +6 -1
  11. package/dist/lib/commands.d.ts +5 -0
  12. package/dist/lib/commands.js +9 -0
  13. package/dist/lib/extension.js +6 -3
  14. package/dist/lib/menu-model.d.ts +3 -3
  15. package/dist/lib/menu-model.js +17 -3
  16. package/dist/lib/menu.d.ts +5 -7
  17. package/dist/lib/menu.js +4 -5
  18. package/dist/lib/model.d.ts +12 -12
  19. package/dist/lib/model.js +49 -35
  20. package/dist/lib/queue.d.ts +8 -2
  21. package/dist/lib/queue.js +64 -19
  22. package/dist/lib/replies.d.ts +4 -0
  23. package/dist/lib/replies.js +14 -0
  24. package/dist/lib/routing.d.ts +1 -0
  25. package/dist/lib/routing.js +5 -1
  26. package/dist/lib/runtime.d.ts +3 -6
  27. package/dist/lib/runtime.js +4 -55
  28. package/dist/lib/telegram-api.js +36 -10
  29. package/dist/package.json +1 -1
  30. package/docs/architecture.md +9 -9
  31. package/docs/multi-instance-bus.md +3 -3
  32. package/docs/public-api.md +1 -1
  33. package/lib/bindings.ts +14 -4
  34. package/lib/bus-api.ts +0 -7
  35. package/lib/bus-follower.ts +13 -2
  36. package/lib/commands.ts +19 -0
  37. package/lib/extension.ts +10 -3
  38. package/lib/menu-model.ts +25 -4
  39. package/lib/menu.ts +14 -9
  40. package/lib/model.ts +77 -44
  41. package/lib/queue.ts +76 -24
  42. package/lib/replies.ts +18 -0
  43. package/lib/routing.ts +7 -1
  44. package/lib/runtime.ts +7 -58
  45. package/lib/telegram-api.ts +43 -17
  46. package/package.json +1 -1
package/lib/model.ts CHANGED
@@ -374,7 +374,6 @@ export interface PendingModelSwitchStore<TSelection> {
374
374
 
375
375
  export interface TelegramInFlightModelSwitchState {
376
376
  isIdle: boolean;
377
- hasActiveTelegramTurn: boolean;
378
377
  hasAbortHandler: boolean;
379
378
  }
380
379
 
@@ -394,21 +393,21 @@ export function createPendingModelSwitchStore<
394
393
  };
395
394
  }
396
395
 
397
- export function canRestartTelegramTurnForModelSwitch(
396
+ export function canRestartAgentRunForTelegramModelSwitch(
398
397
  state: TelegramInFlightModelSwitchState,
399
398
  ): boolean {
400
- return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
399
+ return !state.isIdle && state.hasAbortHandler;
401
400
  }
402
401
 
403
402
  export function shouldTriggerPendingTelegramModelSwitchAbort(state: {
404
403
  hasPendingModelSwitch: boolean;
405
- hasActiveTelegramTurn: boolean;
404
+ hasContinuationTurn: boolean;
406
405
  hasAbortHandler: boolean;
407
406
  activeToolExecutions: number;
408
407
  }): boolean {
409
408
  return (
410
409
  state.hasPendingModelSwitch &&
411
- state.hasActiveTelegramTurn &&
410
+ state.hasContinuationTurn &&
412
411
  state.hasAbortHandler &&
413
412
  state.activeToolExecutions === 0
414
413
  );
@@ -458,13 +457,18 @@ export function buildTelegramModelSwitchContinuationText<
458
457
  const thinkingSuffix = thinkingLevel
459
458
  ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
460
459
  : "";
461
- return `${telegramPrefix} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
460
+ return `${telegramPrefix} Continue the interrupted previous request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
462
461
  }
463
462
 
463
+ export type TelegramModelSwitchContinuationSource = Pick<
464
+ PendingTelegramTurn,
465
+ "chatId" | "replyToMessageId" | "target"
466
+ >;
467
+
464
468
  export function buildTelegramModelSwitchContinuationTurn<
465
469
  TModel extends MenuModel,
466
470
  >(options: {
467
- turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
471
+ turn: TelegramModelSwitchContinuationSource;
468
472
  selection: ScopedTelegramModel<TModel>;
469
473
  telegramPrefix?: string;
470
474
  queueOrder: number;
@@ -494,7 +498,7 @@ export function buildTelegramModelSwitchContinuationTurn<
494
498
  ),
495
499
  },
496
500
  ],
497
- historyText: `Continue interrupted Telegram request on ${modelLabel}`,
501
+ historyText: `Continue interrupted request on ${modelLabel}`,
498
502
  statusSummary: `↻ ${statusLabel || "continue"}`,
499
503
  };
500
504
  }
@@ -506,7 +510,7 @@ export function createTelegramModelSwitchContinuationTurnBuilder<
506
510
  allocateItemOrder: () => number;
507
511
  allocateControlOrder: () => number;
508
512
  }): (options: {
509
- turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
513
+ turn: TelegramModelSwitchContinuationSource;
510
514
  selection: ScopedTelegramModel<TModel>;
511
515
  }) => PendingTelegramTurn {
512
516
  return (options) =>
@@ -523,11 +527,15 @@ export function createTelegramModelSwitchContinuationQueue<
523
527
  TSelection extends ScopedTelegramModel,
524
528
  >(deps: {
525
529
  createContinuationTurn: (options: {
526
- turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
530
+ turn: TelegramModelSwitchContinuationSource;
527
531
  selection: TSelection;
528
532
  }) => PendingTelegramTurn;
529
533
  appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
530
- }): (turn: PendingTelegramTurn, selection: TSelection, ctx: TContext) => void {
534
+ }): (
535
+ turn: TelegramModelSwitchContinuationSource,
536
+ selection: TSelection,
537
+ ctx: TContext,
538
+ ) => void {
531
539
  return (turn, selection, ctx) => {
532
540
  deps.appendQueuedItem(
533
541
  deps.createContinuationTurn({ turn, selection }),
@@ -544,7 +552,11 @@ export function createTelegramModelSwitchContinuationQueueRuntime<
544
552
  allocateItemOrder: () => number;
545
553
  allocateControlOrder: () => number;
546
554
  appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
547
- }): (turn: PendingTelegramTurn, selection: TSelection, ctx: TContext) => void {
555
+ }): (
556
+ turn: TelegramModelSwitchContinuationSource,
557
+ selection: TSelection,
558
+ ctx: TContext,
559
+ ) => void {
548
560
  return createTelegramModelSwitchContinuationQueue<TContext, TSelection>({
549
561
  createContinuationTurn: createTelegramModelSwitchContinuationTurnBuilder({
550
562
  telegramPrefix: deps.telegramPrefix,
@@ -564,7 +576,7 @@ export interface TelegramModelSwitchControllerDeps<TContext, TSelection> {
564
576
  hasAbortHandler: () => boolean;
565
577
  getActiveToolExecutions: () => number;
566
578
  queueContinuation: (
567
- turn: PendingTelegramTurn,
579
+ turn: TelegramModelSwitchContinuationSource,
568
580
  selection: TSelection,
569
581
  ctx: TContext,
570
582
  ) => void;
@@ -573,15 +585,23 @@ export interface TelegramModelSwitchControllerDeps<TContext, TSelection> {
573
585
 
574
586
  export interface TelegramModelSwitchController<TContext, TSelection> {
575
587
  canOfferInFlightSwitch: (ctx: TContext) => boolean;
576
- stagePendingSwitch: (selection: TSelection, ctx: TContext) => void;
588
+ stagePendingSwitch: (
589
+ selection: TSelection,
590
+ ctx: TContext,
591
+ continuationTurn?: TelegramModelSwitchContinuationSource,
592
+ ) => void;
577
593
  clearPendingSwitch: () => void;
578
594
  queueContinuation: (
579
- turn: PendingTelegramTurn,
595
+ turn: TelegramModelSwitchContinuationSource,
580
596
  selection: TSelection,
581
597
  ctx: TContext,
582
598
  ) => void;
583
599
  triggerPendingAbort: (ctx: TContext) => boolean;
584
- restartInterruptedTurn: (selection: TSelection, ctx: TContext) => boolean;
600
+ restartInterruptedTurn: (
601
+ selection: TSelection,
602
+ ctx: TContext,
603
+ continuationTurn?: TelegramModelSwitchContinuationSource,
604
+ ) => boolean;
585
605
  }
586
606
 
587
607
  export interface TelegramModelSwitchControllerRuntimeDeps<
@@ -624,49 +644,62 @@ export function createTelegramModelSwitchControllerRuntime<
624
644
  export function createTelegramModelSwitchController<TContext, TSelection>(
625
645
  deps: TelegramModelSwitchControllerDeps<TContext, TSelection>,
626
646
  ): TelegramModelSwitchController<TContext, TSelection> {
647
+ let pendingContinuationTurn:
648
+ | TelegramModelSwitchContinuationSource
649
+ | undefined;
650
+ const triggerPendingAbort = (ctx: TContext): boolean => {
651
+ const turn = pendingContinuationTurn ?? deps.getActiveTurn();
652
+ if (
653
+ !shouldTriggerPendingTelegramModelSwitchAbort({
654
+ hasPendingModelSwitch: !!deps.getPendingModelSwitch(),
655
+ hasContinuationTurn: !!turn,
656
+ hasAbortHandler: deps.hasAbortHandler(),
657
+ activeToolExecutions: deps.getActiveToolExecutions(),
658
+ })
659
+ ) {
660
+ return false;
661
+ }
662
+ const selection = deps.getPendingModelSwitch();
663
+ const abort = deps.getAbortHandler();
664
+ if (!selection || !turn || !abort) return false;
665
+ pendingContinuationTurn = undefined;
666
+ deps.setPendingModelSwitch(undefined);
667
+ deps.queueContinuation(turn, selection, ctx);
668
+ abort();
669
+ return true;
670
+ };
627
671
  return {
628
672
  canOfferInFlightSwitch: (ctx) =>
629
- canRestartTelegramTurnForModelSwitch({
673
+ canRestartAgentRunForTelegramModelSwitch({
630
674
  isIdle: deps.isIdle(ctx),
631
- hasActiveTelegramTurn: !!deps.getActiveTurn(),
632
675
  hasAbortHandler: deps.hasAbortHandler(),
633
676
  }),
634
- stagePendingSwitch: (selection, ctx) => {
677
+ stagePendingSwitch: (selection, ctx, continuationTurn) => {
678
+ pendingContinuationTurn = deps.getActiveTurn() ?? continuationTurn;
635
679
  deps.setPendingModelSwitch(selection);
636
- deps.updateStatus(ctx);
680
+ try {
681
+ deps.updateStatus(ctx);
682
+ } finally {
683
+ triggerPendingAbort(ctx);
684
+ }
637
685
  },
638
686
  clearPendingSwitch: () => {
687
+ pendingContinuationTurn = undefined;
639
688
  deps.setPendingModelSwitch(undefined);
640
689
  },
641
690
  queueContinuation: deps.queueContinuation,
642
- triggerPendingAbort: (ctx) => {
643
- if (
644
- !shouldTriggerPendingTelegramModelSwitchAbort({
645
- hasPendingModelSwitch: !!deps.getPendingModelSwitch(),
646
- hasActiveTelegramTurn: !!deps.getActiveTurn(),
647
- hasAbortHandler: deps.hasAbortHandler(),
648
- activeToolExecutions: deps.getActiveToolExecutions(),
649
- })
650
- ) {
651
- return false;
652
- }
653
- const selection = deps.getPendingModelSwitch();
654
- const turn = deps.getActiveTurn();
655
- const abort = deps.getAbortHandler();
656
- if (!selection || !turn || !abort) return false;
657
- deps.setPendingModelSwitch(undefined);
658
- deps.queueContinuation(turn, selection, ctx);
659
- abort();
660
- return true;
661
- },
662
- restartInterruptedTurn: (selection, ctx) =>
663
- restartTelegramModelSwitchContinuation({
664
- activeTurn: deps.getActiveTurn(),
691
+ triggerPendingAbort,
692
+ restartInterruptedTurn: (selection, ctx, continuationTurn) => {
693
+ const restarted = restartTelegramModelSwitchContinuation({
694
+ activeTurn: deps.getActiveTurn() ?? continuationTurn,
665
695
  abort: deps.getAbortHandler(),
666
696
  selection,
667
697
  queueContinuation: (turn, nextSelection) => {
668
698
  deps.queueContinuation(turn, nextSelection, ctx);
669
699
  },
670
- }),
700
+ });
701
+ if (restarted) pendingContinuationTurn = undefined;
702
+ return restarted;
703
+ },
671
704
  };
672
705
  }
package/lib/queue.ts CHANGED
@@ -126,6 +126,8 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
126
126
  historyText: string;
127
127
  priorityEmoji?: string;
128
128
  reactionSuppressionEmoji?: string;
129
+ /** Emit the explicit aborted-turn notice when /next settles this active turn. */
130
+ announceNextAbortOnEnd?: boolean;
129
131
 
130
132
  /** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
131
133
  voiceReplyPreferred?: boolean;
@@ -235,6 +237,8 @@ export interface TelegramActiveTurnStore<
235
237
  has: () => boolean;
236
238
  set: (turn: TTurn) => void;
237
239
  clear: () => void;
240
+ markNextAbortAnnouncement: () => boolean;
241
+ clearNextAbortAnnouncement: () => boolean;
238
242
  getChatId: () => number | undefined;
239
243
  getTarget: () => TelegramQueueTarget | undefined;
240
244
  getReplyToMessageId: () => number | undefined;
@@ -461,6 +465,16 @@ export function createTelegramActiveTurnStore<
461
465
  clear: () => {
462
466
  activeTurn = undefined;
463
467
  },
468
+ markNextAbortAnnouncement: () => {
469
+ if (!activeTurn) return false;
470
+ activeTurn.announceNextAbortOnEnd = true;
471
+ return true;
472
+ },
473
+ clearNextAbortAnnouncement: () => {
474
+ if (!activeTurn?.announceNextAbortOnEnd) return false;
475
+ delete activeTurn.announceNextAbortOnEnd;
476
+ return true;
477
+ },
464
478
  getChatId: () => activeTurn?.chatId,
465
479
  getTarget: () =>
466
480
  activeTurn?.target ? { ...activeTurn.target } : undefined,
@@ -1239,7 +1253,6 @@ export interface TelegramAgentStartHookRuntimeDeps<
1239
1253
  export type TelegramAgentStartHookEvent = unknown;
1240
1254
 
1241
1255
  export interface TelegramToolExecutionRuntimeDeps {
1242
- hasActiveTurn: () => boolean;
1243
1256
  getActiveToolExecutions: () => number;
1244
1257
  setActiveToolExecutions: (count: number) => void;
1245
1258
  }
@@ -1347,11 +1360,9 @@ export function createTelegramAgentStartHook<
1347
1360
  }
1348
1361
 
1349
1362
  export function getNextTelegramToolExecutionCount(options: {
1350
- hasActiveTurn: boolean;
1351
1363
  currentCount: number;
1352
1364
  event: "start" | "end";
1353
1365
  }): number {
1354
- if (!options.hasActiveTurn) return options.currentCount;
1355
1366
  if (options.event === "start") {
1356
1367
  return options.currentCount + 1;
1357
1368
  }
@@ -1363,7 +1374,6 @@ export function handleTelegramToolExecutionStartRuntime(
1363
1374
  ): void {
1364
1375
  deps.setActiveToolExecutions(
1365
1376
  getNextTelegramToolExecutionCount({
1366
- hasActiveTurn: deps.hasActiveTurn(),
1367
1377
  currentCount: deps.getActiveToolExecutions(),
1368
1378
  event: "start",
1369
1379
  }),
@@ -1373,15 +1383,13 @@ export function handleTelegramToolExecutionStartRuntime(
1373
1383
  export function handleTelegramToolExecutionEndRuntime(
1374
1384
  deps: TelegramToolExecutionEndRuntimeDeps,
1375
1385
  ): void {
1376
- const hasActiveTurn = deps.hasActiveTurn();
1377
1386
  deps.setActiveToolExecutions(
1378
1387
  getNextTelegramToolExecutionCount({
1379
- hasActiveTurn,
1380
1388
  currentCount: deps.getActiveToolExecutions(),
1381
1389
  event: "end",
1382
1390
  }),
1383
1391
  );
1384
- if (hasActiveTurn) deps.triggerPendingModelSwitchAbort();
1392
+ deps.triggerPendingModelSwitchAbort();
1385
1393
  }
1386
1394
 
1387
1395
  export type TelegramAgentLifecycleHooksRuntimeDeps<
@@ -1471,7 +1479,6 @@ export function createTelegramToolExecutionHooks<TContext>(
1471
1479
  ctx: TContext,
1472
1480
  ): void => {
1473
1481
  handleTelegramToolExecutionEndRuntime({
1474
- hasActiveTurn: deps.hasActiveTurn,
1475
1482
  getActiveToolExecutions: deps.getActiveToolExecutions,
1476
1483
  setActiveToolExecutions: deps.setActiveToolExecutions,
1477
1484
  triggerPendingModelSwitchAbort: () => {
@@ -1488,6 +1495,7 @@ export interface TelegramAgentEndPlan {
1488
1495
  kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
1489
1496
  shouldClearPreview: boolean;
1490
1497
  shouldDispatchNext: boolean;
1498
+ shouldSendAbortMessage: boolean;
1491
1499
  shouldSendErrorMessage: boolean;
1492
1500
  shouldSendAttachmentNotice: boolean;
1493
1501
  }
@@ -1680,6 +1688,7 @@ export function buildTelegramAgentEndPlan(options: {
1680
1688
  hasFinalText: boolean;
1681
1689
  hasQueuedAttachments: boolean;
1682
1690
  foldQueuedPromptsIntoHistory: boolean;
1691
+ announceNextAbortOnEnd?: boolean;
1683
1692
  }): TelegramAgentEndPlan {
1684
1693
  const shouldDispatchNext = shouldDispatchAfterTelegramAgentEnd({
1685
1694
  hasTurn: options.hasTurn,
@@ -1691,6 +1700,7 @@ export function buildTelegramAgentEndPlan(options: {
1691
1700
  kind: "no-turn",
1692
1701
  shouldClearPreview: false,
1693
1702
  shouldDispatchNext,
1703
+ shouldSendAbortMessage: false,
1694
1704
  shouldSendErrorMessage: false,
1695
1705
  shouldSendAttachmentNotice: false,
1696
1706
  };
@@ -1700,6 +1710,7 @@ export function buildTelegramAgentEndPlan(options: {
1700
1710
  kind: "aborted",
1701
1711
  shouldClearPreview: true,
1702
1712
  shouldDispatchNext,
1713
+ shouldSendAbortMessage: options.announceNextAbortOnEnd === true,
1703
1714
  shouldSendErrorMessage: false,
1704
1715
  shouldSendAttachmentNotice: false,
1705
1716
  };
@@ -1709,6 +1720,7 @@ export function buildTelegramAgentEndPlan(options: {
1709
1720
  kind: "error",
1710
1721
  shouldClearPreview: true,
1711
1722
  shouldDispatchNext,
1723
+ shouldSendAbortMessage: false,
1712
1724
  shouldSendErrorMessage: true,
1713
1725
  shouldSendAttachmentNotice: false,
1714
1726
  };
@@ -1718,6 +1730,7 @@ export function buildTelegramAgentEndPlan(options: {
1718
1730
  kind: "text",
1719
1731
  shouldClearPreview: false,
1720
1732
  shouldDispatchNext,
1733
+ shouldSendAbortMessage: false,
1721
1734
  shouldSendErrorMessage: false,
1722
1735
  shouldSendAttachmentNotice: false,
1723
1736
  };
@@ -1727,6 +1740,7 @@ export function buildTelegramAgentEndPlan(options: {
1727
1740
  kind: "attachments-only",
1728
1741
  shouldClearPreview: true,
1729
1742
  shouldDispatchNext,
1743
+ shouldSendAbortMessage: false,
1730
1744
  shouldSendErrorMessage: false,
1731
1745
  shouldSendAttachmentNotice: true,
1732
1746
  };
@@ -1735,6 +1749,7 @@ export function buildTelegramAgentEndPlan(options: {
1735
1749
  kind: "empty",
1736
1750
  shouldClearPreview: true,
1737
1751
  shouldDispatchNext,
1752
+ shouldSendAbortMessage: false,
1738
1753
  shouldSendErrorMessage: false,
1739
1754
  shouldSendAttachmentNotice: false,
1740
1755
  };
@@ -1891,6 +1906,7 @@ export async function handleTelegramAgentEndRuntime<
1891
1906
  hasFinalText: !!finalText || hasOutboundArtifacts,
1892
1907
  hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
1893
1908
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1909
+ announceNextAbortOnEnd: turn?.announceNextAbortOnEnd === true,
1894
1910
  });
1895
1911
  if (!turn) {
1896
1912
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
@@ -2017,22 +2033,29 @@ export async function handleTelegramAgentEndRuntime<
2017
2033
  await clearTurnPreview();
2018
2034
  if (!isDeliveryActive()) return;
2019
2035
  }
2020
- if (endPlan.shouldSendErrorMessage) {
2036
+ if (endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage) {
2021
2037
  const errorMessage = assistant.errorMessage ||
2022
2038
  "Telegram bridge: Pi failed while processing the request.";
2023
- const isOperationAborted = errorMessage.trim().replace(/\.$/, "") ===
2024
- "This operation was aborted";
2025
- await deps.sendTextReply(
2026
- turn.chatId,
2027
- turn.replyToMessageId,
2028
- isOperationAborted
2029
- ? "<b>⏹️ This operation was aborted.</b>"
2030
- : errorMessage,
2031
- {
2032
- target: turn.target,
2033
- ...(isOperationAborted ? { parseMode: "HTML" as const } : {}),
2034
- },
2035
- );
2039
+ const isOperationAborted = endPlan.shouldSendAbortMessage ||
2040
+ errorMessage.trim().replace(/\.$/, "") === "This operation was aborted";
2041
+ try {
2042
+ await deps.sendTextReply(
2043
+ turn.chatId,
2044
+ turn.replyToMessageId,
2045
+ isOperationAborted
2046
+ ? "<b>⏹️ This operation was aborted.</b>"
2047
+ : errorMessage,
2048
+ {
2049
+ target: turn.target,
2050
+ ...(isOperationAborted ? { parseMode: "HTML" as const } : {}),
2051
+ },
2052
+ );
2053
+ } catch (error) {
2054
+ if (!isOperationAborted) throw error;
2055
+ deps.recordRuntimeEvent?.("dispatch", error, {
2056
+ phase: "next-abort-announcement",
2057
+ });
2058
+ }
2036
2059
  if (!isDeliveryActive()) return;
2037
2060
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2038
2061
  return;
@@ -2180,7 +2203,7 @@ export async function handleTelegramAgentEndRuntime<
2180
2203
  };
2181
2204
  if (
2182
2205
  deps.scheduleActiveTurnDelivery &&
2183
- (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)
2206
+ (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)
2184
2207
  ) {
2185
2208
  deps.scheduleActiveTurnDelivery(deliverActiveTurn);
2186
2209
  return;
@@ -3092,6 +3115,9 @@ export interface TelegramQueueDispatchControllerDeps<
3092
3115
  ) => boolean;
3093
3116
  sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
3094
3117
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
3118
+ reconcileNextDispatchAnnouncementReplyOwnership?: (
3119
+ item: PendingTelegramTurn,
3120
+ ) => void;
3095
3121
  isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
3096
3122
  hasPendingInboundQueueMutationForItem?: (
3097
3123
  item: TelegramQueueItem<TContext>,
@@ -3107,6 +3133,7 @@ export interface TelegramQueueDispatchControllerDeps<
3107
3133
  export interface TelegramQueueDispatchController<TContext = unknown> {
3108
3134
  dispatchNext: (ctx: TContext) => void;
3109
3135
  requestNextDispatchAnnouncement: () => void;
3136
+ cancelNextDispatchAnnouncement: () => void;
3110
3137
  }
3111
3138
 
3112
3139
  export function executeTelegramQueueDispatchPlan<TContext = unknown>(
@@ -3161,6 +3188,8 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
3161
3188
  commitPromptDispatch: deps.commitPromptDispatch,
3162
3189
  sendUserMessage: deps.sendUserMessage,
3163
3190
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
3191
+ reconcileNextDispatchAnnouncementReplyOwnership:
3192
+ deps.reconcileNextDispatchAnnouncementReplyOwnership,
3164
3193
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
3165
3194
  hasPendingInboundQueueMutationForItem:
3166
3195
  deps.hasPendingInboundQueueMutationForItem,
@@ -3176,12 +3205,19 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3176
3205
  ): TelegramQueueDispatchController<TContext> {
3177
3206
  let controlDispatchPending = false;
3178
3207
  let nextDispatchAnnouncementRequested = false;
3208
+ let nextDispatchAnnouncementGeneration = 0;
3179
3209
  let nextDispatchAnnouncementAnchor: TelegramQueueItem<TContext> | undefined;
3180
3210
  const controller: TelegramQueueDispatchController<TContext> = {
3181
3211
  requestNextDispatchAnnouncement: () => {
3212
+ nextDispatchAnnouncementGeneration += 1;
3182
3213
  nextDispatchAnnouncementRequested = true;
3183
3214
  nextDispatchAnnouncementAnchor = undefined;
3184
3215
  },
3216
+ cancelNextDispatchAnnouncement: () => {
3217
+ nextDispatchAnnouncementGeneration += 1;
3218
+ nextDispatchAnnouncementRequested = false;
3219
+ nextDispatchAnnouncementAnchor = undefined;
3220
+ },
3185
3221
  dispatchNext: (ctx) => {
3186
3222
  if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
3187
3223
  if (controlDispatchPending) {
@@ -3393,6 +3429,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3393
3429
  if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
3394
3430
  nextDispatchAnnouncementRequested = false;
3395
3431
  controlDispatchPending = true;
3432
+ const announcementGeneration = nextDispatchAnnouncementGeneration;
3396
3433
  const dispatchGeneration = deps.getDispatchGeneration?.();
3397
3434
  deps.updateStatus(ctx);
3398
3435
  void deps.sendTextReply(
@@ -3401,8 +3438,19 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3401
3438
  "<b>⏩ Dispatching next queued turn.</b>",
3402
3439
  { target: dispatchPlan.item.target },
3403
3440
  ).catch((error) => {
3404
- deps.recordRuntimeEvent?.("dispatch", error, { phase: "next-announcement" });
3441
+ deps.recordRuntimeEvent?.("dispatch", error, {
3442
+ phase: "next-announcement",
3443
+ });
3405
3444
  }).finally(() => {
3445
+ try {
3446
+ deps.reconcileNextDispatchAnnouncementReplyOwnership?.(
3447
+ dispatchPlan.item,
3448
+ );
3449
+ } catch (error) {
3450
+ deps.recordRuntimeEvent?.("dispatch", error, {
3451
+ phase: "next-announcement-reply-ownership",
3452
+ });
3453
+ }
3406
3454
  controlDispatchPending = false;
3407
3455
  if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
3408
3456
  if (
@@ -3410,6 +3458,10 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3410
3458
  deps.isDispatchGenerationActive &&
3411
3459
  !deps.isDispatchGenerationActive(dispatchGeneration)
3412
3460
  ) return;
3461
+ if (announcementGeneration !== nextDispatchAnnouncementGeneration) {
3462
+ if (nextDispatchAnnouncementRequested) controller.dispatchNext(ctx);
3463
+ return;
3464
+ }
3413
3465
  executePlan();
3414
3466
  });
3415
3467
  return;
package/lib/replies.ts CHANGED
@@ -68,6 +68,7 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
68
68
  // --- Transport-level dedup ---
69
69
 
70
70
  const lastRepliedToMessageIdByTarget = new Map<string, number>();
71
+ const replyDedupPreservedOnNextReset = new Map<string, number>();
71
72
  let replyDedupGeneration = 0;
72
73
 
73
74
  function getReplyDedupTargetKey(
@@ -83,6 +84,23 @@ function getReplyDedupTargetKey(
83
84
  export function resetTransportReplyDedup(): void {
84
85
  replyDedupGeneration += 1;
85
86
  lastRepliedToMessageIdByTarget.clear();
87
+ for (const [key, messageId] of replyDedupPreservedOnNextReset) {
88
+ lastRepliedToMessageIdByTarget.set(key, messageId);
89
+ }
90
+ replyDedupPreservedOnNextReset.clear();
91
+ }
92
+
93
+ /** Keeps a successfully published transition notice as the first reply of the
94
+ * next agent turn. The following agent-start reset consumes this one-shot
95
+ * preservation, so later messages in that turn do not repeat the reply header. */
96
+ export function preserveTransportReplyDedupOnNextReset(
97
+ chatId: number,
98
+ messageId: number,
99
+ target?: TelegramTarget,
100
+ ): void {
101
+ const key = getReplyDedupTargetKey(chatId, target);
102
+ if (lastRepliedToMessageIdByTarget.get(key) !== messageId) return;
103
+ replyDedupPreservedOnNextReset.set(key, messageId);
86
104
  }
87
105
 
88
106
  export function buildTelegramReplyParameters(
package/lib/routing.ts CHANGED
@@ -627,6 +627,7 @@ export interface TelegramInboundRouteRuntimeDeps<
627
627
  isContextActive?: (ctx: TContext) => boolean;
628
628
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
629
629
  requestNextDispatchAnnouncement?: () => void;
630
+ cancelNextDispatchAnnouncement?: () => void;
630
631
  requestDeferredDispatchNextQueuedTelegramTurn?: (
631
632
  dispatch: (ctx: TContext) => void,
632
633
  ) => void;
@@ -1009,7 +1010,6 @@ export function createTelegramInboundRouteRuntime<
1009
1010
  updateSettingsMenuMessage: deps.updateSettingsMenuMessage,
1010
1011
  answerCallbackQuery: deps.answerCallbackQuery,
1011
1012
  isIdle: deps.isIdle,
1012
- hasActiveTelegramTurn: deps.activeTurnRuntime.has,
1013
1013
  hasAbortHandler: deps.bridgeRuntime.abort.hasHandler,
1014
1014
  getActiveToolExecutions:
1015
1015
  deps.bridgeRuntime.lifecycle.getActiveToolExecutions,
@@ -2222,6 +2222,10 @@ export function createTelegramInboundRouteRuntime<
2222
2222
  isContextActive: deps.isContextActive,
2223
2223
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
2224
2224
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
2225
+ cancelNextTransitionAnnouncements: () => {
2226
+ deps.activeTurnRuntime.clearNextAbortAnnouncement();
2227
+ deps.cancelNextDispatchAnnouncement?.();
2228
+ },
2225
2229
  requestDeferredDispatchNextQueuedTelegramTurn:
2226
2230
  deps.requestDeferredDispatchNextQueuedTelegramTurn,
2227
2231
  startTypingLoop: deps.startTypingLoop,
@@ -2297,6 +2301,8 @@ export function createTelegramInboundRouteRuntime<
2297
2301
  },
2298
2302
  getPromptTemplateCommands,
2299
2303
  sendTextReply: deps.sendTextReply,
2304
+ markActiveTurnNextAbortAnnouncement:
2305
+ deps.activeTurnRuntime.markNextAbortAnnouncement,
2300
2306
  getActiveTurnReply: () => {
2301
2307
  const activeTurn = deps.activeTurnRuntime.get();
2302
2308
  if (!activeTurn) return undefined;