@grinev/opencode-telegram-bot 0.18.0 → 0.19.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,12 +1,30 @@
1
1
  import { config } from "../config.js";
2
+ import { t } from "../i18n/index.js";
2
3
  import { opencodeClient } from "../opencode/client.js";
3
4
  import { logger } from "../utils/logger.js";
4
- const SCHEDULED_TASK_AGENT = "build";
5
+ export const SCHEDULED_TASK_AGENT = "build";
5
6
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
6
7
  const EXECUTION_POLL_INTERVAL_MS = 2000;
7
8
  const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
9
+ const COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS = 500;
10
+ const MAX_COMPLETED_EMPTY_RESULT_RECHECKS = 3;
8
11
  const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
9
12
  const EXECUTION_TIMEOUT_ERROR_PREFIX = "Scheduled task exceeded bot execution timeout";
13
+ const INTERACTIVE_PERMISSION_REJECT_MESSAGE = "Scheduled task cannot continue because it requires interactive permission.";
14
+ class ScheduledTaskEmptyAssistantResponseError extends Error {
15
+ constructor() {
16
+ super("Scheduled task returned an empty assistant response");
17
+ this.name = "ScheduledTaskEmptyAssistantResponseError";
18
+ }
19
+ }
20
+ class ScheduledTaskInteractiveRequestError extends Error {
21
+ constructor(kind) {
22
+ super(t(kind === "question"
23
+ ? "task.run.error.interactive_question"
24
+ : "task.run.error.interactive_permission"));
25
+ this.name = "ScheduledTaskInteractiveRequestError";
26
+ }
27
+ }
10
28
  function collectResponseText(parts) {
11
29
  return parts
12
30
  .filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
@@ -69,7 +87,7 @@ function sleep(ms) {
69
87
  function findLatestAssistantMessage(messages) {
70
88
  for (let index = messages.length - 1; index >= 0; index -= 1) {
71
89
  const message = messages[index];
72
- if (message?.info.role === "assistant") {
90
+ if (message?.info.role === "assistant" && !message.info.summary) {
73
91
  return message;
74
92
  }
75
93
  }
@@ -77,7 +95,7 @@ function findLatestAssistantMessage(messages) {
77
95
  }
78
96
  function extractAssistantResult(message) {
79
97
  if (!message) {
80
- return { resultText: null, errorMessage: null, completed: false };
98
+ return { resultText: null, errorMessage: null, completed: false, message: null };
81
99
  }
82
100
  const errorMessage = extractErrorMessage(message.info.error);
83
101
  if (errorMessage) {
@@ -85,6 +103,7 @@ function extractAssistantResult(message) {
85
103
  resultText: null,
86
104
  errorMessage: normalizeScheduledTaskErrorMessage(errorMessage),
87
105
  completed: true,
106
+ message,
88
107
  };
89
108
  }
90
109
  const resultText = collectResponseText(message.parts);
@@ -92,8 +111,116 @@ function extractAssistantResult(message) {
92
111
  resultText,
93
112
  errorMessage: null,
94
113
  completed: Boolean(message.info.time?.completed),
114
+ message,
95
115
  };
96
116
  }
117
+ function summarizeAssistantParts(parts) {
118
+ return parts.map((part) => ({
119
+ id: part.id,
120
+ type: part.type,
121
+ ignored: part.ignored,
122
+ ...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
123
+ ...(part.tool ? { tool: part.tool } : {}),
124
+ ...(part.state?.status ? { status: part.state.status } : {}),
125
+ }));
126
+ }
127
+ function logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, message, readCount) {
128
+ logger.warn("[ScheduledTaskExecutor] Empty completed assistant response diagnostics", {
129
+ taskId,
130
+ sessionId,
131
+ directory,
132
+ readCount,
133
+ assistantMessage: message
134
+ ? {
135
+ id: message.info.id,
136
+ completed: Boolean(message.info.time?.completed),
137
+ summary: Boolean(message.info.summary),
138
+ errorMessage: extractErrorMessage(message.info.error),
139
+ parts: summarizeAssistantParts(message.parts),
140
+ }
141
+ : null,
142
+ });
143
+ }
144
+ async function loadPendingInteractiveRequest(sessionId, directory) {
145
+ const [questionsResult, permissionsResult] = await Promise.all([
146
+ opencodeClient.question.list({ directory }),
147
+ opencodeClient.permission.list({ directory }),
148
+ ]);
149
+ if (questionsResult.error) {
150
+ logger.warn(`[ScheduledTaskExecutor] Failed to list pending questions: sessionId=${sessionId}`, questionsResult.error);
151
+ }
152
+ const question = questionsResult.data?.find((request) => request.sessionID === sessionId);
153
+ if (question) {
154
+ return { kind: "question", request: question };
155
+ }
156
+ if (permissionsResult.error) {
157
+ logger.warn(`[ScheduledTaskExecutor] Failed to list pending permissions: sessionId=${sessionId}`, permissionsResult.error);
158
+ }
159
+ const permission = permissionsResult.data?.find((request) => request.sessionID === sessionId);
160
+ if (permission) {
161
+ return { kind: "permission", request: permission };
162
+ }
163
+ return null;
164
+ }
165
+ async function rejectInteractiveRequest(request, directory) {
166
+ try {
167
+ if (request.kind === "question") {
168
+ const { error } = await opencodeClient.question.reject({
169
+ requestID: request.request.id,
170
+ directory,
171
+ });
172
+ if (error) {
173
+ logger.warn(`[ScheduledTaskExecutor] Failed to reject pending question: requestId=${request.request.id}`, error);
174
+ }
175
+ return;
176
+ }
177
+ const { error } = await opencodeClient.permission.reply({
178
+ requestID: request.request.id,
179
+ directory,
180
+ reply: "reject",
181
+ message: INTERACTIVE_PERMISSION_REJECT_MESSAGE,
182
+ });
183
+ if (error) {
184
+ logger.warn(`[ScheduledTaskExecutor] Failed to reject pending permission: requestId=${request.request.id}`, error);
185
+ }
186
+ }
187
+ catch (error) {
188
+ logger.warn(`[ScheduledTaskExecutor] Failed to reject pending interactive request: requestId=${request.request.id}`, error);
189
+ }
190
+ }
191
+ async function abortScheduledTaskSession(sessionId, directory) {
192
+ try {
193
+ const { error } = await opencodeClient.session.abort({ sessionID: sessionId, directory });
194
+ if (error) {
195
+ logger.warn(`[ScheduledTaskExecutor] Failed to abort interactive scheduled task session: sessionId=${sessionId}`, error);
196
+ }
197
+ }
198
+ catch (error) {
199
+ logger.warn(`[ScheduledTaskExecutor] Failed to abort interactive scheduled task session: sessionId=${sessionId}`, error);
200
+ }
201
+ }
202
+ async function failIfInteractiveRequest(taskId, sessionId, directory) {
203
+ const interactiveRequest = await loadPendingInteractiveRequest(sessionId, directory);
204
+ if (!interactiveRequest) {
205
+ return;
206
+ }
207
+ logger.warn("[ScheduledTaskExecutor] Scheduled task requested interactive action", {
208
+ taskId,
209
+ sessionId,
210
+ directory,
211
+ kind: interactiveRequest.kind,
212
+ requestId: interactiveRequest.request.id,
213
+ ...(interactiveRequest.kind === "question"
214
+ ? { questionCount: interactiveRequest.request.questions?.length ?? 0 }
215
+ : {
216
+ permission: interactiveRequest.request.permission,
217
+ patterns: interactiveRequest.request.patterns,
218
+ }),
219
+ });
220
+ await rejectInteractiveRequest(interactiveRequest, directory);
221
+ await abortScheduledTaskSession(sessionId, directory);
222
+ throw new ScheduledTaskInteractiveRequestError(interactiveRequest.kind);
223
+ }
97
224
  async function loadAssistantResult(sessionId, directory) {
98
225
  const { data: messages, error: messagesError } = await opencodeClient.session.messages({
99
226
  sessionID: sessionId,
@@ -104,14 +231,16 @@ async function loadAssistantResult(sessionId, directory) {
104
231
  }
105
232
  return extractAssistantResult(findLatestAssistantMessage(messages));
106
233
  }
107
- async function waitForScheduledTaskResult(sessionId, directory) {
234
+ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
108
235
  const startedAtMs = Date.now();
109
236
  const executionTimeoutMs = getExecutionTimeoutMs();
110
237
  let idlePollsWithoutResult = 0;
238
+ let completedEmptyResultReadCount = 0;
111
239
  while (true) {
112
240
  if (Date.now() - startedAtMs >= executionTimeoutMs) {
113
241
  throw new Error(createExecutionTimeoutMessage());
114
242
  }
243
+ await failIfInteractiveRequest(taskId, sessionId, directory);
115
244
  const assistantResult = await loadAssistantResult(sessionId, directory);
116
245
  if (assistantResult.errorMessage) {
117
246
  throw new Error(assistantResult.errorMessage);
@@ -120,8 +249,15 @@ async function waitForScheduledTaskResult(sessionId, directory) {
120
249
  if (assistantResult.resultText) {
121
250
  return assistantResult.resultText;
122
251
  }
123
- throw new Error("Scheduled task returned an empty assistant response");
252
+ completedEmptyResultReadCount += 1;
253
+ if (completedEmptyResultReadCount > MAX_COMPLETED_EMPTY_RESULT_RECHECKS) {
254
+ logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, assistantResult.message, completedEmptyResultReadCount);
255
+ throw new ScheduledTaskEmptyAssistantResponseError();
256
+ }
257
+ await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
258
+ continue;
124
259
  }
260
+ completedEmptyResultReadCount = 0;
125
261
  const { data: statuses, error: statusError } = await opencodeClient.session.status({
126
262
  directory,
127
263
  });
@@ -138,7 +274,13 @@ async function waitForScheduledTaskResult(sessionId, directory) {
138
274
  if (confirmedAssistantResult.resultText) {
139
275
  return confirmedAssistantResult.resultText;
140
276
  }
141
- throw new Error("Scheduled task returned an empty assistant response");
277
+ completedEmptyResultReadCount += 1;
278
+ if (completedEmptyResultReadCount > MAX_COMPLETED_EMPTY_RESULT_RECHECKS) {
279
+ logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, confirmedAssistantResult.message, completedEmptyResultReadCount);
280
+ throw new ScheduledTaskEmptyAssistantResponseError();
281
+ }
282
+ await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
283
+ continue;
142
284
  }
143
285
  idlePollsWithoutResult += 1;
144
286
  if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
@@ -154,6 +296,7 @@ async function waitForScheduledTaskResult(sessionId, directory) {
154
296
  export async function executeScheduledTask(task) {
155
297
  const startedAt = new Date().toISOString();
156
298
  let sessionId = null;
299
+ let deleteTemporarySession = true;
157
300
  try {
158
301
  const { data: session, error: createError } = await opencodeClient.session.create({
159
302
  directory: task.projectWorktree,
@@ -182,7 +325,7 @@ export async function executeScheduledTask(task) {
182
325
  if (promptError) {
183
326
  throw promptError || new Error("Scheduled task prompt execution failed");
184
327
  }
185
- const resultText = await waitForScheduledTaskResult(session.id, session.directory);
328
+ const resultText = await waitForScheduledTaskResult(task.id, session.id, session.directory);
186
329
  return {
187
330
  taskId: task.id,
188
331
  status: "success",
@@ -194,6 +337,10 @@ export async function executeScheduledTask(task) {
194
337
  }
195
338
  catch (error) {
196
339
  const errorMessage = toErrorMessage(error);
340
+ if (error instanceof ScheduledTaskEmptyAssistantResponseError && sessionId) {
341
+ deleteTemporarySession = false;
342
+ logger.warn(`[ScheduledTaskExecutor] Keeping temporary session for inspection: id=${task.id}, sessionId=${sessionId}`);
343
+ }
197
344
  logger.warn(`[ScheduledTaskExecutor] Task execution failed: id=${task.id}, message=${errorMessage}`);
198
345
  return {
199
346
  taskId: task.id,
@@ -205,7 +352,7 @@ export async function executeScheduledTask(task) {
205
352
  };
206
353
  }
207
354
  finally {
208
- if (sessionId) {
355
+ if (sessionId && deleteTemporarySession) {
209
356
  try {
210
357
  await opencodeClient.session.delete({ sessionID: sessionId });
211
358
  }
@@ -4,7 +4,8 @@ import { t } from "../i18n/index.js";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
6
6
  import { sendBotText } from "../bot/utils/telegram-text.js";
7
- import { executeScheduledTask } from "./executor.js";
7
+ import { formatAssistantRunFooter } from "../bot/utils/assistant-run-footer.js";
8
+ import { executeScheduledTask, SCHEDULED_TASK_AGENT } from "./executor.js";
8
9
  import { foregroundSessionState } from "./foreground-state.js";
9
10
  import { computeNextRunAt, isTaskDue } from "./next-run.js";
10
11
  import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
@@ -40,7 +41,15 @@ function normalizeTaskPrompt(prompt) {
40
41
  }
41
42
  return `${normalized.slice(0, TASK_DESCRIPTION_PREVIEW_LENGTH)}...`;
42
43
  }
43
- function buildSuccessDelivery(task, runAt, resultText) {
44
+ function calculateElapsedMs(startedAt, finishedAt) {
45
+ const startedAtMs = Date.parse(startedAt);
46
+ const finishedAtMs = Date.parse(finishedAt);
47
+ if (Number.isNaN(startedAtMs) || Number.isNaN(finishedAtMs)) {
48
+ return 0;
49
+ }
50
+ return finishedAtMs - startedAtMs;
51
+ }
52
+ function buildSuccessDelivery(task, startedAt, runAt, resultText) {
44
53
  return {
45
54
  taskId: task.id,
46
55
  scheduleSummary: task.scheduleSummary,
@@ -51,6 +60,12 @@ function buildSuccessDelivery(task, runAt, resultText) {
51
60
  description: normalizeTaskPrompt(task.prompt),
52
61
  }),
53
62
  resultText,
63
+ footerText: formatAssistantRunFooter({
64
+ agent: SCHEDULED_TASK_AGENT,
65
+ providerID: task.model.providerID,
66
+ modelID: task.model.modelID,
67
+ elapsedMs: calculateElapsedMs(startedAt, runAt),
68
+ }),
54
69
  };
55
70
  }
56
71
  function buildErrorDelivery(task, runAt, errorMessage) {
@@ -280,7 +295,7 @@ export class ScheduledTaskRuntime {
280
295
  try {
281
296
  const result = await executeScheduledTask(runningTask);
282
297
  if (result.status === "success") {
283
- await this.handleSuccessfulExecution(runningTask, result.finishedAt, result.resultText || "");
298
+ await this.handleSuccessfulExecution(runningTask, result.startedAt, result.finishedAt, result.resultText || "");
284
299
  }
285
300
  else {
286
301
  await this.handleFailedExecution(runningTask, result.finishedAt, result.errorMessage || "Unknown error");
@@ -290,8 +305,8 @@ export class ScheduledTaskRuntime {
290
305
  this.runningTaskIds.delete(taskId);
291
306
  }
292
307
  }
293
- async handleSuccessfulExecution(task, finishedAt, resultText) {
294
- const delivery = buildSuccessDelivery(task, finishedAt, resultText);
308
+ async handleSuccessfulExecution(task, startedAt, finishedAt, resultText) {
309
+ const delivery = buildSuccessDelivery(task, startedAt, finishedAt, resultText);
295
310
  if (task.kind === "once") {
296
311
  await removeScheduledTask(task.id);
297
312
  this.removeTask(task.id);
@@ -357,12 +372,22 @@ export class ScheduledTaskRuntime {
357
372
  ? buildScheduledTaskSuccessMessageParts(delivery)
358
373
  : [delivery.notificationText];
359
374
  const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
375
+ const suppressResultNotification = delivery.status === "success" && Boolean(delivery.footerText);
360
376
  for (const part of messageParts) {
361
377
  await sendBotText({
362
378
  api: this.botApi,
363
379
  chatId: this.chatId,
364
380
  text: part,
365
381
  format,
382
+ ...(suppressResultNotification ? { options: { disable_notification: true } } : {}),
383
+ });
384
+ }
385
+ if (delivery.status === "success" && delivery.footerText) {
386
+ await sendBotText({
387
+ api: this.botApi,
388
+ chatId: this.chatId,
389
+ text: delivery.footerText,
390
+ format: "raw",
366
391
  });
367
392
  }
368
393
  return true;
@@ -136,9 +136,8 @@ function upsertDirectory(worktree, lastUpdated) {
136
136
  dedupeAndTrimDirectories(cacheData);
137
137
  return true;
138
138
  }
139
- function buildListParams() {
140
- const hasWatermark = cacheData.lastSyncedUpdatedAt > 0;
141
- if (!hasWatermark) {
139
+ function buildListParams(options) {
140
+ if (options?.force || cacheData.lastSyncedUpdatedAt === 0) {
142
141
  return { limit: INITIAL_WARMUP_LIMIT };
143
142
  }
144
143
  return {
@@ -194,24 +193,38 @@ function isServerUnavailableError(error) {
194
193
  }
195
194
  return false;
196
195
  }
197
- async function runSync() {
196
+ async function runSync(options) {
198
197
  await ensureCacheLoaded();
199
- const params = buildListParams();
198
+ const shouldPrune = options?.force || cacheData.lastSyncedUpdatedAt === 0;
199
+ const params = buildListParams(options);
200
200
  const { data: sessions, error } = await opencodeClient.session.list(params);
201
201
  if (error || !sessions) {
202
202
  throw error || new Error("No session list received from server");
203
203
  }
204
204
  let changed = false;
205
205
  let maxUpdated = cacheData.lastSyncedUpdatedAt;
206
+ const seenDirectories = new Set();
206
207
  for (const session of sessions) {
207
208
  const updatedAt = session.time?.updated ?? Date.now();
208
209
  if (upsertDirectory(session.directory, updatedAt)) {
209
210
  changed = true;
210
211
  }
212
+ if (session.directory && isValidWorktree(session.directory)) {
213
+ seenDirectories.add(worktreeKey(session.directory.trim()));
214
+ }
211
215
  if (updatedAt > maxUpdated) {
212
216
  maxUpdated = updatedAt;
213
217
  }
214
218
  }
219
+ const responseIsTruncated = sessions.length >= INITIAL_WARMUP_LIMIT;
220
+ if (shouldPrune && !responseIsTruncated) {
221
+ const before = cacheData.directories.length;
222
+ cacheData.directories = cacheData.directories.filter((d) => seenDirectories.has(worktreeKey(d.worktree)));
223
+ if (cacheData.directories.length !== before) {
224
+ changed = true;
225
+ logger.info(`[SessionCache] Pruned ${before - cacheData.directories.length} stale directories from cache`);
226
+ }
227
+ }
215
228
  if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
216
229
  cacheData.lastSyncedUpdatedAt = maxUpdated;
217
230
  changed = true;
@@ -397,7 +410,7 @@ export async function syncSessionDirectoryCache(options) {
397
410
  if (syncInFlight) {
398
411
  return syncInFlight;
399
412
  }
400
- syncInFlight = runSync()
413
+ syncInFlight = runSync(options)
401
414
  .then(() => {
402
415
  lastSyncAttemptAt = Date.now();
403
416
  })
@@ -24,6 +24,17 @@ function countDiffChangesFromText(text) {
24
24
  }
25
25
  return { additions, deletions };
26
26
  }
27
+ function normalizeSnapshotValue(value) {
28
+ if (Array.isArray(value)) {
29
+ return value.map((item) => normalizeSnapshotValue(item));
30
+ }
31
+ if (value && typeof value === "object") {
32
+ return Object.fromEntries(Object.entries(value)
33
+ .sort(([left], [right]) => left.localeCompare(right))
34
+ .map(([key, entryValue]) => [key, normalizeSnapshotValue(entryValue)]));
35
+ }
36
+ return value;
37
+ }
27
38
  class SummaryAggregator {
28
39
  currentSessionId = null;
29
40
  textMessageStates = new Map();
@@ -65,6 +76,7 @@ class SummaryAggregator {
65
76
  pendingSubagentCardIdsByParent = new Map();
66
77
  pendingChildSessionIdsByParent = new Map();
67
78
  fallbackSubagentCardIdsByParent = new Map();
79
+ lastSubagentSnapshot = "";
68
80
  setBotAndChatId(bot, chatId) {
69
81
  this.bot = bot;
70
82
  this.chatId = chatId;
@@ -239,6 +251,7 @@ class SummaryAggregator {
239
251
  this.pendingSubagentCardIdsByParent.clear();
240
252
  this.pendingChildSessionIdsByParent.clear();
241
253
  this.fallbackSubagentCardIdsByParent.clear();
254
+ this.lastSubagentSnapshot = "";
242
255
  this.messageCount = 0;
243
256
  this.lastUpdated = 0;
244
257
  if (this.onClearedCallback) {
@@ -312,6 +325,28 @@ class SummaryAggregator {
312
325
  terminalMessage: state.terminalMessage,
313
326
  updatedAt: state.updatedAt,
314
327
  }));
328
+ const snapshot = JSON.stringify(subagents.map((subagent) => ({
329
+ cardId: subagent.cardId,
330
+ sessionId: subagent.sessionId,
331
+ parentSessionId: subagent.parentSessionId,
332
+ agent: subagent.agent,
333
+ description: subagent.description,
334
+ prompt: subagent.prompt,
335
+ command: subagent.command,
336
+ status: subagent.status,
337
+ providerID: subagent.providerID,
338
+ modelID: subagent.modelID,
339
+ tokens: subagent.tokens,
340
+ cost: subagent.cost,
341
+ currentTool: subagent.currentTool,
342
+ currentToolInput: normalizeSnapshotValue(subagent.currentToolInput),
343
+ currentToolTitle: subagent.currentToolTitle,
344
+ terminalMessage: subagent.terminalMessage,
345
+ })));
346
+ if (snapshot === this.lastSubagentSnapshot) {
347
+ return;
348
+ }
349
+ this.lastSubagentSnapshot = snapshot;
315
350
  this.onSubagentCallback(this.currentSessionId, subagents);
316
351
  }
317
352
  createSubagentState(parentSessionId, sessionId, cardId = `subagent-${parentSessionId}-${Date.now()}-${this.subagentOrder.length}`) {
@@ -1,9 +1,9 @@
1
1
  import * as path from "path";
2
- import { convert } from "telegram-markdown-v2";
3
2
  import { config } from "../config.js";
4
3
  import { logger } from "../utils/logger.js";
5
4
  import { t } from "../i18n/index.js";
6
5
  import { getCurrentProject } from "../settings/manager.js";
6
+ import { convertToTelegramMarkdownV2 } from "./markdown-to-telegram-v2.js";
7
7
  import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
8
8
  const TELEGRAM_MESSAGE_LIMIT = 4096;
9
9
  const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
@@ -90,7 +90,7 @@ export function escapePlainTextForTelegramMarkdownV2(text) {
90
90
  function formatMarkdownForTelegram(text) {
91
91
  try {
92
92
  const preprocessed = normalizeMarkdownForTelegramRendering(text);
93
- return escapeMarkdownV2PipesOutsideCode(convert(preprocessed, "keep"));
93
+ return escapeMarkdownV2PipesOutsideCode(convertToTelegramMarkdownV2(preprocessed));
94
94
  }
95
95
  catch (error) {
96
96
  logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
@@ -0,0 +1,99 @@
1
+ import { parseTelegramBlocks } from "../telegram/render/block-parser.js";
2
+ import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
3
+ /**
4
+ * Escapes characters reserved in Telegram MarkdownV2.
5
+ * These must be escaped everywhere except when used as syntactic delimiters
6
+ * we intentionally emit (e.g. the surrounding * of bold).
7
+ */
8
+ function escapeMarkdownV2(text) {
9
+ return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
10
+ }
11
+ function escapeMarkdownV2Code(text) {
12
+ return text.replace(/([`\\])/g, "\\$1");
13
+ }
14
+ function escapeMarkdownV2LinkUrl(text) {
15
+ return text.replace(/([)\\])/g, "\\$1");
16
+ }
17
+ function renderInlineNodes(nodes) {
18
+ return nodes
19
+ .map((node) => {
20
+ switch (node.type) {
21
+ case "text":
22
+ return escapeMarkdownV2(node.text);
23
+ case "bold":
24
+ return `*${renderInlineNodes(node.children)}*`;
25
+ case "italic":
26
+ return `_${renderInlineNodes(node.children)}_`;
27
+ case "strike":
28
+ return `~${renderInlineNodes(node.children)}~`;
29
+ case "code":
30
+ return `\`${escapeMarkdownV2Code(node.text)}\``;
31
+ case "link":
32
+ return `[${renderInlineNodes(node.text)}](${escapeMarkdownV2LinkUrl(node.url)})`;
33
+ case "underline":
34
+ return `__${renderInlineNodes(node.children)}__`;
35
+ case "spoiler":
36
+ return `||${renderInlineNodes(node.children)}||`;
37
+ default:
38
+ return escapeMarkdownV2(String(node));
39
+ }
40
+ })
41
+ .join("");
42
+ }
43
+ function renderBlock(block) {
44
+ switch (block.type) {
45
+ case "paragraph":
46
+ return renderInlineNodes(block.inlines);
47
+ case "heading": {
48
+ const content = renderInlineNodes(block.inlines);
49
+ return `*${content}*`;
50
+ }
51
+ case "blockquote":
52
+ return block.lines
53
+ .map((line) => renderInlineNodes(line)
54
+ .split("\n")
55
+ .map((part) => `> ${part}`)
56
+ .join("\n"))
57
+ .join("\n");
58
+ case "list":
59
+ return block.items
60
+ .map((item, index) => {
61
+ const prefix = block.ordered ? `${index + 1}\\. ` : "\\- ";
62
+ const body = renderInlineNodes(item);
63
+ return `${prefix}${body}`;
64
+ })
65
+ .join("\n");
66
+ case "code":
67
+ return `\`\`\`${escapeMarkdownV2Code(block.language ?? "")}\n${escapeMarkdownV2Code(block.text)}\n\`\`\``;
68
+ case "table": {
69
+ if (block.rows.length === 0) {
70
+ return "";
71
+ }
72
+ const header = `\\| ${block.rows[0].map(escapeMarkdownV2).join(" \\| ")} \\|`;
73
+ const separator = `\\| ${block.rows[0].map(() => "\\-\\-\\-").join(" \\| ")} \\|`;
74
+ const body = block.rows
75
+ .slice(1)
76
+ .map((row) => `\\| ${row.map(escapeMarkdownV2).join(" \\| ")} \\|`);
77
+ return [header, separator, ...body].join("\n");
78
+ }
79
+ case "rule":
80
+ return "---";
81
+ case "plain":
82
+ return escapeMarkdownV2(block.text);
83
+ }
84
+ }
85
+ /**
86
+ * Converts standard Markdown into Telegram MarkdownV2.
87
+ *
88
+ * Uses the project's existing block parser (unified/remark) so that
89
+ * headings, lists, tables, code blocks, etc. are handled consistently
90
+ * with the entity-based Telegram renderer.
91
+ */
92
+ export function convertToTelegramMarkdownV2(markdown) {
93
+ const normalized = normalizeMarkdownForTelegramRendering(markdown);
94
+ const blocks = parseTelegramBlocks(normalized);
95
+ if (blocks.length === 0) {
96
+ return "";
97
+ }
98
+ return blocks.map(renderBlock).join("\n\n");
99
+ }
@@ -1,10 +1,31 @@
1
1
  import { formatModelDisplayName } from "../pinned/format.js";
2
2
  import { t } from "../i18n/index.js";
3
3
  import { formatCompactToolInfo } from "./formatter.js";
4
+ function shouldPreferInputDetails(tool, input) {
5
+ if (!input) {
6
+ return false;
7
+ }
8
+ switch (tool) {
9
+ case "read":
10
+ case "edit":
11
+ case "write":
12
+ case "apply_patch":
13
+ return typeof input.path === "string" || typeof input.filePath === "string";
14
+ case "bash":
15
+ return typeof input.command === "string";
16
+ case "grep":
17
+ case "glob":
18
+ return typeof input.pattern === "string";
19
+ }
20
+ return ["query", "url", "name", "prompt", "text"].some((field) => typeof input[field] === "string");
21
+ }
4
22
  function formatToolStep(subagent) {
5
23
  if (!subagent.currentTool) {
6
24
  return "";
7
25
  }
26
+ const toolTitle = shouldPreferInputDetails(subagent.currentTool, subagent.currentToolInput)
27
+ ? undefined
28
+ : subagent.currentToolTitle;
8
29
  const toolInfo = {
9
30
  sessionId: subagent.sessionId ?? subagent.parentSessionId,
10
31
  messageId: subagent.cardId,
@@ -13,12 +34,12 @@ function formatToolStep(subagent) {
13
34
  state: {
14
35
  status: "running",
15
36
  input: subagent.currentToolInput ?? {},
16
- title: subagent.currentToolTitle,
37
+ title: toolTitle,
17
38
  metadata: {},
18
39
  time: { start: subagent.updatedAt },
19
40
  },
20
41
  input: subagent.currentToolInput,
21
- title: subagent.currentToolTitle,
42
+ title: toolTitle,
22
43
  metadata: {},
23
44
  hasFileAttachment: false,
24
45
  };
@@ -32,6 +32,18 @@ function pushEntity(state, entity) {
32
32
  }
33
33
  state.entities.push(entity);
34
34
  }
35
+ function isTelegramTextLinkUrl(url) {
36
+ try {
37
+ const parsed = new URL(url);
38
+ return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ function isLocalReferenceUrl(url) {
45
+ return url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../");
46
+ }
35
47
  function renderIntoState(state, nodes) {
36
48
  for (const node of nodes) {
37
49
  switch (node.type) {
@@ -81,6 +93,12 @@ function renderIntoState(state, nodes) {
81
93
  case "link": {
82
94
  const offset = state.text.length;
83
95
  renderIntoState(state, node.text);
96
+ if (!isTelegramTextLinkUrl(node.url)) {
97
+ if (isLocalReferenceUrl(node.url)) {
98
+ appendText(state, ` (${node.url})`);
99
+ break;
100
+ }
101
+ }
84
102
  pushEntity(state, {
85
103
  type: "text_link",
86
104
  offset,