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:
Mohamed Boudra
2026-01-11 17:16:08 +07:00
parent e57cad2660
commit 0ec2fc62db
7 changed files with 834 additions and 2 deletions
@@ -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);
});
+214
View File
@@ -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);
});