From 478a525bd06225e79f71dc18916768184004416b Mon Sep 17 00:00:00 2001 From: Flood Sung Date: Thu, 14 May 2026 00:35:13 +0000 Subject: [PATCH] feat(ui): single-line tool indicator on Feishu/Telegram, heartbeat on WeChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users repeatedly told us the running tool-call list is noise β€” they only care about the final answer. Replace the ever-growing per-tool list with one summary line, and hide the section entirely once the turn finishes. Feishu card builders (v1 + v2) - One line during in-flight states: `⏳ Β· N tools`. - Section disappears on `complete` / `error`; card shows only the user prompt, the final response, and the grey footer stats. - No new `CardState` fields β€” derive the indicator from `state.toolCalls[last]` + `.length`, so web UI's collapsible per-tool view (already the preferred surface for users who want detail) keeps working unchanged. Telegram sender - Same single-line treatment + hide-on-complete; matches Feishu cards. WeChat sender - Per-batch progress text is the noisiest surface (WeChat can't edit messages, so each tool batch lands as a new chat message every 5s). Replaced with a single-line heartbeat throttled to 30s: `πŸ”§ 运葌中: Β· N tools`. Long runs still show signs of life without flooding the thread. - Drops the unused `CardStatus` import while we're here. Tests - card-builder + card-builder-v2: assert single-line render during `running`, and zero tool-related element on `complete`. 288 / 288 vitest pass. Lint clean (2 pre-existing warnings, untouched). Co-Authored-By: Claude Opus 4.7 --- src/feishu/card-builder-v2.ts | 24 ++++++++++++------ src/feishu/card-builder.ts | 20 +++++++++------ src/telegram/telegram-sender.ts | 20 ++++++++++----- src/wechat/wechat-sender.ts | 44 +++++++++++++++++++-------------- tests/card-builder-v2.test.ts | 27 +++++++++++++++++--- tests/card-builder.test.ts | 29 +++++++++++++++++++--- 6 files changed, 119 insertions(+), 45 deletions(-) diff --git a/src/feishu/card-builder-v2.ts b/src/feishu/card-builder-v2.ts index 773b6fbc..cfc32739 100644 --- a/src/feishu/card-builder-v2.ts +++ b/src/feishu/card-builder-v2.ts @@ -165,15 +165,25 @@ export function buildCardV2(state: CardState): string { elements.push({ tag: 'hr' }); } - // Tool calls section - if (state.toolCalls.length > 0) { - const toolLines = state.toolCalls.map((t) => { - const icon = t.status === 'running' ? '⏳' : 'βœ…'; - return `${icon} **${t.name}** ${t.detail}`; - }); + // Tool calls indicator β€” single line, no per-tool list. + // Users repeatedly told us the running tool list is noise; they only care + // about the final answer. We still show ONE line while the turn is in + // flight so a hung run is visibly hung instead of looking like a frozen + // card, but we hide the section completely once the turn is complete/ + // errored. Web UI keeps its own collapsible per-tool view (see + // web/src/components/chat/AssistantMessage.tsx); this only affects the + // Feishu surface. + if ( + state.toolCalls.length > 0 && + state.status !== 'complete' && + state.status !== 'error' + ) { + const last = state.toolCalls[state.toolCalls.length - 1]; + const icon = last.status === 'running' ? '⏳' : 'βœ…'; + const total = state.toolCalls.length; elements.push({ tag: 'markdown', - content: toolLines.join('\n'), + content: `${icon} **${last.name}** Β· ${total} tool${total > 1 ? 's' : ''}`, }); elements.push({ tag: 'hr' }); } diff --git a/src/feishu/card-builder.ts b/src/feishu/card-builder.ts index e7af86b0..40d6c63f 100644 --- a/src/feishu/card-builder.ts +++ b/src/feishu/card-builder.ts @@ -92,15 +92,21 @@ export function buildCard(state: CardState): string { elements.push({ tag: 'hr' }); } - // Tool calls section - if (state.toolCalls.length > 0) { - const toolLines = state.toolCalls.map((t) => { - const icon = t.status === 'running' ? '⏳' : 'βœ…'; - return `${icon} **${t.name}** ${t.detail}`; - }); + // Tool calls indicator β€” single line, no per-tool list. See the v2 builder + // for the rationale (users only care about the final answer; the running + // tool list was noise). One line while in flight so a hung run is visibly + // hung; section disappears entirely on complete/error. + if ( + state.toolCalls.length > 0 && + state.status !== 'complete' && + state.status !== 'error' + ) { + const last = state.toolCalls[state.toolCalls.length - 1]; + const icon = last.status === 'running' ? '⏳' : 'βœ…'; + const total = state.toolCalls.length; elements.push({ tag: 'markdown', - content: toolLines.join('\n'), + content: `${icon} **${last.name}** Β· ${total} tool${total > 1 ? 's' : ''}`, }); elements.push({ tag: 'hr' }); } diff --git a/src/telegram/telegram-sender.ts b/src/telegram/telegram-sender.ts index 64d452a9..79dd8f39 100644 --- a/src/telegram/telegram-sender.ts +++ b/src/telegram/telegram-sender.ts @@ -39,12 +39,20 @@ function renderCardHtml(state: CardState): string { parts.push(`${emoji} ${escapeHtml(label)}`); parts.push(''); - // Tool calls - if (state.toolCalls.length > 0) { - for (const t of state.toolCalls) { - const icon = t.status === 'running' ? '\u{23F3}' : '\u{2705}'; // ⏳ / βœ… - parts.push(`${icon} ${escapeHtml(t.name)} ${escapeHtml(t.detail)}`); - } + // Tool calls indicator β€” single line, hidden on complete/error. + // Users only care about the final answer; the running tool list was just + // noise. We keep ONE line while in flight so a hung run still looks alive, + // and drop the section entirely once the turn finishes. Matches the + // Feishu card-builder treatment. + if ( + state.toolCalls.length > 0 && + state.status !== 'complete' && + state.status !== 'error' + ) { + const last = state.toolCalls[state.toolCalls.length - 1]; + const icon = last.status === 'running' ? '\u{23F3}' : '\u{2705}'; // ⏳ / βœ… + const total = state.toolCalls.length; + parts.push(`${icon} ${escapeHtml(last.name)} Β· ${total} tool${total > 1 ? 's' : ''}`); parts.push('---'); } diff --git a/src/wechat/wechat-sender.ts b/src/wechat/wechat-sender.ts index c1c41c7e..dfe30e9d 100644 --- a/src/wechat/wechat-sender.ts +++ b/src/wechat/wechat-sender.ts @@ -1,10 +1,15 @@ import type { IMessageSender } from '../bridge/message-sender.interface.js'; -import type { CardState, CardStatus } from '../types.js'; +import type { CardState } from '../types.js'; import type { Logger } from '../utils/logger.js'; import type { WechatClient } from './wechat-client.js'; const MAX_TEXT_LENGTH = 4000; -const PROGRESS_THROTTLE_MS = 5_000; +/** + * Progress heartbeat throttle. WeChat can't edit messages, so every progress + * tick is a fresh text in the chat β€” keep it sparse. 30 s gives long-running + * tasks visible signal ("it's still going") without flooding the thread. + */ +const PROGRESS_THROTTLE_MS = 30_000; /** * WeChat implementation of IMessageSender. @@ -66,7 +71,12 @@ export class WechatSender implements IMessageSender { return true; } - // Intermediate: send tool progress as actual messages (throttled) + // Intermediate: single-line heartbeat ("running... N tools"), throttled. + // Previous behavior dumped tool names + details for every new tool every + // 5 s β€” way too noisy. Now we emit at most one terse status line per + // PROGRESS_THROTTLE_MS, only when the tool count has grown since the + // last heartbeat, so long runs still show signs of life without flooding + // the chat. const now = Date.now(); const lastProgress = this.lastProgressSent.get(messageId) || 0; const reported = this.reportedToolCount.get(messageId) || 0; @@ -74,11 +84,9 @@ export class WechatSender implements IMessageSender { if (hasNewTools && now - lastProgress > PROGRESS_THROTTLE_MS) { this.lastProgressSent.set(messageId, now); - // Only report new tool calls since last update - const newTools = state.toolCalls.slice(reported); this.reportedToolCount.set(messageId, state.toolCalls.length); - const text = this.renderProgressMessage(newTools, state.status); + const text = this.renderHeartbeatMessage(state); await this.client.sendTextMessage(chatId, text).catch((err) => { this.logger.debug({ err, chatId }, 'Failed to send WeChat progress (may lack context_token)'); }); @@ -144,19 +152,17 @@ export class WechatSender implements IMessageSender { // --- Rendering --- - /** Progress message for new tool calls since last update. */ - private renderProgressMessage(newTools: CardState['toolCalls'], status: CardState['status']): string { - const parts: string[] = []; - const label = status === 'thinking' ? 'πŸ€” 思考中...' : 'πŸ”§ 运葌中...'; - parts.push(label); - - for (const t of newTools.slice(-5)) { - const icon = t.status === 'done' ? 'βœ“' : '⏳'; - const detail = t.detail.length > 80 ? t.detail.slice(0, 80) + '...' : t.detail; - parts.push(`${icon} ${t.name} ${detail}`); - } - - return parts.join('\n'); + /** + * Heartbeat message β€” single line, no per-tool details. Sent at most once + * per PROGRESS_THROTTLE_MS so the WeChat thread stays clean. Just enough + * signal to tell the user the bot hasn't died. + */ + private renderHeartbeatMessage(state: CardState): string { + const label = state.status === 'thinking' ? 'πŸ€” 思考中' : 'πŸ”§ 运葌中'; + const total = state.toolCalls.length; + if (total === 0) return label; + const last = state.toolCalls[state.toolCalls.length - 1]; + return `${label}:${last.name} Β· ${total} tool${total > 1 ? 's' : ''}`; } /** Final message: just the response text (or error). */ diff --git a/tests/card-builder-v2.test.ts b/tests/card-builder-v2.test.ts index f4ac38f2..a38c1844 100644 --- a/tests/card-builder-v2.test.ts +++ b/tests/card-builder-v2.test.ts @@ -103,7 +103,7 @@ describe('buildCardV2', () => { expect(team).toBeUndefined(); }); - it('renders tool calls section', () => { + it('renders the running tool indicator as a single line (latest tool + count)', () => { const state: CardState = { status: 'running', userPrompt: 'fix bug', @@ -114,12 +114,33 @@ describe('buildCardV2', () => { ], }; const elements = findElements(JSON.parse(buildCardV2(state))); + // Exactly the current/last tool + total count β€” earlier tool ("Read") + // must not appear; the section is meant to stay one line. const tools = elements.find( - (e) => e.tag === 'markdown' && typeof e.content === 'string' && e.content.includes('Read'), + (e) => e.tag === 'markdown' && typeof e.content === 'string' && /\*\*Edit\*\* Β· 2 tools/.test(e.content), ); expect(tools).toBeDefined(); - expect(tools.content).toContain('βœ…'); expect(tools.content).toContain('⏳'); + expect(tools.content).not.toContain('Read'); + expect(tools.content).not.toContain('βœ…'); + }); + + it('omits the tool indicator on complete (only response + footer remain)', () => { + const state: CardState = { + status: 'complete', + userPrompt: 'fix bug', + responseText: 'Done.', + toolCalls: [ + { name: 'Read', detail: '`src/index.ts`', status: 'done' }, + { name: 'Edit', detail: '`src/index.ts`', status: 'done' }, + ], + }; + const elements = findElements(JSON.parse(buildCardV2(state))); + const toolEl = elements.find( + (e) => e.tag === 'markdown' && typeof e.content === 'string' + && (e.content.includes('Read') || e.content.includes('Edit') || /\d+ tools?/.test(e.content)), + ); + expect(toolEl).toBeUndefined(); }); it('renders background events with status icon + last event', () => { diff --git a/tests/card-builder.test.ts b/tests/card-builder.test.ts index e9851a14..b5290d3f 100644 --- a/tests/card-builder.test.ts +++ b/tests/card-builder.test.ts @@ -16,7 +16,7 @@ describe('buildCard', () => { expect(json.elements.some((e: any) => e.tag === 'markdown' && /thinking/i.test(e.content))).toBe(true); }); - it('builds running card with tool calls', () => { + it('builds running card with a single-line tool indicator (no per-tool list)', () => { const state: CardState = { status: 'running', userPrompt: 'fix bug', @@ -28,10 +28,33 @@ describe('buildCard', () => { }; const json = JSON.parse(buildCard(state)); expect(json.header.template).toBe('blue'); - const md = json.elements.find((e: any) => e.tag === 'markdown' && e.content.includes('Read')); + // Should show one summary line referencing the latest (running) tool + + // the total tool count, NOT a per-tool list. The earlier completed tool + // ("Read") must NOT appear β€” only the current "Edit" plus the count. + const md = json.elements.find( + (e: any) => e.tag === 'markdown' && /\*\*Edit\*\* Β· 2 tools/.test(e.content), + ); expect(md).toBeDefined(); - expect(md.content).toContain('βœ…'); expect(md.content).toContain('⏳'); + expect(md.content).not.toContain('Read'); + expect(md.content).not.toContain('βœ…'); + }); + + it('omits the tool indicator entirely once the turn is complete', () => { + const state: CardState = { + status: 'complete', + userPrompt: 'fix bug', + responseText: 'Done.', + toolCalls: [ + { name: 'Read', detail: '`src/index.ts`', status: 'done' }, + { name: 'Edit', detail: '`src/index.ts`', status: 'done' }, + ], + }; + const json = JSON.parse(buildCard(state)); + const toolEl = json.elements.find( + (e: any) => e.tag === 'markdown' && (e.content.includes('Read') || e.content.includes('Edit') || /\d+ tools?/.test(e.content)), + ); + expect(toolEl).toBeUndefined(); }); it('builds complete card with stats', () => {