@fonz/tgcc 0.6.17 β†’ 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/streaming.js CHANGED
@@ -10,6 +10,12 @@ export function escapeHtml(text) {
10
10
  .replace(/</g, '&lt;')
11
11
  .replace(/>/g, '&gt;');
12
12
  }
13
+ /** Create visually distinct system message with enhanced styling */
14
+ export function formatSystemMessage(type, content, expandable = false) {
15
+ const emoji = { thinking: 'πŸ’­', tool: '⚑', usage: 'πŸ“Š', error: '⚠️', status: 'ℹ️' }[type];
16
+ const wrapper = expandable ? 'blockquote expandable' : 'blockquote';
17
+ return `<${wrapper}>${emoji} ${content}</${wrapper.split(' ')[0]}>`;
18
+ }
13
19
  /**
14
20
  * Convert markdown text to Telegram-safe HTML using the marked library.
15
21
  * Replaces the old hand-rolled implementation with a proper markdown parser.
@@ -45,21 +51,23 @@ export class StreamAccumulator {
45
51
  lastEditTime = 0;
46
52
  editTimer = null;
47
53
  thinkingIndicatorShown = false;
54
+ thinkingMessageId = null; // preserved separately from tgMessageId
48
55
  messageIds = []; // all message IDs sent during this turn
49
56
  finished = false;
50
57
  sendQueue = Promise.resolve();
51
58
  turnUsage = null;
52
59
  /** Usage from the most recent message_start event β€” represents a single API call's context (not cumulative). */
53
60
  _lastMsgStartCtx = null;
54
- // Per-tool-use independent indicator messages (persists across resets)
61
+ // Per-tool-use consolidated indicator message (persists across resets)
55
62
  toolMessages = new Map();
56
63
  toolInputBuffers = new Map(); // tool block ID β†’ accumulated input JSON
57
64
  currentToolBlockId = null;
65
+ consolidatedToolMsgId = null; // shared TG message for all tool indicators
58
66
  constructor(options) {
59
67
  this.chatId = options.chatId;
60
68
  this.sender = options.sender;
61
69
  this.editIntervalMs = options.editIntervalMs ?? 1000;
62
- this.splitThreshold = options.splitThreshold ?? 4000;
70
+ this.splitThreshold = options.splitThreshold ?? 3500;
63
71
  this.logger = options.logger;
64
72
  this.onError = options.onError;
65
73
  }
