@llblab/pi-kit 0.10.5 β†’ 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.10.7 - 2026-09-12
6
+
7
+ - `Queue Enqueue`: Advances the exact Telegram pin to `0.45.7`, preventing asynchronous voice/file preparation from restoring consumed prompts or overwriting newer queue changes. Surviving history and receipts follow current queue state; an already-wedged in-memory queue still requires separately authorized recovery. The package set, resource inventory, and load order remain unchanged.
8
+
9
+ ## 0.10.6 - 2026-09-12
10
+
11
+ - `Guest Placeholder And Delivery`: Advances the exact Telegram pin to `0.45.6`, so the guest ACK rotates the globe placeholder in whole cycles and holds `🌏 Working on it...`, guest answers are still delivered when a companion extension suppresses the final assistant message, `429` retry waits are recorded as `api` runtime events, the Thread display settings card drops the manual `/name` hint, and empty-queue headings carry no trailing period. The package set, resource inventory, and explicit load order remain unchanged.
12
+
5
13
  ## 0.10.5 - 2026-09-11
6
14
 
7
15
  - `State Flow Telegram Surface`: Advances the exact State Flow pin to `0.9.6`, so the submenu help line is exactly what State Flow records and when a new session resumes from the last committed point, and the state value (`off` or `#N`) renders in monospace. The package set, resource inventory, and explicit load order remain unchanged.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
15
15
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.9.4` | Compact Codex/Spark subscription-limit status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.8.1` | Visible continuation scheduling and bounded worker Skills |
17
17
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.9.6` | Atomic scoped state cohorts with final-eligibility resolution |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.5` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.7` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
19
19
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
20
20
 
21
21
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -2,6 +2,7 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
+ - [ ] `Prompt enqueue hotfix` (`optional`, `operator-gated`): Optional nonblocking operator-authorized disposable-follower smoke: overlap delayed voice processing with turn completion and confirm one-time ordered consumption and truthful counts against the [queue contract](./docs/architecture.md#queue-and-dispatch-safety). Separately authorized supported recovery investigation remains open: prevention does not repair an already-wedged in-memory queue; establish the exact recovery path and preservation/discard consequences before mutation, otherwise report the blocker. No journal/ownership edits, replay of settled input, implicit queue clearing, or restart; live activation requires separate operator authorization.
5
6
  - [ ] `Channel multimedia posts` (`0.45.1`, live-acceptance-gated): `telegram_message` channel delivery accepts one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video, uploads it through the multipart transport as `sendPhoto`/`sendVideo` with `text` as the HTML caption, validates kind and size (photo ≀ 10 MiB, video ≀ 50 MiB) plus ≀ 1024 visible caption characters before issuance, and rejects unsupported types and albums instead of downgrading them to links. The channel-post journal binds kind/file name/byte size/SHA-256 and caption, so duplicate requests and lost acknowledgements never re-upload; media-post edits replace the caption through `editMessageCaption`, and Markdown spoilers render as `<tg-spoiler>`. Live image publication passed on `@llb_log`. Regressions cover confirmed publication, duplicate requests, lost ACK, pre-issuance rejection, caption edits, and reconnect replacement. Remaining: operator-authorized disposable-channel acceptance of rejected upload, duplicate request, and caption edit.
6
7
  - [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes every automatic display mode, reset is leader/follower generation- and target-fenced, and Letters remains the default without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
7
8
  - [ ] `OMP schema acceptance` ([#267](https://github.com/llblab/pi-telegram/issues/267), `human-/environment-gated`): Local emitted-schema, Pi process, and llama.cpp source checks now prove explicit recursive JSON values, root `$defs`, only supported local `#/...` references, and no bare boolean schema. Confirm one connected `telegram_bind` request through the reporter's OMP + llama-server build before closing interoperability acceptance; do not treat this progressive external check as a Pi release blocker.
@@ -4,6 +4,18 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.45.7: Queue Enqueue Race Hotfix
8
+
9
+ - `Queue Enqueue`: Asynchronous voice/file preparation no longer restores consumed prompts or overwrites newer queue changes, preventing a settled phantom head from blocking accepted work. Final assembly uses current queue state and allocates order at commit; abort-history folding retains only surviving intended prompts and receipts while preserving a handed-off head until `agent_start`.
10
+
11
+ ## 0.45.6: Guest Placeholder And Delivery Hotfix
12
+
13
+ - `Guest Placeholder`: The guest ACK answers with the first bold globe frame (`🌎 Working on it.`) and rotates `🌍`/`🌏` once per second with the dots growing every two seconds. Rotation now completes whole 6-frame cycles and stops only after the 20 s minimum, so a pending answer holds the cycle's final frame (`🌏 Working on it...`) instead of cutting mid-step; a 26 s safety bound stays clear of the ~28 s Telegram flood-control wall, and error backoff and pre-replacement cancellation are unchanged.
14
+ - `Guest Answer Delivery`: The guest replacement now falls back to the run's latest completed assistant text when Pi's final assistant message is empty, so an answer preserved by a companion extension's fallback turn (for example State Flow's suppressed final:true patch turn) is still edited into the guest message instead of being skipped as "no editable text".
15
+ - `Rate-Limit Visibility`: A `429` retry wait now records an `api` runtime event with the method, wait duration, attempt, and server `retry_after`, so a silent flood-control pause before the final guest replacement is visible in the runtime log instead of appearing only as a frozen frame.
16
+ - `Thread Display Hint`: The Thread display settings card no longer carries the manual `/name Name` override hint; the current-value line, the three mode descriptions, and the live chooser are unchanged.
17
+ - `Queue Empty Headings`: The default empty-queue line and every rotating refresh title drop the trailing period, so the fully bold queue headings read as headings; wording, emoji, rotation order, callbacks, and refresh behavior are unchanged.
18
+
7
19
  ## 0.45.5: Queue Refresh Icon Hotfix
8
20
 
9
21
  - `Queue Refresh Icon`: The queue menu's Refresh row now uses `πŸ”„`, the canonical refresh glyph, reserving `πŸŒ€` for the State Flow Telegram identity. The button label is the only change; queue refresh behavior, callbacks, and the rotating empty-queue notices are unchanged.
@@ -480,6 +480,8 @@ Menu and reaction controls share the same canonical queue state. A menu Keep can
480
480
 
481
481
  Admission and planning validate lane contracts. Invalid lane/kind pairings fail predictably instead of being silently coerced.
482
482
 
483
+ Prompt preparation completes downloads, inbound handlers, and binary image reads before synchronous final assembly and queue commit. Enqueue captures only intended abort-history identities before preparation; afterward it reads the current queue, folds surviving selected prompts using their current text and receipts, allocates the new turn's order, and appends without another asynchronous boundary. The handed-off head remains in place until `agent_start` consumes it, even if preparation finishes first. Concurrent arrivals, removals, and lane/reaction edits remain intact; preparation failure or stale-generation completion leaves intervening queue changes untouched.
484
+
483
485
  Dispatch requires:
484
486
 
485
487
  - No active Telegram turn.
@@ -104,7 +104,7 @@ The menu may clear internal Skip but cannot remove a reaction created by the use
104
104
 
105
105
  ### Decorative Or Local-Example Emoji
106
106
 
