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', () => {