opencode-plugin-teleprompt 0.2.1 → 0.2.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.
@@ -2,13 +2,14 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { appendFileSync } from "node:fs";
4
4
  import { getCurrentOrCreateSessionID } from "../opencode/binding.js";
5
- import { replyPermission, toPendingPermission, formatPermissionRequestMessage } from "../opencode/permissions.js";
5
+ import { formatPermissionRequestMessage, formatQuestionRequestMessage, normalizeAnswers, rejectQuestion, replyPermission, replyQuestion, toPendingPermission, toPendingQuestion, } from "../opencode/permissions.js";
6
6
  import { createTelegramUserMessageID, submitPrompt } from "../opencode/submit.js";
7
7
  import { formatSummaryForTelegram } from "../summary/format.js";
8
8
  import { LeaseManager } from "../state/lease.js";
9
9
  import { BridgeStore } from "../state/store.js";
10
10
  import { TelegramApi } from "../telegram/api.js";
11
11
  import { TelegramPoller } from "../telegram/poller.js";
12
+ import { buildPermissionKeyboard, buildQuestionKeyboard, } from "../telegram/callback.js";
12
13
  import { SessionEventStream } from "../opencode/events.js";
13
14
  import { createShutdownGuard } from "./shutdown.js";
14
15
  import { PLUGIN_VERSION } from "../version.js";