107
- Some emoji are intentionally local examples or decorative variants, not global semantics. Empty-queue rotating messages (`πŸ«™`, `πŸƒ`, `πŸ•³`, `πŸ¦—`, `πŸŒ™`, `🧘`, `πŸͺ`, `🧺`, `πŸ”­`, `🫧`, `πŸ›Έ`) are copy flavor only and must not become controls. Example extension icons such as `πŸ§ͺ`, `πŸ”§`, and `πŸ—‚` are documentation fixtures for companion extensions, not built-in pi-telegram meanings.
107
+ Some emoji are intentionally local examples or decorative variants, not global semantics. Empty-queue rotating messages (`πŸ«™`, `πŸƒ`, `πŸ•³`, `πŸ¦—`, `πŸŒ™`, `🧘`, `πŸͺ`, `🧺`, `πŸ”­`, `🫧`, `πŸ›Έ`) are copy flavor only and must not become controls. The Guest Mode placeholder frames (`🌎`, `🌍`, `🌏` stepping every second with dots growing once every two seconds) are the same kind of decorative copy: they complete whole 6-frame cycles over at least a ~20 s rotation while a guest answer is pending and then hold the cycle's final frame (a 26 s safety bound caps slow streams), never become controls, and must not carry another meaning. Example extension icons such as `πŸ§ͺ`, `πŸ”§`, and `πŸ—‚` are documentation fixtures for companion extensions, not built-in pi-telegram meanings.
108
108
 
109
109
  Thread UI rule: when a message heading, chooser, or status line is specifically about Telegram/Pi threads or target thread selection, start the heading with `🧡`. Button labels for concrete thread targets should stay clean (`threadName` or slot fallback) and should not add `🧡` to every target button unless the row would otherwise be ambiguous.
110
110
 
@@ -224,7 +224,7 @@ Message cards and standalone informational notices sent by the bot should start
224
224
  Rules:
225
225
 
226
226
  - Start with a bold heading or, for dialogs, a bold question.
227
- - Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period. Menu or chooser headings use the same fully bold form but end in a colon when controls or detail follow.
227
+ - Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period. Menu or chooser headings use the same fully bold form but end in a colon when controls or detail follow. Empty-queue headings are the deliberate exception: fully bold, with no trailing period or colon.
228
228
  - Keep the emoji and complete sentence or heading inside the single bold span; do not bold only a fragment. A material name or phrase may receive nested italic emphasis without breaking the outer bold hierarchyβ€”for example `<b>πŸ“‘ Instance <i>Cedar</i> connected.</b>`.
229
229
  - Apply the same hierarchy to success, progress, empty, busy, unavailable, cancellation, and failure notices.
230
230
  - Callback alerts remain plain text because Telegram does not support rich text there, but still keep the relevant emoji and concise sentence.
