Skip to content

Commit 4ddcbed

Browse files
floodsungFlood Sungclaude
authored andcommitted
feat(ui): single-line tool indicator on Feishu/Telegram, heartbeat on WeChat (xvirobotics#268)
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: `⏳ <latest tool> · 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: `🔧 运行中:<tool> · 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: Flood Sung <floodsung@xvirobotics.ai> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e004d44 commit 4ddcbed

6 files changed

Lines changed: 119 additions & 45 deletions

File tree

src/feishu/card-builder-v2.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,25 @@ export function buildCardV2(state: CardState): string {
165165
elements.push({ tag: 'hr' });
166166
}
167167

168-
// Tool calls section
169-
if (state.toolCalls.length > 0) {
170-
const toolLines = state.toolCalls.map((t) => {
171-
const icon = t.status === 'running' ? '⏳' : '✅';
172-
return `${icon} **${t.name}** ${t.detail}`;
173-
});
168+
// Tool calls indicator — single line, no per-tool list.
169+
// Users repeatedly told us the running tool list is noise; they only care
170+
// about the final answer. We still show ONE line while the turn is in
171+
// flight so a hung run is visibly hung instead of looking like a frozen
172+
// card, but we hide the section completely once the turn is complete/
173+
// errored. Web UI keeps its own collapsible per-tool view (see
174+
// web/src/components/chat/AssistantMessage.tsx); this only affects the
175+
// Feishu surface.
176+
if (
177+
state.toolCalls.length > 0 &&
178+
state.status !== 'complete' &&
179+
state.status !== 'error'
180+
) {
181+
const last = state.toolCalls[state.toolCalls.length - 1];
182+
const icon = last.status === 'running' ? '⏳' : '✅';
183+
const total = state.toolCalls.length;
174184
elements.push({
175185
tag: 'markdown',
176-
content: toolLines.join('\n'),
186+
content: `${icon} **${last.name}** · ${total} tool${total > 1 ? 's' : ''}`,
177187
});
178188
elements.push({ tag: 'hr' });
179189
}

src/feishu/card-builder.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,21 @@ export function buildCard(state: CardState): string {
9292
elements.push({ tag: 'hr' });
9393
}
9494

95-
// Tool calls section
96-
if (state.toolCalls.length > 0) {
97-
const toolLines = state.toolCalls.map((t) => {
98-
const icon = t.status === 'running' ? '⏳' : '✅';
99-
return `${icon} **${t.name}** ${t.detail}`;
100-
});
95+
// Tool calls indicator — single line, no per-tool list. See the v2 builder
96+
// for the rationale (users only care about the final answer; the running
97+
// tool list was noise). One line while in flight so a hung run is visibly
98+
// hung; section disappears entirely on complete/error.
99+
if (
100+
state.toolCalls.length > 0 &&
101+
state.status !== 'complete' &&
102+
state.status !== 'error'
103+
) {
104+
const last = state.toolCalls[state.toolCalls.length - 1];
105+
const icon = last.status === 'running' ? '⏳' : '✅';
106+
const total = state.toolCalls.length;
101107
elements.push({
102108
tag: 'markdown',
103-
content: toolLines.join('\n'),
109+
content: `${icon} **${last.name}** · ${total} tool${total > 1 ? 's' : ''}`,
104110
});
105111
elements.push({ tag: 'hr' });
106112
}

src/telegram/telegram-sender.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,20 @@ function renderCardHtml(state: CardState): string {
3939
parts.push(`${emoji} <b>${escapeHtml(label)}</b>`);
4040
parts.push('');
4141

42-
// Tool calls
43-
if (state.toolCalls.length > 0) {
44-
for (const t of state.toolCalls) {
45-
const icon = t.status === 'running' ? '\u{23F3}' : '\u{2705}'; // ⏳ / ✅
46-
parts.push(`${icon} <b>${escapeHtml(t.name)}</b> ${escapeHtml(t.detail)}`);
47-
}
42+
// Tool calls indicator — single line, hidden on complete/error.
43+
// Users only care about the final answer; the running tool list was just
44+
// noise. We keep ONE line while in flight so a hung run still looks alive,
45+
// and drop the section entirely once the turn finishes. Matches the
46+
// Feishu card-builder treatment.
47+
if (
48+
state.toolCalls.length > 0 &&
49+
state.status !== 'complete' &&
50+
state.status !== 'error'
51+
) {
52+
const last = state.toolCalls[state.toolCalls.length - 1];
53+
const icon = last.status === 'running' ? '\u{23F3}' : '\u{2705}'; // ⏳ / ✅
54+
const total = state.toolCalls.length;
55+
parts.push(`${icon} <b>${escapeHtml(last.name)}</b> · ${total} tool${total > 1 ? 's' : ''}`);
4856
parts.push('---');
4957
}
5058

src/wechat/wechat-sender.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { IMessageSender } from '../bridge/message-sender.interface.js';
2-
import type { CardState, CardStatus } from '../types.js';
2+
import type { CardState } from '../types.js';
33
import type { Logger } from '../utils/logger.js';
44
import type { WechatClient } from './wechat-client.js';
55

66
const MAX_TEXT_LENGTH = 4000;
7-
const PROGRESS_THROTTLE_MS = 5_000;
7+
/**
8+
* Progress heartbeat throttle. WeChat can't edit messages, so every progress
9+
* tick is a fresh text in the chat — keep it sparse. 30 s gives long-running
10+
* tasks visible signal ("it's still going") without flooding the thread.
11+
*/
12+
const PROGRESS_THROTTLE_MS = 30_000;
813

914
/**
1015
* WeChat implementation of IMessageSender.
@@ -66,19 +71,22 @@ export class WechatSender implements IMessageSender {
6671
return true;
6772
}
6873

69-
// Intermediate: send tool progress as actual messages (throttled)
74+
// Intermediate: single-line heartbeat ("running... N tools"), throttled.
75+
// Previous behavior dumped tool names + details for every new tool every
76+
// 5 s — way too noisy. Now we emit at most one terse status line per
77+
// PROGRESS_THROTTLE_MS, only when the tool count has grown since the
78+
// last heartbeat, so long runs still show signs of life without flooding
79+
// the chat.
7080
const now = Date.now();
7181
const lastProgress = this.lastProgressSent.get(messageId) || 0;
7282
const reported = this.reportedToolCount.get(messageId) || 0;
7383
const hasNewTools = state.toolCalls.length > reported;
7484

7585
if (hasNewTools && now - lastProgress > PROGRESS_THROTTLE_MS) {
7686
this.lastProgressSent.set(messageId, now);
77-
// Only report new tool calls since last update
78-
const newTools = state.toolCalls.slice(reported);
7987
this.reportedToolCount.set(messageId, state.toolCalls.length);
8088

81-
const text = this.renderProgressMessage(newTools, state.status);
89+
const text = this.renderHeartbeatMessage(state);
8290
await this.client.sendTextMessage(chatId, text).catch((err) => {
8391
this.logger.debug({ err, chatId }, 'Failed to send WeChat progress (may lack context_token)');
8492
});
@@ -146,19 +154,17 @@ export class WechatSender implements IMessageSender {
146154

147155
// --- Rendering ---
148156

149-
/** Progress message for new tool calls since last update. */
150-
private renderProgressMessage(newTools: CardState['toolCalls'], status: CardState['status']): string {
151-
const parts: string[] = [];
152-
const label = status === 'thinking' ? '🤔 思考中...' : '🔧 运行中...';
153-
parts.push(label);
154-
155-
for (const t of newTools.slice(-5)) {
156-
const icon = t.status === 'done' ? '✓' : '⏳';
157-
const detail = t.detail.length > 80 ? t.detail.slice(0, 80) + '...' : t.detail;
158-
parts.push(`${icon} ${t.name} ${detail}`);
159-
}
160-
161-
return parts.join('\n');
157+
/**
158+
* Heartbeat message — single line, no per-tool details. Sent at most once
159+
* per PROGRESS_THROTTLE_MS so the WeChat thread stays clean. Just enough
160+
* signal to tell the user the bot hasn't died.
161+
*/
162+
private renderHeartbeatMessage(state: CardState): string {
163+
const label = state.status === 'thinking' ? '🤔 思考中' : '🔧 运行中';
164+
const total = state.toolCalls.length;
165+
if (total === 0) return label;
166+
const last = state.toolCalls[state.toolCalls.length - 1];
167+
return `${label}${last.name} · ${total} tool${total > 1 ? 's' : ''}`;
162168
}
163169

164170
/** Final message: just the response text (or error). */

tests/card-builder-v2.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('buildCardV2', () => {
103103
expect(team).toBeUndefined();
104104
});
105105

106-
it('renders tool calls section', () => {
106+
it('renders the running tool indicator as a single line (latest tool + count)', () => {
107107
const state: CardState = {
108108
status: 'running',
109109
userPrompt: 'fix bug',
@@ -114,12 +114,33 @@ describe('buildCardV2', () => {
114114
],
115115
};
116116
const elements = findElements(JSON.parse(buildCardV2(state)));
117+
// Exactly the current/last tool + total count — earlier tool ("Read")
118+
// must not appear; the section is meant to stay one line.
117119
const tools = elements.find(
118-
(e) => e.tag === 'markdown' && typeof e.content === 'string' && e.content.includes('Read'),
120+
(e) => e.tag === 'markdown' && typeof e.content === 'string' && /\*\*Edit\*\* · 2 tools/.test(e.content),
119121
);
120122
expect(tools).toBeDefined();
121-
expect(tools.content).toContain('✅');
122123
expect(tools.content).toContain('⏳');
124+
expect(tools.content).not.toContain('Read');
125+
expect(tools.content).not.toContain('✅');
126+
});
127+
128+
it('omits the tool indicator on complete (only response + footer remain)', () => {
129+
const state: CardState = {
130+
status: 'complete',
131+
userPrompt: 'fix bug',
132+
responseText: 'Done.',
133+
toolCalls: [
134+
{ name: 'Read', detail: '`src/index.ts`', status: 'done' },
135+
{ name: 'Edit', detail: '`src/index.ts`', status: 'done' },
136+
],
137+
};
138+
const elements = findElements(JSON.parse(buildCardV2(state)));
139+
const toolEl = elements.find(
140+
(e) => e.tag === 'markdown' && typeof e.content === 'string'
141+
&& (e.content.includes('Read') || e.content.includes('Edit') || /\d+ tools?/.test(e.content)),
142+
);
143+
expect(toolEl).toBeUndefined();
123144
});
124145

125146
it('renders background events with status icon + last event', () => {

tests/card-builder.test.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('buildCard', () => {
1616
expect(json.elements.some((e: any) => e.tag === 'markdown' && /thinking/i.test(e.content))).toBe(true);
1717
});
1818

19-
it('builds running card with tool calls', () => {
19+
it('builds running card with a single-line tool indicator (no per-tool list)', () => {
2020
const state: CardState = {
2121
status: 'running',
2222
userPrompt: 'fix bug',
@@ -28,10 +28,33 @@ describe('buildCard', () => {
2828
};
2929
const json = JSON.parse(buildCard(state));
3030
expect(json.header.template).toBe('blue');
31-
const md = json.elements.find((e: any) => e.tag === 'markdown' && e.content.includes('Read'));
31+
// Should show one summary line referencing the latest (running) tool +
32+
// the total tool count, NOT a per-tool list. The earlier completed tool
33+
// ("Read") must NOT appear — only the current "Edit" plus the count.
34+
const md = json.elements.find(
35+
(e: any) => e.tag === 'markdown' && /\*\*Edit\*\* · 2 tools/.test(e.content),
36+
);
3237
expect(md).toBeDefined();
33-
expect(md.content).toContain('✅');
3438
expect(md.content).toContain('⏳');
39+
expect(md.content).not.toContain('Read');
40+
expect(md.content).not.toContain('✅');
41+
});
42+
43+
it('omits the tool indicator entirely once the turn is complete', () => {
44+
const state: CardState = {
45+
status: 'complete',
46+
userPrompt: 'fix bug',
47+
responseText: 'Done.',
48+
toolCalls: [
49+
{ name: 'Read', detail: '`src/index.ts`', status: 'done' },
50+
{ name: 'Edit', detail: '`src/index.ts`', status: 'done' },
51+
],
52+
};
53+
const json = JSON.parse(buildCard(state));
54+
const toolEl = json.elements.find(
55+
(e: any) => e.tag === 'markdown' && (e.content.includes('Read') || e.content.includes('Edit') || /\d+ tools?/.test(e.content)),
56+
);
57+
expect(toolEl).toBeUndefined();
3558
});
3659

3760
it('builds complete card with stats', () => {

0 commit comments

Comments
 (0)