@@ -133,20 +134,25 @@ export class BridgeController {
133
134
  pollTask;
134
135
  eventStream;
135
136
  eventRestartTimer;
137
+ // Real-time text accumulator: messageID -> accumulated text from
138
+ // message.part.updated events. More reliable than the SDK's session.messages()
139
+ // or session.message() fetch (which can return 500s) or the TUI's local
140
+ // state.part() (which only has parts the TUI itself rendered).
141
+ streamedText = new Map();
136
142
  shutdownOnce = createShutdownGuard();
137
143
  processingQueue = false;
138
144
  lastEscAt = 0;
139
145
  sessionCredentials;
140
146
  activePromptCheckInFlight = false;
141
147
  completionInFlight = false;
142
- constructor(api, config, storePath, deps) {
148
+ constructor(api, config, storePath, deps, telegram) {
143
149
  this.api = api;
144
150
  this.config = config;
145
151
  this.storePath = storePath;
146
152
  this.deps = { ...DEFAULT_DEPS, ...deps };
147
153
  this.instanceID = this.deps.randomID();
148
154
  this.client = api.client;
149
- this.telegram = new TelegramApi(config.botToken || "__missing_token__");
155
+ this.telegram = telegram ?? new TelegramApi(config.botToken || "__missing_token__");
150
156
  this.store = new BridgeStore(storePath, config.channelID ?? "");
151
157
  this.lease = new LeaseManager(this.instanceID, this.deps.now, config.leaseTtlMs);
152
158
  }
@@ -159,6 +165,20 @@ export class BridgeController {
159
165
  catch {
160
166
  // ignore
161
167
  }
168
+ try {
169
+ appendFileSync("/tmp/teleprompt-bridge.log", `[${new Date().toISOString()}] ${message}\n`, "utf8");
170
+ }
171
+ catch {
172
+ // ignore
173
+ }
174
+ }
175
+ async safeSendTelegram(label, text, options) {
176
+ try {
177
+ await this.telegram.sendMessage(this.config.channelID, text, options);
178
+ }
179
+ catch (error) {
180
+ this.log(`${label} failed: ${String(error)} (textLength=${text.length})`);
181
+ }
162
182
  }
163
183
  async init() {
164
184
  this.data = await this.store.load();
@@ -212,9 +232,61 @@ export class BridgeController {
212
232
  activePrompt: undefined,
213
233
  promptQueue: [],
214
234
  pendingPermissions: {},
235
+ pendingQuestions: {},
236
+ questionAnswers: {},
215
237
  });
216
238
  await this.persist(released);
217
239
  }
240
+ async handleDiag(command) {
241
+ const lines = [];
242
+ try {
243
+ const me = await this.getTelegramApi().getMe();
244
+ lines.push(`Bot: ${me.id}${me.username ? ` @${me.username}` : ""}`);
245
+ }
246
+ catch (err) {
247
+ lines.push(`Bot info: failed — ${String(err)}`);
248
+ }
249
+ try {
250
+ const chat = await this.getTelegramApi().getChat(this.requireChannelID());
251
+ lines.push(`Chat: ${chat.type}${chat.title ? ` "${chat.title}"` : ""}`);
252
+ }
253
+ catch (err) {
254
+ lines.push(`Chat info: failed — ${String(err)}`);
255
+ }
256
+ try {
257
+ const me = await this.getTelegramApi().getMe();
258
+ const member = await this.getTelegramApi().getChatMember(this.requireChannelID(), me.id);
259
+ const status = String(member.status ?? "unknown");
260
+ lines.push(`Role: ${status}`);
261
+ const permKeys = ["can_edit_messages", "can_post_messages", "can_delete_messages",
262
+ "can_restrict_members", "can_promote_members", "can_invite_users",
263
+ "can_pin_messages", "is_anonymous", "can_manage_chat"];
264
+ const enabled = permKeys.filter(k => member[k] === true);
265
+ if (enabled.length > 0) {
266
+ lines.push(`Perms: ${enabled.join(", ")}`);
267
+ }
268
+ if (status === "administrator" && !enabled.includes("can_edit_messages")) {
269
+ lines.push("⚠️ Bot lacks 'can_edit_messages' — callback queries won't work in channels");
270
+ }
271
+ }
272
+ catch (err) {
273
+ lines.push(`Bot member: failed — ${String(err)}`);
274
+ }
275
+ try {
276
+ const wh = await this.getTelegramApi().getWebhookInfo();
277
+ lines.push(`Webhook: ${wh.url || "(none)"}`);
278
+ lines.push(`Pending: ${wh.pending_update_count}`);
279
+ if (wh.last_error_message) {
280
+ lines.push(`Webhook error: ${wh.last_error_message}`);
281
+ }
282
+ }
283
+ catch (err) {
284
+ lines.push(`Webhook: failed — ${String(err)}`);
285
+ }
286
+ lines.push(`Prefix: /${this.config.prefix}`);
287
+ lines.push(`Poll timeout: ${this.config.pollTimeoutSec}s`);
288
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID: command.messageID });
289
+ }
218
290
  async statusLine() {
219
291
  const state = await this.syncState();
220
292
  const owner = state.lease?.ownerInstanceID ?? "none";
@@ -246,19 +318,34 @@ export class BridgeController {
246
318
  return lines.join("\n");
247
319
  }
248
320
  async handleTelegramCommand(command) {
321
+ if (command.callbackQueryID) {
322
+ await this.handleTelegramCallback(command);
323
+ return;
324
+ }
325
+ await this.handleTelegramMessage(command);
326
+ }
327
+ async handleTelegramMessage(command) {
249
328
  this.log(`handleTelegramCommand: kind=${command.command.kind}, updateID=${command.updateID}, messageID=${command.messageID}`);
250
329
  const state = await this.syncState();
251
330
  const isOwner = this.lease.isOwner(state);
252
- const alwaysAllowed = new Set(["status", "who", "health", "reclaim", "version"]);
331
+ const alwaysAllowed = new Set(["status", "diag", "who", "health", "reclaim", "version"]);
253
332
  if (!isOwner && !alwaysAllowed.has(command.command.kind)) {
254
333
  this.log(`handleTelegramCommand: rejected because not owner (isOwner=${isOwner}, kind=${command.command.kind})`);
255
334
  await this.telegram.sendMessage(this.config.channelID, "Bridge is currently owned by another OpenCode instance. Use /tp:reclaim first.", { replyToMessageID: command.messageID });
256
335
  return;
257
336
  }
337
+ if (command.command.kind === "question") {
338
+ await this.handleQuestionCommand(command);
339
+ return;
340
+ }
258
341
  if (command.command.kind === "status") {
259
342
  await this.telegram.sendMessage(this.config.channelID, await this.statusLine(), { replyToMessageID: command.messageID });
260
343
  return;
261
344
  }
345
+ if (command.command.kind === "diag") {
346
+ await this.handleDiag(command);
347
+ return;
348
+ }
262
349
  if (command.command.kind === "version") {
263
350
  await this.telegram.sendMessage(this.config.channelID, this.versionLine(), { replyToMessageID: command.messageID });
264
351
  return;
@@ -540,9 +627,15 @@ export class BridgeController {
540
627
  const summary = await this.buildSummary(sessionID, assistantMessageID, diffMessageID, directParts);
541
628
  const completedAt = this.deps.now();
542
629
  const elapsed = completedAt - (state.activePrompt.startedAt ?? state.activePrompt.createdAt);
543
- const resultText = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
544
- const combinedMessage = `✅ completed in ${formatAgeMs(Math.max(0, elapsed))}\n\n${resultText}`;
545
- await this.telegram.sendMessage(this.config.channelID, combinedMessage, { replyToMessageID: state.activePrompt.telegramMessageID });
630
+ const hasContent = summary.text.trim().length > 0 || summary.changedFiles.length > 0;
631
+ if (hasContent) {
632
+ const resultText = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
633
+ const combinedMessage = `✅ completed in ${formatAgeMs(Math.max(0, elapsed))}\n\n${resultText}`;
634
+ await this.telegram.sendMessage(this.config.channelID, combinedMessage, { replyToMessageID: state.activePrompt.telegramMessageID });
635
+ }
636
+ else {
637
+ this.log(`completeActivePrompt: assistant produced no text and no file changes; skipping Telegram notification for userMessageID=${state.activePrompt.userMessageID}`);
638
+ }
546
639
  await this.persist(this.appendPromptHistory({
547
640
  ...state,
548
641
  activePrompt: undefined,
@@ -560,6 +653,14 @@ export class BridgeController {
560
653
  this.completionInFlight = false;
561
654
  }
562
655
  }
656
+ async onMessagePartUpdated(input) {
657
+ if (input.part.type !== "text")
658
+ return;
659
+ const text = input.part.text;
660
+ if (typeof text !== "string")
661
+ return;
662
+ this.streamedText.set(input.messageID, text);
663
+ }
563
664
  async onUserMessage(sessionID, userMessageID) {
564
665
  const state = await this.syncState();
565
666
  if (!this.lease.isOwner(state))
@@ -583,10 +684,18 @@ export class BridgeController {
583
684
  }
584
685
  async onPermissionAsked(input) {
585
686
  const state = await this.syncState();
586
- if (!this.lease.isOwner(state))
687
+ if (!this.lease.isOwner(state)) {
688
+ this.log(`onPermissionAsked: dropped because not owner (id=${input.id})`);
689
+ return;
690
+ }
691
+ if (state.bound.sessionID !== input.sessionID) {
692
+ this.log(`onPermissionAsked: dropped because session mismatch (bound=${state.bound.sessionID}, event=${input.sessionID})`);
587
693
  return;
588
- if (state.bound.sessionID !== input.sessionID)
694
+ }
695
+ if (state.pendingPermissions[input.id]) {
696
+ this.log(`onPermissionAsked: dropped because already pending (id=${input.id})`);
589
697
  return;
698
+ }
590
699
  const pending = toPendingPermission(input);
591
700
  const next = {
592
701
  ...state,
@@ -597,8 +706,31 @@ export class BridgeController {
597
706
  };
598
707
  await this.persist(next);
599
708
  const activeReplyTo = state.activePrompt?.telegramMessageID;
600
- await this.telegram.sendMessage(this.config.channelID, "waiting-permission", activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
601
- await this.telegram.sendMessage(this.config.channelID, formatPermissionRequestMessage(this.config.prefix, pending), activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
709
+ await this.safeSendTelegram("onPermissionAsked: waiting", "waiting-permission", activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
710
+ let detailMessage = "";
711
+ let keyboard = { inline_keyboard: [] };
712
+ try {
713
+ detailMessage = formatPermissionRequestMessage(this.config.prefix, pending);
714
+ keyboard = buildPermissionKeyboard(pending.requestID);
715
+ }
716
+ catch (formatError) {
717
+ this.log(`onPermissionAsked: format/build failed: ${String(formatError)}`);
718
+ await this.safeSendTelegram("onPermissionAsked: detail (fallback)", `OpenCode permission request (id=${pending.requestID}) — formatting failed.`, activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
719
+ return;
720
+ }
721
+ await this.safeSendTelegram("onPermissionAsked: detail", detailMessage, {
722
+ replyToMessageID: activeReplyTo,
723
+ replyMarkup: keyboard,
724
+ });
725
+ }
726
+ async onPermissionReplied(requestID) {
727
+ const state = await this.syncState();
728
+ if (!this.lease.isOwner(state))
729
+ return;
730
+ if (!state.pendingPermissions[requestID])
731
+ return;
732
+ const { [requestID]: _removed, ...rest } = state.pendingPermissions;
733
+ await this.persist({ ...state, pendingPermissions: rest });
602
734
  }
603
735
  async handlePermissionReply(requestID, action, replyToMessageID) {
604
736
  const state = await this.syncState();
@@ -611,7 +743,7 @@ export class BridgeController {
611
743
  await this.telegram.sendMessage(this.config.channelID, `Permission request not found: ${requestID}`, replyToMessageID ? { replyToMessageID } : undefined);
612
744
  return;
613
745
  }
614
- await replyPermission(this.client, pending.sessionID, requestID, action);
746
+ await replyPermission(this.client, pending.sessionID, requestID, action, this.api.state.path.directory);
615
747
  const { [requestID]: _removed, ...rest } = state.pendingPermissions;
616
748
  await this.persist({
617
749
  ...state,
@@ -619,6 +751,280 @@ export class BridgeController {
619
751
  });
620
752
  await this.telegram.sendMessage(this.config.channelID, `Permission ${requestID} -> ${action}`, replyToMessageID ? { replyToMessageID } : undefined);
621
753
  }
754
+ async handleTelegramCallback(callback) {
755
+ this.log(`handleTelegramCallback: kind=${callback.command.kind}, action=${callback.command.action}, requestID=${callback.command.requestID}, messageID=${callback.messageID}`);
756
+ try {
757
+ await this.telegram.answerCallbackQuery(callback.callbackQueryID);
758
+ }
759
+ catch (err) {
760
+ this.log(`handleTelegramCallback: answerCallbackQuery failed: ${String(err)}`);
761
+ }
762
+ try {
763
+ if (callback.command.kind === "permission") {
764
+ await this.handlePermissionReply(callback.command.requestID, callback.command.action);
765
+ await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
766
+ return;
767
+ }
768
+ if (callback.command.kind === "question") {
769
+ await this.handleCallbackQuestion(callback);
770
+ return;
771
+ }
772
+ }
773
+ catch (err) {
774
+ this.log(`handleTelegramCallback: dispatch failed: ${String(err)}`);
775
+ }
776
+ }
777
+ async handleCallbackQuestion(callback) {
778
+ const command = callback.command;
779
+ if (command.action === "reject") {
780
+ await this.handleQuestionReject(command.requestID, callback.messageID);
781
+ await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
782
+ return;
783
+ }
784
+ if (command.action === "reply") {
785
+ const state = await this.syncState();
786
+ const pending = state.pendingQuestions[command.requestID];
787
+ if (!pending) {
788
+ await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${command.requestID}`, { replyToMessageID: callback.messageID });
789
+ return;
790
+ }
791
+ const qIndex = command.questionIndex ?? 0;
792
+ const optIndex = command.optionIndex ?? -1;
793
+ const info = pending.questions[qIndex];
794
+ const option = info?.options[optIndex];
795
+ if (!info || !option) {
796
+ await this.telegram.sendMessage(this.config.channelID, `Question option no longer valid for request ${command.requestID}.`, { replyToMessageID: callback.messageID });
797
+ return;
798
+ }
799
+ const answers = pending.questions.map((_, idx) => {
800
+ if (idx === qIndex) {
801
+ return { questionIndex: idx, labels: [option.label] };
802
+ }
803
+ return { questionIndex: idx, labels: [] };
804
+ });
805
+ await this.handleQuestionReply(pending, answers, callback.messageID);
806
+ await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
807
+ return;
808
+ }
809
+ if (command.action === "toggle") {
810
+ await this.handleQuestionToggle(command.requestID, command.questionIndex ?? 0, command.optionIndex ?? -1, callback.messageID, callback.channelID);
811
+ return;
812
+ }
813
+ if (command.action === "confirm") {
814
+ await this.handleQuestionConfirm(command.requestID, command.questionIndex ?? 0, callback.messageID);
815
+ await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
816
+ return;
817
+ }
818
+ }
819
+ async handleQuestionToggle(requestID, questionIndex, optionIndex, messageID, channelID) {
820
+ const state = await this.syncState();
821
+ if (!this.lease.isOwner(state))
822
+ return;
823
+ const pending = state.pendingQuestions[requestID];
824
+ if (!pending) {
825
+ await this.telegram.sendMessage(channelID, `Question request not found: ${requestID}`, { replyToMessageID: messageID });
826
+ return;
827
+ }
828
+ const info = pending.questions[questionIndex];
829
+ if (!info)
830
+ return;
831
+ const option = info.options[optionIndex];
832
+ if (!option)
833
+ return;
834
+ const previous = state.questionAnswers[requestID] ?? [];
835
+ const updated = pending.questions.map((_, idx) => {
836
+ const existing = previous.find((a) => a.questionIndex === idx);
837
+ if (idx !== questionIndex) {
838
+ return existing ?? { questionIndex: idx, labels: [] };
839
+ }
840
+ const labels = existing ? [...existing.labels] : [];
841
+ const at = labels.indexOf(option.label);
842
+ if (at >= 0)
843
+ labels.splice(at, 1);
844
+ else
845
+ labels.push(option.label);
846
+ return { questionIndex: idx, labels };
847
+ });
848
+ await this.persist({
849
+ ...state,
850
+ questionAnswers: {
851
+ ...state.questionAnswers,
852
+ [requestID]: updated,
853
+ },
854
+ });
855
+ const selected = updated[questionIndex]?.labels ?? [];
856
+ const keyboard = buildQuestionKeyboard({
857
+ requestID,
858
+ questionIndex,
859
+ options: info.options,
860
+ selectedLabels: selected,
861
+ multiple: info.multiple === true,
862
+ });
863
+ try {
864
+ await this.telegram.editMessageReplyMarkup(channelID, messageID, keyboard);
865
+ }
866
+ catch (err) {
867
+ this.log(`handleQuestionToggle: editMessageReplyMarkup failed: ${String(err)}`);
868
+ }
869
+ }
870
+ async handleQuestionConfirm(requestID, questionIndex, messageID) {
871
+ const state = await this.syncState();
872
+ if (!this.lease.isOwner(state))
873
+ return;
874
+ const pending = state.pendingQuestions[requestID];
875
+ if (!pending) {
876
+ await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${requestID}`, { replyToMessageID: messageID });
877
+ return;
878
+ }
879
+ const previous = state.questionAnswers[requestID] ?? [];
880
+ const answers = pending.questions.map((_, idx) => {
881
+ const found = previous.find((a) => a.questionIndex === idx);
882
+ return found ?? { questionIndex: idx, labels: [] };
883
+ });
884
+ if (answers[questionIndex]?.labels.length === 0) {
885
+ await this.telegram.sendMessage(this.config.channelID, "Pick at least one option before confirming.", { replyToMessageID: messageID });
886
+ return;
887
+ }
888
+ await this.handleQuestionReply(pending, answers, messageID);
889
+ }
890
+ async handleQuestionCommand(command) {
891
+ const cmd = command.command;
892
+ if (cmd.action === "reject") {
893
+ await this.handleQuestionReject(cmd.requestID, command.messageID);
894
+ return;
895
+ }
896
+ if (cmd.action === "reply") {
897
+ if (!cmd.answers)
898
+ return;
899
+ const state = await this.syncState();
900
+ const pending = state.pendingQuestions[cmd.requestID];
901
+ if (!pending) {
902
+ await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${cmd.requestID}`, { replyToMessageID: command.messageID });
903
+ return;
904
+ }
905
+ await this.handleQuestionReply(pending, cmd.answers, command.messageID);
906
+ return;
907
+ }
908
+ }
909
+ async handleQuestionReply(pending, answers, replyToMessageID) {
910
+ const state = await this.syncState();
911
+ if (!this.lease.isOwner(state)) {
912
+ await this.telegram.sendMessage(this.config.channelID, "Cannot answer question: this instance is not the current bridge owner.", replyToMessageID ? { replyToMessageID } : undefined);
913
+ return;
914
+ }
915
+ const normalized = normalizeAnswers(pending, answers);
916
+ try {
917
+ await replyQuestion(this.client, this.api.state.path.directory, pending.requestID, normalized);
918
+ }
919
+ catch (err) {
920
+ await this.telegram.sendMessage(this.config.channelID, `Question reply failed: ${String(err)}`, replyToMessageID ? { replyToMessageID } : undefined);
921
+ return;
922
+ }
923
+ const { [pending.requestID]: _q, ...remaining } = state.pendingQuestions;
924
+ const { [pending.requestID]: _a, ...remainingAnswers } = state.questionAnswers;
925
+ await this.persist({
926
+ ...state,
927
+ pendingQuestions: remaining,
928
+ questionAnswers: remainingAnswers,
929
+ });
930
+ const summary = normalized
931
+ .map((labels, idx) => `Q${idx + 1}: ${labels.join(", ") || "(skipped)"}`)
932
+ .join("\n");
933
+ await this.telegram.sendMessage(this.config.channelID, `Question ${pending.requestID} -> replied\n${summary}`, replyToMessageID ? { replyToMessageID } : undefined);
934
+ }
935
+ async handleQuestionReject(requestID, replyToMessageID) {
936
+ const state = await this.syncState();
937
+ if (!this.lease.isOwner(state)) {
938
+ await this.telegram.sendMessage(this.config.channelID, "Cannot reject question: this instance is not the current bridge owner.", replyToMessageID ? { replyToMessageID } : undefined);
939
+ return;
940
+ }
941
+ const pending = state.pendingQuestions[requestID];
942
+ if (!pending) {
943
+ await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${requestID}`, replyToMessageID ? { replyToMessageID } : undefined);
944
+ return;
945
+ }
946
+ try {
947
+ await rejectQuestion(this.client, this.api.state.path.directory, requestID);
948
+ }
949
+ catch (err) {
950
+ await this.telegram.sendMessage(this.config.channelID, `Question reject failed: ${String(err)}`, replyToMessageID ? { replyToMessageID } : undefined);
951
+ return;
952
+ }
953
+ const { [requestID]: _q, ...remaining } = state.pendingQuestions;
954
+ const { [requestID]: _a, ...remainingAnswers } = state.questionAnswers;
955
+ await this.persist({
956
+ ...state,
957
+ pendingQuestions: remaining,
958
+ questionAnswers: remainingAnswers,
959
+ });
960
+ await this.telegram.sendMessage(this.config.channelID, `Question ${requestID} -> rejected`, replyToMessageID ? { replyToMessageID } : undefined);
961
+ }
962
+ async onQuestionAsked(input) {
963
+ const state = await this.syncState();
964
+ if (!this.lease.isOwner(state)) {
965
+ this.log(`onQuestionAsked: dropped because not owner (id=${input.id})`);
966
+ return;
967
+ }
968
+ if (state.bound.sessionID !== input.sessionID) {
969
+ this.log(`onQuestionAsked: dropped because session mismatch (bound=${state.bound.sessionID}, event=${input.sessionID})`);
970
+ return;
971
+ }
972
+ if (state.pendingQuestions[input.id]) {
973
+ this.log(`onQuestionAsked: dropped because already pending (id=${input.id})`);
974
+ return;
975
+ }
976
+ const pending = toPendingQuestion(input);
977
+ const next = {
978
+ ...state,
979
+ pendingQuestions: {
980
+ ...state.pendingQuestions,
981
+ [pending.requestID]: pending,
982
+ },
983
+ };
984
+ await this.persist(next);
985
+ const activeReplyTo = state.activePrompt?.telegramMessageID;
986
+ await this.safeSendTelegram("onQuestionAsked: waiting", "waiting-question", activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
987
+ for (let i = 0; i < pending.questions.length; i++) {
988
+ const info = pending.questions[i];
989
+ let message = "";
990
+ let keyboard = { inline_keyboard: [] };
991
+ try {
992
+ message = formatQuestionRequestMessage(pending, i);
993
+ keyboard = buildQuestionKeyboard({
994
+ requestID: pending.requestID,
995
+ questionIndex: i,
996
+ options: info.options,
997
+ selectedLabels: [],
998
+ multiple: info.multiple === true,
999
+ });
1000
+ }
1001
+ catch (formatError) {
1002
+ this.log(`onQuestionAsked: format/build failed for question ${i}: ${String(formatError)}`);
1003
+ continue;
1004
+ }
1005
+ await this.safeSendTelegram(`onQuestionAsked: detail[${i}]`, message, {
1006
+ replyToMessageID: activeReplyTo,
1007
+ replyMarkup: keyboard,
1008
+ });
1009
+ }
1010
+ }
1011
+ async onQuestionReplied(input) {
1012
+ const state = await this.syncState();
1013
+ if (!this.lease.isOwner(state))
1014
+ return;
1015
+ if (!state.pendingQuestions[input.id])
1016
+ return;
1017
+ const { [input.id]: _q, ...remaining } = state.pendingQuestions;
1018
+ const { [input.id]: _a, ...remainingAnswers } = state.questionAnswers;
1019
+ await this.persist({
1020
+ ...state,
1021
+ pendingQuestions: remaining,
1022
+ questionAnswers: remainingAnswers,
1023
+ });
1024
+ }
1025
+ async onQuestionRejected(input) {
1026
+ await this.onQuestionReplied(input);
1027
+ }
622
1028
  async handleModelCommand(target, preset, replyToMessageID) {
623
1029
  const state = await this.requireState();
624
1030
  if (state.bound.status !== "online" || !state.bound.sessionID) {
@@ -703,17 +1109,64 @@ export class BridgeController {
703
1109
  }
704
1110
  }
705
1111
  async buildSummary(sessionID, assistantMessageID, userMessageID, directParts) {
706
- const parts = directParts ?? this.api.state.part(assistantMessageID);
707
- const text = parts
708
- .filter((part) => {
709
- return part.type === "text" && typeof part.text === "string";
710
- })
711
- .map((part) => part.text)
712
- .join("")
713
- .trim();
1112
+ const textFromParts = (parts) => {
1113
+ if (!parts)
1114
+ return "";
1115
+ return parts
1116
+ .filter((part) => {
1117
+ return part.type === "text" && typeof part.text === "string";
1118
+ })
1119
+ .map((part) => part.text)
1120
+ .join("")
1121
+ .trim();
1122
+ };
1123
+ // Source 0: streamed text accumulator (from message.part.updated events).
1124
+ // This is the most reliable because it accumulates as the model generates
1125
+ // text and bypasses both the SDK fetch (which can 500) and the TUI state
1126
+ // (which may be empty for bridge-originated messages).
1127
+ let text = this.streamedText.get(assistantMessageID)?.trim() ?? "";
1128
+ let source = text ? "streamed" : "none";
1129
+ // Source 1: parts passed in from the polling loop (may be undefined or
1130
+ // partial depending on the server's response shape for session.messages()).
1131
+ if (!text) {
1132
+ text = textFromParts(directParts);
1133
+ if (text)
1134
+ source = "direct";
1135
+ }
1136
+ // Source 2: TUI plugin's local state (fast, but only has parts the TUI
1137
+ // itself has rendered, so may be empty for bridge-originated messages).
1138
+ if (!text) {
1139
+ try {
1140
+ text = textFromParts(this.api.state.part(assistantMessageID));
1141
+ if (text)
1142
+ source = "tui";
1143
+ }
1144
+ catch (err) {
1145
+ this.log(`buildSummary: api.state.part failed: ${String(err)}`);
1146
+ }
1147
+ }
1148
+ // Source 3: dedicated single-message SDK fetch. The v1 SDK's URL template
1149
+ // is /session/{id}/message/{messageID}, so we pass `id` and `messageID`
1150
+ // flat (NOT under a `path` wrapper) so the SDK's path substitution works.
1151
+ if (!text) {
1152
+ try {
1153
+ const response = await this.client.session.message({ id: sessionID, messageID: assistantMessageID }, { responseStyle: "data", throwOnError: true });
1154
+ const parts = response?.parts;
1155
+ text = textFromParts(parts);
1156
+ if (text)
1157
+ source = "sdk";
1158
+ this.log(`buildSummary: SDK fetch returned ${parts?.length ?? 0} parts, text length=${text.length}`);
1159
+ }
1160
+ catch (err) {
1161
+ this.log(`buildSummary: SDK message fetch failed: ${String(err)}`);
1162
+ }
1163
+ }
1164
+ this.log(`buildSummary: assistantMessageID=${assistantMessageID} text source=${source} length=${text.length} streamedMapSize=${this.streamedText.size}`);
1165
+ // Drop the entry from the accumulator so we don't leak memory.
1166
+ this.streamedText.delete(assistantMessageID);
714
1167
  let changedFiles = [];
715
1168
  try {
716
- const diffResponse = await this.client.session.message({ sessionID, messageID: userMessageID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
1169
+ const diffResponse = await this.client.session.message({ id: sessionID, messageID: userMessageID }, { responseStyle: "data", throwOnError: true });
717
1170
  const diff = diffResponse?.diff;
718
1171
  changedFiles = (diff || []).map((item) => item.file);
719
1172
  }
@@ -721,7 +1174,7 @@ export class BridgeController {
721
1174
  changedFiles = [];
722
1175
  }
723
1176
  return {
724
- text: text || "(assistant completed with no text output)",
1177
+ text,
725
1178
  changedFiles,
726
1179
  };
727
1180
  }
@@ -1100,6 +1553,7 @@ export class BridgeController {
1100
1553
  startPolling() {
1101
1554
  if (this.pollTask)
1102
1555
  return;
1556
+ this.log("startPolling: beginning Telegram long-polling");
1103
1557
  const telegram = this.getTelegramApi();
1104
1558
  this.pollAbort = new AbortController();
1105
1559
  const poller = new TelegramPoller(telegram, this.requireChannelID(), this.config.prefix, this.config.pollTimeoutSec, {
@@ -1116,7 +1570,11 @@ export class BridgeController {
1116
1570
  message: `Teleprompt: received ${count} Telegram updates`,
1117
1571
  });
1118
1572
  },
1573
+ onLog: (message) => {
1574
+ this.log(message);
1575
+ },
1119
1576
  onError: (error) => {
1577
+ this.log(`Telegram polling error: ${String(error)}`);
1120
1578
  this.api.ui.toast({
1121
1579
  variant: "warning",
1122
1580
  message: `Telegram polling error: ${String(error)}`,
@@ -1131,16 +1589,23 @@ export class BridgeController {
1131
1589
  this.eventRestartTimer = undefined;
1132
1590
  }
1133
1591
  await this.eventStream?.stop();
1134
- this.eventStream = new SessionEventStream(this.client, sessionID, {
1592
+ this.eventStream = new SessionEventStream(this.client, sessionID, this.api.state.path.directory, {
1135
1593
  onAssistantCompleted: (sid, assistantID, parentID) => this.onAssistantCompleted(sid, assistantID, parentID),
1136
1594
  onPermissionAsked: (event) => this.onPermissionAsked(event),
1595
+ onPermissionReplied: (requestID) => this.onPermissionReplied(requestID),
1596
+ onQuestionAsked: (event) => this.onQuestionAsked(event),
1597
+ onQuestionReplied: (event) => this.onQuestionReplied(event),
1598
+ onQuestionRejected: (event) => this.onQuestionRejected(event),
1137
1599
  onSessionError: (event) => this.onSessionError(event),
1138
1600
  onUserMessage: (sid, msgID) => this.onUserMessage(sid, msgID),
1601
+ onMessagePartUpdated: (input) => this.onMessagePartUpdated(input),
1139
1602
  onStreamError: (error) => this.onEventStreamError(sessionID, error),
1140
- });
1603
+ }, (msg) => this.log(`eventStream: ${msg}`));
1141
1604
  this.eventStream.start();
1605
+ this.streamedText.clear();
1142
1606
  }
1143
1607
  async onEventStreamError(sessionID, error) {
1608
+ this.log(`onEventStreamError: ${String(error)}`);
1144
1609
  this.eventStream = undefined;
1145
1610
  this.api.ui.toast({
1146
1611
  variant: "warning",
@@ -1254,6 +1719,7 @@ export class BridgeController {
1254
1719
  this.pollTask = undefined;
1255
1720
  await this.eventStream?.stop();
1256
1721
  this.eventStream = undefined;
1722
+ this.streamedText.clear();
1257
1723
  if (!clearJobs)
1258
1724
  return;
1259
1725
  const state = await this.requireState();
@@ -1262,6 +1728,8 @@ export class BridgeController {
1262
1728
  activePrompt: undefined,
1263
1729
  promptQueue: [],
1264
1730
  pendingPermissions: {},
1731
+ pendingQuestions: {},
1732
+ questionAnswers: {},
1265
1733
  });
1266
1734
  }
1267
1735
  async requireState() {
@@ -1291,6 +1759,8 @@ export class BridgeController {
1291
1759
  activePrompt: undefined,
1292
1760
  promptQueue: [],
1293
1761
  pendingPermissions: {},
1762
+ pendingQuestions: {},
1763
+ questionAnswers: {},
1294
1764
  promptHistory: [],
1295
1765
  recentPrompts: [],
1296
1766
  };
@@ -1450,5 +1920,28 @@ export class BridgeController {
1450
1920
  at: this.deps.now(),
1451
1921
  }));
1452
1922
  }
1923
+ /**
1924
+ * Test-only hooks. Exposed as a single object so production code never
1925
+ * reaches for these methods and tests can drive the controller
1926
+ * deterministically without spinning up the full TUI lifecycle.
1927
+ */
1928
+ get __test() {
1929
+ return {
1930
+ onPermissionAsked: (input) => this.onPermissionAsked(input),
1931
+ onQuestionAsked: (input) => this.onQuestionAsked(input),
1932
+ onQuestionReplied: (input) => this.onQuestionReplied(input),
1933
+ onQuestionRejected: (input) => this.onQuestionRejected(input),
1934
+ handleTelegramCallback: (cb) => this.handleTelegramCallback(cb),
1935
+ handleTelegramMessage: (msg) => this.handleTelegramMessage(msg),
1936
+ handleQuestionToggle: (...args) => this.handleQuestionToggle(...args),
1937
+ startEventStream: (sessionID) => this.startEventStream(sessionID),
1938
+ setState: async (next) => {
1939
+ this.data = next;
1940
+ await this.store.save(next);
1941
+ },
1942
+ syncState: () => this.syncState(),
1943
+ getStorePath: () => this.storePath,
1944
+ };
1945
+ }
1453
1946
  }
1454
1947
  //# sourceMappingURL=controller.js.map