@@ -823,6 +823,12 @@ interface TelegramLifecycleBindingDeps {
823
823
  Pi.AgentEndEvent["messages"][number],
824
824
  Keyboard.TelegramInlineKeyboardMarkup
825
825
  >["editGuestReply"];
826
+ stopGuestPlaceholder?: Queue.TelegramAgentEndHookRuntimeDeps<
827
+ Queue.PendingTelegramTurn,
828
+ Pi.ExtensionContext,
829
+ Pi.AgentEndEvent["messages"][number],
830
+ Keyboard.TelegramInlineKeyboardMarkup
831
+ >["stopGuestPlaceholder"];
826
832
  preparePreviewDelivery?: Queue.TelegramAgentEndRuntimeDeps<Queue.PendingTelegramTurn>["preparePreviewDelivery"];
827
833
  finalizeMarkdownPreview: Queue.TelegramAgentEndHookRuntimeDeps<
828
834
  Queue.PendingTelegramTurn,
@@ -876,6 +882,7 @@ export function registerTelegramLifecycleRuntimeHooks({
876
882
  deleteMessage,
877
883
  sendGuestReply,
878
884
  editGuestReply,
885
+ stopGuestPlaceholder,
879
886
  preparePreviewDelivery,
880
887
  finalizeMarkdownPreview,
881
888
  proactivePushTargetGetter,
@@ -1056,7 +1063,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1056
1063
  updateStatus,
1057
1064
  getActiveTurn: activeTurnRuntime.get,
1058
1065
  loadConfig: configStore.load,
1059
- extractAssistant: Replies.extractLatestAssistantMessageText,
1066
+ extractAssistant: Replies.extractRunAssistantMessage,
1060
1067
  getFoldQueuedPromptsIntoHistory:
1061
1068
  lifecycle.shouldFoldQueuedPromptsIntoHistory,
1062
1069
  resetRuntimeState: agentEndResetter,
@@ -1090,6 +1097,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1090
1097
  answerGuestQuery,
1091
1098
  sendGuestReply,
1092
1099
  editGuestReply,
1100
+ stopGuestPlaceholder,
1093
1101
  sendGuestAttachment,
1094
1102
  sendGuestVoiceReply,
1095
1103
  planOutboundReply: outboundReplyPlanner,
@@ -1429,7 +1429,7 @@ export async function handleTelegramNextCommand(deps: {
1429
1429
  deps.clearPendingModelSwitch();
1430
1430
  if (!deps.hasQueuedItems()) {
1431
1431
  await deps.sendTextReply(
1432
- formatTelegramInformationHeading("βŒ›", "Queue is empty."),
1432
+ formatTelegramInformationHeading("βŒ›", "Queue is empty"),
1433
1433
  { parseMode: "HTML" },
1434
1434
  );
1435
1435
  return;
@@ -588,6 +588,12 @@ export default function (pi: Pi.ExtensionAPI) {
588
588
  const editGuestReply = Replies.createGuestMarkdownReplyEditor({
589
589
  editGuestInlineMessage: telegramApiRuntime.editGuestInlineMessage,
590
590
  });
591
+ // Rotate the guest placeholder frames until the final replacement stops them.
592
+ const guestPlaceholderRuntime =
593
+ Replies.createTelegramGuestPlaceholderRuntime({
594
+ editGuestInlineMessage: telegramApiRuntime.editGuestInlineMessage,
595
+ recordRuntimeEvent,
596
+ });
591
597
 
592
598
  const promptDispatchRuntime = Runtime.createTelegramPromptDispatchRuntime({
593
599
  lifecycle,
@@ -978,6 +984,7 @@ export default function (pi: Pi.ExtensionAPI) {
978
984
  deleteMessage: deleteTelegramMessage,
979
985
  answerGuestQuery,
980
986
  answerGuestQueryForInlineMessage,
987
+ startGuestPlaceholder: guestPlaceholderRuntime.start,
981
988
  sendTextReply,
982
989
  setMyCommands,
983
990
  validateThreadName(threadName) {
@@ -1508,6 +1515,7 @@ export default function (pi: Pi.ExtensionAPI) {
1508
1515
  },
1509
1516
  capabilityMonitor: telegramThreadCapabilityMonitor,
1510
1517
  queueWatchdog: queueDispatchWatchdogRuntime,
1518
+ guestPlaceholder: { stopAll: guestPlaceholderRuntime.stopAll },
1511
1519
  },
1512
1520
  });
1513
1521
  const sessionLifecycleRuntime =
@@ -1828,6 +1836,7 @@ export default function (pi: Pi.ExtensionAPI) {
1828
1836
  deleteMessage: deleteTelegramMessage,
1829
1837
  sendGuestReply,
1830
1838
  editGuestReply,
1839
+ stopGuestPlaceholder: guestPlaceholderRuntime.stop,
1831
1840
  finalizeMarkdownPreview,
1832
1841
  preparePreviewDelivery,
1833
1842
  proactivePushTargetGetter,
@@ -265,6 +265,7 @@ export interface TelegramBridgeSessionServiceRuntime {
265
265
  inboundWorker: { onSessionShutdown(): Promise<void> };
266
266
  capabilityMonitor: { start(ctx: ExtensionContext): void; stop(): void };
267
267
  queueWatchdog: { start(ctx: ExtensionContext): void; stop(): void };
268
+ guestPlaceholder?: { stopAll(): void };
268
269
  }
269
270
 
270
271
  export interface TelegramBridgeSessionLifecycleAssemblyDeps<
@@ -320,6 +321,7 @@ export interface TelegramBridgeSessionLifecyclePorts<
320
321
  inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
321
322
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
322
323
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
324
+ guestPlaceholder?: TelegramBridgeSessionServiceRuntime["guestPlaceholder"];
323
325
  };
324
326
  }
325
327
 
@@ -347,6 +349,7 @@ export function createTelegramBridgeSessionLifecycleDeps<
347
349
  inboundWorker: ports.services.inboundWorker,
348
350
  capabilityMonitor: ports.services.capabilityMonitor,
349
351
  queueWatchdog: ports.services.queueWatchdog,
352
+ guestPlaceholder: ports.services.guestPlaceholder,
350
353
  },
351
354
  };
352
355
  }
@@ -383,6 +386,7 @@ export function createTelegramBridgeSessionLifecycleAssembly<
383
386
  },
384
387
  async onSessionShutdown(event, ctx) {
385
388
  if (!isSessionActive(ctx)) return;
389
+ deps.services.guestPlaceholder?.stopAll();
386
390
  await deps.services.delivery.onSessionShutdown();
387
391
  if (!isSessionActive(ctx)) return;
388
392
  deps.services.queueWatchdog.stop();
@@ -14,18 +14,18 @@ import * as Queue from "./queue.ts";
14
14
  const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
15
15
  const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
16
16
  const EMPTY_QUEUE_REFRESH_TITLES = [
17
- "<b>βŒ› Queue is still empty.</b>",
18
- "<b>πŸ«™ Still nothing in queue.</b>",
19
- "<b>πŸƒ Queue remains empty.</b>",
20
- "<b>πŸ•³ Nothing queued yet.</b>",
21
- "<b>πŸ¦— Queue crickets continue.</b>",
22
- "<b>πŸŒ™ Queue is peacefully idle.</b>",
23
- "<b>🧘 Nothing waiting. Very zen.</b>",
24
- "<b>πŸͺ Queue orbit is clear.</b>",
25
- "<b>🧺 Basket is empty.</b>",
26
- "<b>πŸ”­ No prompts on the horizon.</b>",
27
- "<b>🫧 Queue bubbles: none.</b>",
28
- "<b>πŸ›Έ No queued signals detected.</b>",
17
+ "<b>βŒ› Queue is still empty</b>",
18
+ "<b>πŸ«™ Still nothing in queue</b>",
19
+ "<b>πŸƒ Queue remains empty</b>",
20
+ "<b>πŸ•³ Nothing queued yet</b>",
21
+ "<b>πŸ¦— Queue crickets continue</b>",
22
+ "<b>πŸŒ™ Queue is peacefully idle</b>",
23
+ "<b>🧘 Nothing waiting. Very zen</b>",
24
+ "<b>πŸͺ Queue orbit is clear</b>",
25
+ "<b>🧺 Basket is empty</b>",
26
+ "<b>πŸ”­ No prompts on the horizon</b>",
27
+ "<b>🫧 Queue bubbles: none</b>",
28
+ "<b>πŸ›Έ No queued signals detected</b>",
29
29
  ] as const;
30
30
  type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
31
31
  interface TelegramQueueMenuItem {
@@ -340,7 +340,7 @@ function getTelegramQueueMenuListText(
340
340
  emptyRefreshIndex?: number,
341
341
  ): string {
342
342
  if (items.length > 0) return "<b>⏳ Queue:</b>";
343
- if (emptyRefreshIndex === undefined) return "<b>βŒ› Queue is empty.</b>";
343
+ if (emptyRefreshIndex === undefined) return "<b>βŒ› Queue is empty</b>";
344
344
  return EMPTY_QUEUE_REFRESH_TITLES[
345
345
  emptyRefreshIndex % EMPTY_QUEUE_REFRESH_TITLES.length
346
346
  ];
@@ -184,7 +184,6 @@ export function buildThreadDisplaySettingsText(mode: TelegramThreadDisplayMode):
184
184
  "<code>-</code> <code>letters</code> (default): show the unique slot, such as <b><i>A</i></b> or <b><i>B</i></b>.",
185
185
  "<code>-</code> <code>names</code>: show the generated dictionary name for the slot, such as <b><i>Anchor</i></b> or <b><i>Briar</i></b>.",
186
186
  "<code>-</code> <code>directories</code>: show the directory, such as <b><i>extensions</i></b>; shared Workspaces keep slot suffixes, such as <b><i>extensions_a</i></b> and <b><i>extensions_c</i></b>.",
187
- "A manual <code>/name Name</code> overrides this Thread display name until reset.",
188
187
  ].join("\n");
189
188
  }
190
189
 
@@ -1554,6 +1554,8 @@ export interface TelegramAgentEndRuntimeDeps<
1554
1554
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
1555
1555
  /** Replaces the early guest ACK with the final text. */
1556
1556
  editGuestReply?: (inlineMessageId: string, markdown: string) => Promise<void>;
1557
+ /** Cancels the animated guest placeholder before the final edit. */
1558
+ stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
1557
1559
  sendGuestAttachment?: (
1558
1560
  turn: TTurn,
1559
1561
  attachment: QueuedAttachment,
@@ -1633,6 +1635,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1633
1635
  answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
1634
1636
  sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
1635
1637
  editGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["editGuestReply"];
1638
+ stopGuestPlaceholder?: TelegramAgentEndRuntimeDeps<TTurn>["stopGuestPlaceholder"];
1636
1639
  sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
1637
1640
  sendGuestVoiceReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestVoiceReply"];
1638
1641
  planOutboundReply?: TelegramAgentEndRuntimeDeps<
@@ -1774,6 +1777,7 @@ export function createTelegramAgentEndHook<
1774
1777
  answerGuestQuery: deps.answerGuestQuery,
1775
1778
  sendGuestReply: deps.sendGuestReply,
1776
1779
  editGuestReply: deps.editGuestReply,
1780
+ stopGuestPlaceholder: deps.stopGuestPlaceholder,
1777
1781
  sendGuestAttachment: deps.sendGuestAttachment,
1778
1782
  sendGuestVoiceReply: deps.sendGuestVoiceReply,
1779
1783
  planOutboundReply: deps.planOutboundReply,
@@ -1866,6 +1870,16 @@ export async function handleTelegramAgentEndRuntime<
1866
1870
  return;
1867
1871
  }
1868
1872
  if (turn.guestQueryId) {
1873
+ if (turn.guestInlineMessageId && deps.stopGuestPlaceholder) {
1874
+ try {
1875
+ await deps.stopGuestPlaceholder(turn.guestInlineMessageId);
1876
+ } catch (error) {
1877
+ deps.recordRuntimeEvent?.("delivery", error, {
1878
+ phase: "guest-placeholder-stop",
1879
+ guestQueryId: turn.guestQueryId,
1880
+ });
1881
+ }
1882
+ }
1869
1883
  if (turn.guestInlineMessageId && deps.editGuestReply) {
1870
1884
  const experimentText = assistant.errorMessage
1871
1885
  ? "Telegram bridge: Pi failed while processing the request."
@@ -1875,7 +1889,7 @@ export async function handleTelegramAgentEndRuntime<
1875
1889
  await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
1876
1890
  deps.recordRuntimeEvent?.(
1877
1891
  "guest",
1878
- new Error("Guest ACK experiment edited the guest answer"),
1892
+ new Error("Guest ACK edited the guest answer"),
1879
1893
  { phase: "guest-ack-edited", guestQueryId: turn.guestQueryId },
1880
1894
  );
1881
1895
  } catch (error) {
@@ -1887,7 +1901,7 @@ export async function handleTelegramAgentEndRuntime<
1887
1901
  } else {
1888
1902
  deps.recordRuntimeEvent?.(
1889
1903
  "delivery",
1890
- new Error("Guest ACK experiment turn produced no editable text"),
1904
+ new Error("Guest ACK turn produced no editable text"),
1891
1905
  { phase: "guest-ack-edit-empty", guestQueryId: turn.guestQueryId },
1892
1906
  );
1893
1907
  }
@@ -2335,16 +2349,18 @@ export interface TelegramControlQueueController<TContext> {
2335
2349
  ) => void;
2336
2350
  }
2337
2351
 
2352
+ export type TelegramPreparedPromptTurn = (
2353
+ historyTurns: PendingTelegramTurn[],
2354
+ ) => PendingTelegramTurn;
2355
+
2338
2356
  export interface TelegramPromptEnqueueRuntimeDeps<
2339
2357
  TMessage,
2340
2358
  TContext = unknown,
2341
2359
  > extends TelegramQueueStore<TContext> {
2360
+ hasPendingDispatch: () => boolean;
2342
2361
  getFoldQueuedPromptsIntoHistory: () => boolean;
2343
2362
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
2344
- createTurn: (
2345
- messages: TMessage[],
2346
- historyTurns: PendingTelegramTurn[],
2347
- ) => Promise<PendingTelegramTurn>;
2363
+ prepareTurn: (messages: TMessage[]) => Promise<TelegramPreparedPromptTurn>;
2348
2364
  updateStatus: () => void;
2349
2365
  dispatchNextQueuedTelegramTurn: () => void;
2350
2366
  assertExecutionCurrent?: () => void;
@@ -2355,13 +2371,13 @@ export interface TelegramPromptEnqueueControllerDeps<
2355
2371
  TMessage,
2356
2372
  TContext = unknown,
2357
2373
  > extends TelegramQueueStore<TContext> {
2374
+ hasPendingDispatch: () => boolean;
2358
2375
  getFoldQueuedPromptsIntoHistory: () => boolean;
2359
2376
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
2360
- createTurn: (
2377
+ prepareTurn: (
2361
2378
  messages: TMessage[],
2362
- historyTurns: PendingTelegramTurn[],
2363
2379
  ctx: TContext,
2364
- ) => Promise<PendingTelegramTurn>;
2380
+ ) => Promise<TelegramPreparedPromptTurn>;
2365
2381
  updateStatus: (ctx: TContext) => void;
2366
2382
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
2367
2383
  assertExecutionCurrent?: (messages: TMessage[]) => void;
@@ -2713,17 +2729,25 @@ export async function enqueueTelegramPromptTurnRuntime<
2713
2729
  messages: TMessage[],
2714
2730
  deps: TelegramPromptEnqueueRuntimeDeps<TMessage, TContext>,
2715
2731
  ): Promise<PendingTelegramTurn> {
2716
- const enqueuePlan = planTelegramPromptEnqueue(
2732
+ deps.assertExecutionCurrent?.();
2733
+ const historyOrders = new Set(planTelegramPromptEnqueue(
2717
2734
  deps.getQueuedItems(),
2718
2735
  deps.getFoldQueuedPromptsIntoHistory(),
2719
- );
2720
- deps.assertExecutionCurrent?.();
2736
+ ).historyTurns.map((turn) => turn.queueOrder));
2721
2737
  deps.setFoldQueuedPromptsIntoHistory(false);
2722
- const turn = await deps.createTurn(messages, enqueuePlan.historyTurns);
2738
+ const buildTurn = await deps.prepareTurn(messages);
2723
2739
  deps.assertExecutionCurrent?.();
2724
- deps.setQueuedItems(
2725
- appendTelegramQueueItem(enqueuePlan.remainingItems, turn),
2726
- );
2740
+ // Preserve the Pi-owned head until agent_start, plus later arrivals and current edits/reactions.
2741
+ const pendingDispatch = deps.hasPendingDispatch();
2742
+ const historyTurns: PendingTelegramTurn[] = [];
2743
+ const remainingItems = deps.getQueuedItems().filter((item, index) => {
2744
+ if ((pendingDispatch && index === 0) || !isPendingTelegramTurn(item) ||
2745
+ !historyOrders.has(item.queueOrder)) return true;
2746
+ historyTurns.push(item);
2747
+ return false;
2748
+ });
2749
+ const turn = buildTurn(historyTurns);
2750
+ deps.setQueuedItems(appendTelegramQueueItem(remainingItems, turn));
2727
2751
  deps.onQueued?.(turn);
2728
2752
  deps.updateStatus();
2729
2753
  deps.dispatchNextQueuedTelegramTurn();
@@ -2740,8 +2764,7 @@ export function createTelegramPromptEnqueueController<
2740
2764
  enqueue: (messages, ctx, onQueued) =>
2741
2765
  enqueueTelegramPromptTurnRuntime(messages, {
2742
2766
  ...deps,
2743
- createTurn: (nextMessages, historyTurns) =>
2744
- deps.createTurn(nextMessages, historyTurns, ctx),
2767
+ prepareTurn: (nextMessages) => deps.prepareTurn(nextMessages, ctx),
2745
2768
  updateStatus: () => deps.updateStatus(ctx),
2746
2769
  dispatchNextQueuedTelegramTurn: () =>
2747
2770
  deps.dispatchNextQueuedTelegramTurn(ctx),
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram reply delivery helpers
3
3
  * Zones: telegram outbound, native rich markdown, UI/compat rendering transport
4
- * Owns native assistant replies, rendered UI delivery, reply transport wiring, and plain text replies
4
+ * Owns native assistant replies, rendered UI delivery, guest placeholder rotation, reply transport wiring, and plain text replies
5
5
  */
6
6
 
7
7
  import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
@@ -177,6 +177,47 @@ export function extractLatestAssistantMessageText(
177
177
  return {};
178
178
  }
179
179
 
180
+ /**
181
+ * Extract the run's answer without trusting an empty final assistant message.
182
+ * A low-level run may end with a completed assistant message whose content was
183
+ * suppressed (a companion extension preserving an earlier draft, for example
184
+ * State Flow's fallback final:true patch turn). In that case the run's answer
185
+ * is the latest earlier completed assistant message that carries text;
186
+ * tool-use prefaces, errors, and aborts stay excluded.
187
+ */
188
+ export function extractRunAssistantMessage(
189
+ messages: readonly unknown[],
190
+ ): {
191
+ text?: string;
192
+ stopReason?: string;
193
+ errorMessage?: string;
194
+ } {
195
+ const latest = extractLatestAssistantMessageText(messages);
196
+ if (latest.text || latest.stopReason !== "stop") return latest;
197
+ for (let i = messages.length - 1; i >= 0; i--) {
198
+ const message = messages[i];
199
+ if (!message || !isAssistantAgentMessage(message)) continue;
200
+ const rawStopReason = getAgentMessageField(message, "stopReason");
201
+ if (
202
+ rawStopReason === "toolUse" ||
203
+ rawStopReason === "error" ||
204
+ rawStopReason === "aborted"
205
+ ) {
206
+ continue;
207
+ }
208
+ const text = getAgentMessageText(message);
209
+ if (!text) continue;
210
+ const rawErrorMessage = getAgentMessageField(message, "errorMessage");
211
+ return {
212
+ text,
213
+ stopReason: typeof rawStopReason === "string" ? rawStopReason : undefined,
214
+ errorMessage:
215
+ typeof rawErrorMessage === "string" ? rawErrorMessage : undefined,
216
+ };
217
+ }
218
+ return latest;
219
+ }
220
+
180
221
  export interface TelegramReplyOwnershipRecorder {
181
222
  record: (input: {
182
223
  chatId: number;
@@ -900,9 +941,9 @@ export function createGuestMarkdownReplySender(deps: {
900
941
  }
901
942
 
902
943
  /**
903
- * Guest reply editor: replaces an early Guest Mode answer (the temporary ACK
904
- * experiment) with native Rich Markdown content addressed by
905
- * `inline_message_id` instead of a chat/message pair.
944
+ * Guest reply editor: replaces the early Guest Mode placeholder ACK with
945
+ * native Rich Markdown content addressed by `inline_message_id` instead of a
946
+ * chat/message pair.
906
947
  */
907
948
  export function createGuestMarkdownReplyEditor(deps: {
908
949
  editGuestInlineMessage: (
@@ -917,3 +958,219 @@ export function createGuestMarkdownReplyEditor(deps: {
917
958
  });
918
959
  };
919
960
  }
961
+
962
+ /**
963
+ * Guest Mode placeholder rotation: the early ACK is the first placeholder
964
+ * frame and the runtime steps the remaining frames once per interval, moving
965
+ * the globe every second while the trailing dots grow once every two seconds.
966
+ *
967
+ * Rotation completes whole six-frame cycles and only stops once at least
968
+ * `TELEGRAM_GUEST_PLACEHOLDER_MIN_MS` has elapsed, so a pending answer holds
969
+ * the finished cycle's last frame (`🌏 Working on it...`) instead of whatever
970
+ * step a hard time cap happens to cut. The `TELEGRAM_GUEST_PLACEHOLDER_MAX_MS`
971
+ * safety bound keeps the edit stream clear of the first flood-control
972
+ * rejections measured in live guest runs (+26.5 s at ~53 edits, +27.8 s at
973
+ * ~28 edits): rotation caps at 23 edits and never starts a frame after 26 s.
974
+ */
975
+
976
+ export const TELEGRAM_GUEST_PLACEHOLDER_FRAME_MS = 1_000;
977
+
978
+ export const TELEGRAM_GUEST_PLACEHOLDER_MIN_MS = 20_000;
979
+
980
+ export const TELEGRAM_GUEST_PLACEHOLDER_MAX_MS = 26_000;
981
+
982
+ export const TELEGRAM_GUEST_PLACEHOLDER_FRAMES = [
983
+ "<b>🌎 Working on it.</b>",
984
+ "<b>🌍 Working on it.</b>",
985
+ "<b>🌏 Working on it..</b>",
986
+ "<b>🌎 Working on it..</b>",
987
+ "<b>🌍 Working on it...</b>",
988
+ "<b>🌏 Working on it...</b>",
989
+ ] as const;
990
+
991
+ export function buildTelegramGuestPlaceholderFrame(step: number): string {
992
+ const frames: readonly string[] = TELEGRAM_GUEST_PLACEHOLDER_FRAMES;
993
+ const index = ((step % frames.length) + frames.length) % frames.length;
994
+ return frames[index]!;
995
+ }
996
+
997
+ export interface TelegramGuestPlaceholderRuntimeDeps {
998
+ editGuestInlineMessage: (
999
+ inlineMessageId: string,
1000
+ content: { text: string; parseMode: "HTML" },
1001
+ ) => Promise<void>;
1002
+ recordRuntimeEvent?: (
1003
+ category: string,
1004
+ error: unknown,
1005
+ details?: Record<string, unknown>,
1006
+ ) => void;
1007
+ intervalMs?: number;
1008
+ minMs?: number;
1009
+ maxMs?: number;
1010
+ now?: () => number;
1011
+ setTimer?: (
1012
+ callback: () => void,
1013
+ ms: number,
1014
+ ) => ReturnType<typeof setTimeout>;
1015
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
1016
+ }
1017
+
1018
+ export interface TelegramGuestPlaceholderRuntime {
1019
+ /** Starts the frame loop on an answered guest inline message. */
1020
+ start: (inlineMessageId: string) => void;
1021
+ /** Cancels the loop and waits for any in-flight frame edit before returning. */
1022
+ stop: (inlineMessageId: string) => Promise<void>;
1023
+ /** Cancels every loop without waiting for in-flight edits (session shutdown). */
1024
+ stopAll: () => void;
1025
+ }
1026
+
1027
+ interface TelegramGuestPlaceholderSession {
1028
+ step: number;
1029
+ stopped: boolean;
1030
+ startedAtMs: number;
1031
+ finished: boolean;
1032
+ timer?: ReturnType<typeof setTimeout>;
1033
+ inflight?: Promise<void>;
1034
+ }
1035
+
1036
+ function getTelegramGuestPlaceholderRetryDelayMs(
1037
+ error: unknown,
1038
+ fallbackMs: number,
1039
+ ): number {
1040
+ const retryAfterSeconds = (
1041
+ error as { retryAfterSeconds?: unknown } | undefined
1042
+ )?.retryAfterSeconds;
1043
+ return typeof retryAfterSeconds === "number" && retryAfterSeconds > 0
1044
+ ? Math.max(fallbackMs, retryAfterSeconds * 1_000)
1045
+ : fallbackMs;
1046
+ }
1047
+
1048
+ export function createTelegramGuestPlaceholderRuntime(
1049
+ deps: TelegramGuestPlaceholderRuntimeDeps,
1050
+ ): TelegramGuestPlaceholderRuntime {
1051
+ const intervalMs = deps.intervalMs ?? TELEGRAM_GUEST_PLACEHOLDER_FRAME_MS;
1052
+ const minMs = deps.minMs ?? TELEGRAM_GUEST_PLACEHOLDER_MIN_MS;
1053
+ const maxMs = deps.maxMs ?? TELEGRAM_GUEST_PLACEHOLDER_MAX_MS;
1054
+ const now = deps.now ?? Date.now;
1055
+ const setTimer =
1056
+ deps.setTimer ??
1057
+ ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
1058
+ setTimeout(callback, ms));
1059
+ const clearTimer =
1060
+ deps.clearTimer ??
1061
+ ((timer: ReturnType<typeof setTimeout>): void => clearTimeout(timer));
1062
+ const sessions = new Map<string, TelegramGuestPlaceholderSession>();
1063
+
1064
+ const finishRotation = (
1065
+ session: TelegramGuestPlaceholderSession,
1066
+ elapsedMs: number,
1067
+ ): void => {
1068
+ if (session.finished) return;
1069
+ session.finished = true;
1070
+ deps.recordRuntimeEvent?.(
1071
+ "guest",
1072
+ "Guest placeholder rotation reached its bound",
1073
+ {
1074
+ phase: "guest-placeholder-capped",
1075
+ minMs,
1076
+ maxMs,
1077
+ elapsedMs,
1078
+ step: session.step,
1079
+ },
1080
+ );
1081
+ };
1082
+
1083
+ const scheduleFrame = (
1084
+ inlineMessageId: string,
1085
+ session: TelegramGuestPlaceholderSession,
1086
+ delayMs: number,
1087
+ ): void => {
1088
+ if (session.stopped) return;
1089
+ const elapsedMs = now() - session.startedAtMs;
1090
+ const frameCount = TELEGRAM_GUEST_PLACEHOLDER_FRAMES.length;
1091
+ const cycleComplete =
1092
+ session.step > 0 && session.step % frameCount === frameCount - 1;
1093
+ if (cycleComplete && elapsedMs >= minMs) {
1094
+ finishRotation(session, elapsedMs);
1095
+ return;
1096
+ }
1097
+ if (elapsedMs + delayMs > maxMs) {
1098
+ finishRotation(session, elapsedMs);
1099
+ return;
1100
+ }
1101
+ const timer = setTimer(() => {
1102
+ session.timer = undefined;
1103
+ runFrame(inlineMessageId, session);
1104
+ }, delayMs);
1105
+ timer.unref?.();
1106
+ session.timer = timer;
1107
+ };
1108
+
1109
+ const runFrame = (
1110
+ inlineMessageId: string,
1111
+ session: TelegramGuestPlaceholderSession,
1112
+ ): void => {
1113
+ if (session.stopped) return;
1114
+ session.step += 1;
1115
+ let nextDelayMs = intervalMs;
1116
+ session.inflight = (async () => {
1117
+ try {
1118
+ await deps.editGuestInlineMessage(inlineMessageId, {
1119
+ text: buildTelegramGuestPlaceholderFrame(session.step),
1120
+ parseMode: "HTML",
1121
+ });
1122
+ } catch (error) {
1123
+ nextDelayMs = getTelegramGuestPlaceholderRetryDelayMs(error, intervalMs);
1124
+ deps.recordRuntimeEvent?.("guest", error, {
1125
+ phase: "guest-placeholder-edit",
1126
+ retryAfterMs: nextDelayMs,
1127
+ });
1128
+ } finally {
1129
+ session.inflight = undefined;
1130
+ scheduleFrame(inlineMessageId, session, nextDelayMs);
1131
+ }
1132
+ })();
1133
+ };
1134
+
1135
+ const stop = async (inlineMessageId: string): Promise<void> => {
1136
+ const session = sessions.get(inlineMessageId);
1137
+ if (!session) return;
1138
+ sessions.delete(inlineMessageId);
1139
+ session.stopped = true;
1140
+ if (session.timer !== undefined) {
1141
+ clearTimer(session.timer);
1142
+ session.timer = undefined;
1143
+ }
1144
+ try {
1145
+ await session.inflight;
1146
+ } catch {
1147
+ // Frame failures are reported as runtime events; stopping stays fail-open.
1148
+ }
1149
+ };
1150
+
1151
+ return {
1152
+ start(inlineMessageId) {
1153
+ const existing = sessions.get(inlineMessageId);
1154
+ if (existing) {
1155
+ existing.stopped = true;
1156
+ if (existing.timer !== undefined) clearTimer(existing.timer);
1157
+ }
1158
+ const session: TelegramGuestPlaceholderSession = {
1159
+ step: 0,
1160
+ stopped: false,
1161
+ startedAtMs: now(),
1162
+ finished: false,
1163
+ };
1164
+ sessions.set(inlineMessageId, session);
1165
+ scheduleFrame(inlineMessageId, session, intervalMs);
1166
+ },
1167
+ stop,
1168
+ stopAll() {
1169
+ for (const session of sessions.values()) {
1170
+ session.stopped = true;
1171
+ if (session.timer !== undefined) clearTimer(session.timer);
1172
+ }
1173
+ sessions.clear();
1174
+ },
1175
+ };
1176
+ }
@@ -16,6 +16,7 @@ import * as Model from "./model.ts";
16
16
  import * as OutboundHandlers from "./outbound.ts";
17
17
  import * as PromptTemplates from "./prompt-templates.ts";
18
18
  import * as Queue from "./queue.ts";
19
+ import * as Replies from "./replies.ts";
19
20
  import type { TelegramBridgeRuntime } from "./runtime.ts";
20
21
  import type { TelegramSectionRegistry } from "./sections.ts";
21
22
  import * as TextGroups from "./text-groups.ts";
@@ -665,6 +666,8 @@ export interface TelegramInboundRouteRuntimeDeps<
665
666
  text: string,
666
667
  options?: { parseMode?: "HTML" },
667
668
  ) => Promise<string | undefined>;
669
+ /** Starts the animated placeholder on an answered guest inline message. */
670
+ startGuestPlaceholder?: (inlineMessageId: string) => void;
668
671
  sendTextReply: (
669
672
  chatId: number,
670
673
  replyToMessageId: number,
@@ -2092,7 +2095,7 @@ export function createTelegramInboundRouteRuntime<
2092
2095
  }
2093
2096
  await menuCallbackHandler(query, ctx);
2094
2097
  };
2095
- const promptTurnBuilder = Turns.createTelegramPromptTurnRuntimeBuilder<
2098
+ const preparePromptTurn = Turns.createTelegramPromptTurnRuntimePreparer<
2096
2099
  TMessage,
2097
2100
  TContext
2098
2101
  >({
@@ -2121,7 +2124,8 @@ export function createTelegramInboundRouteRuntime<
2121
2124
  text: "continue",
2122
2125
  caption: undefined,
2123
2126
  } as TMessage;
2124
- const turn = await promptTurnBuilder([continueMessage], [], ctx);
2127
+ const buildTurn = await preparePromptTurn([continueMessage], ctx);
2128
+ const turn = buildTurn([]);
2125
2129
  const continueTurn = {
2126
2130
  ...turn,
2127
2131
  queueLane: "control" as const,
@@ -2259,15 +2263,19 @@ export function createTelegramInboundRouteRuntime<
2259
2263
  const promptEnqueueController =
2260
2264
  Queue.createTelegramPromptEnqueueController<TMessage, TContext>({
2261
2265
  ...deps.telegramQueueStore,
2266
+ hasPendingDispatch: deps.bridgeRuntime.lifecycle.hasDispatchPending,
2262
2267
  getFoldQueuedPromptsIntoHistory:
2263
2268
  deps.bridgeRuntime.lifecycle.shouldFoldQueuedPromptsIntoHistory,
2264
2269
  setFoldQueuedPromptsIntoHistory:
2265
2270
  deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory,
2266
- createTurn: async (messages, historyTurns, turnCtx) => {
2267
- const turn = await promptTurnBuilder(messages, historyTurns, turnCtx);
2268
- return turn.replyToMessageId > 0
2269
- ? turn
2270
- : { ...turn, replyToMessageId: 0 };
2271
+ prepareTurn: async (messages, turnCtx) => {
2272
+ const buildTurn = await preparePromptTurn(messages, turnCtx);
2273
+ return (historyTurns) => {
2274
+ const turn = buildTurn(historyTurns);
2275
+ return turn.replyToMessageId > 0
2276
+ ? turn
2277
+ : { ...turn, replyToMessageId: 0 };
2278
+ };
2271
2279
  },
2272
2280
  updateStatus: deps.updateStatus,
2273
2281
  dispatchNextQueuedTelegramTurn: requestDispatchNextQueuedTelegramTurn,
@@ -2662,8 +2670,10 @@ export function createTelegramInboundRouteRuntime<
2662
2670
  assertExecutionCurrent();
2663
2671
  };
2664
2672
  // Answer the guest query immediately so the agent-end edit can replace the
2665
- // early ACK once the turn settles. See BACKLOG.md for live acceptance.
2666
- const TELEGRAM_GUEST_ACK_HTML = "<b>βš™οΈ Received. Working on it…</b>";
2673
+ // early ACK once the turn settles. The ACK is the first placeholder frame and
2674
+ // the loop rotates through the remaining frames until the replacement.
2675
+ const TELEGRAM_GUEST_ACK_HTML =
2676
+ Replies.buildTelegramGuestPlaceholderFrame(0);
2667
2677
  const handleAuthorizedTelegramGuestMessage = async (
2668
2678
  guestMessage: Updates.TelegramGuestMessage & { from: TelegramUser },
2669
2679
  ctx: TContext,
@@ -2679,9 +2689,12 @@ export function createTelegramInboundRouteRuntime<
2679
2689
  TELEGRAM_GUEST_ACK_HTML,
2680
2690
  { parseMode: "HTML" },
2681
2691
  );
2692
+ if (guestInlineMessageId) {
2693
+ deps.startGuestPlaceholder?.(guestInlineMessageId);
2694
+ }
2682
2695
  deps.recordRuntimeEvent?.(
2683
2696
  "guest",
2684
- new Error("Guest ACK experiment answered the guest query"),
2697
+ new Error("Guest ACK answered the guest query"),
2685
2698
  {
2686
2699
  phase: "guest-ack-sent",
2687
2700
  guestQueryId: guestMessage.guest_query_id,
@@ -374,6 +374,13 @@ interface TelegramApiResponse<T> {
374
374
  parameters?: { retry_after?: number };
375
375
  }
376
376
 
377
+ export interface TelegramApiRetryWait {
378
+ method: string;
379
+ delayMs: number;
380
+ attempt: number;
381
+ retryAfterSeconds?: number;
382
+ }
383
+
377
384
  export interface TelegramApiCallOptions {
378
385
  signal?: AbortSignal;
379
386
  maxAttempts?: number;
@@ -381,6 +388,8 @@ export interface TelegramApiCallOptions {
381
388
  retrySafety?: "safe" | "non-idempotent";
382
389
  retryBaseDelayMs?: number;
383
390
  sleep?: (ms: number) => Promise<void>;
391
+ /** Observability hook fired before a 429 retry wait, never for 5xx waits. */
392
+ onRetryWait?: (wait: TelegramApiRetryWait) => void;
384
393
  }
385
394
 
386
395
  interface TelegramGetFileResult {
@@ -434,6 +443,7 @@ export interface TelegramAnswerGuestQueryOptions {
434
443
  export interface TelegramEditGuestInlineMessageContent {
435
444
  text?: string;
436
445
  richMessage?: TelegramInputRichMessage;
446
+ parseMode?: "HTML";
437
447
  }
438
448
 
439
449
  export interface TelegramAnswerCallbackQueryOptions {
@@ -1380,7 +1390,25 @@ async function callTelegramWithRetry<TResponse>(
1380
1390
  isTelegramApiMethodRetrySafe(method));
1381
1391
  const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
1382
1392
  const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
1383
- const waitBeforeRetry = async (ms: number): Promise<void> => {
1393
+ const waitBeforeRetry = async (
1394
+ error: unknown,
1395
+ attempt: number,
1396
+ ): Promise<void> => {
1397
+ const ms = getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs);
1398
+ if (
1399
+ !options?.signal?.aborted &&
1400
+ error instanceof TelegramApiHttpError &&
1401
+ error.status === 429
1402
+ ) {
1403
+ options?.onRetryWait?.({
1404
+ method,
1405
+ delayMs: ms,
1406
+ attempt,
1407
+ ...(error.retryAfterSeconds !== undefined
1408
+ ? { retryAfterSeconds: error.retryAfterSeconds }
1409
+ : {}),
1410
+ });
1411
+ }
1384
1412
  if (options?.sleep) await options.sleep(ms);
1385
1413
  else await sleepTelegramRetry(ms, options?.signal);
1386
1414
  throwIfTelegramApiCallAborted(options?.signal);
@@ -1406,9 +1434,7 @@ async function callTelegramWithRetry<TResponse>(
1406
1434
  if (!retrySafe) {
1407
1435
  if (error instanceof TelegramApiHttpError && error.status === 429) {
1408
1436
  if (attempt >= maxAttempts - 1) throw error;
1409
- await waitBeforeRetry(
1410
- getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
1411
- );
1437
+ await waitBeforeRetry(error, attempt);
1412
1438
  continue;
1413
1439
  }
1414
1440
  if (
@@ -1423,9 +1449,7 @@ async function callTelegramWithRetry<TResponse>(
1423
1449
  throw error;
1424
1450
  }
1425
1451
  if (attempt >= maxAttempts - 1 || !retryable) throw error;
1426
- await waitBeforeRetry(
1427
- getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
1428
- );
1452
+ await waitBeforeRetry(error, attempt);
1429
1453
  }
1430
1454
  }
1431
1455
  }
@@ -2095,6 +2119,7 @@ export function createTelegramBridgeApiRuntime(
2095
2119
  ...(content.richMessage
2096
2120
  ? { rich_message: content.richMessage }
2097
2121
  : { text: content.text }),
2122
+ ...(content.parseMode ? { parse_mode: content.parseMode } : {}),
2098
2123
  });
2099
2124
  },
2100
2125
  prepareTempDir: () =>
@@ -2117,6 +2142,7 @@ export function createTelegramApiClient(
2117
2142
  options: TelegramAnswerCallbackQueryOptions & { now?: () => number } = {},
2118
2143
  ): TelegramApiClient {
2119
2144
  const now = options.now ?? Date.now;
2145
+ const recordRuntimeEvent = options.recordRuntimeEvent;
2120
2146
  const draftRetryNotBeforeByTarget = new Map<string, number>();
2121
2147
  return {
2122
2148
  call: async <TResponse>(
@@ -2140,9 +2166,31 @@ export function createTelegramApiClient(
2140
2166
  }
2141
2167
  try {
2142
2168
  // A draft is a replaceable snapshot, not a body to replay after backoff.
2143
- return await callTelegram<TResponse>(
2144
- token, method, body, isDraft ? { ...options, maxAttempts: 1 } : options,
2145
- );
2169
+ const retryWaitOptions: TelegramApiCallOptions = recordRuntimeEvent
2170
+ ? {
2171
+ onRetryWait: (wait) => {
2172
+ recordRuntimeEvent(
2173
+ "api",
2174
+ new Error(
2175
+ `Telegram API rate limit: waiting ${wait.delayMs} ms before retrying ${wait.method}`,
2176
+ ),
2177
+ {
2178
+ phase: "retry-wait",
2179
+ method: wait.method,
2180
+ waitMs: wait.delayMs,
2181
+ attempt: wait.attempt,
2182
+ ...(wait.retryAfterSeconds !== undefined
2183
+ ? { retryAfterSeconds: wait.retryAfterSeconds }
2184
+ : {}),
2185
+ },
2186
+ );
2187
+ },
2188
+ }
2189
+ : {};
2190
+ return await callTelegram<TResponse>(token, method, body, {
2191
+ ...(isDraft ? { ...options, maxAttempts: 1 } : options),
2192
+ ...retryWaitOptions,
2193
+ });
2146
2194
  } catch (error) {
2147
2195
  if (draftKey && isRetryableTelegramApiError(error)) {
2148
2196
  draftRetryNotBeforeByTarget.set(draftKey, Math.max(
@@ -26,6 +26,7 @@ import {
26
26
  createTelegramQueueAdmissionReceipt,
27
27
  truncateTelegramQueueSummary,
28
28
  type PendingTelegramTurn,
29
+ type TelegramPreparedPromptTurn,
29
30
  type TelegramPromptContent,
30
31
  type TelegramQueueAdmissionReceipt,
31
32
  type TelegramQueueItem,
@@ -454,17 +455,16 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
454
455
  assertExecutionCurrent?: (message: TelegramTurnMessage) => void;
455
456
  }
456
457
 
457
- export function createTelegramPromptTurnRuntimeBuilder<
458
+ export function createTelegramPromptTurnRuntimePreparer<
458
459
  TMessage extends TelegramTurnMessage & TelegramMediaMessage,
459
460
  TContext = unknown,
460
461
  >(
461
462
  deps: TelegramPromptTurnRuntimeBuilderDeps<TContext>,
462
463
  ): (
463
464
  messages: TMessage[],
464
- historyTurns?: PendingTelegramTurn[],
465
465
  ctx?: TContext,
466
- ) => Promise<PendingTelegramTurn> {
467
- return async (messages, historyTurns = [], ctx) => {
466
+ ) => Promise<TelegramPreparedPromptTurn> {
467
+ return async (messages, ctx) => {
468
468
  const rawText = extractTelegramMessagesText(messages);
469
469
  const firstMessage = messages[0];
470
470
  if (firstMessage) deps.assertExecutionCurrent?.(firstMessage);
@@ -577,11 +577,10 @@ export function createTelegramPromptTurnRuntimeBuilder<
577
577
  thread: threadLabel,
578
578
  "from-thread": firstMessage?.pi_telegram_agent_source_thread,
579
579
  });
580
- return buildTelegramPromptTurnRuntime({
580
+ const buildTurn = await prepareTelegramPromptTurn({
581
581
  telegramPrefix,
582
582
  messages,
583
- historyTurns,
584
- queueOrder: deps.allocateQueueOrder(),
583
+ readBinaryFile: readFile,
585
584
  rawText: promptRawText,
586
585
  sourceContext,
587
586
  statusText: processed.rawText,
@@ -602,6 +601,10 @@ export function createTelegramPromptTurnRuntimeBuilder<
602
601
  admissionScope: deps.getAdmissionScope?.(),
603
602
  admissionJournalBinding: deps.getAdmissionJournalBinding?.(),
604
603
  });
604
+ return (historyTurns) => {
605
+ if (firstMessage) deps.assertExecutionCurrent?.(firstMessage);
606
+ return buildTurn(deps.allocateQueueOrder(), historyTurns);
607
+ };
605
608
  };
606
609
  }
607
610
 
@@ -670,9 +673,29 @@ function collectTelegramTurnAdmissionReceipts(
670
673
  return [...receipts.values()];
671
674
  }
672
675
 
673
- export async function buildTelegramPromptTurn(
676
+ async function prepareTelegramPromptTurn(
677
+ options: Omit<BuildTelegramPromptTurnOptions, "queueOrder" | "historyTurns">,
678
+ ): Promise<(queueOrder: number, historyTurns: PendingTelegramTurn[]) => PendingTelegramTurn> {
679
+ const images: TelegramPromptContent[] = [];
680
+ for (const file of options.files) {
681
+ if (!file.isImage) continue;
682
+ const mediaType = file.mimeType || options.inferImageMimeType(file.path);
683
+ if (!mediaType) continue;
684
+ const buffer = await options.readBinaryFile(file.path);
685
+ images.push({
686
+ type: "image",
687
+ data: Buffer.from(buffer).toString("base64"),
688
+ mimeType: mediaType,
689
+ });
690
+ }
691
+ return (queueOrder, historyTurns) =>
692
+ buildPreparedTelegramPromptTurn({ ...options, queueOrder, historyTurns }, images);
693
+ }
694
+
695
+ function buildPreparedTelegramPromptTurn(
674
696
  options: BuildTelegramPromptTurnOptions,
675
- ): Promise<PendingTelegramTurn> {
697
+ images: TelegramPromptContent[],
698
+ ): PendingTelegramTurn {
676
699
  const firstMessage = options.messages[0];
677
700
  if (!firstMessage) {
678
701
  throw new Error("Missing Telegram message for turn creation");
@@ -703,18 +726,8 @@ export async function buildTelegramPromptTurn(
703
726
  ),
704
727
  }),
705
728
  },
729
+ ...images,
706
730
  ];
707
- for (const file of options.files) {
708
- if (!file.isImage) continue;
709
- const mediaType = file.mimeType || options.inferImageMimeType(file.path);
710
- if (!mediaType) continue;
711
- const buffer = await options.readBinaryFile(file.path);
712
- content.push({
713
- type: "image",
714
- data: Buffer.from(buffer).toString("base64"),
715
- mimeType: mediaType,
716
- });
717
- }
718
731
  if (options.voicePromptContribution?.trim()) {
719
732
  const textItem = content.find((c) => c.type === "text") as
720
733
  { type: "text"; text: string } | undefined;
@@ -759,6 +772,13 @@ export async function buildTelegramPromptTurn(
759
772
  };
760
773
  }
761
774
 
775
+ export async function buildTelegramPromptTurn(
776
+ options: BuildTelegramPromptTurnOptions,
777
+ ): Promise<PendingTelegramTurn> {
778
+ const buildTurn = await prepareTelegramPromptTurn(options);
779
+ return buildTurn(options.queueOrder, options.historyTurns ?? []);
780
+ }
781
+
762
782
  export async function buildTelegramPromptTurnRuntime(
763
783
  options: BuildTelegramPromptTurnRuntimeOptions,
764
784
  ): Promise<PendingTelegramTurn> {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.45.5",
3
+ "version": "0.45.7",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,7 +45,7 @@
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
46
  "@llblab/pi-grow-loop": "0.8.1",
47
47
  "@llblab/pi-state-flow": "0.9.6",
48
- "@llblab/pi-telegram": "0.45.5",
48
+ "@llblab/pi-telegram": "0.45.7",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [