From 0ec2fc62db5866981c031042ea04c8652a6e8050 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 11 Jan 2026 17:16:08 +0700 Subject: [PATCH] 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 --- packages/app/src/app/index.tsx | 28 ++- .../src/poc-commands/commands-poc.test.ts | 134 +++++++++++ .../investigate-command-output.ts | 189 ++++++++++++++++ packages/server/src/poc-commands/run-poc.ts | 214 ++++++++++++++++++ .../src/server/agent/agent-sdk-types.ts | 31 +++ .../providers/claude-agent-commands.test.ts | 180 +++++++++++++++ .../server/agent/providers/claude-agent.ts | 60 ++++- 7 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/poc-commands/commands-poc.test.ts create mode 100644 packages/server/src/poc-commands/investigate-command-output.ts create mode 100644 packages/server/src/poc-commands/run-poc.ts create mode 100644 packages/server/src/server/agent/providers/claude-agent-commands.test.ts diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index c95743b2a..034c5af27 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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(); + 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() { } /> - + + ) : null} + ({ }, contentContainer: { flex: 1, + overflow: "hidden", + }, + scrollView: { + flex: 1, }, inputAreaWrapper: { backgroundColor: theme.colors.background, diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts new file mode 100644 index 000000000..79fe79a51 --- /dev/null +++ b/packages/server/src/poc-commands/commands-poc.test.ts @@ -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 { + 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 + }); + }); +}); diff --git a/packages/server/src/poc-commands/investigate-command-output.ts b/packages/server/src/poc-commands/investigate-command-output.ts new file mode 100644 index 000000000..b837e0a0f --- /dev/null +++ b/packages/server/src/poc-commands/investigate-command-output.ts @@ -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 implements AsyncIterable { + private queue: T[] = []; + private resolvers: Array<(value: IteratorResult) => 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 { + return { + next: (): Promise> => { + 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>((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } +} + +async function investigateCommand(commandName: string): Promise { + console.log(`\n${"=".repeat(60)}`); + console.log(`Investigating: /${commandName}`); + console.log("=".repeat(60)); + + const input = new Pushable(); + + 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); +}); diff --git a/packages/server/src/poc-commands/run-poc.ts b/packages/server/src/poc-commands/run-poc.ts new file mode 100644 index 000000000..2c6e96081 --- /dev/null +++ b/packages/server/src/poc-commands/run-poc.ts @@ -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 { + return (async function* empty() {})(); +} + +// Utility: Create a pushable stream for SDK input (for command execution demo) +class Pushable implements AsyncIterable { + private queue: T[] = []; + private resolvers: Array<(value: IteratorResult) => 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 { + return { + next: (): Promise> => { + 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>((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } +} + +async function listAvailableCommands(): Promise { + // 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 { + console.log(`\n=== Executing command: /${commandName} ===`); + + // For command execution, we need a proper input stream + const input = new Pushable(); + + 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); +}); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 52a8c8d00..13d6341db 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; @@ -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; + /** + * 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; } export interface ListModelsOptions { diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts new file mode 100644 index 000000000..2e83473da --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index b757344a3..e8ed6d44f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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 { + 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 { + const commandPrompt = args ? `/${commandName} ${args}` : `/${commandName}`; + + // Commands return their output in a user message with 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(/([\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 { 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" });