@llblab/pi-telegram 0.55.1 → 0.56.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.
package/lib/lifecycle.ts CHANGED
@@ -26,6 +26,8 @@ import type {
26
26
  ToolExecutionEndEvent,
27
27
  ToolExecutionStartEvent,
28
28
  ToolExecutionUpdateEvent,
29
+ TurnEndEvent,
30
+ TurnEndEventResult,
29
31
  UIPromptEndEvent,
30
32
  UIPromptStartEvent,
31
33
  } from "./pi.ts";
@@ -100,6 +102,7 @@ export interface TelegramLifecycleRegistrationDeps {
100
102
  event: { model: TelegramLifecycleModel },
101
103
  ctx: ExtensionContext,
102
104
  ) => Promise<void> | void;
105
+ onTurnEnd?: (event: TurnEndEvent, ctx: ExtensionContext) => TurnEndEventResult | undefined;
103
106
  onAgentStart: (
104
107
  event: AgentStartEvent,
105
108
  ctx: ExtensionContext,
@@ -575,7 +578,6 @@ export function createTelegramBridgeSessionLifecycleAssembly<
575
578
  >(
576
579
  deps: TelegramBridgeSessionLifecycleAssemblyDeps<TQueueItem, TModel>,
577
580
  ): TelegramSessionLifecycleHooks {
578
- const isSessionActive = deps.contextStore.isCurrent;
579
581
  const suspendForReplacement =
580
582
  BusFollower.createTelegramBusFollowerSessionReplacementSuspender({
581
583
  registrationState: deps.follower.registrationState,
@@ -583,32 +585,44 @@ export function createTelegramBridgeSessionLifecycleAssembly<
583
585
  suspendPolling: deps.follower.suspendPolling,
584
586
  recordRuntimeEvent: deps.follower.recordRuntimeEvent,
585
587
  });
586
- let preserveTarget = true;
587
- const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
588
- ...deps.queue,
589
- isSessionActive,
590
- stopPolling: () => suspendForReplacement(preserveTarget),
591
- clearPendingMediaGroups: deps.services.suspendGroupedInput,
592
- });
588
+ // A reused context can be current for a successor; inner awaits need this invocation's generation too.
589
+ const queueLifecycle = (ctx: ExtensionContext, generation: number, preserveTarget = true) =>
590
+ Queue.createTelegramSessionLifecycleRuntime({
591
+ ...deps.queue,
592
+ isSessionActive: () => deps.contextStore.isCurrent(ctx, generation),
593
+ stopPolling: () => suspendForReplacement(preserveTarget),
594
+ clearPendingMediaGroups: deps.services.suspendGroupedInput,
595
+ });
593
596
  const servicesLifecycle: TelegramSessionLifecycleHooks = {
594
597
  async onSessionStart(event, ctx) {
598
+ const generation = deps.contextStore.getGeneration();
599
+ const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
595
600
  const resumeConnection = deps.services.connection?.prepare(event, ctx);
596
- await queueLifecycle.onSessionStart(event, ctx);
597
- if (!isSessionActive(ctx)) return;
601
+ await queueLifecycle(ctx, generation).onSessionStart(event, ctx);
602
+ if (!isCurrent()) return;
598
603
  deps.services.resumeGroupedInput(ctx);
599
604
  await deps.services.delivery.onSessionStart();
600
- if (!isSessionActive(ctx)) return;
605
+ if (!isCurrent()) return;
601
606
  if (resumeConnection) resumeConnection();
602
607
  else await deps.services.polling.onSessionStart(event, ctx);
608
+ if (!isCurrent()) return;
603
609
  deps.services.capabilityMonitor.start(ctx);
604
610
  deps.services.queueWatchdog.start(ctx);
611
+ await BusFollower.createTelegramBusFollowerSessionRefreshHook({
612
+ registrationState: deps.follower.registrationState,
613
+ registrationRuntime: deps.follower.registrationRuntime,
614
+ getLeaderState: deps.follower.getLeaderState,
615
+ isSessionActive: isCurrent,
616
+ updateStatus: deps.follower.updateStatus,
617
+ recordRuntimeEvent: deps.follower.recordRuntimeEvent,
618
+ })(event, ctx);
605
619
  },
606
620
  async onSessionShutdown(event, ctx) {
607
621
  const generation = deps.contextStore.getGeneration();
608
622
  const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
609
623
  if (!isCurrent()) return;
610
624
  deps.services.connection?.onSessionShutdown(event, ctx);
611
- preserveTarget = event.reason !== "resume";
625
+ const preserveTarget = event.reason !== "resume";
612
626
  let preserveThread: (() => Promise<void>) | undefined;
613
627
  if (event.reason === "quit") {
614
628
  try {
@@ -625,7 +639,7 @@ export function createTelegramBridgeSessionLifecycleAssembly<
625
639
  if (!isCurrent()) return;
626
640
  deps.services.queueWatchdog.stop();
627
641
  deps.services.capabilityMonitor.stop();
628
- await queueLifecycle.onSessionShutdown(event, ctx);
642
+ await queueLifecycle(ctx, generation, preserveTarget).onSessionShutdown(event, ctx);
629
643
  if (!isCurrent()) return;
630
644
  await deps.services.inboundWorker.onSessionShutdown();
631
645
  if (!isCurrent()) return;
@@ -638,23 +652,9 @@ export function createTelegramBridgeSessionLifecycleAssembly<
638
652
  }
639
653
  },
640
654
  };
641
- const followerLifecycle = appendTelegramLifecycleHooks(
642
- servicesLifecycle,
643
- {
644
- onSessionStart: BusFollower.createTelegramBusFollowerSessionRefreshHook({
645
- registrationState: deps.follower.registrationState,
646
- registrationRuntime: deps.follower.registrationRuntime,
647
- getLeaderState: deps.follower.getLeaderState,
648
- isSessionActive,
649
- updateStatus: deps.follower.updateStatus,
650
- recordRuntimeEvent: deps.follower.recordRuntimeEvent,
651
- }),
652
- },
653
- isSessionActive,
654
- );
655
655
  return createTelegramSessionGenerationFence(
656
656
  deps.contextStore,
657
- followerLifecycle,
657
+ servicesLifecycle,
658
658
  );
659
659
  }
660
660
 
@@ -1042,6 +1042,12 @@ export function registerTelegramLifecycleHooks(
1042
1042
  if (!isActive(ctx)) return;
1043
1043
  await deps.onAgentStart(event, ctx);
1044
1044
  });
1045
+ if (deps.onTurnEnd) {
1046
+ pi.on("turn_end", (event, ctx) => {
1047
+ if (!isActive(ctx)) return;
1048
+ return deps.onTurnEnd?.(event, ctx);
1049
+ });
1050
+ }
1045
1051
  pi.on("tool_execution_start", async (event, ctx) => {
1046
1052
  if (!isActive(ctx)) return;
1047
1053
  await deps.onToolExecutionStart(event, ctx);
package/lib/menu-queue.ts CHANGED
@@ -37,7 +37,12 @@ interface TelegramQueueMenuItem {
37
37
  queueOrder: number;
38
38
  isGuest: boolean;
39
39
  queuePosition: number;
40
- isPriority: boolean;
40
+ queueLane: Queue.TelegramQueueLane;
41
+ canSelectLane: boolean;
42
+ canSelectDisposition: boolean;
43
+ dispatchProtected: boolean;
44
+ attemptFrozen: boolean;
45
+ exceedsIdleCapacity: boolean;
41
46
  priorityEmoji?: string;
42
47
  reactionSuppressionEmoji?: string;
43
48
  hasAttachments: boolean;
@@ -60,15 +65,26 @@ function getTelegramQueueItemPromptText<Context>(
60
65
 
61
66
  function toTelegramQueueMenuItems<Context>(
62
67
  items: readonly Queue.TelegramQueueItem<Context>[],
68
+ pendingDispatch = false,
63
69
  ): TelegramQueueMenuItem[] {
70
+ const head = items[0];
64
71
  return items.map((item, index) => {
72
+ const dispatchProtected = pendingDispatch && (item === head ||
73
+ (head?.kind === "prompt" && head.steerPacketTail?.includes(item as Queue.PendingTelegramTurn) === true));
74
+ const attemptFrozen = item.kind === "prompt" && item.steerAttempted === true && !dispatchProtected;
75
+ const editable = item.kind === "prompt" && item.guestQueryId === undefined && item.guestInlineMessageId === undefined && !dispatchProtected && !attemptFrozen;
65
76
  return {
66
77
  chatId: item.chatId,
67
78
  replyToMessageId: item.replyToMessageId,
68
79
  queueOrder: item.queueOrder,
69
80
  isGuest: item.kind === "prompt" && item.guestQueryId !== undefined,
70
81
  queuePosition: index + 1,
71
- isPriority: item.queueLane === "priority",
82
+ queueLane: item.queueLane,
83
+ canSelectLane: editable && item.queueLane !== "control",
84
+ canSelectDisposition: editable && (item.queueLane !== "control" || item.sourceMessageIds.includes(item.replyToMessageId)),
85
+ dispatchProtected, attemptFrozen,
86
+ exceedsIdleCapacity: item.kind === "prompt" && item.queueLane === "steer" && !item.steerAttempted &&
87
+ !Queue.isTelegramSteerItemWithinIdleCapacity(item),
72
88
  priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
73
89
  reactionSuppressionEmoji:
74
90
  item.kind === "prompt" ? item.reactionSuppressionEmoji : undefined,
@@ -98,18 +114,16 @@ function buildTelegramQueueMenuReplyMarkup(
98
114
  const refreshRow = [{ text: "🔄 Refresh", callback_data: refreshData }];
99
115
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
100
116
  const rows = items.map((item) => {
101
- const prefix = item.reactionSuppressionEmoji
102
- ? `${item.reactionSuppressionEmoji} `
103
- : item.isPriority
104
- ? `${item.priorityEmoji ?? "⚡"} `
105
- : item.hasAttachments
106
- ? "📎 "
107
- : "";
117
+ const badge = getTelegramQueueMenuLaneBadge(item);
118
+ const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` :
119
+ badge ? `${badge} ` : item.hasAttachments ? "📎 " : "";
108
120
  const position = item.reactionSuppressionEmoji
109
121
  ? formatSkippedTelegramQueuePosition(item.queuePosition)
110
122
  : String(item.queuePosition);
111
123
  const ordinalSeparator = item.reactionSuppressionEmoji ? "\u200A" : "";
112
- const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}`;
124
+ const state = item.attemptFrozen ? " · frozen" : item.dispatchProtected ? " · dispatch protected" :
125
+ item.exceedsIdleCapacity ? " · idle capacity" : "";
126
+ const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}${state}`;
113
127
  return [
114
128
  {
115
129
  text: label,
@@ -165,18 +179,27 @@ function escapeTelegramQueueMenuHtmlPreview(text: string): string {
165
179
  return truncated ? escaped + suffix : escaped;
166
180
  }
167
181
 
182
+ function getTelegramQueueMenuLaneBadge(item: Pick<TelegramQueueMenuItem, "queueLane" | "priorityEmoji">): string {
183
+ if (item.queueLane === "control") return "⚡";
184
+ if (item.queueLane === "steer") return "🔥";
185
+ if (item.queueLane !== "priority") return "";
186
+ const emoji = item.priorityEmoji?.replace(/\uFE0F/g, "");
187
+ return emoji === "❤" || emoji === "🕊" ? emoji : "👍";
188
+ }
189
+
168
190
  function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
169
- const badge = item.reactionSuppressionEmoji
170
- ? ` ${item.reactionSuppressionEmoji}`
171
- : item.isPriority
172
- ? ` ${item.priorityEmoji ?? "⚡"}`
173
- : "";
191
+ const marker = item.reactionSuppressionEmoji ?? getTelegramQueueMenuLaneBadge(item);
192
+ const badge = marker ? ` ${marker}` : "";
174
193
  const position = item.reactionSuppressionEmoji
175
194
  ? `<s>${item.queuePosition}</s>.`
176
195
  : `<b>${item.queuePosition}.</b>`;
177
196
  const heading = `${position}${badge}`;
178
197
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
179
- return `${heading}\n${preview}`;
198
+ const note = item.attemptFrozen ? "\n<b>⚠️ Attempt frozen.</b>\nSDK/model use is unconfirmed; not editable or automatically retried." :
199
+ item.dispatchProtected ? "\n<b>⏳ Dispatch protected.</b>\nWaiting for agent_start; SDK/model use is unconfirmed." :
200
+ item.exceedsIdleCapacity ? "\n<b>⚠️ Idle Steer capacity exceeded.</b>\nEdit the source or choose Priority/Normal; source groups are never split." :
201
+ item.queueLane === "control" ? "\nControl lane is producer-owned." : "";
202
+ return `${heading}\n${preview}${note}`;
180
203
  }
181
204
 
182
205
  function buildTelegramGuestQueueItemSubmenuReplyMarkup(
@@ -195,37 +218,18 @@ function buildTelegramGuestQueueItemSubmenuReplyMarkup(
195
218
  };
196
219
  }
197
220
 
198
- function buildTelegramQueueItemSubmenuReplyMarkup(
199
- chatId: number,
200
- replyToMessageId: number,
201
- isPriority: boolean,
202
- isSkipped: boolean,
203
- ): TelegramQueueMenuReplyMarkup {
204
- return {
205
- inline_keyboard: [
206
- [{ text: "⬆️ Back", callback_data: "queue:list" }],
207
- [
208
- {
209
- text: isPriority ? "🟡 Priority" : "⚫️ Priority",
210
- callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
211
- },
212
- {
213
- text: isPriority ? "⚫️ Normal" : "🔵 Normal",
214
- callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
215
- },
216
- ],
217
- [
218
- {
219
- text: isSkipped ? "⚫️ Keep" : "🟢 Keep",
220
- callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:keep`,
221
- },
222
- {
223
- text: isSkipped ? "🔴 Skip" : "⚫️ Skip",
224
- callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:skip`,
225
- },
226
- ],
227
- ],
228
- };
221
+ function buildTelegramQueueItemSubmenuReplyMarkup(item: TelegramQueueMenuItem): TelegramQueueMenuReplyMarkup {
222
+ const rows: TelegramQueueMenuReplyMarkup["inline_keyboard"] = [[{ text: "⬆️ Back", callback_data: "queue:list" }]];
223
+ if (item.canSelectLane) rows.push([
224
+ { text: item.queueLane === "default" ? "🔵 Normal" : "⚫️ Normal", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:normal` },
225
+ { text: item.queueLane === "priority" ? "🟣 Priority" : "⚫️ Priority", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:priority` },
226
+ { text: item.queueLane === "steer" ? "🟡 Steer" : "⚫️ Steer", callback_data: `queue:lane-set:${item.chatId}:${item.replyToMessageId}:steer` },
227
+ ]);
228
+ if (item.canSelectDisposition) rows.push([
229
+ { text: item.reactionSuppressionEmoji ? "⚫️ Keep" : "🟢 Keep", callback_data: `queue:skip-set:${item.chatId}:${item.replyToMessageId}:keep` },
230
+ { text: item.reactionSuppressionEmoji ? "🔴 Skip" : "⚫️ Skip", callback_data: `queue:skip-set:${item.chatId}:${item.replyToMessageId}:skip` },
231
+ ]);
232
+ return { inline_keyboard: rows };
229
233
  }
230
234
 
231
235
  interface TelegramQueueMenuCallbackDeps<Context = unknown> {
@@ -236,12 +240,7 @@ interface TelegramQueueMenuCallbackDeps<Context = unknown> {
236
240
  ) => TelegramQueueMenuItem | undefined;
237
241
  findGuestItem: (queueOrder: number) => TelegramQueueMenuItem | undefined;
238
242
  skipGuest: (queueOrder: number, ctx: Context) => Promise<boolean>;
239
- togglePriority: (chatId: number, replyToMessageId: number) => boolean;
240
- setPriority: (
241
- chatId: number,
242
- replyToMessageId: number,
243
- enabled: boolean,
244
- ) => boolean;
243
+ setLane: (chatId: number, replyToMessageId: number, lane: Exclude<Queue.TelegramQueueLane, "control">) => boolean;
245
244
  setSkipped: (
246
245
  chatId: number,
247
246
  replyToMessageId: number,
@@ -307,7 +306,13 @@ async function handleTelegramQueueMenuCallback<Context>(
307
306
  }
308
307
  const guestSkipMatch = data.match(/^queue:guest-skip:(\d+)$/);
309
308
  if (guestSkipMatch) {
310
- const skipped = await deps.skipGuest(Number(guestSkipMatch[1]), ctx);
309
+ const queueOrder = Number(guestSkipMatch[1]);
310
+ const item = deps.findGuestItem(queueOrder);
311
+ if (item?.dispatchProtected || item?.attemptFrozen) {
312
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
313
+ return true;
314
+ }
315
+ const skipped = await deps.skipGuest(queueOrder, ctx);
311
316
  if (!skipped) {
312
317
  await refreshStaleTelegramQueueMenuItem(
313
318
  callbackQueryId,
@@ -339,16 +344,16 @@ async function handleTelegramQueueMenuCallback<Context>(
339
344
  return true;
340
345
  }
341
346
  const prioSetMatch = data.match(
342
- /^queue:prio-set:(\d+):(\d+):(priority|normal)$/,
347
+ /^queue:(?:lane-set|prio-set):(\d+):(\d+):(steer|priority|normal)$/,
343
348
  );
344
349
  if (prioSetMatch) {
345
- await handleTelegramQueueMenuPrioritySet(
350
+ await handleTelegramQueueMenuLaneSet(
346
351
  callbackQueryId,
347
352
  replyChatId,
348
353
  replyMessageId,
349
354
  Number(prioSetMatch[1]),
350
355
  Number(prioSetMatch[2]),
351
- prioSetMatch[3] === "priority",
356
+ prioSetMatch[3] === "normal" ? "default" : prioSetMatch[3] as "steer" | "priority",
352
357
  ctx,
353
358
  deps,
354
359
  );
@@ -448,7 +453,9 @@ async function handleTelegramGuestQueueMenuPick<Context>(
448
453
  replyChatId,
449
454
  replyMessageId,
450
455
  getTelegramQueueMenuItemText(item),
451
- buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder),
456
+ item.dispatchProtected || item.attemptFrozen
457
+ ? buildTelegramQueueItemSubmenuReplyMarkup(item)
458
+ : buildTelegramGuestQueueItemSubmenuReplyMarkup(queueOrder),
452
459
  );
453
460
  await deps.answerCallbackQuery(callbackQueryId);
454
461
  }
@@ -474,12 +481,7 @@ async function handleTelegramQueueMenuPick<Context>(
474
481
  replyChatId,
475
482
  replyMessageId,
476
483
  getTelegramQueueMenuItemText(item),
477
- buildTelegramQueueItemSubmenuReplyMarkup(
478
- chatId,
479
- msgId,
480
- item.isPriority,
481
- item.reactionSuppressionEmoji !== undefined,
482
- ),
484
+ buildTelegramQueueItemSubmenuReplyMarkup(item),
483
485
  );
484
486
  await deps.answerCallbackQuery(callbackQueryId);
485
487
  }
@@ -502,25 +504,25 @@ async function handleTelegramQueueMenuPriority<Context>(
502
504
  deps,
503
505
  );
504
506
  }
505
- await updateTelegramQueueMenuPriority(
507
+ await updateTelegramQueueMenuLane(
506
508
  callbackQueryId,
507
509
  replyChatId,
508
510
  replyMessageId,
509
511
  chatId,
510
512
  msgId,
511
- !item.isPriority,
513
+ item.queueLane === "priority" ? "default" : "priority",
512
514
  ctx,
513
515
  deps,
514
516
  );
515
517
  }
516
518
 
517
- async function handleTelegramQueueMenuPrioritySet<Context>(
519
+ async function handleTelegramQueueMenuLaneSet<Context>(
518
520
  callbackQueryId: string,
519
521
  replyChatId: number,
520
522
  replyMessageId: number,
521
523
  chatId: number,
522
524
  msgId: number,
523
- enabled: boolean,
525
+ lane: Exclude<Queue.TelegramQueueLane, "control">,
524
526
  ctx: Context,
525
527
  deps: TelegramQueueMenuCallbackDeps<Context>,
526
528
  ): Promise<void> {
@@ -533,29 +535,32 @@ async function handleTelegramQueueMenuPrioritySet<Context>(
533
535
  deps,
534
536
  );
535
537
  }
536
- await updateTelegramQueueMenuPriority(
538
+ await updateTelegramQueueMenuLane(
537
539
  callbackQueryId,
538
540
  replyChatId,
539
541
  replyMessageId,
540
542
  chatId,
541
543
  msgId,
542
- enabled,
544
+ lane,
543
545
  ctx,
544
546
  deps,
545
547
  );
546
548
  }
547
549
 
548
- async function updateTelegramQueueMenuPriority<Context>(
550
+ async function updateTelegramQueueMenuLane<Context>(
549
551
  callbackQueryId: string,
550
552
  replyChatId: number,
551
553
  replyMessageId: number,
552
554
  chatId: number,
553
555
  msgId: number,
554
- enabled: boolean,
556
+ lane: Exclude<Queue.TelegramQueueLane, "control">,
555
557
  ctx: Context,
556
558
  deps: TelegramQueueMenuCallbackDeps<Context>,
557
559
  ): Promise<void> {
558
- deps.setPriority(chatId, msgId, enabled);
560
+ if (!deps.setLane(chatId, msgId, lane)) {
561
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
562
+ return;
563
+ }
559
564
  deps.updateStatus(ctx);
560
565
  const updated = deps.findItem(chatId, msgId);
561
566
  if (!updated) {
@@ -570,16 +575,11 @@ async function updateTelegramQueueMenuPriority<Context>(
570
575
  replyChatId,
571
576
  replyMessageId,
572
577
  getTelegramQueueMenuItemText(updated),
573
- buildTelegramQueueItemSubmenuReplyMarkup(
574
- chatId,
575
- msgId,
576
- updated.isPriority,
577
- updated.reactionSuppressionEmoji !== undefined,
578
- ),
578
+ buildTelegramQueueItemSubmenuReplyMarkup(updated),
579
579
  );
580
580
  await deps.answerCallbackQuery(
581
581
  callbackQueryId,
582
- updated.isPriority ? "Prioritized" : "Normal priority",
582
+ updated.queueLane === "steer" ? "Steer selected" : updated.queueLane === "priority" ? "Prioritized" : "Normal priority",
583
583
  );
584
584
  }
585
585
 
@@ -602,7 +602,10 @@ async function handleTelegramQueueMenuSkipSet<Context>(
602
602
  deps,
603
603
  );
604
604
  }
605
- deps.setSkipped(chatId, msgId, skipped);
605
+ if (!deps.setSkipped(chatId, msgId, skipped)) {
606
+ await deps.answerCallbackQuery(callbackQueryId, "Queue item is protected");
607
+ return;
608
+ }
606
609
  deps.updateStatus(ctx);
607
610
  const updated = deps.findItem(chatId, msgId);
608
611
  if (!updated) {
@@ -617,12 +620,7 @@ async function handleTelegramQueueMenuSkipSet<Context>(
617
620
  replyChatId,
618
621
  replyMessageId,
619
622
  getTelegramQueueMenuItemText(updated),
620
- buildTelegramQueueItemSubmenuReplyMarkup(
621
- chatId,
622
- msgId,
623
- updated.isPriority,
624
- updated.reactionSuppressionEmoji !== undefined,
625
- ),
623
+ buildTelegramQueueItemSubmenuReplyMarkup(updated),
626
624
  );
627
625
  await deps.answerCallbackQuery(callbackQueryId);
628
626
  }
@@ -649,6 +647,7 @@ export function createTelegramQueueMenuRuntime<
649
647
  Context,
650
648
  TModel extends MenuModel = MenuModel,
651
649
  >(deps: {
650
+ hasPendingDispatch?: () => boolean;
652
651
  telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
653
652
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
654
653
  sendInteractiveMessage: TelegramMenuMessageRuntimeDeps["sendInteractiveMessage"];
@@ -695,6 +694,7 @@ export function createTelegramQueueMenuRuntime<
695
694
  );
696
695
  return createOpenQueueMenu<Context, TModel>({
697
696
  getQueuedItems: deps.telegramQueueStore.getQueuedItems,
697
+ hasPendingDispatch: deps.hasPendingDispatch,
698
698
  getModelMenuState: (chat, context) =>
699
699
  deps.getModelMenuState(chat, context, threadId),
700
700
  storeModelMenuState: delivery.storeModelMenuState,
@@ -704,6 +704,7 @@ export function createTelegramQueueMenuRuntime<
704
704
  })(chatId, replyToMessageId, ctx);
705
705
  },
706
706
  handleCallbackQuery: createQueueMenuCallbackHandler<Context, TModel>({
707
+ hasPendingDispatch: deps.hasPendingDispatch,
707
708
  telegramQueueStore: deps.telegramQueueStore,
708
709
  queueMutationRuntime: deps.queueMutationRuntime,
709
710
  editInteractiveMessage: editQueueMenuMessage,
@@ -721,6 +722,7 @@ function createOpenQueueMenu<
721
722
  TModel extends MenuModel = MenuModel,
722
723
  >(deps: {
723
724
  getQueuedItems: () => Queue.TelegramQueueItem<Context>[];
725
+ hasPendingDispatch?: () => boolean;
724
726
  getModelMenuState: (
725
727
  chatId: number,
726
728
  ctx: Context,
@@ -739,7 +741,7 @@ function createOpenQueueMenu<
739
741
  ctx: Context,
740
742
  ): Promise<void> => {
741
743
  const state = await deps.getModelMenuState(chatId, ctx);
742
- const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems());
744
+ const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems(), deps.hasPendingDispatch?.());
743
745
  const text = getTelegramQueueMenuListText(menuItems);
744
746
  const messageId = await deps.sendInteractiveMessage(
745
747
  chatId,
@@ -758,6 +760,7 @@ function createQueueMenuCallbackHandler<
758
760
  Context,
759
761
  TModel extends MenuModel = MenuModel,
760
762
  >(deps: {
763
+ hasPendingDispatch?: () => boolean;
761
764
  telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
762
765
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
763
766
  editInteractiveMessage: (
@@ -797,7 +800,7 @@ function createQueueMenuCallbackHandler<
797
800
  return true;
798
801
  }
799
802
  const menuItems = toTelegramQueueMenuItems(
800
- deps.telegramQueueStore.getQueuedItems(),
803
+ deps.telegramQueueStore.getQueuedItems(), deps.hasPendingDispatch?.(),
801
804
  );
802
805
  await deps.editInteractiveMessage(
803
806
  chatId,
@@ -814,7 +817,7 @@ function createQueueMenuCallbackHandler<
814
817
  return deps.telegramQueueStore.getQueuedItems();
815
818
  };
816
819
  const toMenuItems = () => {
817
- return toTelegramQueueMenuItems(getQueueSnapshot());
820
+ return toTelegramQueueMenuItems(getQueueSnapshot(), deps.hasPendingDispatch?.());
818
821
  };
819
822
  const findItem = (cId: number, rId: number) => {
820
823
  return findTelegramQueueItem(toMenuItems(), cId, rId);
@@ -827,6 +830,7 @@ function createQueueMenuCallbackHandler<
827
830
  const flagDeps = {
828
831
  getQueueSnapshot,
829
832
  queueMutationRuntime: deps.queueMutationRuntime,
833
+ hasPendingDispatch: deps.hasPendingDispatch,
830
834
  };
831
835
  return handleTelegramQueueMenuCallback(
832
836
  query.id,
@@ -845,34 +849,10 @@ function createQueueMenuCallbackHandler<
845
849
  dismissGuestPlaceholder: deps.dismissGuestPlaceholder,
846
850
  });
847
851
  },
848
- togglePriority: (cId, rId) =>
849
- applyQueuedTelegramPromptMenuFlags(
850
- cId,
851
- rId,
852
- ctx,
853
- flagDeps,
854
- (item) => ({
855
- priority: item.queueLane !== "priority",
856
- skipped: isQueuedTelegramPromptSkipped(item),
857
- }),
858
- ),
859
- setPriority: (cId, rId, enabled) =>
860
- applyQueuedTelegramPromptMenuFlags(
861
- cId,
862
- rId,
863
- ctx,
864
- flagDeps,
865
- (item) => ({
866
- priority: enabled,
867
- skipped: isQueuedTelegramPromptSkipped(item),
868
- }),
869
- ),
852
+ setLane: (cId, rId, lane) =>
853
+ applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, () => ({ lane })),
870
854
  setSkipped: (cId, rId, skipped) =>
871
- applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, (item) =>
872
- item.kind === "prompt"
873
- ? { priority: item.queueLane === "priority", skipped }
874
- : undefined,
875
- ),
855
+ applyQueuedTelegramPromptMenuFlags(cId, rId, ctx, flagDeps, () => ({ skipped })),
876
856
  updateQueueMessage: deps.editInteractiveMessage,
877
857
  answerCallbackQuery: deps.answerCallbackQuery,
878
858
  updateStatus: deps.updateStatus,
@@ -911,43 +891,12 @@ async function skipQueuedTelegramGuestPrompt<Context>(
911
891
  return true;
912
892
  }
913
893
 
914
- function getQueueMenuReactionDisposition<Context>(
915
- item: Queue.TelegramQueueItem<Context>,
916
- priority: boolean,
917
- skipped: boolean,
918
- ): Queue.TelegramQueueReactionDisposition {
919
- if (priority && skipped) {
920
- return {
921
- kind: "priority-suppressed",
922
- priorityEmoji:
923
- item.kind === "prompt" ? (item.priorityEmoji ?? "⚡") : "⚡",
924
- suppressionEmoji:
925
- item.kind === "prompt" ? (item.reactionSuppressionEmoji ?? "👎") : "👎",
926
- };
927
- }
928
- if (priority) {
929
- return {
930
- kind: "priority",
931
- emoji: item.kind === "prompt" ? (item.priorityEmoji ?? "⚡") : "⚡",
932
- };
933
- }
934
- if (skipped) {
935
- return {
936
- kind: "suppressed",
937
- emoji:
938
- item.kind === "prompt" ? (item.reactionSuppressionEmoji ?? "👎") : "👎",
939
- };
940
- }
941
- return { kind: "default" };
942
- }
943
-
944
- function isQueuedTelegramPromptSkipped<Context>(
945
- item: Queue.TelegramQueueItem<Context>,
946
- ): boolean {
947
- return item.kind === "prompt" && item.reactionSuppressionEmoji !== undefined;
894
+ interface TelegramQueueMenuFlags {
895
+ lane?: Exclude<Queue.TelegramQueueLane, "control">;
896
+ skipped?: boolean;
948
897
  }
949
898
 
950
- /** Applies a menu Priority/Skip choice to one queued item through the reaction disposition path. */
899
+ /** Each menu selection changes one dimension through the existing Queue owner. */
951
900
  function applyQueuedTelegramPromptMenuFlags<Context>(
952
901
  chatId: number,
953
902
  replyToMessageId: number,
@@ -955,10 +904,9 @@ function applyQueuedTelegramPromptMenuFlags<Context>(
955
904
  deps: {
956
905
  getQueueSnapshot: () => Queue.TelegramQueueItem<Context>[];
957
906
  queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
907
+ hasPendingDispatch?: () => boolean;
958
908
  },
959
- chooseFlags: (
960
- item: Queue.TelegramQueueItem<Context>,
961
- ) => { priority: boolean; skipped: boolean } | undefined,
909
+ chooseFlags: (item: Queue.TelegramQueueItem<Context>) => TelegramQueueMenuFlags | undefined,
962
910
  ): boolean {
963
911
  const item = findTelegramQueueItem(
964
912
  deps.getQueueSnapshot(),
@@ -966,13 +914,19 @@ function applyQueuedTelegramPromptMenuFlags<Context>(
966
914
  replyToMessageId,
967
915
  );
968
916
  const flags = item ? chooseFlags(item) : undefined;
969
- if (!item || !flags) return false;
970
- deps.queueMutationRuntime.applyReactionByMessageId(
971
- replyToMessageId,
972
- getQueueMenuReactionDisposition(item, flags.priority, flags.skipped),
973
- ctx,
974
- );
975
- return true;
917
+ if (!item || item.kind !== "prompt" || !flags) return false;
918
+ const view = findTelegramQueueItem(toTelegramQueueMenuItems(deps.getQueueSnapshot(), deps.hasPendingDispatch?.()), chatId, replyToMessageId);
919
+ if (!view || (flags.lane !== undefined && !view.canSelectLane) || (flags.skipped !== undefined && !view.canSelectDisposition)) return false;
920
+ const disposition: Extract<Queue.TelegramQueueReactionDisposition, { kind: "reaction-transition" }> = { kind: "reaction-transition" };
921
+ if (flags.lane !== undefined) {
922
+ disposition.promptLane = flags.lane;
923
+ disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ?
924
+ getTelegramQueueMenuLaneBadge({ queueLane: "priority", priorityEmoji: item.queueLane === "priority" ? item.priorityEmoji : undefined }) : null;
925
+ }
926
+ if (flags.skipped !== undefined) disposition.suppressionEmoji = flags.skipped ? (item.reactionSuppressionEmoji ?? "👎") : null;
927
+ return deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, disposition, ctx) ||
928
+ ((flags.lane === undefined || flags.lane === item.queueLane) &&
929
+ (flags.skipped === undefined || flags.skipped === (item.reactionSuppressionEmoji !== undefined)));
976
930
  }
977
931
 
978
932
  function createQueueMenuSendMessageAdapter(