Skip to content

Commit 226ef8e

Browse files
committed
Merge branch 'ls-dev' into 'master'
feat(delegate): Claude Code ACP delegation + parallel tool_calls fix See merge request ai_native/DeepVCode/DeepVcodeClient!496
2 parents ec1ad34 + 488c777 commit 226ef8e

19 files changed

Lines changed: 2225 additions & 45 deletions

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,31 @@ $env:ANTHROPIC_API_KEY="sk-ant-your-key-here"
966966

967967
---
968968

969+
## 🤝 派发任务给本机 Claude Code
970+
971+
Easy Code 可以作为 **ACP 编排方**,把编码任务派发给你本机安装并登录的 **Claude Code** 执行——尤其适合飞书网关模式:你在飞书里说一句话,Easy Code 要么自己干,要么转交给 Claude Code 干。
972+
973+
### 前提
974+
975+
- 本机已安装并登录 **Claude Code**(派发会复用其本地登录态,不需要额外配置密钥)。
976+
- Easy Code 通过 Zed 官方的 `@zed-industries/claude-code-acp` 桥按需拉起(默认 `npx` 运行,不打入安装包)。
977+
- 可用环境变量 `EASYCODE_CLAUDE_CODE_ACP_CMD` 覆盖启动命令(例如指向全局安装或本地构建)。
978+
979+
### 两种派发方式
980+
981+
| 方式 | 说明 |
982+
|:---|:---|
983+
| **模型自主** | Easy Code 的 AI 会按任务自行判断,必要时调用内置工具 `delegate_to_claude_code` 派发。 |
984+
| **显式强制(飞书)** | 消息前加 `@cc`(或 `/cc`)前缀单条强制派发,例如:`@cc 给 src/foo.ts 加单元测试`|
985+
| **群默认(飞书)** | `/bind <项目路径> --agent claude-code` 把整个群的默认执行方设为 Claude Code;`/bind --agent self` 改回 Easy Code 自己。 |
986+
987+
派发期间,Claude Code 的执行进度(消息与工具调用)会通过飞书 CardKit 卡片**实时流式**回传,最终结果汇总返回。
988+
989+
> ⚠️ 在飞书无人值守场景下,派发的 Claude Code 默认**自动放行权限并可改文件**,请仅对可信项目使用。
990+
> ℹ️ 后续将支持把 `codex` 作为另一个可派发的本机 agent(同一套 ACP 客户端)。
991+
992+
---
993+
969994
## 🪝 Hooks 钩子机制
970995

