diff --git a/src/__tests__/bot-identity-context.test.ts b/src/__tests__/bot-identity-context.test.ts index 5a19110..02bce2b 100644 --- a/src/__tests__/bot-identity-context.test.ts +++ b/src/__tests__/bot-identity-context.test.ts @@ -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() })); diff --git a/src/feishu/__tests__/chat-history-fork.test.ts b/src/feishu/__tests__/chat-history-fork.test.ts index e87a8e2..83f8ac6 100644 --- a/src/feishu/__tests__/chat-history-fork.test.ts +++ b/src/feishu/__tests__/chat-history-fork.test.ts @@ -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), diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts index 61692b3..40eb3a8 100644 --- a/src/feishu/__tests__/event-handler.test.ts +++ b/src/feishu/__tests__/event-handler.test.ts @@ -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(), })); diff --git a/src/feishu/__tests__/message-builder.test.ts b/src/feishu/__tests__/message-builder.test.ts index e46264a..7a4ca81 100644 --- a/src/feishu/__tests__/message-builder.test.ts +++ b/src/feishu/__tests__/message-builder.test.ts @@ -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'; @@ -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; @@ -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[] = [ @@ -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) => ({ diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index f7d9f87..af33e04 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -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'; @@ -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, ); @@ -2512,7 +2512,7 @@ export async function executeClaudeTask( ); } else { await sendResultCard( - prompt, restartResult, totalDurationMs, totalCostUsd, + restartResult, totalDurationMs, totalCostUsd, threadReplyMsgId, chatId, undefined, turnCount, ); } @@ -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, ); } @@ -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; @@ -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) { @@ -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 { + // 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; + } + // 成功但无输出(如模型 thinking 后决定不回复)→ 静默,不发 "(无输出)" if (result.success && !result.output) { logger.debug({ messageId }, 'Direct reply skipped — empty output (silent)'); @@ -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 { @@ -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, @@ -3041,24 +3071,24 @@ async function sendResultCard( /** 最后一个缓冲的 turn(逐条模式),其内容合并进底部结果卡片 */ lastTurn?: TurnInfo, /** 逐条模式的轮次计数 */ - _turnCount?: number, + turnCount?: number, ): Promise { 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 { diff --git a/src/feishu/message-builder.ts b/src/feishu/message-builder.ts index 3ef7649..94460b0 100644 --- a/src/feishu/message-builder.ts +++ b/src/feishu/message-builder.ts @@ -2,8 +2,6 @@ * 飞书消息卡片构建器 * 用于构建执行状态卡片、结果卡片等 */ -import { hostname } from 'os'; - import { PHASE_META } from '../pipeline/types.js'; import type { PipelinePhase } from '../pipeline/types.js'; import type { TurnInfo, ToolCallInfo } from '../claude/types.js'; @@ -94,59 +92,6 @@ export function buildProgressCard(prompt: string, statusText: string = '正在 }; } -/** 构建 "执行完成" 结果卡片 */ -export function buildResultCard( - prompt: string, - output: string, - success: boolean, - durationStr: string, - timedOut?: boolean, -): Record { - const icon = timedOut ? '⏱️' : success ? '✅' : '❌'; - const status = timedOut ? '执行超时' : success ? '执行完成' : '执行失败'; - const headerTemplate = timedOut ? 'orange' : success ? 'green' : 'red'; - - const formattedOutput = formatOutputAsMarkdown(output); - const outputElement: Record = { - tag: 'div', - text: { - tag: 'lark_md', - content: formattedOutput, - }, - }; - - const elements: Record[] = [ - { - tag: 'div', - text: { - tag: 'lark_md', - content: `**指令:** ${escapeMarkdown(truncate(prompt, 200))}`, - }, - }, - { tag: 'hr' }, - ...conditionalCollapsible('📋 查看完整回复', [outputElement], formattedOutput), - { tag: 'hr' }, - { - tag: 'note', - elements: [ - { - tag: 'plain_text', - content: `${icon} ${status} | ⏱️ ${durationStr} | 🖥️ ${hostname()}:${process.pid}`, - }, - ], - }, - ]; - - return { - config: { wide_screen_mode: true }, - header: { - title: { tag: 'plain_text', content: `🤖 Coding Agent - ${status}` }, - template: headerTemplate, - }, - elements, - }; -} - /** 构建 "执行中" 流式更新卡片(显示实时输出) */ export function buildStreamingCard( prompt: string, @@ -736,7 +681,8 @@ export function buildCombinedProgressCard( } // --- 空状态 --- - if (!hasText && !hasTools) { + // 完成态且带 result 时跳过:否则会与下方的错误块/状态栏矛盾("⏳ 正在处理..." + "❌ 执行失败" 同框) + if (!hasText && !hasTools && !(completed && result)) { elements.push({ tag: 'div', text: { tag: 'lark_md', content: '⏳ 正在处理...' }, @@ -864,79 +810,6 @@ export function buildOverviewCard( }; } -/** 构建底部结果卡片(逐条模式用,最后一轮内容合并进来,指令已在顶部概览中) */ -export function buildSimpleResultCard( - _prompt: string, - success: boolean, - durationStr: string, - error?: string, - lastTurn?: TurnInfo, -): Record { - const icon = success ? '✅' : '❌'; - const status = success ? '执行完成' : '执行失败'; - const headerTemplate = success ? 'green' : 'red'; - - const elements: Record[] = []; - - // 合并最后一轮 turn 的内容 - if (lastTurn) { - const parts: string[] = []; - if (lastTurn.textContent) { - const maxLen = 3000; - const text = lastTurn.textContent.length > maxLen - ? lastTurn.textContent.slice(0, maxLen) + '\n\n_(内容过长,已截断)_' - : lastTurn.textContent; - parts.push(text.trim()); - } - if (lastTurn.toolCalls.length > 0) { - parts.push(lastTurn.toolCalls.map(formatToolCall).join('\n')); - } - if (parts.length > 0) { - const joinedContent = parts.join('\n\n'); - const contentElement: Record = { - tag: 'div', - text: { - tag: 'lark_md', - content: joinedContent, - }, - }; - elements.push(...conditionalCollapsible('📋 查看完整回复', [contentElement], joinedContent)); - } - } - - // 失败时显示错误信息(不折叠,需要直接看到) - if (!success && error) { - if (elements.length > 0) elements.push({ tag: 'hr' }); - elements.push({ - tag: 'div', - text: { - tag: 'lark_md', - content: truncate(error, 1000), - }, - }); - } - - if (elements.length > 0) elements.push({ tag: 'hr' }); - elements.push({ - tag: 'note', - elements: [ - { - tag: 'plain_text', - content: `${icon} ${status} | ⏱️ ${durationStr}`, - }, - ], - }); - - return { - config: { wide_screen_mode: true }, - header: { - title: { tag: 'plain_text', content: `🤖 Coding Agent - ${status}` }, - template: headerTemplate, - }, - elements, - }; -} - /** 构建审批请求卡片(owner 看到,带允许/拒绝按钮) */ export function buildApprovalCard( approvalId: string, @@ -1144,22 +1017,6 @@ function truncate(text: string, maxLen: number): string { return text.slice(0, maxLen) + '...'; } -/** 将 Claude Code 输出格式化为飞书 lark_md 格式 */ -function formatOutputAsMarkdown(output: string): string { - if (!output) return '_(无输出)_'; - - // 飞书卡片内容长度限制约 30000 字符,但太长影响阅读 - const maxLen = 3000; - const truncated = output.length > maxLen; - const text = truncated ? output.slice(0, maxLen) : output; - - const result = text - // 不需要额外处理,lark_md 支持基本 markdown - .trim(); - - return truncated ? result + '\n\n_(输出过长,已截断)_' : result; -} - // ============================================================ // 记忆管理卡片 // ============================================================