Files
openclaw/extensions/telegram/src/progress-summary.ts
Peter Lindsey 653e0aeb8c feat(telegram): collapse summary bar + multi-line lane rendering
Port the dev-beta2 progress-summary work onto the compositor-era dispatch:

- Post a one-line activity digest (🧠 N thoughts · 💬 N notes · 🛠️ N tool
  calls · ⏱️ Ns, Discord parity) as a durable message when the progress
  window collapses at end-of-turn, before the final answer. Counted
  per-burst channel-side; only window-streamed activity feeds the bar
  (durable rv/verbose items never do — persistent message XOR bar count),
  no bar for error finals or turns where the window never rendered.
- Render multi-line commentary with its line structure intact (per-line
  markdown conversion, joined as line breaks) instead of collapsing to
  one run-on line; per-line conversion also keeps block markdown (setext
  headings) from forming across lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:33:24 +08:00

169 lines
6.7 KiB
TypeScript

// Post-turn collapse summary for the Telegram progress window.
//
// Mirrors Discord's collapse-summary line (extensions/discord/src/monitor/
// message-handler.process.ts `buildProgressSummaryLine`): when the ephemeral
// progress draft collapses at end-of-turn, Discord posts a one-line activity
// digest like `🧠 3 thoughts · 💬 2 notes · 🛠️ 4 tool calls · ⏱️ 21s`.
//
// Telegram had no equivalent (conformance discrepancy #4). This tracks the same
// turn-activity counters channel-side (Discord also tallies these in its handler,
// not in core) and formats the same content. The only divergence is the line
// prefix: Discord wraps the line in its `-#` small-text syntax, which Telegram
// markdown has no analog for, so the Telegram line is emitted plain.
export type TelegramProgressSummaryCounters = {
reasoningSteps: number;
commentaryNotes: number;
toolCalls: number;
};
// Tracks turn activity for the collapse summary. The summary reflects ONLY what
// actually streamed to the progress window — never durable-delivered items
// (per the user's spec: "only summarize messages that ACTUALLY streamed").
// So there is deliberately no durable-reasoning counter: in rv (/reasoning on)
// thoughts persist as standalone messages and must NOT feed the bar, or the bar
// would show even though nothing streamed to the window. A reasoning "burst" is
// counted once at whichever boundary arrives first — the reasoning-end event,
// the next tool call, or the summary flush — because some models (e.g. deepseek)
// do not emit a reliable thinking_end per burst, so counting on the end event
// alone undercounts.
export type TelegramProgressSummaryTracker = {
/** A reasoning delta arrived; opens (or keeps open) the current burst. */
noteReasoningActivity(): void;
/** Reasoning-end fired; close and count the current burst if one is open. */
closeReasoningBurst(): void;
/**
* A window-rendered tool call started: it is the boundary for any open
* reasoning/commentary burst, so close+count those first, then count one tool.
* Callers count the tool only when it is suppressed (window-rendered); under
* verbose the tool persists durably and they close the bursts directly instead
* (closeReasoningBurst/closeCommentaryBurst) without calling this.
*/
noteToolCall(): void;
/**
* A commentary/preamble note arrived for the window. Opens (or keeps open) a
* commentary burst — it is NOT counted here. The burst is counted once when it
* closes at the next boundary (tool start, reasoning-end, a different note, or
* the summary flush). Counting per-burst rather than per-id is deliberate: the
* anthropic core tags every note in a turn with the SAME turn-local id
* ("commentary-0"), so an id-Set dedup collapsed a multi-tool turn's notes to
* one (D3). A tool follows each note in a tool-using turn, closing its burst
* before the next note opens => N notes.
*/
noteCommentary(itemId?: string, text?: string): void;
/** Close and count the current commentary burst if one is open. */
closeCommentaryBurst(): void;
/** Snapshot of the current counters (closes any open bursts into the tally). */
counts(): TelegramProgressSummaryCounters;
/** True when there is at least one thought, note, or tool call to summarize. */
hasActivity(): boolean;
};
export function createTelegramProgressSummaryTracker(): TelegramProgressSummaryTracker {
let reasoningSteps = 0;
let commentaryNotes = 0;
let toolCalls = 0;
let reasoningBurstOpen = false;
// One open commentary burst at a time (mirrors Discord's windowCommentaryOpen /
// closePendingWindowCommentary). A re-fire of the SAME note (same id, or prefix
// growth of the same id-less streamed text) keeps the burst open; a different
// note with no intervening boundary closes the previous burst before opening
// the new one.
let commentaryBurstOpen = false;
let openCommentaryItemId: string | undefined;
let openCommentaryText = "";
const closeReasoningBurst = () => {
if (reasoningBurstOpen) {
reasoningBurstOpen = false;
reasoningSteps += 1;
}
};
const closeCommentaryBurst = () => {
if (commentaryBurstOpen) {
commentaryBurstOpen = false;
openCommentaryItemId = undefined;
openCommentaryText = "";
commentaryNotes += 1;
}
};
return {
noteReasoningActivity() {
reasoningBurstOpen = true;
},
closeReasoningBurst,
noteToolCall() {
closeReasoningBurst();
closeCommentaryBurst();
toolCalls += 1;
},
noteCommentary(itemId?: string, text?: string) {
const trimmed = text?.trim();
if (!trimmed) {
return;
}
const id = itemId?.trim() || undefined;
if (commentaryBurstOpen) {
const sameNote = openCommentaryItemId
? id === openCommentaryItemId
: !id &&
(trimmed === openCommentaryText ||
trimmed.startsWith(openCommentaryText) ||
openCommentaryText.startsWith(trimmed));
if (sameNote) {
openCommentaryText = trimmed;
return;
}
// A different note arrived with no intervening boundary: close the
// previous burst before opening the new one.
closeCommentaryBurst();
}
commentaryBurstOpen = true;
openCommentaryItemId = id;
openCommentaryText = trimmed;
},
closeCommentaryBurst,
counts() {
closeReasoningBurst();
closeCommentaryBurst();
return { reasoningSteps, commentaryNotes, toolCalls };
},
hasActivity() {
return (
reasoningBurstOpen ||
commentaryBurstOpen ||
reasoningSteps > 0 ||
commentaryNotes > 0 ||
toolCalls > 0
);
},
};
}
// Formats the collapse-summary line. Returns undefined when there is nothing to
// summarize (no thoughts, notes, or tool calls) so a degenerate "⏱️ Ns"-only
// line is never emitted. Content and ordering mirror Discord exactly.
export function formatTelegramProgressSummaryLine(
counters: TelegramProgressSummaryCounters,
elapsedMs: number,
): string | undefined {
const { reasoningSteps, commentaryNotes, toolCalls } = counters;
if (reasoningSteps <= 0 && commentaryNotes <= 0 && toolCalls <= 0) {
return undefined;
}
const seconds = Math.max(1, Math.round(elapsedMs / 1000));
const parts = [
...(reasoningSteps > 0
? [`🧠 ${reasoningSteps} thought${reasoningSteps === 1 ? "" : "s"}`]
: []),
...(commentaryNotes > 0
? [`💬 ${commentaryNotes} note${commentaryNotes === 1 ? "" : "s"}`]
: []),
...(toolCalls > 0 ? [`🛠️ ${toolCalls} tool call${toolCalls === 1 ? "" : "s"}`] : []),
`⏱️ ${seconds}s`,
];
return parts.join(" · ");
}