Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/cli/src/tests/agent-prompt-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Property test: Task prompt extraction from agent slash command
*
* **Validates: Requirements 3.3**
*
* Property statement: For any input string of the form `/<agent-name> <remaining text>`
* where `<agent-name>` matches a discovered agent, the CLI SHALL extract
* `<remaining text>` (trimmed) as the task prompt passed to the sub-agent session.
*
* This tests the extraction logic used in PromptInput.tsx handleSlashSelection
* for "agent" kind items.
*/

import { test } from "node:test";
import assert from "node:assert/strict";

// -- Extraction helper mirroring PromptInput.tsx logic --

/**
* Extracts the task prompt from a full input string given an agent command prefix.
* This mirrors the logic in PromptInput.tsx handleSlashSelection for "agent" kind:
*
* const fullText = buffer.text.trim();
* const commandPrefix = `/${item.name}`;
* const taskText = fullText.startsWith(commandPrefix)
* ? fullText.slice(commandPrefix.length).trim()
* : "";
*/
function extractAgentTaskPrompt(fullText: string, agentName: string): string {
const trimmed = fullText.trim();
const commandPrefix = `/${agentName}`;
return trimmed.startsWith(commandPrefix) ? trimmed.slice(commandPrefix.length).trim() : "";
}

// -- Property tests --

test("Property 5: basic task extraction — /agent-name some task text", () => {
const result = extractAgentTaskPrompt("/deploy-assistant fix the build", "deploy-assistant");
assert.equal(result, "fix the build");
});

test("Property 5: extra spaces in task text are trimmed", () => {
const result = extractAgentTaskPrompt("/ut-agent extra spaces ", "ut-agent");
assert.equal(result, "extra spaces");
});

test("Property 5: no task text after agent name yields empty string", () => {
const result = extractAgentTaskPrompt("/deploy-assistant", "deploy-assistant");
assert.equal(result, "");
});

test("Property 5: newlines in task body are preserved", () => {
const result = extractAgentTaskPrompt("/coder multi\nline task", "coder");
assert.equal(result, "multi\nline task");
});

test("Property 5: leading whitespace in input is trimmed before matching", () => {
const result = extractAgentTaskPrompt(" /eaa leading spaces ", "eaa");
assert.equal(result, "leading spaces");
});

test("Property 5: non-matching prefix returns empty string", () => {
const result = extractAgentTaskPrompt("/other-agent some text", "deploy-assistant");
assert.equal(result, "");
});

test("Property 5: agent name with single character works", () => {
const result = extractAgentTaskPrompt("/x do something", "x");
assert.equal(result, "do something");
});

test("Property 5: agent name as substring of input does not falsely match", () => {
// /deploy is a prefix of /deploy-assistant, but agentName is "deploy-assistant"
const result = extractAgentTaskPrompt("/deploy run tests", "deploy-assistant");
assert.equal(result, "");
});

test("Property 5: agent name exactly at boundary — task starts immediately after prefix", () => {
// No space between prefix and task — slice still captures it, trim just returns it
const result = extractAgentTaskPrompt("/codertask without space", "coder");
assert.equal(result, "task without space");
});

test("Property 5: empty input string yields empty string", () => {
const result = extractAgentTaskPrompt("", "deploy-assistant");
assert.equal(result, "");
});

test("Property 5: whitespace-only input yields empty string", () => {
const result = extractAgentTaskPrompt(" ", "deploy-assistant");
assert.equal(result, "");
});

test("Property 5: task with special characters is preserved", () => {
const result = extractAgentTaskPrompt(
"/ut-agent generate tests for fn(a: string[], b?: {x: number}): void",
"ut-agent"
);
assert.equal(result, "generate tests for fn(a: string[], b?: {x: number}): void");
});

test("Property 5: task with unicode characters is preserved", () => {
const result = extractAgentTaskPrompt("/coder 修复登录bug", "coder");
assert.equal(result, "修复登录bug");
});

test("Property 5: trailing whitespace on input is handled by initial trim", () => {
const result = extractAgentTaskPrompt("/deploy-assistant fix build \n\n", "deploy-assistant");
assert.equal(result, "fix build");
});
132 changes: 132 additions & 0 deletions packages/cli/src/tests/slash-commands-agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Property test: Slash command generation matches discovered agents
*
* **Validates: Requirements 3.1**
*
* Property statement: For any non-empty set of discovered agents,
* `buildSlashCommands` SHALL produce exactly one slash command item
* of kind "agent" for each discovered agent, with the command name
* matching the agent's name.
*/

