Skip to content
Merged
12 changes: 9 additions & 3 deletions src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,8 @@ function buildWorkspaceSystemPrompt(workingDir?: string, options?: { isRestart?:

当你需要判断用户要在哪个仓库工作时,先读取 \`${projectsDir}/.repo-registry.json\`,根据用户消息中的关键词、项目名、技术栈等信息匹配。
- 如果匹配到唯一仓库,直接调用 setup_workspace(使用 registry 中的 repo URL)
- 如果匹配到多个或无法确定,**明确询问用户是哪个仓库,不要猜测**
- 即使 registry 没有精确匹配,但从上下文(项目名、功能描述、技术栈等)能高置信度推断出唯一仓库,也应直接调用 setup_workspace,不要反复确认
- 仅当确实存在多个同等可能的候选仓库、无法区分时,才询问用户
- 用户澄清后,调用 update_repo_registry 记录新的关键词映射,以便下次自动匹配

**默认从 bare cache 创建隔离工作区。**
Expand All @@ -382,14 +383,19 @@ function buildWorkspaceSystemPrompt(workingDir?: string, options?: { isRestart?:

**重要:\`${cacheDir}\` 下的是 bare clone(无文件树),仅用于定位仓库 URL。不要在 bare repo 中直接工作(\`git show\`/\`git grep\` 等)。** 找到仓库后,如果项目不在 \`${projectsDir}\` 下,必须调用 setup_workspace 创建完整工作区,这样才能正确加载 CLAUDE.md、使用搜索工具、获得完整的代码上下文。

**绝对不要用 setup_workspace 来切换当前工作区。** 当前工作区已经配置好了正确的权限,直接在当前目录工作即可。
**setup_workspace 后不要再次调用,除非发现进错了仓库。** 当前工作区已经配置好了正确的权限,直接在当前目录工作即可。

**重要:调用 setup_workspace 后,系统将自动重启以加载项目配置(CLAUDE.md 等)。
请在调用后仅输出简短确认(如"工作区已就绪,正在重新加载项目配置..."),不要继续执行后续任务。**`;
}

// restart 后虽然跳过仓库探索,但仍需告知 agent 发现进错仓库时可以纠正
const switchNote = isRestart
? `\n\n如果发现当前仓库不对(setup 错了),可以再次调用 setup_workspace 切换到正确的仓库。`
: '';

const basePrompt = `你正在通过飞书消息与用户交互。请保持回复简洁,适合在聊天消息中阅读。
${explorationSection}
${explorationSection}${switchNote}

## 自动开发流程

Expand Down
127 changes: 126 additions & 1 deletion src/feishu/__tests__/message-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildCombinedProgressCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
import type { CombinedCardResult } from '../message-builder.js';
import type { TurnInfo, ToolCallInfo, ActivityStatus } from '../../claude/types.js';

describe('buildProgressCard', () => {
Expand Down Expand Up @@ -513,6 +514,130 @@ describe('buildToolProgressCard', () => {
const body = card.elements[0].text.content as string;
expect(body).toContain('_(无工具调用)_');
});

it('should collapse tool calls when completed with many entries', () => {
const tools: ToolCallInfo[] = Array.from({ length: 8 }, (_, i) => ({
name: 'Read',
input: { file_path: `/src/file${i}.ts` },
}));
const card = buildToolProgressCard(tools, 10, undefined, true) as any;
// 前 3 行作为摘要直接显示
const summary = card.elements[0].text.content as string;
expect(summary).toContain('file0.ts');
expect(summary).toContain('file2.ts');
expect(summary).not.toContain('file3.ts');
// 剩余部分在折叠面板中
const panel = card.elements[1];
expect(panel.tag).toBe('collapsible_panel');
expect(panel.expanded).toBe(false);
const panelContent = panel.elements[0].text.content as string;
expect(panelContent).toContain('file3.ts');
expect(panelContent).toContain('file7.ts');
});
});

describe('buildCombinedProgressCard', () => {
it('should show text and tool calls panel when in progress', () => {
const tools: ToolCallInfo[] = [
{ name: 'Read', input: { file_path: '/src/index.ts' } },
{ name: 'Bash', input: { command: 'npm test' } },
];
const card = buildCombinedProgressCard('正在分析代码...', tools, 2) as any;
expect(card.header).toBeUndefined();
// 文本区域直接展示
expect(card.elements[0].text.content).toContain('正在分析代码');
// hr 分隔
expect(card.elements[1].tag).toBe('hr');
// 工具调用在折叠面板中
const panel = card.elements[2];
expect(panel.tag).toBe('collapsible_panel');
expect(panel.expanded).toBe(false);
// 面板标题包含最新 tool call
expect(panel.header.title.content).toContain('npm test');
expect(panel.header.title.content).toContain('2');
// 面板内容包含所有 tool calls
const panelContent = panel.elements[0].text.content as string;
expect(panelContent).toContain('/src/index.ts');
expect(panelContent).toContain('npm test');
});

it('should show indigo header and collapsed tools when completed', () => {
const tools: ToolCallInfo[] = Array.from({ length: 5 }, (_, i) => ({
name: 'Read',
input: { file_path: `/src/file${i}.ts` },
}));
const card = buildCombinedProgressCard('分析完成', tools, 5, true) as any;
expect(card.header).toBeUndefined();
// 工具调用折叠面板
const panel = card.elements.find((e: any) => e.tag === 'collapsible_panel');
expect(panel).toBeDefined();
expect(panel.expanded).toBe(false);
expect(panel.header.title.content).toContain('5 条');
// note 无 "执行中"
const note = card.elements.find((e: any) => e.tag === 'note');
expect(note.elements[0].content).not.toContain('⏳');
expect(note.elements[0].content).toContain('5 轮');
});

it('should show only text when no tool calls', () => {
const card = buildCombinedProgressCard('只有文字', [], 1) as any;
expect(card.elements[0].text.content).toContain('只有文字');
// 无 collapsible_panel
const panel = card.elements.find((e: any) => e.tag === 'collapsible_panel');
expect(panel).toBeUndefined();
});

it('should show only tool panel when no text', () => {
const tools: ToolCallInfo[] = [
{ name: 'Glob', input: { pattern: '**/*.ts' } },
];
const card = buildCombinedProgressCard('', tools, 1) as any;
// 第一个元素应该是 collapsible_panel(无文本区和 hr)
expect(card.elements[0].tag).toBe('collapsible_panel');
});

it('should show placeholder when both empty', () => {
const card = buildCombinedProgressCard('', [], 0) as any;
expect(card.elements[0].text.content).toContain('正在处理');
});

it('should keep combined card payload under 30KB', () => {
const longText = '内'.repeat(12000);
const tools: ToolCallInfo[] = Array.from({ length: 16 }, (_, i) => ({
name: 'Bash',
input: { command: `very-long-command-number-${i} --flag=value --another=arg` },
}));
const card = buildCombinedProgressCard(longText, tools, 20, true);
const serialized = JSON.stringify(card);
expect(Buffer.byteLength(serialized, 'utf-8')).toBeLessThan(30720);
});

it('should show green header on success result', () => {
const tools: ToolCallInfo[] = [
{ name: 'Bash', input: { command: 'npm test' } },
];
const result: CombinedCardResult = { success: true, durationStr: '12s | 💰 $0.05' };
const card = buildCombinedProgressCard('All tests passed', tools, 3, true, undefined, result) as any;
expect(card.header).toBeUndefined();
const note = card.elements.find((e: any) => e.tag === 'note');
expect(note.elements[0].content).toContain('✅ 执行完成');
expect(note.elements[0].content).toContain('12s');
});

it('should show red header and error on failure result', () => {
const result: CombinedCardResult = { success: false, durationStr: '5s', error: 'Something broke' };
const card = buildCombinedProgressCard('', [], 1, true, undefined, result) as any;
expect(card.header).toBeUndefined();
// 错误信息应直接展示
const errorEl = card.elements.find((e: any) => e.text?.content?.includes('Something broke'));
expect(errorEl).toBeDefined();
});

it('should show orange header on timeout result', () => {
const result: CombinedCardResult = { success: false, durationStr: '300s', timedOut: true };
const card = buildCombinedProgressCard('partial output', [], 10, true, undefined, result) as any;
expect(card.header).toBeUndefined();
});
});

describe('buildTextContentCard', () => {
Expand Down
Loading
Loading