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
4 changes: 2 additions & 2 deletions src/__tests__/bot-identity-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ vi.mock('../agent/config-loader.js', () => ({
}));
vi.mock('../agent/tools/discussion.js', () => ({ createDiscussionMcpServer: vi.fn() }));
vi.mock('../feishu/message-builder.js', () => ({
buildResultCard: vi.fn(), buildStatusCard: vi.fn(), buildCancelledCard: vi.fn(),
buildStatusCard: vi.fn(), buildCancelledCard: vi.fn(),
buildPipelineCard: vi.fn(), buildPipelineConfirmCard: vi.fn(), buildProgressCard: vi.fn(),
buildToolProgressCard: vi.fn(), buildTextContentCard: vi.fn(), buildSimpleResultCard: vi.fn(),
buildToolProgressCard: vi.fn(), buildTextContentCard: vi.fn(), buildCombinedProgressCard: vi.fn(),
}));
vi.mock('../feishu/message-parser.js', () => ({ formatMergeForwardSubMessage: vi.fn() }));
vi.mock('../feishu/mention-resolver.js', () => ({ resolveMentions: vi.fn() }));
Expand Down
2 changes: 1 addition & 1 deletion src/feishu/__tests__/chat-history-fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ vi.mock('../../session/queue.js', () => ({
taskQueue: { enqueue: vi.fn(), dequeue: vi.fn(), complete: vi.fn(), pendingCount: vi.fn(() => 0), cancelPending: vi.fn(() => 0), isBusy: vi.fn(() => false) },
}));
vi.mock('../message-builder.js', () => ({
buildProgressCard: vi.fn(), buildResultCard: vi.fn(), buildStatusCard: vi.fn(),
buildProgressCard: vi.fn(), buildCombinedProgressCard: vi.fn(), buildStatusCard: vi.fn(),
}));
vi.mock('../../utils/security.js', () => ({
isUserAllowed: vi.fn(() => true), containsDangerousCommand: vi.fn(() => false),
Expand Down
4 changes: 2 additions & 2 deletions src/feishu/__tests__/event-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ vi.mock('../message-builder.js', () => ({
buildProgressCard: vi.fn((prompt: string, status?: string) => ({
type: 'progress', prompt, status: status || '正在处理...',
})),
buildResultCard: vi.fn((_prompt: string, output: string, success: boolean) => ({
type: 'result', output, success,
buildCombinedProgressCard: vi.fn((text: string, _tools: unknown[], _turnCount: number, completed?: boolean, _max?: unknown, result?: { success?: boolean }) => ({
type: 'combined', text, completed: !!completed, success: result?.success ?? null,
})),
buildStatusCard: vi.fn(),
}));
Expand Down
113 changes: 12 additions & 101 deletions src/feishu/__tests__/message-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildCombinedProgressCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
import { buildProgressCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildCombinedProgressCard, buildOverviewCard } from '../message-builder.js';
import type { CombinedCardResult } from '../message-builder.js';
import type { TurnInfo, ToolCallInfo, ActivityStatus } from '../../claude/types.js';

Expand Down Expand Up @@ -41,59 +41,6 @@ describe('buildProgressCard', () => {
});
});

describe('buildResultCard', () => {
it('should build a success card', () => {
const card = buildResultCard('test', 'done', true, '3.2s') as any;
expect(card.header.template).toBe('green');
expect(card.header.title.content).toContain('执行完成');
const note = card.elements[4];
expect(note.elements[0].content).toContain('✅');
expect(note.elements[0].content).toContain('3.2s');
});

it('should build a failure card', () => {
const card = buildResultCard('test', 'error', false, '1.0s') as any;
expect(card.header.template).toBe('red');
expect(card.header.title.content).toContain('执行失败');
const note = card.elements[4];
expect(note.elements[0].content).toContain('❌');
});

it('should build a timeout card', () => {
const card = buildResultCard('test', 'timeout', false, '300s', true) as any;
expect(card.header.template).toBe('orange');
expect(card.header.title.content).toContain('执行超时');
const note = card.elements[4];
expect(note.elements[0].content).toContain('⏱️');
});

it('should show empty output placeholder', () => {
const card = buildResultCard('test', '', true, '0.1s') as any;
const outputEl = card.elements[2];
expect(outputEl.text.content).toContain('_(无输出)_');
});

it('should show long single-line output directly without folding', () => {
// 行数 ≤ 5 但字符多的情况,直接展示不折叠
const longOutput = 'x'.repeat(5000);
const card = buildResultCard('test', longOutput, true, '1s') as any;
const outputEl = card.elements[2];
expect(outputEl.tag).toBe('div');
expect(outputEl.text.content.length).toBeGreaterThan(300);
});

it('should use collapsible panel for multi-line long output', () => {
// 行数 > 5 且字符多的情况,触发折叠面板
const longOutput = Array.from({ length: 20 }, (_, i) => `line ${i}: ${'x'.repeat(50)}`).join('\n');
const card = buildResultCard('test', longOutput, true, '1s') as any;
const previewHeader = card.elements[2];
expect(previewHeader.text.content).toContain('💬 回复预览');
const foldPanel = card.elements[4];
expect(foldPanel.tag).toBe('collapsible_panel');
expect(foldPanel.expanded).toBe(false);
});
});

describe('buildStreamingCard', () => {
it('should display prompt, content, and elapsed time', () => {
const card = buildStreamingCard('do something', 'partial output here', 15) as any;
Expand Down Expand Up @@ -417,53 +364,6 @@ describe('buildOverviewCard', () => {
});
});

describe('buildSimpleResultCard', () => {
it('should show minimal card when no lastTurn', () => {
const card = buildSimpleResultCard('do something', true, '5s | 💰 $0.02') as any;
expect(card.header.template).toBe('green');
expect(card.header.title.content).toContain('执行完成');
// only note (no prompt, no content, no hr)
expect(card.elements).toHaveLength(1);
expect(card.elements[0].elements[0].content).toContain('✅');
expect(card.elements[0].elements[0].content).toContain('5s');
});

it('should merge lastTurn content into the card', () => {
const lastTurn: TurnInfo = {
turnIndex: 1,
textContent: 'Here is the answer.',
toolCalls: [{ name: 'Read', input: { file_path: '/src/app.ts' } }],
};
const card = buildSimpleResultCard('question', true, '3s', undefined, lastTurn) as any;
// content + hr + note = 3 elements
expect(card.elements).toHaveLength(3);
const allText = card.elements.map((e: any) => e.text?.content ?? '').join(' ');
expect(allText).toContain('Here is the answer.');
expect(allText).toContain('📖');
expect(allText).toContain('/src/app.ts');
});

it('should show error message on failure', () => {
const card = buildSimpleResultCard('test', false, '10s', 'something broke') as any;
expect(card.header.template).toBe('red');
expect(card.header.title.content).toContain('执行失败');
// error + hr + note = 3 elements
expect(card.elements).toHaveLength(3);
const allText = card.elements.map((e: any) => e.text?.content ?? '').join(' ');
expect(allText).toContain('something broke');
});

it('should show both lastTurn and error on failure', () => {
const lastTurn: TurnInfo = { turnIndex: 1, textContent: 'partial work', toolCalls: [] };
const card = buildSimpleResultCard('test', false, '10s', 'something broke', lastTurn) as any;
// content + hr + error + hr + note = 5 elements
expect(card.elements).toHaveLength(5);
const allText = card.elements.map((e: any) => e.text?.content ?? '').join(' ');
expect(allText).toContain('partial work');
expect(allText).toContain('something broke');
});
});

describe('buildToolProgressCard', () => {
it('should show tool calls with blue header when in progress', () => {
const tools: ToolCallInfo[] = [
Expand Down Expand Up @@ -601,6 +501,17 @@ describe('buildCombinedProgressCard', () => {
expect(card.elements[0].text.content).toContain('正在处理');
});

it('should NOT show "正在处理" placeholder on completed failure with no text/tools', () => {
// 回归:失败 + 无 output 时,不能同时出现 "⏳ 正在处理..." 和 "❌ 执行失败"
const result: CombinedCardResult = { success: false, durationStr: '5s', error: 'Boom' };
const card = buildCombinedProgressCard('', [], 1, true, undefined, result) as any;
const placeholder = card.elements.find((e: any) => e.text?.content?.includes('正在处理'));
expect(placeholder).toBeUndefined();
// 错误信息仍应直接展示
const errorEl = card.elements.find((e: any) => e.text?.content?.includes('Boom'));
expect(errorEl).toBeDefined();
});

it('should keep combined card payload under 30KB', () => {
const longText = '内'.repeat(12000);
const tools: ToolCallInfo[] = Array.from({ length: 16 }, (_, i) => ({
Expand Down
70 changes: 50 additions & 20 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { taskQueue } from '../session/queue.js';
import { claudeExecutor } from '../claude/executor.js';
import { DEFAULT_IMAGE_PROMPT, DEFAULT_DOCUMENT_PROMPT } from '../claude/types.js';
import type { TurnInfo, ToolCallInfo, ImageAttachment, DocumentAttachment, ConversationTurn } from '../claude/types.js';
import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildCombinedProgressCard, buildSimpleResultCard, buildAskUserQuestionCard, buildAskUserAnsweredCard } from './message-builder.js';
import { buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildCombinedProgressCard, buildAskUserQuestionCard, buildAskUserAnsweredCard } from './message-builder.js';
import type { AskUserQuestionItem } from './message-builder.js';
import { TOTAL_PHASES } from '../pipeline/types.js';
import { feishuClient, feishuClientContext, runWithAccountId } from './client.js';
Expand Down Expand Up @@ -2412,7 +2412,7 @@ export async function executeClaudeTask(
);
} else {
await sendResultCard(
prompt, { ...result, success: false, output: '', error: '工作区准备失败,目录不存在' },
{ ...result, success: false, output: '', error: '工作区准备失败,目录不存在' },
result.durationMs, result.costUsd,
threadReplyMsgId, chatId,
);
Expand Down Expand Up @@ -2512,7 +2512,7 @@ export async function executeClaudeTask(
);
} else {
await sendResultCard(
prompt, restartResult, totalDurationMs, totalCostUsd,
restartResult, totalDurationMs, totalCostUsd,
threadReplyMsgId, chatId, undefined, turnCount,
);
}
Expand Down Expand Up @@ -2584,7 +2584,7 @@ export async function executeClaudeTask(
);
} else {
await sendResultCard(
prompt, result, result.durationMs, result.costUsd,
result, result.durationMs, result.costUsd,
threadReplyMsgId, chatId, undefined, turnCount,
);
}
Expand Down Expand Up @@ -2675,10 +2675,13 @@ export async function executeDirectTask(
// /t 命令:强制创建话题,后续回复在话题中
let threadReplyMsgId: string | undefined = eventThreadId ? rootId : undefined;
let threadId: string | undefined = eventThreadId;
// ensureThread 创建新话题时返回的初始进度卡片 ID,结果出来后原地更新为完成态
let progressCardMsgId: string | undefined;

if (options?.forceThread && !eventThreadId) {
const threadResult = await ensureThread(chatId, userId, messageId, rootId, undefined, agentId);
threadReplyMsgId = threadResult.threadReplyMsgId;
progressCardMsgId = threadResult.greetingMsgId;
if (threadReplyMsgId) {
const s = sessionManager.getOrCreate(chatId, userId, agentId);
threadId = s.threadId;
Expand Down Expand Up @@ -2893,7 +2896,8 @@ export async function executeDirectTask(
}

// 发送结果(统一走轻量回复,话题内通过 threadReplyMsgId 路由)
await sendDirectReply(messageId, chatId, result, threadReplyMsgId);
// progressCardMsgId 仅在 /t 创建新话题时有值,原地更新为完成态合并卡片
await sendDirectReply(messageId, chatId, result, threadReplyMsgId, progressCardMsgId);

// 记忆抽取 (fire-and-forget)
if (config.memory.enabled && result.success && result.output) {
Expand Down Expand Up @@ -2971,16 +2975,37 @@ async function buildDirectTaskHistory(
}

/**
* 直接回复结果(轻量模式,短文本纯文字、长文本才用卡片
* 直接回复结果(轻量模式,短文本纯文字、长文本或已有占位卡片走 combined card
*
* @param threadReplyMsgId 话题内时传入,使用 replyTextInThread / replyCardInThread
* @param progressCardMsgId /t 创建话题时 ensureThread 返回的占位卡片 ID,原地更新为完成态
*/
async function sendDirectReply(
messageId: string,
chatId: string,
result: import('../claude/types.js').ClaudeResult,
threadReplyMsgId?: string,
progressCardMsgId?: string,
): Promise<void> {
// progressCardMsgId 存在:始终原地更新占位卡片为完成态(避免遗留 "正在处理...")
if (progressCardMsgId) {
const durationStr = formatDuration(result.durationMs);
const costInfo = result.costUsd ? ` | 💰 $${result.costUsd.toFixed(4)}` : '';
// 失败时也保留 partial output(执行器可能在 timeout/budget 触发前已产出文本)
const text = result.success
? (result.output || '_(无输出)_')
: (result.output || '');
const card = buildCombinedProgressCard(text, [], 1, true, undefined, {
success: result.success,
durationStr: durationStr + costInfo,
error: result.error,
});
await feishuClient.updateCard(progressCardMsgId, card).catch((err) => {
logger.warn({ err, progressCardMsgId }, 'Failed to update direct-reply progress card');
});
return;
}
Comment thread
lishuceo marked this conversation as resolved.

// 成功但无输出(如模型 thinking 后决定不回复)→ 静默,不发 "(无输出)"
if (result.success && !result.output) {
logger.debug({ messageId }, 'Direct reply skipped — empty output (silent)');
Expand Down Expand Up @@ -3016,10 +3041,13 @@ async function sendDirectReply(
await feishuClient.replyText(messageId, output);
}
} else {
// 长文本:卡片
// 长文本:合并卡片(无 header,含状态栏)
const durationStr = formatDuration(result.durationMs);
const costInfo = result.costUsd ? ` | 💰 $${result.costUsd.toFixed(4)}` : '';
const card = buildResultCard(output, output, true, durationStr + costInfo);
const card = buildCombinedProgressCard(output, [], 1, true, undefined, {
success: result.success,
durationStr: durationStr + costInfo,
});
if (threadReplyMsgId) {
await feishuClient.replyCardInThread(threadReplyMsgId, card);
} else {
Expand All @@ -3030,9 +3058,11 @@ async function sendDirectReply(

/**
* 发送结果卡片(提取为独立函数,避免 restart 和正常流程重复代码)
*
* 仅在 progressCardMsgId 缺失时(极少见的兜底场景)作为新卡片发送。
* 统一使用合并卡片样式,与原地更新路径保持一致。
*/
async function sendResultCard(
prompt: string,
result: import('../claude/types.js').ClaudeResult,
totalDurationMs: number,
totalCostUsd: number | undefined,
Expand All @@ -3041,24 +3071,24 @@ async function sendResultCard(
/** 最后一个缓冲的 turn(逐条模式),其内容合并进底部结果卡片 */
lastTurn?: TurnInfo,
/** 逐条模式的轮次计数 */
_turnCount?: number,
turnCount?: number,
): Promise<void> {
const durationStr = formatDuration(totalDurationMs);
const costInfo = totalCostUsd
? ` | 💰 $${totalCostUsd.toFixed(4)}`
: '';

// 结果卡片:逐条模式包含最后一轮内容,否则包含完整输出
const resultCard = lastTurn
? buildSimpleResultCard(prompt, result.success, durationStr + costInfo, result.error, lastTurn)
: buildResultCard(
prompt,
result.output || result.error || '(无输出)',
result.success,
durationStr + costInfo,
);
// 合并卡片:合并最后一轮文本 + 工具调用,附带状态栏
const text = lastTurn?.textContent ?? result.output ?? '';
const tools = lastTurn?.toolCalls ?? [];
const turns = turnCount ?? 1;

const resultCard = buildCombinedProgressCard(text, tools, turns, true, undefined, {
success: result.success,
durationStr: durationStr + costInfo,
error: result.error,
});

// 发送到话题底部(作为新消息)
if (threadReplyMsgId) {
await feishuClient.replyCardInThread(threadReplyMsgId, resultCard);
} else {
Expand Down
Loading
Loading