import { test } from "node:test";
import assert from "node:assert/strict";
import { buildSlashCommands } from "../ui";
import type { AgentManifest } from "@vegamo/deepcode-core";
import type { SkillInfo } from "@vegamo/deepcode-core";

// -- Test helpers --

function makeAgent(overrides: Partial<AgentManifest> = {}): AgentManifest {
return {
name: overrides.name ?? "test-agent",
description: overrides.description ?? "A test agent",
model: overrides.model ?? "claude",
skills: overrides.skills ?? [],
instructions: overrides.instructions ?? "# Test Agent",
sourcePath: overrides.sourcePath ?? "/fake/path/AGENT.md",
sourceRoot: overrides.sourceRoot ?? "./.deepcode/agents",
};
}

const emptySkills: SkillInfo[] = [];

// -- Property tests --

test("Property 4: no agents produces no agent items", () => {
const items = buildSlashCommands(emptySkills, []);
const agentItems = items.filter((i) => i.kind === "agent");
assert.equal(agentItems.length, 0);
});

test("Property 4: single agent produces exactly one agent item with matching name", () => {
const agents: AgentManifest[] = [makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" })];

const items = buildSlashCommands(emptySkills, agents);
const agentItems = items.filter((i) => i.kind === "agent");

assert.equal(agentItems.length, 1);
assert.equal(agentItems[0].name, "deploy-assistant");
assert.equal(agentItems[0].kind, "agent");
});

test("Property 4: multiple agents produce one item each with correct names", () => {
const agents: AgentManifest[] = [
makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" }),
makeAgent({ name: "ut-agent", description: "Unit test generation" }),
makeAgent({ name: "code-review", description: "Reviews code changes" }),
];

const items = buildSlashCommands(emptySkills, agents);
const agentItems = items.filter((i) => i.kind === "agent");

assert.equal(agentItems.length, 3);
assert.deepEqual(
agentItems.map((i) => i.name),
["deploy-assistant", "ut-agent", "code-review"]
);
});

test("Property 4: agent items appear before skill items and built-in items", () => {
const skills: SkillInfo[] = [
{ name: "testing-skill", path: "/skills/testing-skill/SKILL.md", description: "A skill" },
];
const agents: AgentManifest[] = [makeAgent({ name: "deploy-assistant", description: "Deploy agent" })];

const items = buildSlashCommands(skills, agents);

// Find positions
const agentIndex = items.findIndex((i) => i.kind === "agent");
const skillIndex = items.findIndex((i) => i.kind === "skill");
const builtinIndex = items.findIndex((i) => i.kind !== "agent" && i.kind !== "skill");

assert.ok(agentIndex < skillIndex, "Agent items should appear before skill items");
assert.ok(agentIndex < builtinIndex, "Agent items should appear before built-in items");
});

test("Property 4: each agent item has kind 'agent' and label matching /<name>", () => {
const agents: AgentManifest[] = [
makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" }),
makeAgent({ name: "eaa", description: "Accessibility helper" }),
];

const items = buildSlashCommands(emptySkills, agents);
const agentItems = items.filter((i) => i.kind === "agent");

for (const agent of agents) {
const item = agentItems.find((i) => i.name === agent.name);
assert.ok(item, `Expected to find item for agent "${agent.name}"`);
assert.equal(item.kind, "agent");
assert.equal(item.label, `/${agent.name}`);
assert.equal(item.description, agent.description);
}
});

test("Property 4: agent count matches exactly — no extra agent items", () => {
const agents: AgentManifest[] = [
makeAgent({ name: "agent-a", description: "First" }),
makeAgent({ name: "agent-b", description: "Second" }),
makeAgent({ name: "agent-c", description: "Third" }),
makeAgent({ name: "agent-d", description: "Fourth" }),
makeAgent({ name: "agent-e", description: "Fifth" }),
];

const items = buildSlashCommands(emptySkills, agents);
const agentItems = items.filter((i) => i.kind === "agent");

assert.equal(agentItems.length, agents.length);
for (const agent of agents) {
const matching = agentItems.filter((i) => i.name === agent.name);
assert.equal(matching.length, 1, `Expected exactly one item for agent "${agent.name}"`);
}
});

test("Property 4: agent with empty description gets '(no description)'", () => {
const agents: AgentManifest[] = [makeAgent({ name: "no-desc-agent", description: "" })];

const items = buildSlashCommands(emptySkills, agents);
const agentItems = items.filter((i) => i.kind === "agent");

assert.equal(agentItems.length, 1);
assert.equal(agentItems[0].description, "(no description)");
});
1 change: 1 addition & 0 deletions packages/cli/src/tests/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
assert.equal(items[0].name, "skill-writer");
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
assert.deepEqual(builtinNames, [
"agents",
"skills",
"model",
"new",
Expand Down
43 changes: 42 additions & 1 deletion packages/cli/src/ui/components/MessageView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./utils";
import type { DiffPreviewLine, MessageViewProps } from "./types";
import { RawMode, useRawModeContext } from "../../contexts";
import type { SessionMessage } from "@vegamo/deepcode-core";

const PROMPT_ECHO_PREFIX_WIDTH = 2;
const PROMPT_ECHO_MARGIN_LEFT = 1;
Expand Down Expand Up @@ -125,6 +126,9 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
</Box>
);
}
if (message.meta?.agentName) {
return <SubAgentActivityLine message={message} width={width} />;
}
return null;
}

