feat(agent): add command support to ClaudeAgentSession
- Add AgentSlashCommand and AgentCommandResult types - Add listCommands() and executeCommand() to AgentSession interface - Implement command support in ClaudeAgentSession - Fix user message handling for local command output (string content) - Add POC scripts for command investigation - Add TDD tests for command functionality
This commit is contained in:
@@ -10,6 +10,9 @@ import {
|
||||
} from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { Monitor } from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
@@ -79,9 +82,26 @@ type DraftAgentParams = {
|
||||
export default function HomeScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const params = useLocalSearchParams<DraftAgentParams>();
|
||||
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [insets.bottom, bottomInset]);
|
||||
|
||||
const animatedKeyboardStyle = useAnimatedStyle(() => {
|
||||
"worklet";
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
const shift = Math.max(0, absoluteHeight - bottomInset.value);
|
||||
return {
|
||||
transform: [{ translateY: -shift }],
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedServerId = getParamValue(params.serverId);
|
||||
const resolvedProvider = getValidProvider(getParamValue(params.provider));
|
||||
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
|
||||
@@ -569,7 +589,8 @@ export default function HomeScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.contentContainer} contentContainerStyle={styles.configScrollContent}>
|
||||
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
<WorkingDirectoryDropdown
|
||||
workingDir={workingDir}
|
||||
@@ -658,6 +679,7 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
<View style={styles.inputAreaWrapper}>
|
||||
<AgentInputArea
|
||||
agentId={DRAFT_AGENT_ID}
|
||||
@@ -695,6 +717,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
inputAreaWrapper: {
|
||||
backgroundColor: theme.colors.background,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* TDD Test Suite for Command Support POC
|
||||
*
|
||||
* This tests the ability to:
|
||||
* 1. Get available commands/skills from the Claude Agent SDK
|
||||
* 2. Execute commands (determine if they're just prompts or something else)
|
||||
*
|
||||
* Key findings from SDK analysis:
|
||||
* - `supportedCommands()` returns SlashCommand[] with name, description, argumentHint
|
||||
* - Commands are executed by sending them as prompts with / prefix
|
||||
* - The SDK init message includes `slash_commands: string[]` and `skills: string[]`
|
||||
*
|
||||
* IMPORTANT: Control methods like supportedCommands() work WITHOUT iterating the query first!
|
||||
* Use an empty async generator for the prompt when you just need control methods.
|
||||
* This pattern is used in claude-agent.ts listModels().
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
query,
|
||||
type Query,
|
||||
type SlashCommand,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
// Pattern from claude-agent.ts listModels():
|
||||
// Use an empty async generator when you just need control methods
|
||||
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
describe("Claude Agent SDK Commands POC", () => {
|
||||
describe("supportedCommands() API", () => {
|
||||
it("should return an array of SlashCommand objects", async () => {
|
||||
// Use the pattern from claude-agent.ts:
|
||||
// Create a query with empty prompt generator for control methods
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
|
||||
const claudeQuery = query({
|
||||
prompt: emptyPrompt,
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: ["user", "project"], // Required to load skills
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// supportedCommands() is a control method - works without iterating
|
||||
const commands = await claudeQuery.supportedCommands();
|
||||
|
||||
// Should be an array
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
|
||||
// Log what we found
|
||||
console.log(`[Test] Found ${commands.length} commands:`);
|
||||
commands.slice(0, 10).forEach((cmd) => {
|
||||
console.log(` - /${cmd.name}: ${cmd.description} [${cmd.argumentHint}]`);
|
||||
});
|
||||
if (commands.length > 10) {
|
||||
console.log(` ... and ${commands.length - 10} more`);
|
||||
}
|
||||
|
||||
// Verify structure
|
||||
if (commands.length > 0) {
|
||||
const firstCommand = commands[0];
|
||||
expect(typeof firstCommand.name).toBe("string");
|
||||
expect(typeof firstCommand.description).toBe("string");
|
||||
expect(typeof firstCommand.argumentHint).toBe("string");
|
||||
expect(firstCommand.name.startsWith("/")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
if (typeof claudeQuery.return === "function") {
|
||||
try {
|
||||
await claudeQuery.return();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("should have valid SlashCommand structure for all commands", async () => {
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
|
||||
const claudeQuery = query({
|
||||
prompt: emptyPrompt,
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
settingSources: ["user", "project"],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const commands = await claudeQuery.supportedCommands();
|
||||
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all commands have valid structure
|
||||
for (const cmd of commands) {
|
||||
expect(cmd).toHaveProperty("name");
|
||||
expect(cmd).toHaveProperty("description");
|
||||
expect(cmd).toHaveProperty("argumentHint");
|
||||
expect(typeof cmd.name).toBe("string");
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(typeof cmd.argumentHint).toBe("string");
|
||||
expect(cmd.name.length).toBeGreaterThan(0);
|
||||
expect(cmd.name.startsWith("/")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await claudeQuery.return?.();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Command Execution", () => {
|
||||
it("should explain that commands are prompts with / prefix", () => {
|
||||
// This is a documentation test - commands ARE just prompts with / prefix
|
||||
// To execute a command:
|
||||
// 1. Create a user message with content: "/{commandName}"
|
||||
// 2. Push it to the input stream
|
||||
// 3. Iterate the query to receive responses
|
||||
|
||||
// The SDK handles command expansion:
|
||||
// - Slash commands expand to their template content
|
||||
// - Skills invoke the Skill tool
|
||||
// - The model processes the expanded prompt like any other
|
||||
|
||||
expect(true).toBe(true); // Documentation-only test
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Investigation: What does command execution actually return?
|
||||
*
|
||||
* This script logs ALL message types and their full structure
|
||||
* to understand how command output is delivered.
|
||||
*/
|
||||
|
||||
import {
|
||||
query,
|
||||
type SDKUserMessage,
|
||||
type SDKMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
|
||||
private closed = false;
|
||||
|
||||
push(item: T) {
|
||||
if (this.closed) return;
|
||||
if (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value: item, done: false });
|
||||
} else {
|
||||
this.queue.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
end() {
|
||||
this.closed = true;
|
||||
while (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<T, void> {
|
||||
return {
|
||||
next: (): Promise<IteratorResult<T, void>> => {
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift();
|
||||
if (value !== undefined) {
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
}
|
||||
if (this.closed) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise<IteratorResult<T, void>>((resolve) => {
|
||||
this.resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function investigateCommand(commandName: string): Promise<void> {
|
||||
console.log(`\n${"=".repeat(60)}`);
|
||||
console.log(`Investigating: /${commandName}`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const claudeQuery = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: true, // Include streaming partial messages
|
||||
settingSources: ["user", "project"],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const userMessage: SDKUserMessage = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: `/${commandName}`,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: "",
|
||||
};
|
||||
|
||||
input.push(userMessage);
|
||||
|
||||
let messageCount = 0;
|
||||
for await (const message of claudeQuery) {
|
||||
messageCount++;
|
||||
console.log(`\n--- Message ${messageCount} ---`);
|
||||
console.log(`Type: ${message.type}`);
|
||||
|
||||
// Log the full structure based on type
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
console.log(`Subtype: ${message.subtype}`);
|
||||
if (message.subtype === "init") {
|
||||
console.log(`Session: ${message.session_id}`);
|
||||
console.log(`Model: ${message.model}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "user":
|
||||
console.log(`User message content:`, JSON.stringify(message.message?.content, null, 2));
|
||||
break;
|
||||
|
||||
case "assistant":
|
||||
console.log(`Assistant message content:`);
|
||||
const content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
console.log(` Block type: ${block.type}`);
|
||||
if (block.type === "text") {
|
||||
console.log(` Text: ${block.text}`);
|
||||
} else if (block.type === "tool_use") {
|
||||
console.log(` Tool: ${block.name}`);
|
||||
console.log(` Input: ${JSON.stringify(block.input, null, 2)}`);
|
||||
} else {
|
||||
console.log(` Full block: ${JSON.stringify(block, null, 2)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(` Content: ${JSON.stringify(content, null, 2)}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "stream_event":
|
||||
console.log(`Stream event type: ${message.event?.type}`);
|
||||
if (message.event?.type === "content_block_delta") {
|
||||
const delta = message.event.delta;
|
||||
if (delta?.type === "text_delta") {
|
||||
console.log(` Text delta: ${delta.text}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "result":
|
||||
console.log(`Result subtype: ${message.subtype}`);
|
||||
if ("errors" in message && message.errors) {
|
||||
console.log(`Errors: ${JSON.stringify(message.errors)}`);
|
||||
}
|
||||
// Check for any other properties
|
||||
const resultKeys = Object.keys(message).filter(k => !["type", "subtype"].includes(k));
|
||||
if (resultKeys.length > 0) {
|
||||
console.log(`Other result properties: ${resultKeys.join(", ")}`);
|
||||
for (const key of resultKeys) {
|
||||
console.log(` ${key}: ${JSON.stringify((message as any)[key], null, 2)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Full message: ${JSON.stringify(message, null, 2)}`);
|
||||
}
|
||||
|
||||
if (message.type === "result") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal messages received: ${messageCount}`);
|
||||
|
||||
} finally {
|
||||
input.end();
|
||||
await claudeQuery.return?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== Command Output Investigation ===\n");
|
||||
|
||||
// Test /context - a local command that shows context info
|
||||
await investigateCommand("context");
|
||||
|
||||
// Test /cost - another local command
|
||||
await investigateCommand("cost");
|
||||
|
||||
// Test /prompt-engineer - a SKILL (not a local command)
|
||||
await investigateCommand("prompt-engineer");
|
||||
|
||||
console.log("\n=== Investigation Complete ===");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* POC Script: Claude Agent SDK Commands
|
||||
*
|
||||
* This script demonstrates how to:
|
||||
* 1. Get available slash commands/skills from the Claude Agent SDK
|
||||
* 2. Execute commands (they are prompts sent with / prefix)
|
||||
*
|
||||
* Key insight from existing claude-agent.ts:
|
||||
* - Control methods like supportedCommands() work WITHOUT iterating the query first
|
||||
* - Use an empty async generator for the prompt when you just want to call control methods
|
||||
* - Commands are executed by sending them as prompts with / prefix to a streaming query
|
||||
*
|
||||
* Usage: npx tsx src/poc-commands/run-poc.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
query,
|
||||
type Query,
|
||||
type SlashCommand,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
// Pattern from claude-agent.ts listModels():
|
||||
// Use an empty async generator when you just need control methods
|
||||
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
// Utility: Create a pushable stream for SDK input (for command execution demo)
|
||||
class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
|
||||
private closed = false;
|
||||
|
||||
push(item: T) {
|
||||
if (this.closed) return;
|
||||
if (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value: item, done: false });
|
||||
} else {
|
||||
this.queue.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
end() {
|
||||
this.closed = true;
|
||||
while (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<T, void> {
|
||||
return {
|
||||
next: (): Promise<IteratorResult<T, void>> => {
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift();
|
||||
if (value !== undefined) {
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
}
|
||||
if (this.closed) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise<IteratorResult<T, void>>((resolve) => {
|
||||
this.resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function listAvailableCommands(): Promise<SlashCommand[]> {
|
||||
// Use the pattern from claude-agent.ts listModels():
|
||||
// Create a query with an empty prompt generator to call control methods
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
|
||||
const claudeQuery = query({
|
||||
prompt: emptyPrompt,
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: ["user", "project"], // Required to load skills
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// supportedCommands() is a control method - works without iterating
|
||||
const commands = await claudeQuery.supportedCommands();
|
||||
return commands;
|
||||
} finally {
|
||||
// Clean up
|
||||
if (typeof claudeQuery.return === "function") {
|
||||
try {
|
||||
await claudeQuery.return();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCommand(commandName: string): Promise<void> {
|
||||
console.log(`\n=== Executing command: /${commandName} ===`);
|
||||
|
||||
// For command execution, we need a proper input stream
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const claudeQuery = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: ["user", "project"],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Push the command as a user message with / prefix
|
||||
const userMessage: SDKUserMessage = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: `/${commandName}`,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: "",
|
||||
};
|
||||
|
||||
input.push(userMessage);
|
||||
|
||||
// Iterate the query to process the command
|
||||
let gotSystemInit = false;
|
||||
for await (const message of claudeQuery) {
|
||||
console.log(` [${message.type}]`, message.type === "system" ? message.subtype : "");
|
||||
|
||||
if (message.type === "system" && message.subtype === "init") {
|
||||
gotSystemInit = true;
|
||||
console.log(" Session:", message.session_id);
|
||||
console.log(" Model:", message.model);
|
||||
}
|
||||
|
||||
if (message.type === "assistant") {
|
||||
const content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text") {
|
||||
console.log(" Response:", block.text.slice(0, 200) + (block.text.length > 200 ? "..." : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "result") {
|
||||
console.log(" Result:", message.subtype);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
input.end();
|
||||
await claudeQuery.return?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== Claude Agent SDK Commands POC ===\n");
|
||||
|
||||
// PART 1: List available commands using supportedCommands()
|
||||
console.log("=== Part 1: List Available Commands ===\n");
|
||||
|
||||
try {
|
||||
const commands = await listAvailableCommands();
|
||||
|
||||
console.log(`Found ${commands.length} commands:\n`);
|
||||
commands.forEach((cmd, index) => {
|
||||
console.log(` ${index + 1}. /${cmd.name}`);
|
||||
console.log(` Description: ${cmd.description}`);
|
||||
if (cmd.argumentHint) {
|
||||
console.log(` Arguments: ${cmd.argumentHint}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
|
||||
// PART 2: Demonstrate command execution (optional - uncomment to test)
|
||||
// Commands are just prompts sent with / prefix
|
||||
console.log("=== Part 2: Command Execution Explanation ===");
|
||||
console.log("");
|
||||
console.log("Commands are executed by sending them as prompts with / prefix.");
|
||||
console.log("For example, to execute the 'help' command:");
|
||||
console.log(' 1. Create a user message with content: "/help"');
|
||||
console.log(" 2. Push it to the input stream");
|
||||
console.log(" 3. Iterate the query to receive responses");
|
||||
console.log("");
|
||||
|
||||
// Actually execute a command to demonstrate it works:
|
||||
// Using "context" as it's fast and doesn't require arguments
|
||||
await executeCommand("context");
|
||||
|
||||
} catch (error) {
|
||||
console.error("ERROR:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("=== POC Complete ===");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -152,6 +152,25 @@ export type AgentRuntimeInfo = {
|
||||
extra?: AgentMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a slash command available in an agent session.
|
||||
* Commands are executed by sending them as prompts with / prefix.
|
||||
*/
|
||||
export type AgentSlashCommand = {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result from executing a slash command.
|
||||
*/
|
||||
export type AgentCommandResult = {
|
||||
text: string;
|
||||
timeline: AgentTimelineItem[];
|
||||
usage?: AgentUsage;
|
||||
};
|
||||
|
||||
export type AgentControlMcpConfig = {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
@@ -213,6 +232,18 @@ export interface AgentSession {
|
||||
* (for parent-child agent relationships).
|
||||
*/
|
||||
setManagedAgentId?(agentId: string): void;
|
||||
/**
|
||||
* List available slash commands for this session.
|
||||
* Commands are provider-specific - Claude supports skills and built-in commands.
|
||||
*/
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
/**
|
||||
* Execute a slash command by name.
|
||||
* The command name should NOT include the leading "/" - it will be added automatically.
|
||||
* @param commandName The command name (e.g., "help", "context")
|
||||
* @param args Optional arguments to pass to the command
|
||||
*/
|
||||
executeCommand?(commandName: string, args?: string): Promise<AgentCommandResult>;
|
||||
}
|
||||
|
||||
export interface ListModelsOptions {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* TDD Tests for Claude Agent Commands Integration
|
||||
*
|
||||
* Tests the ability to:
|
||||
* 1. List available slash commands from a ClaudeAgentSession
|
||||
* 2. Execute slash commands through ClaudeAgentSession
|
||||
*
|
||||
* These tests verify that the agent abstraction layer properly exposes
|
||||
* the Claude Agent SDK's command capabilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js";
|
||||
|
||||
describe("ClaudeAgentSession Commands", () => {
|
||||
let client: ClaudeAgentClient;
|
||||
let session: AgentSession;
|
||||
|
||||
// Mock config for testing - uses plan mode to avoid actual tool execution
|
||||
const testConfig: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "plan",
|
||||
agentControlMcp: {
|
||||
url: "http://localhost:6767/mcp", // Placeholder - not actually used in plan mode
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new ClaudeAgentClient();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (session) {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("listCommands()", () => {
|
||||
it("should return an array of AgentSlashCommand objects", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
// The session should have a listCommands method
|
||||
expect(typeof session.listCommands).toBe("function");
|
||||
|
||||
const commands = await session.listCommands!();
|
||||
|
||||
// Should be an array
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
|
||||
// Should have at least some built-in commands
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
console.log(`[Test] Found ${commands.length} commands:`);
|
||||
commands.slice(0, 5).forEach((cmd) => {
|
||||
console.log(` - /${cmd.name}: ${cmd.description}`);
|
||||
});
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
|
||||
it("should have valid AgentSlashCommand structure for all commands", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
const commands = await session.listCommands!();
|
||||
|
||||
// Verify all commands have valid structure
|
||||
for (const cmd of commands) {
|
||||
expect(cmd).toHaveProperty("name");
|
||||
expect(cmd).toHaveProperty("description");
|
||||
expect(cmd).toHaveProperty("argumentHint");
|
||||
expect(typeof cmd.name).toBe("string");
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(typeof cmd.argumentHint).toBe("string");
|
||||
expect(cmd.name.length).toBeGreaterThan(0);
|
||||
// Names should NOT have the / prefix (that's added when executing)
|
||||
expect(cmd.name.startsWith("/")).toBe(false);
|
||||
}
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
|
||||
it("should include user-defined skills", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
const commands = await session.listCommands!();
|
||||
const commandNames = commands.map((cmd) => cmd.name);
|
||||
|
||||
// Should have at least one command (skills are loaded from user/project settings)
|
||||
// The exact commands depend on what skills are configured
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
// Log all available commands for debugging
|
||||
console.log(`[Test] Available commands: ${commandNames.join(", ")}`);
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("executeCommand()", () => {
|
||||
it("should execute a command and return a result", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
// The session should have an executeCommand method
|
||||
expect(typeof session.executeCommand).toBe("function");
|
||||
|
||||
// First get available commands to use one that exists
|
||||
const commands = await session.listCommands!();
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
// Execute one of the available commands
|
||||
const firstCommand = commands[0];
|
||||
console.log(`[Test] Executing command: /${firstCommand.name}`);
|
||||
const result = await session.executeCommand!(firstCommand.name);
|
||||
|
||||
// Should return a result object
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.text).toBe("string");
|
||||
// Note: Some commands may return empty text (e.g., plugin descriptions)
|
||||
|
||||
console.log(`[Test] Command /${firstCommand.name} result (first 200 chars): ${result.text.slice(0, 200)}...`);
|
||||
|
||||
await session.close();
|
||||
}, 60000);
|
||||
|
||||
it("should return non-empty output for /context command", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
// Execute /context - a local command that returns token usage info
|
||||
const result = await session.executeCommand!("context");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.text).toBe("string");
|
||||
|
||||
// /context MUST return actual content - this is the key test
|
||||
// If this fails, user messages from SDK are not being captured
|
||||
expect(result.text.length).toBeGreaterThan(0);
|
||||
expect(result.text).toContain("Context");
|
||||
|
||||
console.log(`[Test] Command /context result: ${result.text.slice(0, 300)}...`);
|
||||
|
||||
await session.close();
|
||||
}, 60000);
|
||||
|
||||
it("should include timeline items in result", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
// First get available commands to use one that exists
|
||||
const commands = await session.listCommands!();
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
const firstCommand = commands[0];
|
||||
const result = await session.executeCommand!(firstCommand.name);
|
||||
|
||||
// Result should include timeline items from the command execution
|
||||
expect(Array.isArray(result.timeline)).toBe(true);
|
||||
|
||||
// Timeline may or may not have items depending on command execution
|
||||
// Just verify the structure is correct
|
||||
console.log(`[Test] Timeline has ${result.timeline.length} items`);
|
||||
|
||||
await session.close();
|
||||
}, 60000);
|
||||
|
||||
it("should throw for non-existent command", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
// Non-existent commands should still work (they get sent as prompts)
|
||||
// but the response should indicate the command doesn't exist
|
||||
// This is how the SDK works - invalid commands become regular prompts
|
||||
const result = await session.executeCommand!("nonexistent_command_xyz");
|
||||
|
||||
// The result should exist (command is treated as a prompt)
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await session.close();
|
||||
}, 60000);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCommandResult,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
@@ -720,6 +722,55 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.managedAgentId = agentId;
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
const q = await this.ensureQuery();
|
||||
const commands = await q.supportedCommands();
|
||||
return commands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
argumentHint: cmd.argumentHint,
|
||||
}));
|
||||
}
|
||||
|
||||
async executeCommand(commandName: string, args?: string): Promise<AgentCommandResult> {
|
||||
const commandPrompt = args ? `/${commandName} ${args}` : `/${commandName}`;
|
||||
|
||||
// Commands return their output in a user message with <local-command-stdout> tags,
|
||||
// NOT as an assistant message. We need to extract that specifically.
|
||||
const events = this.stream(commandPrompt);
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
let commandOutput = "";
|
||||
let usage: AgentUsage | undefined;
|
||||
|
||||
for await (const event of events) {
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
// Check for command output in user messages
|
||||
if (event.item.type === "user_message") {
|
||||
const text = event.item.text;
|
||||
const match = text.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
|
||||
if (match) {
|
||||
commandOutput = match[1].trim();
|
||||
}
|
||||
}
|
||||
// Also capture assistant messages (some commands may produce both)
|
||||
if (event.item.type === "assistant_message" && !commandOutput) {
|
||||
commandOutput = event.item.text;
|
||||
}
|
||||
} else if (event.type === "turn_completed") {
|
||||
usage = event.usage;
|
||||
} else if (event.type === "turn_failed") {
|
||||
throw new Error(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: commandOutput,
|
||||
timeline,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureQuery(): Promise<Query> {
|
||||
if (this.query) {
|
||||
return this.query;
|
||||
@@ -976,7 +1027,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
break;
|
||||
case "user": {
|
||||
const content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
// String content from user messages (e.g., local command output)
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { type: "user_message", text: content },
|
||||
provider: "claude",
|
||||
});
|
||||
} else if (Array.isArray(content)) {
|
||||
const timelineItems = this.mapBlocksToTimeline(content, { turnContext });
|
||||
for (const item of timelineItems) {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
|
||||
Reference in New Issue
Block a user