@grinev/opencode-telegram-bot 0.20.6 → 0.21.1

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.
@@ -1,5 +1,5 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
2
+ import { selectModel, fetchCurrentModel, getModelSelectionLists, searchModels, } from "../../model/manager.js";
3
3
  import { formatModelForDisplay } from "../../model/types.js";
4
4
  import { formatVariantForButton } from "../../variant/manager.js";
5
5
  import { logger } from "../../utils/logger.js";
@@ -8,7 +8,11 @@ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
10
  import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
11
+ import { interactionManager } from "../../interaction/manager.js";
11
12
  import { t } from "../../i18n/index.js";
13
+ const MODEL_SEARCH_CALLBACK = "model:search";
14
+ const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
15
+ const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
12
16
  function buildModelSelectionMenuText(modelLists) {
13
17
  const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
14
18
  if (modelLists.favorites.length === 0) {
@@ -20,9 +24,51 @@ function buildModelSelectionMenuText(modelLists) {
20
24
  }
21
25
  return lines.join("\n");
22
26
  }
27
+ function parseModelSearchMetadata() {
28
+ const state = interactionManager.getSnapshot();
29
+ if (!state || state.kind !== "custom") {
30
+ return null;
31
+ }
32
+ const flow = state.metadata.flow;
33
+ const stage = state.metadata.stage;
34
+ if (flow !== "model-search" || typeof stage !== "string") {
35
+ return null;
36
+ }
37
+ const messageId = typeof state.metadata.messageId === "number" ? state.metadata.messageId : undefined;
38
+ return { flow, stage, messageId };
39
+ }
23
40
  /**
24
- * Handle model selection callback
25
- * @param ctx grammY context
41
+ * Shared logic for applying a model selection and updating UI.
42
+ * Used by both the regular inline menu flow and the search results flow.
43
+ */
44
+ async function applyModelSelectionAndNotify(ctx, modelInfo) {
45
+ if (ctx.chat) {
46
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
47
+ }
48
+ selectModel(modelInfo);
49
+ keyboardManager.updateModel(modelInfo);
50
+ await pinnedMessageManager.refreshContextLimit();
51
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
52
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
53
+ (pinnedMessageManager.getContextLimit() > 0
54
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
55
+ : null);
56
+ keyboardManager.updateAgent(currentAgent);
57
+ if (contextInfo) {
58
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
59
+ }
60
+ const variantName = formatVariantForButton(modelInfo.variant || "default");
61
+ const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
62
+ const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
63
+ await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
64
+ await ctx.reply(t("model.changed_message", { name: displayName }), {
65
+ reply_markup: keyboard,
66
+ });
67
+ await ctx.deleteMessage().catch(() => { });
68
+ }
69
+ /**
70
+ * Handle model selection callback from the inline menu.
71
+ * Skips search-related callbacks (handled separately).
26
72
  * @returns true if handled, false otherwise
27
73
  */
28
74
  export async function handleModelSelect(ctx) {
@@ -30,15 +76,18 @@ export async function handleModelSelect(ctx) {
30
76
  if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
31
77
  return false;
32
78
  }
79
+ // Skip search callbacks — handled by handleModelSearchCallback / handleModelSearchResults
80
+ if (callbackQuery.data === MODEL_SEARCH_CALLBACK ||
81
+ callbackQuery.data === MODEL_SEARCH_AGAIN_CALLBACK ||
82
+ callbackQuery.data === MODEL_SEARCH_CANCEL_CALLBACK) {
83
+ return false;
84
+ }
33
85
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "model");
34
86
  if (!isActiveMenu) {
35
87
  return true;
36
88
  }
37
89
  logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
38
90
  try {
39
- if (ctx.chat) {
40
- keyboardManager.initialize(ctx.api, ctx.chat.id);
41
- }
42
91
  // Parse callback data: "model:providerID:modelID"
43
92
  const parts = callbackQuery.data.split(":");
44
93
  if (parts.length < 3) {
@@ -52,35 +101,10 @@ export async function handleModelSelect(ctx) {
52
101
  const modelInfo = {
53
102
  providerID,
54
103
  modelID,
55
- variant: "default", // Reset to default when switching models
104
+ variant: "default",
56
105
  };
57
- // Select model and persist
58
- selectModel(modelInfo);
59
- // Update keyboard manager state (may not be initialized if no session selected)
60
- keyboardManager.updateModel(modelInfo);
61
- // Refresh context limit for new model
62
- await pinnedMessageManager.refreshContextLimit();
63
- // Update Reply Keyboard with new model and context
64
- const currentAgent = await resolveProjectAgent(getStoredAgent());
65
- const contextInfo = pinnedMessageManager.getContextInfo() ??
66
- (pinnedMessageManager.getContextLimit() > 0
67
- ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
68
- : null);
69
- keyboardManager.updateAgent(currentAgent);
70
- if (contextInfo) {
71
- keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
72
- }
73
- const variantName = formatVariantForButton(modelInfo.variant || "default");
74
- const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
75
- const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
76
106
  clearActiveInlineMenu("model_selected");
77
- // Send confirmation message with updated keyboard
78
- await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
79
- await ctx.reply(t("model.changed_message", { name: displayName }), {
80
- reply_markup: keyboard,
81
- });
82
- // Delete the inline menu message
83
- await ctx.deleteMessage().catch(() => { });
107
+ await applyModelSelectionAndNotify(ctx, modelInfo);
84
108
  return true;
85
109
  }
86
110
  catch (err) {
@@ -91,15 +115,15 @@ export async function handleModelSelect(ctx) {
91
115
  }
92
116
  }
93
117
  /**
94
- * Build inline keyboard with favorite and recent models
95
- * @param currentModel Current model for highlighting
96
- * @returns InlineKeyboard with model selection buttons
118
+ * Build inline keyboard with favorite and recent models, plus a search button at the top.
97
119
  */
98
120
  export async function buildModelSelectionMenu(currentModel, modelLists) {
99
121
  const keyboard = new InlineKeyboard();
100
122
  const lists = modelLists ?? (await getModelSelectionLists());
101
123
  const favorites = lists.favorites;
102
124
  const recent = lists.recent;
125
+ // Search button — always present as first row
126
+ keyboard.text(t("model.search.button"), MODEL_SEARCH_CALLBACK).row();
103
127
  if (favorites.length === 0 && recent.length === 0) {
104
128
  logger.warn("[ModelHandler] No model choices found in favorites/recent");
105
129
  return keyboard;
@@ -108,7 +132,6 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
108
132
  const isActive = currentModel &&
109
133
  model.providerID === currentModel.providerID &&
110
134
  model.modelID === currentModel.modelID;
111
- // Inline buttons use full model ID without truncation
112
135
  const label = `${prefix} ${model.providerID}/${model.modelID}`;
113
136
  const labelWithCheck = isActive ? `✅ ${label}` : label;
114
137
  keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
@@ -119,17 +142,13 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
119
142
  }
120
143
  /**
121
144
  * Show model selection menu
122
- * @param ctx grammY context
123
145
  */
124
146
  export async function showModelSelectionMenu(ctx) {
125
147
  try {
126
148
  const currentModel = fetchCurrentModel();
127
149
  const modelLists = await getModelSelectionLists();
128
150
  const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
129
- if (keyboard.inline_keyboard.length === 0) {
130
- await ctx.reply(t("model.menu.empty"));
131
- return;
132
- }
151
+ // keyboard always has at least the search button, so length > 0
133
152
  const text = buildModelSelectionMenuText(modelLists);
134
153
  await replyWithInlineMenu(ctx, {
135
154
  menuKind: "model",
@@ -142,3 +161,147 @@ export async function showModelSelectionMenu(ctx) {
142
161
  await ctx.reply(t("model.menu.error"));
143
162
  }
144
163
  }
164
+ // ─── Model search handlers ────────────────────────────────────────────────
165
+ /**
166
+ * Handle the search button callback (model:search) from the inline menu.
167
+ * Transitions the interaction to text-input mode and prompts the user.
168
+ */
169
+ export async function handleModelSearchCallback(ctx) {
170
+ const data = ctx.callbackQuery?.data;
171
+ if (!data) {
172
+ return false;
173
+ }
174
+ if (data !== MODEL_SEARCH_CALLBACK) {
175
+ return false;
176
+ }
177
+ const isActive = await ensureActiveInlineMenu(ctx, "model");
178
+ if (!isActive) {
179
+ return true;
180
+ }
181
+ await ctx.answerCallbackQuery().catch(() => { });
182
+ await ctx.deleteMessage().catch(() => { });
183
+ // Start a new interaction for search text input
184
+ // interactionManager.start() clears any existing interaction automatically
185
+ interactionManager.start({
186
+ kind: "custom",
187
+ expectedInput: "text",
188
+ metadata: {
189
+ flow: "model-search",
190
+ stage: "input",
191
+ },
192
+ });
193
+ await ctx.reply(t("model.search.prompt"));
194
+ logger.debug("[ModelHandler] Model search prompt shown");
195
+ return true;
196
+ }
197
+ /**
198
+ * Handle text input for model search.
199
+ * Searches the full provider catalog and shows results (or "not found").
200
+ */
201
+ export async function handleModelSearchTextInput(ctx) {
202
+ const meta = parseModelSearchMetadata();
203
+ if (!meta || meta.stage !== "input") {
204
+ return false;
205
+ }
206
+ const text = ctx.message?.text;
207
+ if (!text) {
208
+ return false;
209
+ }
210
+ logger.debug(`[ModelHandler] Model search query: "${text}"`);
211
+ try {
212
+ const results = await searchModels(text);
213
+ const keyboard = new InlineKeyboard();
214
+ for (const model of results) {
215
+ const label = `${model.providerID}/${model.modelID}`;
216
+ keyboard.text(label, `model:${model.providerID}:${model.modelID}`).row();
217
+ }
218
+ keyboard.row();
219
+ keyboard.text(t("model.search.search_again"), MODEL_SEARCH_AGAIN_CALLBACK);
220
+ keyboard.text(t("inline.button.cancel"), MODEL_SEARCH_CANCEL_CALLBACK);
221
+ const replyText = results.length === 0
222
+ ? t("model.search.no_results", { query: text })
223
+ : t("model.search.results_title", { query: text });
224
+ const sent = await ctx.reply(replyText, { reply_markup: keyboard });
225
+ // Transition to results stage (callback-only)
226
+ interactionManager.transition({
227
+ expectedInput: "callback",
228
+ metadata: {
229
+ flow: "model-search",
230
+ stage: "results",
231
+ messageId: sent.message_id,
232
+ },
233
+ });
234
+ return true;
235
+ }
236
+ catch (err) {
237
+ logger.error("[ModelHandler] Model search error:", err);
238
+ await ctx.reply(t("model.search.error"));
239
+ interactionManager.clear("model_search_error");
240
+ return true;
241
+ }
242
+ }
243
+ /**
244
+ * Handle callbacks from the search results menu:
245
+ * - model:search:cancel — clears interaction, deletes message
246
+ * - model:search:again — delegates to handleModelSearchCallback
247
+ * - model:provider:model — selects the model from search results
248
+ */
249
+ export async function handleModelSearchResults(ctx) {
250
+ const data = ctx.callbackQuery?.data;
251
+ if (!data) {
252
+ return false;
253
+ }
254
+ const meta = parseModelSearchMetadata();
255
+ if (!meta || meta.stage !== "results") {
256
+ return false;
257
+ }
258
+ // Verify message ID matches to reject stale callbacks
259
+ const callbackMessageId = ctx.callbackQuery?.message?.message_id;
260
+ if (meta.messageId !== undefined && callbackMessageId !== meta.messageId) {
261
+ await ctx
262
+ .answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true })
263
+ .catch(() => { });
264
+ return true;
265
+ }
266
+ // Cancel
267
+ if (data === MODEL_SEARCH_CANCEL_CALLBACK) {
268
+ interactionManager.clear("model_search_cancelled");
269
+ await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") }).catch(() => { });
270
+ await ctx.deleteMessage().catch(() => { });
271
+ return true;
272
+ }
273
+ // Search again — inline implementation
274
+ if (data === MODEL_SEARCH_AGAIN_CALLBACK) {
275
+ await ctx.answerCallbackQuery().catch(() => { });
276
+ await ctx.deleteMessage().catch(() => { });
277
+ interactionManager.start({
278
+ kind: "custom",
279
+ expectedInput: "text",
280
+ metadata: {
281
+ flow: "model-search",
282
+ stage: "input",
283
+ },
284
+ });
285
+ await ctx.reply(t("model.search.prompt"));
286
+ logger.debug("[ModelHandler] Model search prompt shown (search again)");
287
+ return true;
288
+ }
289
+ // Model selection from search results
290
+ if (data.startsWith("model:")) {
291
+ const parts = data.split(":");
292
+ if (parts.length < 3) {
293
+ return true;
294
+ }
295
+ const providerID = parts[1];
296
+ const modelID = parts.slice(2).join(":");
297
+ const modelInfo = {
298
+ providerID,
299
+ modelID,
300
+ variant: "default",
301
+ };
302
+ interactionManager.clear("model_search_selected");
303
+ await applyModelSelectionAndNotify(ctx, modelInfo);
304
+ return true;
305
+ }
306
+ return false;
307
+ }
package/dist/bot/index.js CHANGED
@@ -25,13 +25,14 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
25
25
  import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
26
26
  import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
27
27
  import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
28
+ import { handleMessagesCallback, messagesCommand } from "./commands/messages.js";
28
29
  import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
29
30
  import { mcpsCommand, handleMcpsCallback } from "./commands/mcps.js";
30
31
  import { ttsCommand } from "./commands/tts.js";
31
32
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
32
33
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
33
34
  import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
34
- import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
35
+ import { handleModelSelect, handleModelSearchCallback, handleModelSearchResults, handleModelSearchTextInput, showModelSelectionMenu, } from "./handlers/model.js";
35
36
  import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
36
37
  import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
37
38
  import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
@@ -59,12 +60,12 @@ import { handleVoiceMessage } from "./handlers/voice.js";
59
60
  import { handleDocumentMessage } from "./handlers/document.js";
60
61
  import { createMediaGroupAttachmentMiddleware } from "./handlers/media-group.js";
61
62
  import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
62
- import { reconcileBusyState } from "./utils/busy-reconciliation.js";
63
+ import { reconcileBusyState, setResponseStreamerForReconciliation } from "./utils/busy-reconciliation.js";
63
64
  import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
64
65
  import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
65
66
  import { deliverThinkingMessage } from "./utils/thinking-message.js";
66
67
  import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
67
- import { editRenderedBotPart, getTelegramRenderedPartSignature, sendRenderedBotPart, } from "./utils/telegram-text.js";
68
+ import { completeDraftPart, editRenderedBotPart, getTelegramRenderedPartSignature, sendDraftBotPart, sendRenderedBotPart, } from "./utils/telegram-text.js";
68
69
  import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
69
70
  import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
70
71
  import { getStoredModel } from "../model/manager.js";
@@ -86,6 +87,7 @@ let heartbeatTimer = null;
86
87
  let unsubscribeReadyRestore = null;
87
88
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
88
89
  const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
90
+ const RESPONSE_STREAMING_MODE = config.bot.responseStreamingMode;
89
91
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
90
92
  const SESSION_RETRY_PREFIX = "🔁";
91
93
  const SUBAGENT_STREAM_PREFIX = "🧩";
@@ -168,56 +170,105 @@ const toolMessageBatcher = new ToolMessageBatcher({
168
170
  }
169
171
  },
170
172
  });
171
- const responseStreamer = new ResponseStreamer({
172
- throttleMs: RESPONSE_STREAM_THROTTLE_MS,
173
- sendPart: async (part, options) => {
174
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
175
- throw new Error("Bot context missing for streamed send");
176
- }
177
- return sendRenderedBotPart({
178
- api: botInstance.api,
179
- chatId: chatIdInstance,
180
- part,
181
- options,
182
- });
183
- },
184
- editPart: async (messageId, part, options) => {
185
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
186
- throw new Error("Bot context missing for streamed edit");
187
- }
188
- try {
189
- return await editRenderedBotPart({
173
+ let nextDraftId = 1;
174
+ function getNextDraftId() {
175
+ const id = nextDraftId;
176
+ nextDraftId += 1;
177
+ return id;
178
+ }
179
+ const responseStreamer = RESPONSE_STREAMING_MODE === "draft"
180
+ ? new ResponseStreamer({
181
+ throttleMs: RESPONSE_STREAM_THROTTLE_MS,
182
+ sendPart: async (part) => {
183
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
184
+ throw new Error("Bot context missing for draft send");
185
+ }
186
+ const draftId = getNextDraftId();
187
+ const result = await sendDraftBotPart({
188
+ api: botInstance.api,
189
+ chatId: chatIdInstance,
190
+ draftId,
191
+ part,
192
+ });
193
+ return { messageId: draftId, deliveredSignature: result.deliveredSignature };
194
+ },
195
+ editPart: async (messageId, part) => {
196
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
197
+ throw new Error("Bot context missing for draft edit");
198
+ }
199
+ return sendDraftBotPart({
200
+ api: botInstance.api,
201
+ chatId: chatIdInstance,
202
+ draftId: messageId,
203
+ part,
204
+ });
205
+ },
206
+ deleteText: async () => {
207
+ // Drafts are ephemeral — no need to delete
208
+ },
209
+ completePart: async (part, options) => {
210
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
211
+ throw new Error("Bot context missing for draft complete");
212
+ }
213
+ return completeDraftPart({
190
214
  api: botInstance.api,
191
215
  chatId: chatIdInstance,
192
- messageId,
193
216
  part,
194
217
  options,
195
218
  });
196
- }
197
- catch (error) {
198
- const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
199
- if (errorMessage.includes("message is not modified")) {
200
- return {
201
- deliveredSignature: getTelegramRenderedPartSignature(part),
202
- };
219
+ },
220
+ })
221
+ : new ResponseStreamer({
222
+ throttleMs: RESPONSE_STREAM_THROTTLE_MS,
223
+ sendPart: async (part, options) => {
224
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
225
+ throw new Error("Bot context missing for streamed send");
203
226
  }
204
- throw error;
205
- }
206
- },
207
- deleteText: async (messageId) => {
208
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
209
- throw new Error("Bot context missing for streamed delete");
210
- }
211
- await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
212
- const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
213
- if (errorMessage.includes("message to delete not found") ||
214
- errorMessage.includes("message identifier is not specified")) {
215
- return;
227
+ return sendRenderedBotPart({
228
+ api: botInstance.api,
229
+ chatId: chatIdInstance,
230
+ part,
231
+ options,
232
+ });
233
+ },
234
+ editPart: async (messageId, part, options) => {
235
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
236
+ throw new Error("Bot context missing for streamed edit");
216
237
  }
217
- throw error;
218
- });
219
- },
220
- });
238
+ try {
239
+ return await editRenderedBotPart({
240
+ api: botInstance.api,
241
+ chatId: chatIdInstance,
242
+ messageId,
243
+ part,
244
+ options,
245
+ });
246
+ }
247
+ catch (error) {
248
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
249
+ if (errorMessage.includes("message is not modified")) {
250
+ return {
251
+ deliveredSignature: getTelegramRenderedPartSignature(part),
252
+ };
253
+ }
254
+ throw error;
255
+ }
256
+ },
257
+ deleteText: async (messageId) => {
258
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
259
+ throw new Error("Bot context missing for streamed delete");
260
+ }
261
+ await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
262
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
263
+ if (errorMessage.includes("message to delete not found") ||
264
+ errorMessage.includes("message identifier is not specified")) {
265
+ return;
266
+ }
267
+ throw error;
268
+ });
269
+ },
270
+ });
271
+ setResponseStreamerForReconciliation(responseStreamer);
221
272
  const toolCallStreamer = new ToolCallStreamer({
222
273
  throttleMs: RESPONSE_STREAM_THROTTLE_MS,
223
274
  sendText: async (sessionId, text) => {
@@ -928,6 +979,7 @@ export function createBot() {
928
979
  bot.command("open", openCommand);
929
980
  bot.command("ls", lsCommand);
930
981
  bot.command("sessions", sessionsCommand);
982
+ bot.command("messages", messagesCommand);
931
983
  bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
932
984
  bot.command("abort", abortCommand);
933
985
  bot.command("detach", detachCommand);
@@ -964,6 +1016,8 @@ export function createBot() {
964
1016
  const handledQuestion = await handleQuestionCallback(ctx);
965
1017
  const handledPermission = await handlePermissionCallback(ctx);
966
1018
  const handledAgent = await handleAgentSelect(ctx);
1019
+ const handledModelSearch = await handleModelSearchCallback(ctx);
1020
+ const handledModelSearchResults = await handleModelSearchResults(ctx);
967
1021
  const handledModel = await handleModelSelect(ctx);
968
1022
  const handledVariant = await handleVariantSelect(ctx);
969
1023
  const handledCompactConfirm = await handleCompactConfirm(ctx);
@@ -971,9 +1025,10 @@ export function createBot() {
971
1025
  const handledTaskList = await handleTaskListCallback(ctx);
972
1026
  const handledRenameCancel = await handleRenameCancel(ctx);
973
1027
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
1028
+ const handledMessages = await handleMessagesCallback(ctx, { bot, ensureEventSubscription });
974
1029
  const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
975
1030
  const handledMcps = await handleMcpsCallback(ctx);
976
- logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
1031
+ logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
977
1032
  if (!handledBackgroundSession &&
978
1033
  !handledInlineCancel &&
979
1034
  !handledSession &&
@@ -984,6 +1039,8 @@ export function createBot() {
984
1039
  !handledQuestion &&
985
1040
  !handledPermission &&
986
1041
  !handledAgent &&
1042
+ !handledModelSearch &&
1043
+ !handledModelSearchResults &&
987
1044
  !handledModel &&
988
1045
  !handledVariant &&
989
1046
  !handledCompactConfirm &&
@@ -991,6 +1048,7 @@ export function createBot() {
991
1048
  !handledTaskList &&
992
1049
  !handledRenameCancel &&
993
1050
  !handledCommands &&
1051
+ !handledMessages &&
994
1052
  !handledSkills &&
995
1053
  !handledMcps) {
996
1054
  logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
@@ -1183,6 +1241,10 @@ export function createBot() {
1183
1241
  if (handledTask) {
1184
1242
  return;
1185
1243
  }
1244
+ const handledModelSearchText = await handleModelSearchTextInput(ctx);
1245
+ if (handledModelSearchText) {
1246
+ return;
1247
+ }
1186
1248
  const handledRename = await handleRenameTextAnswer(ctx);
1187
1249
  if (handledRename) {
1188
1250
  return;
@@ -56,12 +56,14 @@ export class ResponseStreamer {
56
56
  sendPart;
57
57
  editPart;
58
58
  deleteText;
59
+ completePart;
59
60
  states = new Map();
60
61
  constructor(options) {
61
62
  this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
62
63
  this.sendPart = options.sendPart;
63
64
  this.editPart = options.editPart;
64
65
  this.deleteText = options.deleteText;
66
+ this.completePart = options.completePart;
65
67
  }
66
68
  enqueue(sessionId, messageId, payload) {
67
69
  const normalizedPayload = normalizePayload(payload);
@@ -105,8 +107,22 @@ export class ResponseStreamer {
105
107
  let synced = true;
106
108
  if (options?.flushFinal !== false) {
107
109
  synced = await this.enqueueTask(state, () => this.flushState(state, "complete"));
108
- if (!synced && state.isBroken) {
109
- await this.cleanupBrokenStream(state, "final_sync_failed_cleanup");
110
+ }
111
+ if (synced && this.completePart && state.latestPayload) {
112
+ try {
113
+ const realMessageIds = [];
114
+ for (const part of state.latestPayload.parts) {
115
+ if (!part.text) {
116
+ continue;
117
+ }
118
+ const result = await this.completePart(part, state.latestPayload.sendOptions);
119
+ realMessageIds.push(result.messageId);
120
+ }
121
+ state.telegramMessageIds = realMessageIds;
122
+ }
123
+ catch (error) {
124
+ logger.error(`[ResponseStreamer] Failed to persist draft message: session=${sessionId}, message=${messageId}`, error);
125
+ synced = false;
110
126
  }
111
127
  }
112
128
  const messageIds = [...state.telegramMessageIds];
@@ -144,6 +160,14 @@ export class ResponseStreamer {
144
160
  logger.debug(`[ResponseStreamer] Cleared all streams: count=${count}, reason=${reason}`);
145
161
  }
146
162
  }
163
+ hasActiveStream(sessionId) {
164
+ for (const state of this.states.values()) {
165
+ if (state.sessionId === sessionId && !state.cancelled) {
166
+ return true;
167
+ }
168
+ }
169
+ return false;
170
+ }
147
171
  getOrCreateState(sessionId, messageId) {
148
172
  const key = buildStateKey(sessionId, messageId);
149
173
  const existing = this.states.get(key);
@@ -1,9 +1,20 @@
1
1
  import { getAgentDisplayName } from "../../agent/types.js";
2
- function formatElapsedSeconds(elapsedMs) {
3
- const safeElapsedMs = Math.max(0, elapsedMs);
4
- return `${(safeElapsedMs / 1000).toFixed(1)}s`;
2
+ function formatDuration(elapsedMs) {
3
+ const safeElapsedMs = Math.max(0, Math.round(elapsedMs));
4
+ const totalSeconds = Math.floor(safeElapsedMs / 1000);
5
+ const hours = Math.floor(totalSeconds / 3600);
6
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
7
+ const seconds = totalSeconds % 60;
8
+ const parts = [];
9
+ if (hours > 0)
10
+ parts.push(`${hours}h`);
11
+ if (minutes > 0)
12
+ parts.push(`${minutes}m`);
13
+ if (seconds > 0 || parts.length === 0)
14
+ parts.push(`${seconds}s`);
15
+ return parts.join(" ");
5
16
  }
6
17
  export function formatAssistantRunFooter({ agent, providerID, modelID, elapsedMs, }) {
7
18
  const agentDisplay = getAgentDisplayName(agent);
8
- return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${formatElapsedSeconds(elapsedMs)}`;
19
+ return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${formatDuration(elapsedMs)}`;
9
20
  }
@@ -10,6 +10,10 @@ const RECONCILE_MIN_INTERVAL_MS = 10_000;
10
10
  const FOREGROUND_BUSY_RECONCILE_GRACE_MS = 2_000;
11
11
  const inFlightDirectories = new Set();
12
12
  const lastReconcileAtByDirectory = new Map();
13
+ let responseStreamerInstance = null;
14
+ export function setResponseStreamerForReconciliation(streamer) {
15
+ responseStreamerInstance = streamer;
16
+ }
13
17
  function getReconciliationTargets(directory) {
14
18
  const foregroundBusySessions = foregroundSessionState
15
19
  .getBusySessions()
@@ -72,6 +76,10 @@ export async function reconcileBusyStateNow(directory, now = Date.now()) {
72
76
  logger.debug(`[BusyReconciliation] Skipping fresh foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
73
77
  continue;
74
78
  }
79
+ if (responseStreamerInstance?.hasActiveStream(session.sessionId)) {
80
+ logger.debug(`[BusyReconciliation] Skipping clear, responseStreamer still active: session=${session.sessionId}`);
81
+ continue;
82
+ }
75
83
  logger.info(`[BusyReconciliation] Clearing stale foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
76
84
  if (attachedSessionForDirectory?.sessionId !== session.sessionId) {
77
85
  await markAttachedSessionIdle(session.sessionId);