Expand Down Expand Up @@ -167,7 +171,7 @@ function StatusLine({
params,
width,
}: {
bulletColor: "gray" | "green" | "red";
bulletColor: "gray" | "green" | "red" | "magenta" | "yellow";
name: string;
params: string;
width: number;
Expand Down Expand Up @@ -218,6 +222,43 @@ function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElemen
);
}

/**
* Renders a persisted sub-agent progress message (tagged with meta.agentName)
* distinctly from the main agent's own tool/thinking lines, so it's clear in
* the transcript which lines came from a delegated sub-agent.
*/
function SubAgentActivityLine({ message, width }: { message: SessionMessage; width: number }): React.ReactElement {
const agentName = message.meta?.agentName ?? "agent";
const status = message.meta?.subAgentStatus;

if (status) {
const bulletColor = status === "error" ? "red" : status === "model_fallback" ? "yellow" : "magenta";
return (
<Box marginLeft={1} marginBottom={1} marginY={0}>
<StatusLine width={width} bulletColor={bulletColor} name={`[${agentName}]`} params={message.content ?? ""} />
</Box>
);
}

// Sub-agent tool_result message: same visual shape as the main agent's
// own tool status line, but prefixed with the sub-agent's name.
const summary = buildToolSummary(message);
const diffLines = getToolDiffPreviewLines(summary);
const planLines = getUpdatePlanPreviewLines(summary);
return (
<Box flexDirection="column" marginLeft={1} marginBottom={1} marginY={0}>
<StatusLine
width={width}
bulletColor={summary.ok ? "magenta" : "red"}
name={`[${agentName}] ${formatStatusName(summary.name)}`}
params={formatToolStatusParams(summary)}
/>
{diffLines.length > 0 ? <DiffPreview lines={diffLines} /> : null}
{planLines.length > 0 ? <PlanPreview lines={planLines} /> : null}
</Box>
);
}

function PlanPreview({ lines }: { lines: string[] }): React.ReactElement {
return (
<Box flexDirection="column" marginLeft={2}>
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/ui/components/MessageView/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,30 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s
if (message.meta?.isSummary) {
return chalk.dim.italic("(conversation summary inserted)");
}
if (message.meta?.agentName) {
return renderSubAgentMessageToStdout(message);
}
return "";
}

return "";
}

/** Renders a persisted sub-agent progress message for Raw-mode scrollback output. */
function renderSubAgentMessageToStdout(message: SessionMessage): string {
const agentName = message.meta?.agentName ?? "agent";
if (message.meta?.subAgentStatus) {
return chalk(`✧ [${agentName}] ${message.content ?? ""}`);
}

const summary = buildToolSummary(message);
const params = formatToolStatusParams(summary);
const statusLine = chalk(`✧ [${agentName}] ${formatStatusName(summary.name)}${params ? ` ${params}` : ""}`);
const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : "";
const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : "";
return `${statusLine}${result}`;
}

export function getUpdatePlanPreviewLines(summary: ToolSummary): string[] {
if (!summary.ok || summary.name !== "UpdatePlan") {
return [];
Expand Down
Loading