Skip to content

Commit 427e903

Browse files
lishuceoclaude
andauthored
feat: 合并工具调用+文本+结果为单一卡片,简化飞书卡片体验 (#223)
* feat: 合并工具调用卡片与文本卡片为单一卡片 - 新增 buildCombinedProgressCard,文本直接展示,工具调用收入折叠面板 - 执行中:折叠面板标题显示计数+最新 tool call,充当实时状态指示器 - 完成后:工具调用始终折叠,文本条件折叠(长文本时) - event-handler 删除独立文本卡片逻辑,统一维护单一卡片 ID - conditionalCollapsible 支持 summaryLines/alwaysFold/showSummaryHeader 选项 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: 结果卡片合入合并卡片,一张卡片走完整个生命周期 - buildCombinedProgressCard 新增 CombinedCardResult 参数 - 完成时原地更新为成功(绿)/失败(红)/超时(橙)状态 - Header/footer 显示状态、耗时和花费 - thread 模式不再新发 result card,非 thread 模式降级不变 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: 简化合并卡片 header,统一为 "Coding Agent" 状态信息已在底部状态栏展示,header 不再重复显示执行状态, 仅通过颜色区分:蓝(执行中)/绿(成功)/红(失败)/橙(超时)/靛(活动记录) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: 移除合并卡片 header,仅保留底部状态栏 去掉卡片顶部 header 和颜色条,卡片更简洁,状态通过底部 note 展示 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 放宽 setup_workspace 触发条件,鼓励高置信度推断时积极切换 原 prompt 中"不要猜测"过于保守,导致 agent 即使能从上下文明显 推断出唯一仓库也不敢主动 setup_workspace。改为仅在确实存在多个 同等可能候选时才询问用户。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 允许切换到其他仓库,restart 后保留 setup_workspace 指引 - "绝对不要切换工作区" 改为 "不要对当前仓库重复调用" - restart 模式下补充 setup_workspace 可用提示,允许跨仓库切换 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 移除未使用的 import 修复 lint error Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 折叠面板标题去除 markdown 符号,避免 plain_text 显示原始格式 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 941e5c2 commit 427e903

4 files changed

Lines changed: 375 additions & 90 deletions

File tree

src/claude/executor.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,8 @@ function buildWorkspaceSystemPrompt(workingDir?: string, options?: { isRestart?:
360360
361361
当你需要判断用户要在哪个仓库工作时,先读取 \`${projectsDir}/.repo-registry.json\`,根据用户消息中的关键词、项目名、技术栈等信息匹配。
362362
- 如果匹配到唯一仓库,直接调用 setup_workspace(使用 registry 中的 repo URL)
363-
- 如果匹配到多个或无法确定,**明确询问用户是哪个仓库,不要猜测**
363+
- 即使 registry 没有精确匹配,但从上下文(项目名、功能描述、技术栈等)能高置信度推断出唯一仓库,也应直接调用 setup_workspace,不要反复确认
364+
- 仅当确实存在多个同等可能的候选仓库、无法区分时,才询问用户
364365
- 用户澄清后,调用 update_repo_registry 记录新的关键词映射,以便下次自动匹配
365366
366367
**默认从 bare cache 创建隔离工作区。**
@@ -382,14 +383,19 @@ function buildWorkspaceSystemPrompt(workingDir?: string, options?: { isRestart?:
382383
383384
**重要:\`${cacheDir}\` 下的是 bare clone(无文件树),仅用于定位仓库 URL。不要在 bare repo 中直接工作(\`git show\`/\`git grep\` 等)。** 找到仓库后,如果项目不在 \`${projectsDir}\` 下,必须调用 setup_workspace 创建完整工作区,这样才能正确加载 CLAUDE.md、使用搜索工具、获得完整的代码上下文。
384385
385-
**绝对不要用 setup_workspace 来切换当前工作区。** 当前工作区已经配置好了正确的权限,直接在当前目录工作即可。
386+
**setup_workspace 后不要再次调用,除非发现进错了仓库。** 当前工作区已经配置好了正确的权限,直接在当前目录工作即可。
386387
387388
**重要:调用 setup_workspace 后,系统将自动重启以加载项目配置(CLAUDE.md 等)。
388389
请在调用后仅输出简短确认(如"工作区已就绪,正在重新加载项目配置..."),不要继续执行后续任务。**`;
389390
}
390391

392+
// restart 后虽然跳过仓库探索,但仍需告知 agent 发现进错仓库时可以纠正
393+
const switchNote = isRestart
394+
? `\n\n如果发现当前仓库不对(setup 错了),可以再次调用 setup_workspace 切换到正确的仓库。`
395+
: '';
396+
391397
const basePrompt = `你正在通过飞书消息与用户交互。请保持回复简洁,适合在聊天消息中阅读。
392-
${explorationSection}
398+
${explorationSection}${switchNote}
393399
394400
## 自动开发流程
395401

src/feishu/__tests__/message-builder.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2-
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
2+
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildCombinedProgressCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
3+
import type { CombinedCardResult } from '../message-builder.js';
34
import type { TurnInfo, ToolCallInfo, ActivityStatus } from '../../claude/types.js';
45

56
describe('buildProgressCard', () => {
@@ -513,6 +514,130 @@ describe('buildToolProgressCard', () => {
513514
const body = card.elements[0].text.content as string;
514515
expect(body).toContain('_(无工具调用)_');
515516
});
517+
518+
it('should collapse tool calls when completed with many entries', () => {
519+
const tools: ToolCallInfo[] = Array.from({ length: 8 }, (_, i) => ({
520+
name: 'Read',
521+
input: { file_path: `/src/file${i}.ts` },
522+
}));
523+
const card = buildToolProgressCard(tools, 10, undefined, true) as any;
524+
// 前 3 行作为摘要直接显示
525+
const summary = card.elements[0].text.content as string;
526+
expect(summary).toContain('file0.ts');
527+
expect(summary).toContain('file2.ts');
528+
expect(summary).not.toContain('file3.ts');
529+
// 剩余部分在折叠面板中
530+
const panel = card.elements[1];
531+
expect(panel.tag).toBe('collapsible_panel');
532+
expect(panel.expanded).toBe(false);
533+
const panelContent = panel.elements[0].text.content as string;
534+
expect(panelContent).toContain('file3.ts');
535+
expect(panelContent).toContain('file7.ts');
536+
});
537+
});
538+
539+
describe('buildCombinedProgressCard', () => {
540+
it('should show text and tool calls panel when in progress', () => {
541+
const tools: ToolCallInfo[] = [
542+
{ name: 'Read', input: { file_path: '/src/index.ts' } },
543+
{ name: 'Bash', input: { command: 'npm test' } },
544+
];
545+
const card = buildCombinedProgressCard('正在分析代码...', tools, 2) as any;
546+
expect(card.header).toBeUndefined();
547+
// 文本区域直接展示
548+
expect(card.elements[0].text.content).toContain('正在分析代码');
549+
// hr 分隔
550+
expect(card.elements[1].tag).toBe('hr');
551+
// 工具调用在折叠面板中
552+
const panel = card.elements[2];
553+
expect(panel.tag).toBe('collapsible_panel');
554+
expect(panel.expanded).toBe(false);
555+
// 面板标题包含最新 tool call
556+
expect(panel.header.title.content).toContain('npm test');
557+
expect(panel.header.title.content).toContain('2');
558+
// 面板内容包含所有 tool calls
559+
const panelContent = panel.elements[0].text.content as string;
560+
expect(panelContent).toContain('/src/index.ts');
561+
expect(panelContent).toContain('npm test');
562+
});
563+
564+
it('should show indigo header and collapsed tools when completed', () => {
565+
const tools: ToolCallInfo[] = Array.from({ length: 5 }, (_, i) => ({
566+
name: 'Read',
567+
input: { file_path: `/src/file${i}.ts` },
568+
}));
569+
const card = buildCombinedProgressCard('分析完成', tools, 5, true) as any;
570+
expect(card.header).toBeUndefined();
571+
// 工具调用折叠面板
572+
const panel = card.elements.find((e: any) => e.tag === 'collapsible_panel');
573+
expect(panel).toBeDefined();
574+
expect(panel.expanded).toBe(false);
575+
expect(panel.header.title.content).toContain('5 条');
576+
// note 无 "执行中"
577+
const note = card.elements.find((e: any) => e.tag === 'note');
578+
expect(note.elements[0].content).not.toContain('⏳');
579+
expect(note.elements[0].content).toContain('5 轮');
580+
});
581+
582+
it('should show only text when no tool calls', () => {
583+
const card = buildCombinedProgressCard('只有文字', [], 1) as any;
584+
expect(card.elements[0].text.content).toContain('只有文字');
585+
// 无 collapsible_panel
586+
const panel = card.elements.find((e: any) => e.tag === 'collapsible_panel');
587+
expect(panel).toBeUndefined();
588+
});
589+
590+
it('should show only tool panel when no text', () => {
591+
const tools: ToolCallInfo[] = [
592+
{ name: 'Glob', input: { pattern: '**/*.ts' } },
593+
];
594+
const card = buildCombinedProgressCard('', tools, 1) as any;
595+
// 第一个元素应该是 collapsible_panel(无文本区和 hr)
596+
expect(card.elements[0].tag).toBe('collapsible_panel');
597+
});
598+
599+
it('should show placeholder when both empty', () => {
600+
const card = buildCombinedProgressCard('', [], 0) as any;
601+
expect(card.elements[0].text.content).toContain('正在处理');
602+
});
603+
604+
it('should keep combined card payload under 30KB', () => {
605+
const longText = '内'.repeat(12000);
606+
const tools: ToolCallInfo[] = Array.from({ length: 16 }, (_, i) => ({
607+
name: 'Bash',
608+
input: { command: `very-long-command-number-${i} --flag=value --another=arg` },
609+
}));
610+
const card = buildCombinedProgressCard(longText, tools, 20, true);
611+
const serialized = JSON.stringify(card);
612+
expect(Buffer.byteLength(serialized, 'utf-8')).toBeLessThan(30720);
613+
});
614+
615+
it('should show green header on success result', () => {
616+
const tools: ToolCallInfo[] = [
617+
{ name: 'Bash', input: { command: 'npm test' } },
618+
];
619+
const result: CombinedCardResult = { success: true, durationStr: '12s | 💰 $0.05' };
620+
const card = buildCombinedProgressCard('All tests passed', tools, 3, true, undefined, result) as any;
621+
expect(card.header).toBeUndefined();
622+
const note = card.elements.find((e: any) => e.tag === 'note');
623+
expect(note.elements[0].content).toContain('✅ 执行完成');
624+
expect(note.elements[0].content).toContain('12s');
625+
});
626+
627+
it('should show red header and error on failure result', () => {
628+
const result: CombinedCardResult = { success: false, durationStr: '5s', error: 'Something broke' };
629+
const card = buildCombinedProgressCard('', [], 1, true, undefined, result) as any;
630+
expect(card.header).toBeUndefined();
631+
// 错误信息应直接展示
632+
const errorEl = card.elements.find((e: any) => e.text?.content?.includes('Something broke'));
633+
expect(errorEl).toBeDefined();
634+
});
635+
636+
it('should show orange header on timeout result', () => {
637+
const result: CombinedCardResult = { success: false, durationStr: '300s', timedOut: true };
638+
const card = buildCombinedProgressCard('partial output', [], 10, true, undefined, result) as any;
639+
expect(card.header).toBeUndefined();
640+
});
516641
});
517642

518643
describe('buildTextContentCard', () => {

0 commit comments

Comments
 (0)