@@ -122,23 +130,44 @@ export class StreamAccumulator {
122
130
  if (blockType === 'thinking') {
123
131
  this.currentBlockType = 'thinking';
124
132
  if (!this.thinkingIndicatorShown && !this.buffer) {
125
- await this.sendOrEdit('<blockquote>πŸ’­ Thinking...</blockquote>', true);
133
+ await this.sendOrEdit(formatSystemMessage('thinking', 'Processing...'), true);
126
134
  this.thinkingIndicatorShown = true;
127
135
  }
128
136
  }
129
137
  else if (blockType === 'text') {
130
138
  this.currentBlockType = 'text';
131
- // Text always gets its own message β€” don't touch tool indicator messages
132
- if (!this.tgMessageId) {
133
- // Will create a new message on next sendOrEdit
139
+ // If thinking indicator was shown, update it with actual thinking content NOW
140
+ // (before text starts streaming below) β€” don't wait for finalize()
141
+ if (this.thinkingIndicatorShown && this.tgMessageId) {
142
+ this.thinkingMessageId = this.tgMessageId;
143
+ this.tgMessageId = null; // force new message for response text
144
+ // Flush "Processing..." β†’ actual thinking content immediately
145
+ if (this.thinkingBuffer && this.thinkingMessageId) {
146
+ const thinkingPreview = this.thinkingBuffer.length > 1024
147
+ ? this.thinkingBuffer.slice(0, 1024) + '…'
148
+ : this.thinkingBuffer;
149
+ const html = formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true);
150
+ this.sendQueue = this.sendQueue.then(async () => {
151
+ try {
152
+ await this.sender.editMessage(this.chatId, this.thinkingMessageId, html, 'HTML');
153
+ }
154
+ catch {
155
+ // Non-critical β€” thinking message update
156
+ }
157
+ }).catch(err => {
158
+ this.logger?.error?.({ err }, 'Failed to flush thinking indicator');
159
+ });
160
+ }
134
161
  }
135
162
  }
136
163
  else if (blockType === 'tool_use') {
137
164
  this.currentBlockType = 'tool_use';
138
165
  const block = event.content_block;
139
166
  this.currentToolBlockId = block.id;
140
- // Send an independent indicator message for this tool_use block
141
- await this.sendToolIndicator(block.id, block.name);
167
+ // Sub-agent tools are handled by SubAgentTracker β€” skip duplicate indicator
168
+ if (!isSubAgentTool(block.name)) {
169
+ await this.sendToolIndicator(block.id, block.name);
170
+ }
142
171
  }
143
172
  else if (blockType === 'image') {
144
173
  this.currentBlockType = 'image';
@@ -238,23 +267,74 @@ export class StreamAccumulator {
238
267
  }
239
268
  this.imageBase64Buffer = '';
240
269
  }
241
- /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
270
+ /** Send or update the consolidated tool indicator message. */
242
271
  async sendToolIndicator(blockId, toolName) {
243
272
  const startTime = Date.now();
273
+ if (this.consolidatedToolMsgId) {
274
+ // Reuse existing consolidated message
275
+ this.toolMessages.set(blockId, { msgId: this.consolidatedToolMsgId, toolName, startTime, resolved: false, isError: false, elapsed: null });
276
+ this.updateConsolidatedToolMessage();
277
+ }
278
+ else {
279
+ // Create the first consolidated tool message
280
+ this.sendQueue = this.sendQueue.then(async () => {
281
+ try {
282
+ const html = this.buildConsolidatedToolHtml(blockId, toolName);
283
+ const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
284
+ this.consolidatedToolMsgId = msgId;
285
+ this.toolMessages.set(blockId, { msgId, toolName, startTime, resolved: false, isError: false, elapsed: null });
286
+ }
287
+ catch (err) {
288
+ this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
289
+ }
290
+ }).catch(err => {
291
+ this.logger?.error?.({ err }, 'sendToolIndicator queue error');
292
+ });
293
+ return this.sendQueue;
294
+ }
295
+ }
296
+ /** Build HTML for the consolidated tool indicator message. */
297
+ buildConsolidatedToolHtml(pendingBlockId, pendingToolName) {
298
+ const lines = [];
299
+ for (const [blockId, entry] of this.toolMessages) {
300
+ const summary = this.toolIndicatorLastSummary.get(blockId);
301
+ const codePart = summary ? ` Β· <code>${escapeHtml(summary)}</code>` : '';
302
+ if (entry.resolved) {
303
+ const statLine = this.toolResolvedStats.get(blockId);
304
+ const statPart = statLine ? ` Β· ${escapeHtml(statLine)}` : '';
305
+ const icon = entry.isError ? '❌' : 'βœ…';
306
+ lines.push(`${icon} ${escapeHtml(entry.toolName)} (${entry.elapsed})${codePart}${statPart}`);
307
+ }
308
+ else {
309
+ lines.push(`⚑ ${escapeHtml(entry.toolName)}…${codePart}`);
310
+ }
311
+ }
312
+ // Include pending tool that hasn't been added to toolMessages yet
313
+ if (pendingBlockId && pendingToolName && !this.toolMessages.has(pendingBlockId)) {
314
+ lines.push(`⚑ ${escapeHtml(pendingToolName)}…`);
315
+ }
316
+ return `<blockquote expandable>${lines.join('\n')}</blockquote>`;
317
+ }
318
+ /** Resolved tool result stats, keyed by blockId */
319
+ toolResolvedStats = new Map();
320
+ /** Edit the consolidated tool message with current state of all tools. */
321
+ updateConsolidatedToolMessage() {
322
+ if (!this.consolidatedToolMsgId)
323
+ return;
324
+ const msgId = this.consolidatedToolMsgId;
325
+ const html = this.buildConsolidatedToolHtml();
244
326
  this.sendQueue = this.sendQueue.then(async () => {
245
327
  try {
246
- const html = `<blockquote expandable>⚑ ${escapeHtml(toolName)}…</blockquote>`;
247
- const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
248
- this.toolMessages.set(blockId, { msgId, toolName, startTime });
328
+ await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
249
329
  }
250
330
  catch (err) {
251
- // Tool indicator is non-critical β€” log and continue
252
- this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
331
+ if (err instanceof Error && err.message.includes('message is not modified'))
332
+ return;
333
+ this.logger?.debug?.({ err }, 'Failed to update consolidated tool message');
253
334
  }
254
335
  }).catch(err => {
255
- this.logger?.error?.({ err }, 'sendToolIndicator queue error');
336
+ this.logger?.error?.({ err }, 'updateConsolidatedToolMessage queue error');
256
337
  });
257
- return this.sendQueue;
258
338
  }
259
339
  /** Update a tool indicator message with input preview once the JSON value is complete. */
260
340
  toolIndicatorLastSummary = new Map(); // blockId β†’ last rendered summary
@@ -272,105 +352,118 @@ export class StreamAccumulator {
272
352
  if (this.toolIndicatorLastSummary.get(blockId) === summary)
273
353
  return;
274
354
  this.toolIndicatorLastSummary.set(blockId, summary);
275
- const codeLine = `\n<code>${escapeHtml(summary)}</code>`;
276
- const html = `<blockquote expandable>⚑ ${escapeHtml(entry.toolName)}…${codeLine}</blockquote>`;
277
- this.sendQueue = this.sendQueue.then(async () => {
278
- try {
279
- await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
280
- }
281
- catch (err) {
282
- // "message is not modified" or other edit failure β€” non-critical
283
- this.logger?.debug?.({ err }, 'Failed to update tool indicator with input');
284
- }
285
- }).catch(err => {
286
- this.logger?.error?.({ err }, 'updateToolIndicatorWithInput queue error');
287
- });
288
- return this.sendQueue;
355
+ // Update the consolidated tool message
356
+ this.updateConsolidatedToolMessage();
289
357
  }
290
- /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
358
+ /** MCP media tools β€” on success, delete indicator (the media was sent directly). On failure, keep + react ❌. */
359
+ static MCP_MEDIA_TOOLS = new Set(['mcp__tgcc__send_image', 'mcp__tgcc__send_file', 'mcp__tgcc__send_voice']);
360
+ /** Resolve a tool indicator with success/failure status. Updates the consolidated message. */
291
361
  async resolveToolMessage(blockId, isError, errorMessage, resultContent, toolUseResult) {
292
362
  const entry = this.toolMessages.get(blockId);
293
363
  if (!entry)
294
364
  return;
295
- const { msgId, toolName, startTime } = entry;
365
+ const { toolName, startTime } = entry;
296
366
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
367
+ // MCP media tools: remove from consolidated on success (the media itself is the result)
368
+ if (StreamAccumulator.MCP_MEDIA_TOOLS.has(toolName) && !isError) {
369
+ this.toolInputBuffers.delete(blockId);
370
+ this.toolIndicatorLastSummary.delete(blockId);
371
+ this.toolResolvedStats.delete(blockId);
372
+ this.toolMessages.delete(blockId);
373
+ // If that was the only tool, delete the consolidated message
374
+ if (this.toolMessages.size === 0 && this.consolidatedToolMsgId) {
375
+ const msgId = this.consolidatedToolMsgId;
376
+ this.consolidatedToolMsgId = null;
377
+ this.sendQueue = this.sendQueue.then(async () => {
378
+ try {
379
+ if (this.sender.deleteMessage)
380
+ await this.sender.deleteMessage(this.chatId, msgId);
381
+ }
382
+ catch (err) {
383
+ this.logger?.debug?.({ err, toolName }, 'Failed to delete consolidated tool message');
384
+ }
385
+ }).catch(err => {
386
+ this.logger?.error?.({ err }, 'resolveToolMessage queue error');
387
+ });
388
+ }
389
+ else {
390
+ this.updateConsolidatedToolMessage();
391
+ }
392
+ return;
393
+ }
394
+ // Compute final input summary if we don't have one yet
297
395
  const inputJson = this.toolInputBuffers.get(blockId) ?? '';
298
- const summary = extractToolInputSummary(toolName, inputJson);
396
+ if (!this.toolIndicatorLastSummary.has(blockId)) {
397
+ const summary = extractToolInputSummary(toolName, inputJson);
398
+ if (summary)
399
+ this.toolIndicatorLastSummary.set(blockId, summary);
400
+ }
401
+ // Store result stat for display
299
402
  const resultStat = extractToolResultStat(toolName, resultContent, toolUseResult);
300
- const codeLine = summary ? `\n<code>${escapeHtml(summary)}</code>` : '';
301
- const statLine = resultStat ? `\n${escapeHtml(resultStat)}` : '';
302
- const icon = isError ? '❌' : 'βœ…';
303
- const html = `<blockquote expandable>${icon} ${escapeHtml(toolName)} (${elapsed}s)${codeLine}${statLine}</blockquote>`;
403
+ if (resultStat)
404
+ this.toolResolvedStats.set(blockId, resultStat);
405
+ // Mark as resolved
406
+ entry.resolved = true;
407
+ entry.isError = isError;
408
+ entry.elapsed = elapsed + 's';
304
409
  // Clean up input buffer
305
410
  this.toolInputBuffers.delete(blockId);
306
- this.toolIndicatorLastSummary.delete(blockId);
307
- this.sendQueue = this.sendQueue.then(async () => {
308
- try {
309
- await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
310
- }
311
- catch (err) {
312
- // Edit failure on resolve β€” non-critical
313
- this.logger?.debug?.({ err, toolName }, 'Failed to resolve tool indicator');
314
- }
315
- }).catch(err => {
316
- this.logger?.error?.({ err }, 'resolveToolMessage queue error');
317
- });
318
- return this.sendQueue;
411
+ // Update the consolidated message
412
+ this.updateConsolidatedToolMessage();
319
413
  }
320
- /** Edit a specific tool indicator message by block ID. */
321
- async editToolMessage(blockId, html) {
322
- const entry = this.toolMessages.get(blockId);
323
- if (!entry)
414
+ /** Edit a specific tool indicator message by block ID (updates the consolidated message). */
415
+ async editToolMessage(blockId, _html) {
416
+ if (!this.toolMessages.has(blockId))
324
417
  return;
325
- this.sendQueue = this.sendQueue.then(async () => {
326
- try {
327
- await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
328
- }
329
- catch (err) {
330
- // Edit failure β€” non-critical
331
- this.logger?.debug?.({ err }, 'Failed to edit tool message');
332
- }
333
- }).catch(err => {
334
- this.logger?.error?.({ err }, 'editToolMessage queue error');
335
- });
336
- return this.sendQueue;
418
+ // With consolidation, just rebuild the consolidated message
419
+ this.updateConsolidatedToolMessage();
337
420
  }
338
- /** Delete a specific tool indicator message by block ID. */
421
+ /** Delete a specific tool from the consolidated indicator. */
339
422
  async deleteToolMessage(blockId) {
340
- const entry = this.toolMessages.get(blockId);
341
- if (!entry)
423
+ if (!this.toolMessages.has(blockId))
342
424
  return;
343
425
  this.toolMessages.delete(blockId);
344
- this.sendQueue = this.sendQueue.then(async () => {
345
- try {
346
- if (this.sender.deleteMessage) {
347
- await this.sender.deleteMessage(this.chatId, entry.msgId);
426
+ this.toolInputBuffers.delete(blockId);
427
+ this.toolIndicatorLastSummary.delete(blockId);
428
+ this.toolResolvedStats.delete(blockId);
429
+ if (this.toolMessages.size === 0 && this.consolidatedToolMsgId) {
430
+ // Last tool removed β€” delete the consolidated message
431
+ const msgId = this.consolidatedToolMsgId;
432
+ this.consolidatedToolMsgId = null;
433
+ this.sendQueue = this.sendQueue.then(async () => {
434
+ try {
435
+ if (this.sender.deleteMessage)
436
+ await this.sender.deleteMessage(this.chatId, msgId);
348
437
  }
349
- }
350
- catch (err) {
351
- // Delete failure β€” non-critical
352
- this.logger?.debug?.({ err }, 'Failed to delete tool message');
353
- }
354
- }).catch(err => {
355
- this.logger?.error?.({ err }, 'deleteToolMessage queue error');
356
- });
357
- return this.sendQueue;
438
+ catch (err) {
439
+ this.logger?.debug?.({ err }, 'Failed to delete consolidated tool message');
440
+ }
441
+ }).catch(err => {
442
+ this.logger?.error?.({ err }, 'deleteToolMessage queue error');
443
+ });
444
+ }
445
+ else {
446
+ this.updateConsolidatedToolMessage();
447
+ }
358
448
  }
359
- /** Delete all tool indicator messages. */
449
+ /** Delete the consolidated tool indicator message. */
360
450
  async deleteAllToolMessages() {
361
- const ids = [...this.toolMessages.values()];
362
451
  this.toolMessages.clear();
363
- if (!this.sender.deleteMessage)
452
+ this.toolInputBuffers.clear();
453
+ this.toolIndicatorLastSummary.clear();
454
+ this.toolResolvedStats.clear();
455
+ if (!this.consolidatedToolMsgId || !this.sender.deleteMessage) {
456
+ this.consolidatedToolMsgId = null;
364
457
  return;
458
+ }
459
+ const msgId = this.consolidatedToolMsgId;
460
+ this.consolidatedToolMsgId = null;
365
461
  this.sendQueue = this.sendQueue.then(async () => {
366
- for (const { msgId } of ids) {
367
- try {
368
- await this.sender.deleteMessage(this.chatId, msgId);
369
- }
370
- catch (err) {
371
- // Delete failure β€” non-critical
372
- this.logger?.debug?.({ err }, 'Failed to delete tool message in batch');
373
- }
462
+ try {
463
+ await this.sender.deleteMessage(this.chatId, msgId);
464
+ }
465
+ catch (err) {
466
+ this.logger?.debug?.({ err }, 'Failed to delete consolidated tool message');
374
467
  }
375
468
  }).catch(err => {
376
469
  this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
@@ -401,16 +494,19 @@ export class StreamAccumulator {
401
494
  */
402
495
  buildFullText(includeSuffix = false) {
403
496
  let text = '';
404
- if (this.thinkingBuffer) {
497
+ // Thinking goes in its own message (thinkingMessageId), not prepended to response
498
+ if (this.thinkingBuffer && !this.thinkingMessageId) {
499
+ // Only prepend if thinking didn't get its own message (edge case: no indicator was shown)
405
500
  const thinkingPreview = this.thinkingBuffer.length > 1024
406
501
  ? this.thinkingBuffer.slice(0, 1024) + '…'
407
502
  : this.thinkingBuffer;
408
- text += `<blockquote expandable>πŸ’­ Thinking\n${markdownToTelegramHtml(thinkingPreview)}</blockquote>\n`;
503
+ text += formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true) + '\n';
409
504
  }
410
505
  // Convert markdown buffer to HTML-safe text
411
506
  text += makeHtmlSafe(this.buffer);
412
507
  if (includeSuffix && this.turnUsage) {
413
- text += '\n' + formatUsageFooter(this.turnUsage, this.turnUsage.model);
508
+ const usageContent = formatUsageFooter(this.turnUsage, this.turnUsage.model).replace(/<\/?i>/g, '');
509
+ text += '\n' + formatSystemMessage('usage', usageContent);
414
510
  }
415
511
  return { text, hasHtmlSuffix: includeSuffix && !!this.turnUsage };
416
512
  }
@@ -467,17 +563,29 @@ export class StreamAccumulator {
467
563
  clearTimeout(this.editTimer);
468
564
  this.editTimer = null;
469
565
  }
566
+ // Update thinking message with final content (if it has its own message)
567
+ if (this.thinkingBuffer && this.thinkingMessageId) {
568
+ const thinkingPreview = this.thinkingBuffer.length > 1024
569
+ ? this.thinkingBuffer.slice(0, 1024) + '…'
570
+ : this.thinkingBuffer;
571
+ try {
572
+ await this.sender.editMessage(this.chatId, this.thinkingMessageId, formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true), 'HTML');
573
+ }
574
+ catch {
575
+ // Thinking message edit failed β€” not critical
576
+ }
577
+ }
470
578
  if (this.buffer) {
471
- // Final edit with complete text including thinking blockquote and usage footer
579
+ // Final edit with complete text (thinking is in its own message)
472
580
  const { text } = this.buildFullText(true);
473
581
  await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
474
582
  }
475
- else if (this.thinkingBuffer && this.thinkingIndicatorShown) {
476
- // Only thinking happened, no text β€” show thinking as expandable blockquote
583
+ else if (this.thinkingBuffer && !this.thinkingMessageId && this.thinkingIndicatorShown) {
584
+ // Only thinking happened, no text, no separate message β€” show as expandable blockquote
477
585
  const thinkingPreview = this.thinkingBuffer.length > 1024
478
586
  ? this.thinkingBuffer.slice(0, 1024) + '…'
479
587
  : this.thinkingBuffer;
480
- await this.sendOrEdit(`<blockquote expandable>πŸ’­ Thinking\n${markdownToTelegramHtml(thinkingPreview)}</blockquote>`, true);
588
+ await this.sendOrEdit(formatSystemMessage('thinking', markdownToTelegramHtml(thinkingPreview), true), true);
481
589
  }
482
590
  }
483
591
  clearEditTimer() {
@@ -496,6 +604,7 @@ export class StreamAccumulator {
496
604
  this.currentToolBlockId = null;
497
605
  this.lastEditTime = 0;
498
606
  this.thinkingIndicatorShown = false;
607
+ this.thinkingMessageId = null;
499
608
  this.finished = false;
500
609
  this.turnUsage = null;
501
610
  this._lastMsgStartCtx = null;
@@ -503,12 +612,18 @@ export class StreamAccumulator {
503
612
  }
504
613
  /** Full reset: also clears tgMessageId (next send creates a new message).
505
614
  * Chains on the existing sendQueue so any pending finalize() edits complete first.
506
- * toolMessages persists β€” they are independent fire-and-forget messages. */
615
+ * Consolidated tool message resets so next turn starts a fresh batch. */
507
616
  reset() {
508
617
  const prevQueue = this.sendQueue;
509
618
  this.softReset();
510
619
  this.tgMessageId = null;
511
620
  this.messageIds = [];
621
+ // Reset tool consolidation for next turn
622
+ this.consolidatedToolMsgId = null;
623
+ this.toolMessages.clear();
624
+ this.toolInputBuffers.clear();
625
+ this.toolIndicatorLastSummary.clear();
626
+ this.toolResolvedStats.clear();
512
627
  this.sendQueue = prevQueue.catch(() => { }); // swallow errors from prev turn
513
628
  }
514
629
  }
@@ -727,7 +842,7 @@ export function formatUsageFooter(usage, _model) {
727
842
  const ctxPct = Math.round(totalCtx / CONTEXT_WINDOW * 100);
728
843
  const overLimit = ctxPct > 90;
729
844
  const parts = [
730
- `↩️ ${formatTokens(usage.inputTokens)} in`,
845
+ `${formatTokens(usage.inputTokens)} in`,
731
846
  `${formatTokens(usage.outputTokens)} out`,
732
847
  ];
733
848
  if (usage.costUsd != null) {
@@ -878,10 +993,12 @@ export class SubAgentTracker {
878
993
  continue;
879
994
  info.status = 'completed';
880
995
  const label = info.label || info.toolName;
881
- const text = `βœ… ${escapeHtml(label)} β€” see main message`;
996
+ const text = `<blockquote>πŸ€– ${escapeHtml(label)} β€” see main message</blockquote>`;
997
+ const agentMsgId = info.tgMessageId;
882
998
  this.sendQueue = this.sendQueue.then(async () => {
883
999
  try {
884
- await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
1000
+ await this.sender.editMessage(this.chatId, agentMsgId, text, 'HTML');
1001
+ await this.sender.setReaction?.(this.chatId, agentMsgId, 'πŸ‘');
885
1002
  }
886
1003
  catch (err) {
887
1004
  // Non-critical β€” edit failure on dispatched agent status
@@ -956,14 +1073,17 @@ export class SubAgentTracker {
956
1073
  return;
957
1074
  info.status = 'completed';
958
1075
  const label = info.label || info.toolName;
959
- // Truncate result for blockquote (TG message limit ~4096 chars)
960
1076
  const maxResultLen = 3500;
961
1077
  const resultText = result.length > maxResultLen ? result.slice(0, maxResultLen) + '…' : result;
962
- // Use expandable blockquote β€” collapsed shows "βœ… label" + first line, tap to expand
963
- const text = `<blockquote expandable>βœ… ${escapeHtml(label)}\n${escapeHtml(resultText)}</blockquote>`;
1078
+ // Blockquote header + expandable result (not nested β€” TG doesn't support nested blockquotes)
1079
+ const text = `<blockquote>πŸ€– ${escapeHtml(label)}</blockquote>\n<blockquote expandable>${escapeHtml(resultText)}</blockquote>`;
1080
+ const msgId = info.tgMessageId;
964
1081
  this.sendQueue = this.sendQueue.then(async () => {
965
1082
  try {
966
- await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
1083
+ await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
1084
+ if (this.sender.setReaction) {
1085
+ await this.sender.setReaction(this.chatId, msgId, 'πŸ‘');
1086
+ }
967
1087
  }
968
1088
  catch {
969
1089
  // Edit failure on tool result β€” non-critical
@@ -1000,7 +1120,7 @@ export class SubAgentTracker {
1000
1120
  else {
1001
1121
  this.sendQueue = this.sendQueue.then(async () => {
1002
1122
  try {
1003
- const msgId = await this.sender.sendMessage(this.chatId, 'πŸ€– Starting sub-agent…', 'HTML');
1123
+ const msgId = await this.sender.sendMessage(this.chatId, '<blockquote>πŸ€– Starting sub-agent…</blockquote>', 'HTML');
1004
1124
  info.tgMessageId = msgId;
1005
1125
  this.consolidatedAgentMsgId = msgId;
1006
1126
  }
@@ -1024,7 +1144,7 @@ export class SubAgentTracker {
1024
1144
  : 'Working…';
1025
1145
  lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
1026
1146
  }
1027
- const text = lines.join('\n');
1147
+ const text = `<blockquote>${lines.join('\n')}</blockquote>`;
1028
1148
  this.sendQueue = this.sendQueue.then(async () => {
1029
1149
  try {
1030
1150
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
@@ -1264,7 +1384,7 @@ export class SubAgentTracker {
1264
1384
  lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
1265
1385
  }
1266
1386
  }
1267
- const text = lines.join('\n');
1387
+ const text = `<blockquote>${lines.join('\n')}</blockquote>`;
1268
1388
  this.sendQueue = this.sendQueue.then(async () => {
1269
1389
  try {
1270
1390
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
@@ -1295,7 +1415,7 @@ export class SubAgentTracker {
1295
1415
  }
1296
1416
  }
1297
1417
  // ── Utility: split a completed text into TG-sized chunks ──
1298
- export function splitText(text, maxLength = 4000) {
1418
+ export function splitText(text, maxLength = 3500) {
1299
1419
  if (text.length <= maxLength)
1300
1420
  return [text];
1301
1421
  const chunks = [];