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