971996
Hooks 允许你在关键工作流节点注入自定义逻辑。
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Easy Code team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
parseDelegatePrefix,
10+
resolveDelegation,
11+
buildDelegateDirective,
12+
parseBindAgentFlag,
13+
} from './delegateDirective.js';
14+
15+
describe('parseDelegatePrefix', () => {
16+
it.each([
17+
['@cc fix the bug', 'fix the bug'],
18+
['/cc fix the bug', 'fix the bug'],
19+
['@CC Fix It', 'Fix It'],
20+
['/claude-code do x', 'do x'],
21+
['@claudecode do y', 'do y'],
22+
['@cc: refactor', 'refactor'],
23+
['@cc:重构一下', '重构一下'],
24+
])('matches prefix in %j', (input, task) => {
25+
const r = parseDelegatePrefix(input);
26+
expect(r.matched).toBe(true);
27+
expect(r.task).toBe(task);
28+
});
29+
30+
it('does not match a bare word or unrelated text', () => {
31+
expect(parseDelegatePrefix('ccc do x').matched).toBe(false);
32+
expect(parseDelegatePrefix('please fix').matched).toBe(false);
33+
expect(parseDelegatePrefix('account stuff').matched).toBe(false);
34+
});
35+
36+
it('handles empty/undefined input', () => {
37+
expect(parseDelegatePrefix('').matched).toBe(false);
38+
// @ts-expect-error testing undefined robustness
39+
expect(parseDelegatePrefix(undefined).matched).toBe(false);
40+
});
41+
});
42+
43+
describe('resolveDelegation', () => {
44+
it('prefix wins regardless of default agent', () => {
45+
const r = resolveDelegation('@cc do x', 'self');
46+
expect(r).toMatchObject({ delegate: true, agent: 'claude-code', task: 'do x', reason: 'prefix' });
47+
});
48+
49+
it('delegates when chat default agent is claude-code', () => {
50+
const r = resolveDelegation('do x', 'claude-code');
51+
expect(r).toMatchObject({ delegate: true, task: 'do x', reason: 'route' });
52+
});
53+
54+
it('does not delegate by default', () => {
55+
const r = resolveDelegation('do x');
56+
expect(r.delegate).toBe(false);
57+
expect(r.reason).toBe('none');
58+
});
59+
60+
it('does not delegate when default agent is self', () => {
61+
expect(resolveDelegation('do x', 'self').delegate).toBe(false);
62+
});
63+
});
64+
65+
describe('buildDelegateDirective', () => {
66+
it('embeds the task and names the tool', () => {
67+
const d = buildDelegateDirective('add tests');
68+
expect(d).toContain('delegate_to_claude_code');
69+
expect(d).toContain('add tests');
70+
});
71+
});
72+
73+
describe('parseBindAgentFlag', () => {
74+
it('extracts --agent and leaves the path', () => {
75+
const r = parseBindAgentFlag('D:\\proj --agent claude-code');
76+
expect(r.agent).toBe('claude-code');
77+
expect(r.rest).toBe('D:\\proj');
78+
});
79+
80+
it('supports --agent=value and cc alias', () => {
81+
expect(parseBindAgentFlag('--agent=cc').agent).toBe('claude-code');
82+
expect(parseBindAgentFlag('/path -a self').agent).toBe('self');
83+
});
84+
85+
it('returns undefined agent when flag absent', () => {
86+
const r = parseBindAgentFlag('D:\\proj');
87+
expect(r.agent).toBeUndefined();
88+
expect(r.rest).toBe('D:\\proj');
89+
});
90+
91+
it('ignores invalid agent value', () => {
92+
const r = parseBindAgentFlag('D:\\proj --agent bogus');
93+
expect(r.agent).toBeUndefined();
94+
expect(r.rest).toBe('D:\\proj');
95+
});
96+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Easy Code team
4+
* https://github.com/OrionStarAI/EasyCode
5+
* SPDX-License-Identifier: Apache-2.0
6+
*/
7+
8+
/**
9+
* Pure helpers for the Feishu "explicit dispatch" track: deciding when an
10+
* incoming message should be forcibly delegated to an external agent (Claude
11+
* Code today), and building the directive that forces Easy Code's agent loop to
12+
* call the `delegate_to_claude_code` tool.
13+
*
14+
* Kept free of any Feishu/runtime dependencies so it can be unit-tested in
15+
* isolation (the surrounding feishuCommand module is too large to exercise).
16+
*/
17+
18+
/** External agents that a Feishu chat can be routed to. `self` means Easy Code. */
19+
export type FeishuAgentTarget = 'self' | 'claude-code';
20+
21+
/** Leading prefixes that force per-message delegation to Claude Code. */
22+
const CLAUDE_PREFIX_RE = /^[@/](cc|claudecode|claude-code)(?=$|[\s:])[\s:]*/i;
23+
24+
export interface DelegationDecision {
25+
/** Whether this message should be delegated to an external agent. */
26+
delegate: boolean;
27+
/** Target agent when delegating. */
28+
agent: Exclude<FeishuAgentTarget, 'self'>;
29+
/** The task text to hand off (prefix stripped). */
30+
task: string;
31+
/** Why we decided to delegate ('prefix' | 'route' | 'none'). */
32+
reason: 'prefix' | 'route' | 'none';
33+
}
34+
35+
/**
36+
* Strip a leading `@cc` / `/cc` (and aliases) delegation prefix.
37+
* Returns whether it matched and the remaining task text.
38+
*/
39+
export function parseDelegatePrefix(text: string): {
40+
matched: boolean;
41+
task: string;
42+
} {
43+
const raw = text ?? '';
44+
const m = raw.match(CLAUDE_PREFIX_RE);
45+
if (!m) return { matched: false, task: raw.trim() };
46+
return { matched: true, task: raw.slice(m[0].length).trim() };
47+
}
48+
49+
/**
50+
* Decide whether to delegate, combining the per-message prefix (highest
51+
* priority) with the chat's default agent route.
52+
*/
53+
export function resolveDelegation(
54+
text: string,
55+
defaultAgent?: FeishuAgentTarget,
56+
): DelegationDecision {
57+
const prefix = parseDelegatePrefix(text);
58+
if (prefix.matched) {
59+
return { delegate: true, agent: 'claude-code', task: prefix.task, reason: 'prefix' };
60+
}
61+
if (defaultAgent === 'claude-code') {
62+
return {
63+
delegate: true,
64+
agent: 'claude-code',
65+
task: (text ?? '').trim(),
66+
reason: 'route',
67+
};
68+
}
69+
return { delegate: false, agent: 'claude-code', task: (text ?? '').trim(), reason: 'none' };
70+
}
71+
72+
/**
73+
* Build the message handed to Easy Code's agent loop so it is forced to delegate
74+
* via the `delegate_to_claude_code` tool instead of doing the work itself. The
75+
* tool's streaming output then flows to the Feishu card unchanged.
76+
*/
77+
export function buildDelegateDirective(task: string): string {
78+
return [
79+
'【强制派发指令】用户要求将以下任务交给本机的 Claude Code 执行。',
80+
'你必须立即调用 delegate_to_claude_code 工具,task 参数填写下面的完整原文。',
81+
'不要自己动手处理这个任务,也不要追问,直接派发:',
82+
'',
83+
task,
84+
].join('\n');
85+
}
86+
87+
/**
88+
* Parse the optional `--agent <self|claude-code>` flag out of a `/bind` command
89+
* argument string. Returns the recognized agent (if any) and the remaining
90+
* tokens with the flag removed (so the path argument can still be parsed).
91+
*/
92+
export function parseBindAgentFlag(argString: string): {
93+
agent?: FeishuAgentTarget;
94+
rest: string;
95+
} {
96+
const tokens = (argString ?? '').split(/\s+/).filter(Boolean);
97+
const out: string[] = [];
98+
let agent: FeishuAgentTarget | undefined;
99+
for (let i = 0; i < tokens.length; i++) {
100+
const t = tokens[i];
101+
if (t === '--agent' || t === '-a') {
102+
const val = (tokens[i + 1] ?? '').toLowerCase();
103+
i++; // always consume the value token, even if invalid
104+
if (val === 'self' || val === 'claude-code' || val === 'cc') {
105+
agent = val === 'cc' ? 'claude-code' : (val as FeishuAgentTarget);
106+
}
107+
continue;
108+
}
109+
const inline = t.match(/^--agent=(\S+)$/i);
110+
if (inline) {
111+
const val = inline[1].toLowerCase();
112+
if (val === 'self' || val === 'claude-code' || val === 'cc') {
113+
agent = val === 'cc' ? 'claude-code' : (val as FeishuAgentTarget);
114+
}
115+
continue;
116+
}
117+
out.push(t);
118+
}
119+
return { agent, rest: out.join(' ') };
120+
}

packages/cli/src/ui/App.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
4141
import { useGoalActive } from './hooks/useGoalActive.js';
4242
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
4343
import { useBackgroundTaskNotifications, formatBackgroundTaskResult } from './hooks/useBackgroundTaskNotifications.js';
44+
import { formatClaudeCodeTaskResult } from 'deepv-code-core';
4445
import { BackgroundTaskPanel } from './components/BackgroundTaskPanel.js';
4546
import { BackgroundTaskHint } from './components/BackgroundTaskHint.js';
4647
import { Header } from './components/Header.js';
@@ -1486,19 +1487,21 @@ const App = ({ config, settings, startupWarnings = [], version, promptExtensions
14861487
useBackgroundTaskNotifications({
14871488
onTaskCompleted: useCallback((task: BackgroundTask) => {
14881489
console.log('[App] Background task completed, adding to history:', task.id);
1489-
const result = formatBackgroundTaskResult(task);
1490+
1491+
const isClaudeCode = task.kind === 'claude-code';
14901492

14911493
// 🎯 使用 tool_group 格式显示任务输出(仿 Claude Code 风格)
1492-
// 🔧 截断大型输出,防止 CLI 界面压力过大
14931494
const shortId = task.id;
14941495
const truncatedOutput = truncateBackgroundTaskOutput(task.output);
14951496
const toolGroupItem: IndividualToolCallDisplay = {
14961497
callId: `bg-${task.id}`,
1497-
name: t('background.task.output'),
1498-
toolId: 'background_task_output',
1498+
name: isClaudeCode ? 'Claude Code' : t('background.task.output'),
1499+
toolId: isClaudeCode ? 'claude_code_task_output' : 'background_task_output',
14991500
description: `${shortId} ${task.command}`,
1500-
resultDisplay: truncatedOutput || `Exit code: ${task.exitCode ?? 'unknown'}`,
1501-
status: task.exitCode === 0 ? ToolCallStatus.Success : ToolCallStatus.Error,
1501+
resultDisplay: isClaudeCode
1502+
? (task.answer || truncatedOutput || 'Task completed')
1503+
: (truncatedOutput || `Exit code: ${task.exitCode ?? 'unknown'}`),
1504+
status: isClaudeCode ? ToolCallStatus.Success : (task.exitCode === 0 ? ToolCallStatus.Success : ToolCallStatus.Error),
15021505
confirmationDetails: undefined,
15031506
};
15041507
addItem(
@@ -1507,29 +1510,31 @@ const App = ({ config, settings, startupWarnings = [], version, promptExtensions
15071510
);
15081511

15091512
// 🎯 构建通知消息(包含完整的任务信息,供 AI 理解)
1510-
const notificationText = `[Easy Code - SYSTEM NOTIFICATION] Background task completed (Task ID: ${task.id}). Exit code: ${task.exitCode ?? 'unknown'}. Output:\n${task.output?.substring(0, 1000) || '(no output)'}`;
1513+
const resultText = isClaudeCode
1514+
? formatClaudeCodeTaskResult(task)
1515+
: formatBackgroundTaskResult(task);
1516+
const notificationText = isClaudeCode
1517+
? `[Easy Code - SYSTEM NOTIFICATION] Claude Code task completed (Task ID: ${task.id}).\n\n${resultText}`
1518+
: `[Easy Code - SYSTEM NOTIFICATION] Background task completed (Task ID: ${task.id}). Exit code: ${task.exitCode ?? 'unknown'}. Output:\n${task.output?.substring(0, 1000) || '(no output)'}`;
15111519

15121520
// 🎯 如果 AI 当前空闲,自动触发 AI 继续处理(静默模式,不显示用户消息)
15131521
if (streamingState === StreamingState.Idle) {
15141522
console.log('[App] AI is idle, auto-triggering continuation for background task:', task.id);
1515-
// 直接发送包含完整信息的消息,让 AI 能看到结果
15161523
submitQuery(notificationText, { silent: true });
15171524
} else {
1518-
// AI 正忙,加入队列等待
15191525
console.log('[App] AI is busy, queuing background task notification:', task.id);
15201526
setPendingBackgroundNotifications(prev => [...prev, notificationText]);
15211527
}
15221528
}, [addItem, streamingState, submitQuery]),
15231529
onTaskFailed: useCallback((task: BackgroundTask) => {
15241530
console.log('[App] Background task failed:', task.id);
1525-
// 🎯 使用 tool_group 格式显示任务失败
1526-
// 🔧 截断大型输出,防止 CLI 界面压力过大
1531+
const isClaudeCode = task.kind === 'claude-code';
15271532
const shortId = task.id;
15281533
const truncatedOutput = truncateBackgroundTaskOutput(task.error || task.output);
15291534
const toolGroupItem: IndividualToolCallDisplay = {
15301535
callId: `bg-${task.id}`,
1531-
name: t('background.task.output'),
1532-
toolId: 'background_task_output',
1536+
name: isClaudeCode ? 'Claude Code' : t('background.task.output'),
1537+
toolId: isClaudeCode ? 'claude_code_task_output' : 'background_task_output',
15331538
description: `${shortId} ${task.command}`,
15341539
resultDisplay: truncatedOutput || 'Unknown error',
15351540
status: ToolCallStatus.Error,
@@ -1541,7 +1546,9 @@ const App = ({ config, settings, startupWarnings = [], version, promptExtensions
15411546
);
15421547

15431548
// 🎯 构建通知消息(包含完整的任务信息,供 AI 理解)
1544-
const notificationText = `[System] Background task failed (Task ID: ${task.id}). Command: ${task.command}. Error: ${task.error || 'Unknown error'}. Output:\n${task.output?.substring(0, 1000) || '(no output)'}`;
1549+
const notificationText = isClaudeCode
1550+
? `[Easy Code - SYSTEM NOTIFICATION] Claude Code task failed (Task ID: ${task.id}).\n\n${formatClaudeCodeTaskResult(task)}`
1551+
: `[System] Background task failed (Task ID: ${task.id}). Command: ${task.command}. Error: ${task.error || 'Unknown error'}. Output:\n${task.output?.substring(0, 1000) || '(no output)'}`;
15451552

15461553
// 🎯 如果 AI 当前空闲,自动触发 AI 继续处理(静默模式,不显示用户消息)
15471554
if (streamingState === StreamingState.Idle) {

0 commit comments

Comments
 (0)