Skip to content

Commit f01ab32

Browse files
author
jinlongqi
committed
feat: 支持子agents能力,自动加载子agent,目录格式要求.deepcode/[name]/AGENT.md
1 parent 1299ba4 commit f01ab32

25 files changed

Lines changed: 4186 additions & 11 deletions
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Property test: Task prompt extraction from agent slash command
3+
*
4+
* **Validates: Requirements 3.3**
5+
*
6+
* Property statement: For any input string of the form `/<agent-name> <remaining text>`
7+
* where `<agent-name>` matches a discovered agent, the CLI SHALL extract
8+
* `<remaining text>` (trimmed) as the task prompt passed to the sub-agent session.
9+
*
10+
* This tests the extraction logic used in PromptInput.tsx handleSlashSelection
11+
* for "agent" kind items.
12+
*/
13+
14+
import { test } from "node:test";
15+
import assert from "node:assert/strict";
16+
17+
// -- Extraction helper mirroring PromptInput.tsx logic --
18+
19+
/**
20+
* Extracts the task prompt from a full input string given an agent command prefix.
21+
* This mirrors the logic in PromptInput.tsx handleSlashSelection for "agent" kind:
22+
*
23+
* const fullText = buffer.text.trim();
24+
* const commandPrefix = `/${item.name}`;
25+
* const taskText = fullText.startsWith(commandPrefix)
26+
* ? fullText.slice(commandPrefix.length).trim()
27+
* : "";
28+
*/
29+
function extractAgentTaskPrompt(fullText: string, agentName: string): string {
30+
const trimmed = fullText.trim();
31+
const commandPrefix = `/${agentName}`;
32+
return trimmed.startsWith(commandPrefix) ? trimmed.slice(commandPrefix.length).trim() : "";
33+
}
34+
35+
// -- Property tests --
36+
37+
test("Property 5: basic task extraction — /agent-name some task text", () => {
38+
const result = extractAgentTaskPrompt("/deploy-assistant fix the build", "deploy-assistant");
39+
assert.equal(result, "fix the build");
40+
});
41+
42+
test("Property 5: extra spaces in task text are trimmed", () => {
43+
const result = extractAgentTaskPrompt("/ut-agent extra spaces ", "ut-agent");
44+
assert.equal(result, "extra spaces");
45+
});
46+
47+
test("Property 5: no task text after agent name yields empty string", () => {
48+
const result = extractAgentTaskPrompt("/deploy-assistant", "deploy-assistant");
49+
assert.equal(result, "");
50+
});
51+
52+
test("Property 5: newlines in task body are preserved", () => {
53+
const result = extractAgentTaskPrompt("/coder multi\nline task", "coder");
54+
assert.equal(result, "multi\nline task");
55+
});
56+
57+
test("Property 5: leading whitespace in input is trimmed before matching", () => {
58+
const result = extractAgentTaskPrompt(" /eaa leading spaces ", "eaa");
59+
assert.equal(result, "leading spaces");
60+
});
61+
62+
test("Property 5: non-matching prefix returns empty string", () => {
63+
const result = extractAgentTaskPrompt("/other-agent some text", "deploy-assistant");
64+
assert.equal(result, "");
65+
});
66+
67+
test("Property 5: agent name with single character works", () => {
68+
const result = extractAgentTaskPrompt("/x do something", "x");
69+
assert.equal(result, "do something");
70+
});
71+
72+
test("Property 5: agent name as substring of input does not falsely match", () => {
73+
// /deploy is a prefix of /deploy-assistant, but agentName is "deploy-assistant"
74+
const result = extractAgentTaskPrompt("/deploy run tests", "deploy-assistant");
75+
assert.equal(result, "");
76+
});
77+
78+
test("Property 5: agent name exactly at boundary — task starts immediately after prefix", () => {
79+
// No space between prefix and task — slice still captures it, trim just returns it
80+
const result = extractAgentTaskPrompt("/codertask without space", "coder");
81+
assert.equal(result, "task without space");
82+
});
83+
84+
test("Property 5: empty input string yields empty string", () => {
85+
const result = extractAgentTaskPrompt("", "deploy-assistant");
86+
assert.equal(result, "");
87+
});
88+
89+
test("Property 5: whitespace-only input yields empty string", () => {
90+
const result = extractAgentTaskPrompt(" ", "deploy-assistant");
91+
assert.equal(result, "");
92+
});
93+
94+
test("Property 5: task with special characters is preserved", () => {
95+
const result = extractAgentTaskPrompt(
96+
"/ut-agent generate tests for fn(a: string[], b?: {x: number}): void",
97+
"ut-agent"
98+
);
99+
assert.equal(result, "generate tests for fn(a: string[], b?: {x: number}): void");
100+
});
101+
102+
test("Property 5: task with unicode characters is preserved", () => {
103+
const result = extractAgentTaskPrompt("/coder 修复登录bug", "coder");
104+
assert.equal(result, "修复登录bug");
105+
});
106+
107+
test("Property 5: trailing whitespace on input is handled by initial trim", () => {
108+
const result = extractAgentTaskPrompt("/deploy-assistant fix build \n\n", "deploy-assistant");
109+
assert.equal(result, "fix build");
110+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Property test: Slash command generation matches discovered agents
3+
*
4+
* **Validates: Requirements 3.1**
5+
*
6+
* Property statement: For any non-empty set of discovered agents,
7+
* `buildSlashCommands` SHALL produce exactly one slash command item
8+
* of kind "agent" for each discovered agent, with the command name
9+
* matching the agent's name.
10+
*/
11+
12+
import { test } from "node:test";
13+
import assert from "node:assert/strict";
14+
import { buildSlashCommands } from "../ui";
15+
import type { AgentManifest } from "@vegamo/deepcode-core";
16+
import type { SkillInfo } from "@vegamo/deepcode-core";
17+
18+
// -- Test helpers --
19+
20+
function makeAgent(overrides: Partial<AgentManifest> = {}): AgentManifest {
21+
return {
22+
name: overrides.name ?? "test-agent",
23+
description: overrides.description ?? "A test agent",
24+
model: overrides.model ?? "claude",
25+
skills: overrides.skills ?? [],
26+
instructions: overrides.instructions ?? "# Test Agent",
27+
sourcePath: overrides.sourcePath ?? "/fake/path/AGENT.md",
28+
sourceRoot: overrides.sourceRoot ?? "./.deepcode/agents",
29+
};
30+
}
31+
32+
const emptySkills: SkillInfo[] = [];
33+
34+
// -- Property tests --
35+
36+
test("Property 4: no agents produces no agent items", () => {
37+
const items = buildSlashCommands(emptySkills, []);
38+
const agentItems = items.filter((i) => i.kind === "agent");
39+
assert.equal(agentItems.length, 0);
40+
});
41+
42+
test("Property 4: single agent produces exactly one agent item with matching name", () => {
43+
const agents: AgentManifest[] = [makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" })];
44+
45+
const items = buildSlashCommands(emptySkills, agents);
46+
const agentItems = items.filter((i) => i.kind === "agent");
47+
48+
assert.equal(agentItems.length, 1);
49+
assert.equal(agentItems[0].name, "deploy-assistant");
50+
assert.equal(agentItems[0].kind, "agent");
51+
});
52+
53+
test("Property 4: multiple agents produce one item each with correct names", () => {
54+
const agents: AgentManifest[] = [
55+
makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" }),
56+
makeAgent({ name: "ut-agent", description: "Unit test generation" }),
57+
makeAgent({ name: "code-review", description: "Reviews code changes" }),
58+
];
59+
60+
const items = buildSlashCommands(emptySkills, agents);
61+
const agentItems = items.filter((i) => i.kind === "agent");
62+
63+
assert.equal(agentItems.length, 3);
64+
assert.deepEqual(
65+
agentItems.map((i) => i.name),
66+
["deploy-assistant", "ut-agent", "code-review"]
67+
);
68+
});
69+
70+
test("Property 4: agent items appear before skill items and built-in items", () => {
71+
const skills: SkillInfo[] = [
72+
{ name: "testing-skill", path: "/skills/testing-skill/SKILL.md", description: "A skill" },
73+
];
74+
const agents: AgentManifest[] = [makeAgent({ name: "deploy-assistant", description: "Deploy agent" })];
75+
76+
const items = buildSlashCommands(skills, agents);
77+
78+
// Find positions
79+
const agentIndex = items.findIndex((i) => i.kind === "agent");
80+
const skillIndex = items.findIndex((i) => i.kind === "skill");
81+
const builtinIndex = items.findIndex((i) => i.kind !== "agent" && i.kind !== "skill");
82+
83+
assert.ok(agentIndex < skillIndex, "Agent items should appear before skill items");
84+
assert.ok(agentIndex < builtinIndex, "Agent items should appear before built-in items");
85+
});
86+
87+
test("Property 4: each agent item has kind 'agent' and label matching /<name>", () => {
88+
const agents: AgentManifest[] = [
89+
makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" }),
90+
makeAgent({ name: "eaa", description: "Accessibility helper" }),
91+
];
92+
93+
const items = buildSlashCommands(emptySkills, agents);
94+
const agentItems = items.filter((i) => i.kind === "agent");
95+
96+
for (const agent of agents) {
97+
const item = agentItems.find((i) => i.name === agent.name);
98+
assert.ok(item, `Expected to find item for agent "${agent.name}"`);
99+
assert.equal(item.kind, "agent");
100+
assert.equal(item.label, `/${agent.name}`);
101+
assert.equal(item.description, agent.description);
102+
}
103+
});
104+
105+
test("Property 4: agent count matches exactly — no extra agent items", () => {
106+
const agents: AgentManifest[] = [
107+
makeAgent({ name: "agent-a", description: "First" }),
108+
makeAgent({ name: "agent-b", description: "Second" }),
109+
makeAgent({ name: "agent-c", description: "Third" }),
110+
makeAgent({ name: "agent-d", description: "Fourth" }),
111+
makeAgent({ name: "agent-e", description: "Fifth" }),
112+
];
113+
114+
const items = buildSlashCommands(emptySkills, agents);
115+
const agentItems = items.filter((i) => i.kind === "agent");
116+
117+
assert.equal(agentItems.length, agents.length);
118+
for (const agent of agents) {
119+
const matching = agentItems.filter((i) => i.name === agent.name);
120+
assert.equal(matching.length, 1, `Expected exactly one item for agent "${agent.name}"`);
121+
}
122+
});
123+
124+
test("Property 4: agent with empty description gets '(no description)'", () => {
125+
const agents: AgentManifest[] = [makeAgent({ name: "no-desc-agent", description: "" })];
126+
127+
const items = buildSlashCommands(emptySkills, agents);
128+
const agentItems = items.filter((i) => i.kind === "agent");
129+
130+
assert.equal(agentItems.length, 1);
131+
assert.equal(agentItems[0].description, "(no description)");
132+
});

packages/cli/src/tests/slash-commands.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
2020
assert.equal(items[0].name, "skill-writer");
2121
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
2222
assert.deepEqual(builtinNames, [
23+
"agents",
2324
"skills",
2425
"model",
2526
"new",

packages/cli/src/ui/components/MessageView/index.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "./utils";
1212
import type { DiffPreviewLine, MessageViewProps } from "./types";
1313
import { RawMode, useRawModeContext } from "../../contexts";
14+
import type { SessionMessage } from "@vegamo/deepcode-core";
1415

1516
const PROMPT_ECHO_PREFIX_WIDTH = 2;
1617
const PROMPT_ECHO_MARGIN_LEFT = 1;
@@ -125,6 +126,9 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
125126
</Box>
126127
);
127128
}
129+
if (message.meta?.agentName) {
130+
return <SubAgentActivityLine message={message} width={width} />;
131+
}
128132
return null;
129133
}
130134

@@ -167,7 +171,7 @@ function StatusLine({
167171
params,
168172
width,
169173
}: {
170-
bulletColor: "gray" | "green" | "red";
174+
bulletColor: "gray" | "green" | "red" | "magenta" | "yellow";
171175
name: string;
172176
params: string;
173177
width: number;
@@ -218,6 +222,43 @@ function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElemen
218222
);
219223
}
220224

225+
/**
226+
* Renders a persisted sub-agent progress message (tagged with meta.agentName)
227+
* distinctly from the main agent's own tool/thinking lines, so it's clear in
228+
* the transcript which lines came from a delegated sub-agent.
229+
*/
230+
function SubAgentActivityLine({ message, width }: { message: SessionMessage; width: number }): React.ReactElement {
231+
const agentName = message.meta?.agentName ?? "agent";
232+
const status = message.meta?.subAgentStatus;
233+
234+
if (status) {
235+
const bulletColor = status === "error" ? "red" : status === "model_fallback" ? "yellow" : "magenta";
236+
return (
237+
<Box marginLeft={1} marginBottom={1} marginY={0}>
238+
<StatusLine width={width} bulletColor={bulletColor} name={`[${agentName}]`} params={message.content ?? ""} />
239+
</Box>
240+
);
241+
}
242+
243+
// Sub-agent tool_result message: same visual shape as the main agent's
244+
// own tool status line, but prefixed with the sub-agent's name.
245+
const summary = buildToolSummary(message);
246+
const diffLines = getToolDiffPreviewLines(summary);
247+
const planLines = getUpdatePlanPreviewLines(summary);
248+
return (
249+
<Box flexDirection="column" marginLeft={1} marginBottom={1} marginY={0}>
250+
<StatusLine
251+
width={width}
252+
bulletColor={summary.ok ? "magenta" : "red"}
253+
name={`[${agentName}] ${formatStatusName(summary.name)}`}
254+
params={formatToolStatusParams(summary)}
255+
/>
256+
{diffLines.length > 0 ? <DiffPreview lines={diffLines} /> : null}
257+
{planLines.length > 0 ? <PlanPreview lines={planLines} /> : null}
258+
</Box>
259+
);
260+
}
261+
221262
function PlanPreview({ lines }: { lines: string[] }): React.ReactElement {
222263
return (
223264
<Box flexDirection="column" marginLeft={2}>

packages/cli/src/ui/components/MessageView/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,30 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s
271271
if (message.meta?.isSummary) {
272272
return chalk.dim.italic("(conversation summary inserted)");
273273
}
274+
if (message.meta?.agentName) {
275+
return renderSubAgentMessageToStdout(message);
276+
}
274277
return "";
275278
}
276279

277280
return "";
278281
}
279282

283+
/** Renders a persisted sub-agent progress message for Raw-mode scrollback output. */
284+
function renderSubAgentMessageToStdout(message: SessionMessage): string {
285+
const agentName = message.meta?.agentName ?? "agent";
286+
if (message.meta?.subAgentStatus) {
287+
return chalk(`✧ [${agentName}] ${message.content ?? ""}`);
288+
}
289+
290+
const summary = buildToolSummary(message);
291+
const params = formatToolStatusParams(summary);
292+
const statusLine = chalk(`✧ [${agentName}] ${formatStatusName(summary.name)}${params ? ` ${params}` : ""}`);
293+
const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : "";
294+
const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : "";
295+
return `${statusLine}${result}`;
296+
}
297+
280298
export function getUpdatePlanPreviewLines(summary: ToolSummary): string[] {
281299
if (!summary.ok || summary.name !== "UpdatePlan") {
282300
return [];

0 commit comments

Comments
 (0)