Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/feishu/card-builder-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down
20 changes: 13 additions & 7 deletions src/feishu/card-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down
20 changes: 14 additions & 6 deletions src/telegram/telegram-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,20 @@ function renderCardHtml(state: CardState): string {
parts.push(`${emoji} <b>${escapeHtml(label)}</b>`);
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} <b>${escapeHtml(t.name)}</b> ${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} <b>${escapeHtml(last.name)}</b> · ${total} tool${total > 1 ? 's' : ''}`);
parts.push('---');
}

Expand Down
44 changes: 25 additions & 19 deletions src/wechat/wechat-sender.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -66,19 +71,22 @@ 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;
const hasNewTools = state.toolCalls.length > reported;

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)');
});
Expand Down Expand Up @@ -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). */
Expand Down
27 changes: 24 additions & 3 deletions tests/card-builder-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
29 changes: 26 additions & 3 deletions tests/card-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
Loading