diff --git a/apps/backend/cmd/kandev/initial_agent.go b/apps/backend/cmd/kandev/initial_agent.go index 22df49babb..613d6ddac9 100644 --- a/apps/backend/cmd/kandev/initial_agent.go +++ b/apps/backend/cmd/kandev/initial_agent.go @@ -14,6 +14,13 @@ func runInitialAgentSetup( agentSettingsController *agentsettingscontroller.Controller, log *logger.Logger, ) error { + // Always run EnsureInitialAgentProfiles to detect newly added agents + // This is idempotent - it only creates profiles for agents that don't have any + if err := agentSettingsController.EnsureInitialAgentProfiles(ctx); err != nil { + return err + } + + // Mark initial setup as complete if not already settings, err := userSvc.GetUserSettings(ctx) if err != nil { return err @@ -21,9 +28,6 @@ func runInitialAgentSetup( if settings.InitialSetupComplete { return nil } - if err := agentSettingsController.EnsureInitialAgentProfiles(ctx); err != nil { - return err - } complete := true if _, err := userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{ InitialSetupComplete: &complete, diff --git a/apps/backend/docs/add_agent.md b/apps/backend/docs/add_agent.md new file mode 100644 index 0000000000..81724e842f --- /dev/null +++ b/apps/backend/docs/add_agent.md @@ -0,0 +1,498 @@ +# Adding New Agents to Kandev + +This guide explains how to add support for new AI coding agents (like Gemini, OpenCode, Amp, etc.) to the Kandev backend. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent Architecture │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ agents.json Registry Adapter Factory │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ Agent │───────▶│ Registry │─────────▶│ NewAdapter() │ │ +│ │ Config │ │ │ │ │ │ +│ └──────────┘ └──────────┘ └──────┬───────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Protocol Adapters │ │ +│ ├─────────────┬─────────────┬─────────────┬─────────────┐ │ │ +│ │ ACP Adapter │Codex Adapter│Claude Adapter│ New Adapter │ │ │ +│ │ (JSON-RPC) │ (JSON-RPC) │(stream-json) │ (???) │ │ │ +│ └─────────────┴─────────────┴─────────────┴─────────────┘ │ │ +│ │ │ │ +│ ▼ │ │ +│ AgentEvent (normalized) │ │ +│ │ │ │ +│ ▼ │ │ +│ Lifecycle Manager │ │ +│ │ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +All agents communicate through a **protocol adapter** that normalizes their output into `AgentEvent` structs. This allows the rest of the system to work uniformly regardless of the underlying agent. + +## Files to Modify/Create + +### 1. Protocol Definition +**File:** `pkg/agent/protocol.go` + +Add a new protocol constant: + +```go +const ( + ProtocolACP Protocol = "acp" + ProtocolCodex Protocol = "codex" + ProtocolClaudeCode Protocol = "claude-code" + ProtocolGemini Protocol = "gemini" // New + ProtocolOpenCode Protocol = "opencode" // New +) + +func (p Protocol) IsValid() bool { + switch p { + case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, + ProtocolClaudeCode, ProtocolGemini, ProtocolOpenCode: + return true + } + return false +} +``` + +### 2. Protocol Types (if needed) +**File:** `pkg//types.go` + +If the agent uses a custom protocol, create a types package: + +```go +package gemini + +// Message types specific to this agent's protocol +type CLIMessage struct { + Type string `json:"type"` + // ... agent-specific fields +} + +// Request/response types for control flow +type ControlRequest struct { + // ... +} +``` + +### 3. Protocol Client (if needed) +**File:** `pkg//client.go` + +For agents with custom protocols over stdin/stdout: + +```go +package gemini + +type Client struct { + stdin io.Writer + stdout io.Reader + // ... +} + +func NewClient(stdin io.Writer, stdout io.Reader) *Client +func (c *Client) Start(ctx context.Context) +func (c *Client) SendMessage(msg *Message) error +func (c *Client) SetMessageHandler(handler MessageHandler) +``` + +### 4. Protocol Adapter +**File:** `internal/agentctl/server/adapter/_adapter.go` + +Implement the `AgentAdapter` interface: + +```go +package adapter + +type GeminiAdapter struct { + cfg *Config + logger *logger.Logger + stdin io.Writer + stdout io.Reader + client *gemini.Client + updatesCh chan AgentEvent + // ... +} + +// Required interface methods: +func (a *GeminiAdapter) PrepareEnvironment() error +func (a *GeminiAdapter) Connect(stdin io.Writer, stdout io.Reader) error +func (a *GeminiAdapter) Initialize(ctx context.Context) error +func (a *GeminiAdapter) Prompt(ctx context.Context, message string) error +func (a *GeminiAdapter) Cancel(ctx context.Context) error +func (a *GeminiAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) +func (a *GeminiAdapter) LoadSession(ctx context.Context, sessionID string) error +func (a *GeminiAdapter) Updates() <-chan AgentEvent +func (a *GeminiAdapter) Close() error +func (a *GeminiAdapter) GetAgentInfo() *AgentInfo +func (a *GeminiAdapter) SetPermissionHandler(handler PermissionHandler) +func (a *GeminiAdapter) RespondToPermission(ctx context.Context, requestID string, response *PermissionResponse) error +``` + +### 5. Adapter Factory +**File:** `internal/agentctl/server/adapter/factory.go` + +Register the new adapter: + +```go +func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (AgentAdapter, error) { + switch protocol { + case agent.ProtocolACP: + return NewACPAdapter(cfg, log), nil + case agent.ProtocolCodex: + return NewCodexAdapter(cfg, log), nil + case agent.ProtocolClaudeCode: + return NewClaudeCodeAdapter(cfg, log), nil + case agent.ProtocolGemini: + return NewGeminiAdapter(cfg, log), nil // New + default: + return nil, fmt.Errorf("unsupported protocol: %s", protocol) + } +} +``` + +### 6. Registry Protocol Parser +**File:** `internal/agent/registry/registry.go` + +Add protocol string parsing: + +```go +func parseProtocol(p string) agent.Protocol { + switch p { + case "acp": + return agent.ProtocolACP + case "codex": + return agent.ProtocolCodex + case "claude-code": + return agent.ProtocolClaudeCode + case "gemini": + return agent.ProtocolGemini // New + default: + return agent.ProtocolACP + } +} +``` + +### 7. Agent Configuration +**File:** `internal/agent/registry/agents.json` + +Add the agent definition: + +```json +{ + "id": "gemini", + "name": "Google Gemini Agent", + "display_name": "Gemini", + "description": "Google Gemini CLI-powered coding agent.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "gemini-cli", + "--output-format=json", + "--non-interactive" + ], + "working_dir": "{workspace}", + "required_env": ["GOOGLE_API_KEY"], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "shell_execution" + ], + "protocol": "gemini", + "protocol_config": {}, + "model_flag": "--model {model}", + "workspace_flag": "--workspace", + "session_config": { + "resume_via_acp": false, + "resume_flag": "--resume", + "can_recover": true, + "session_dir_template": "{home}/.gemini/sessions", + "session_dir_target": "" + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": ["shell", "write_file", "edit_file"] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": false, + "label": "Auto-approve", + "description": "Automatically approve tool calls", + "apply_method": "cli_flag", + "cli_flag": "--auto-approve" + } + }, + "discovery": { + "supports_mcp": false, + "mcp_config_path": { "linux": [], "windows": [], "macos": [] }, + "installation_path": { + "linux": ["~/.gemini/config.json"], + "windows": ["~/.gemini/config.json"], + "macos": ["~/.gemini/config.json"] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "gemini-2.0-flash", + "available_models": [ + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "description": "Fast and efficient model", + "provider": "google", + "context_window": 1000000, + "is_default": true + }, + { + "id": "gemini-2.0-pro", + "name": "Gemini 2.0 Pro", + "description": "Most capable model", + "provider": "google", + "context_window": 2000000, + "is_default": false + } + ] + } +} +``` + +## Key Considerations + +### Execution Modes + +Agents can run in two modes: + +1. **Docker-based**: Set `image` and `tag` fields. The agent runs in a container. +2. **Standalone**: Set `cmd` array, leave `image` empty. The agent runs directly on the host. + +The validation in `registry.go` requires either an `image` OR a `cmd`: +```go +if config.Image == "" && len(config.Cmd) == 0 { + return fmt.Errorf("agent type requires either image (Docker) or cmd (standalone)") +} +``` + +### Event Types + +All adapters must emit normalized `AgentEvent` structs. Key event types: + +| Event Type | Purpose | +|------------|---------| +| `message_chunk` | Streaming text from the agent | +| `reasoning` | Chain-of-thought/thinking content | +| `tool_call` | Agent is invoking a tool | +| `tool_update` | Tool execution result | +| `complete` | Agent turn finished (triggers state change to REVIEW) | +| `error` | Error occurred | +| `permission_request` | Agent needs permission for an action | +| `session_status` | Session state update (NOT turn completion) | +| `context_window` | Token usage information | + +**Important:** Only emit `complete` when the agent truly finishes a turn. Don't emit it for initialization messages. + +### Tool Call Events + +When emitting tool calls, set both `ToolName` and `ToolTitle`: + +```go +a.sendUpdate(AgentEvent{ + Type: EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolID, + ToolName: "Bash", // Machine-readable name + ToolTitle: "ls -la /tmp", // Human-readable title for UI + ToolArgs: args, + ToolStatus: "running", +}) +``` + +### Permission Handling + +If the agent requires permission for certain actions: + +1. Set `tools_requiring_permission` in the agent config +2. Implement `SetPermissionHandler` and `RespondToPermission` in the adapter +3. Emit `permission_request` events when the agent needs approval: + +```go +a.sendUpdate(AgentEvent{ + Type: EventTypePermissionRequest, + SessionID: sessionID, + PendingID: requestID, + ToolCallID: toolID, + PermissionTitle: "Execute: rm -rf /tmp/test", + PermissionOptions: []PermissionOption{ + {ID: "allow", Label: "Allow"}, + {ID: "deny", Label: "Deny"}, + }, + ActionType: "shell", + ActionDetails: map[string]any{"command": "rm -rf /tmp/test"}, +}) +``` + +### Session Management + +Configure session handling in `session_config`: + +- `resume_via_acp`: If true, sessions resume via ACP protocol. If false, use CLI flag. +- `resume_flag`: CLI flag for resuming (e.g., `--resume`) +- `can_recover`: Whether sessions can survive backend restarts +- `session_dir_template`: Where session data is stored on host +- `session_dir_target`: Mount path inside container (if Docker-based) +- `reports_status_via_stream`: If true, agent reports session status via stream protocol (prevents duplicate status messages) + +**Session ID Tracking for Stream-Based Protocols:** + +For agents that report their session ID via the stream (like Claude Code), you must: + +1. Capture the session ID from the protocol message (e.g., `system` message in Claude Code) +2. Emit a `session_status` event with the session ID in `SessionID` field +3. The orchestrator will automatically store this as the resume token + +Example from Claude Code adapter: +```go +func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { + // Update session ID if provided + if msg.SessionID != "" { + a.mu.Lock() + a.sessionID = msg.SessionID + a.mu.Unlock() + } + + // Send session status event - this triggers resume token storage + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: msg.SessionID, // Agent's native session ID + Data: map[string]any{ + "session_status": msg.SessionStatus, + "init": true, + }, + }) +} +``` + +The `SessionID` field in the event is mapped to `ACPSessionID` in the event payload, which the orchestrator uses to store the resume token. + +### Model Configuration + +The `model_flag` field defines how to pass the model to the CLI: + +```json +"model_flag": "--model {model}" // Results in: --model gemini-2.0-flash +"model_flag": "-c model=\"{model}\"" // Results in: -c model="gemini-2.0-flash" +``` + +The `{model}` placeholder is replaced with the selected model ID. + +### Discovery + +The discovery system detects installed agents by checking for files: + +```json +"installation_path": { + "linux": ["~/.gemini/config.json"], + "windows": ["~/.gemini/config.json"], + "macos": ["~/.gemini/config.json"] +} +``` + +When a file exists at any of these paths, the agent is considered "available" and a default profile is auto-created. + +### Context Window Tracking + +Track token usage and emit context window events: + +```go +a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + ContextWindowSize: 200000, + ContextWindowUsed: 15000, + ContextWindowRemaining: 185000, + ContextEfficiency: 7.5, // percentage +}) +``` + +## Testing Checklist + +Before releasing a new agent integration: + +- [ ] Agent appears in settings when installed (discovery works) +- [ ] Default profile created with correct model +- [ ] Agent starts successfully with a simple prompt +- [ ] Streaming text appears in real-time +- [ ] Tool calls display with proper titles +- [ ] Tool results are shown correctly +- [ ] Permission requests work (if applicable) +- [ ] Session stays IN_PROGRESS while agent is working +- [ ] Session moves to REVIEW when agent finishes +- [ ] Follow-up prompts work in same session +- [ ] Session resume works after stopping agent +- [ ] Context window tracking shows accurate usage +- [ ] Errors are properly displayed to user +- [ ] Model switching works + +## Example: Existing Adapters + +### ACP Adapter (`acp_adapter.go`) +- Used by: Auggie +- Protocol: JSON-RPC over stdin/stdout +- Features: Full ACP protocol support, MCP server integration + +### Codex Adapter (`codex_adapter.go`) +- Used by: OpenAI Codex +- Protocol: Custom JSON-RPC (codex app-server) +- Features: Auto-approval via ACP, session resume + +### Claude Code Adapter (`claude_code_adapter.go`) +- Used by: Claude Code CLI +- Protocol: stream-json over stdin/stdout +- Features: Permission prompts via stdio, thinking/reasoning support + +## Troubleshooting + +### Agent not appearing in settings +- Check `installation_path` paths exist on system +- Verify `enabled: true` in agents.json +- Check backend logs for discovery errors + +### Protocol not recognized (falls back to ACP) +- Add protocol to `parseProtocol()` in `registry.go` +- Add to `Protocol.IsValid()` in `pkg/agent/protocol.go` + +### Session moves to REVIEW too quickly +- Don't emit `EventTypeComplete` for initialization messages +- Only emit `complete` when agent truly finishes turn + +### Tool titles not showing +- Set both `ToolName` and `ToolTitle` in tool_call events +- `ToolTitle` is the human-readable display text + +### Model flag not working +- Ensure `model_flag` includes `{model}` placeholder +- Check profile has model set (not empty) + +### Session resume not working +- For stream-based protocols: ensure adapter emits `session_status` event with agent's native session ID +- Check that `resume_flag` is set in agent config (e.g., `"--resume"`) +- Verify `resume_via_acp` is `false` for CLI-based resume +- Check `executor_running` table has the correct `resume_token` value +- For Claude Code: the session ID from `system` message should be stored as resume token diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index d2d4056819..8e11f761d8 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -917,6 +917,13 @@ func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExec // providedACPSessionID is the ACP session ID that was provided BEFORE initialization (for resumption). // This must be captured before initializeACPSession runs, as that overwrites execution.ACPSessionID. func (m *Manager) emitSessionStatusEvent(execution *AgentExecution, agentConfig *registry.AgentTypeConfig, providedACPSessionID string) { + // Skip emitting for agents that report their session status via the stream protocol. + // These agents emit session_status events from their adapter when the real session info arrives, + // so emitting here would create duplicate "New session started" messages. + if agentConfig.SessionConfig.ReportsStatusViaStream { + return + } + wasResumed := false if providedACPSessionID != "" { if agentConfig.SessionConfig.ResumeViaACP { diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index e92fa2dcd3..5c93122ddd 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -268,6 +268,138 @@ } ] } + }, + { + "id": "claude-code", + "name": "Claude Code CLI Agent", + "display_name": "Claude", + "description": "Anthropic Claude Code CLI-powered autonomous coding agent using the stream-json protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "@anthropic-ai/claude-code@2.1.12", + "-p", + "--output-format=stream-json", + "--input-format=stream-json", + "--permission-prompt-tool=stdio", + "--disallowedTools=AskUserQuestion", + "--verbose" + ], + "working_dir": "{workspace}", + "required_env": [ + "ANTHROPIC_API_KEY" + ], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "claude-code", + "protocol_config": {}, + "model_flag": "--model {model}", + "workspace_flag": "", + "session_config": { + "resume_via_acp": false, + "resume_flag": "--resume", + "can_recover": true, + "session_dir_template": "{home}/.claude", + "session_dir_target": "", + "reports_status_via_stream": true + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": [ + "Bash", + "Write", + "Edit", + "NotebookEdit" + ] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": false, + "label": "Auto-approve", + "description": "Automatically approve tool calls via stdio protocol", + "apply_method": "stdio", + "cli_flag": "" + }, + "dangerously_skip_permissions": { + "supported": true, + "default": true, + "label": "Skip Permissions", + "description": "Bypass all permission checks (dangerous but fast for trusted tasks)", + "apply_method": "cli_flag", + "cli_flag": "--dangerously-skip-permissions" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": [ + "~/.claude.json" + ], + "windows": [ + "~/.claude.json" + ], + "macos": [ + "~/.claude.json" + ] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "claude-sonnet-4-5", + "context_window_dynamic": true, + "default_context_window": 200000, + "available_models": [ + { + "id": "claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "Latest Sonnet with improved reasoning", + "provider": "anthropic", + "context_window": 200000, + "is_default": true + }, + { + "id": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "Most capable model for complex tasks", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + }, + { + "id": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "description": "Fast and affordable model for simple tasks", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + } + ] + } } ] } diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 1aff44f464..64f9677a8c 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -91,6 +91,13 @@ type SessionConfig struct { // SessionDirTarget is the container path where the session directory is mounted. // Example: "/root/.augment/sessions" for auggie. SessionDirTarget string `json:"session_dir_target,omitempty"` + + // ReportsStatusViaStream indicates that the agent reports its session status + // via the stream protocol (e.g., system message in stream-json). + // When true, the lifecycle manager will NOT emit a session_status event, + // since the agent's adapter will emit it when the real session info arrives. + // This prevents duplicate "New session started" messages. + ReportsStatusViaStream bool `json:"reports_status_via_stream,omitempty"` } // SupportsRecovery returns whether the agent supports session recovery after backend restart. @@ -375,10 +382,12 @@ func ValidateConfig(config *AgentTypeConfig) error { if config.Name == "" { return fmt.Errorf("agent type name is required") } - if config.Image == "" { - return fmt.Errorf("agent type image is required") + // Standalone agents (like Claude Code) don't need a Docker image - they use Cmd directly + // Docker-based agents need an image + if config.Image == "" && len(config.Cmd) == 0 { + return fmt.Errorf("agent type requires either image (Docker) or cmd (standalone)") } - if config.Tag == "" { + if config.Image != "" && config.Tag == "" { config.Tag = "latest" // Default to latest if not specified } if config.ResourceLimits.MemoryMB <= 0 { @@ -434,6 +443,8 @@ func parseProtocol(p string) agent.Protocol { return agent.ProtocolMCP case "codex": return agent.ProtocolCodex + case "claude-code": + return agent.ProtocolClaudeCode default: return agent.ProtocolACP // Default to ACP } diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index eb9aafb581..3a524a4ba2 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -86,9 +86,9 @@ func TestRegistry_RegisterValidation(t *testing.T) { errMsg: "agent type name is required", }, { - name: "empty image", + name: "empty image and cmd", config: &AgentTypeConfig{ID: "test", Name: "test", ResourceLimits: ResourceLimits{MemoryMB: 1, CPUCores: 1, TimeoutSeconds: 1}}, - errMsg: "agent type image is required", + errMsg: "agent type requires either image (Docker) or cmd (standalone)", }, { name: "zero memory", @@ -327,7 +327,7 @@ func TestAgentTypeConfig_ToAPIType(t *testing.T) { } func TestValidateConfig(t *testing.T) { - // Valid config with default tag + // Valid config with default tag (Docker-based agent) config := &AgentTypeConfig{ ID: "test", Name: "test", @@ -348,5 +348,27 @@ func TestValidateConfig(t *testing.T) { if config.Tag != "latest" { t.Errorf("expected default tag 'latest', got %q", config.Tag) } + + // Valid standalone agent with cmd but no image (like Claude Code) + standaloneConfig := &AgentTypeConfig{ + ID: "standalone-test", + Name: "Standalone Test", + Cmd: []string{"npx", "-y", "some-cli"}, + ResourceLimits: ResourceLimits{ + MemoryMB: 1024, + CPUCores: 1.0, + TimeoutSeconds: 3600, + }, + } + + err = ValidateConfig(standaloneConfig) + if err != nil { + t.Fatalf("standalone agent should be valid: %v", err) + } + + // Tag should NOT be set when image is empty + if standaloneConfig.Tag != "" { + t.Errorf("expected no tag for standalone agent, got %q", standaloneConfig.Tag) + } } diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 886d8e2168..8a8bdfeef5 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -37,6 +37,7 @@ const ( EventTypeError = streams.EventTypeError EventTypePermissionRequest = streams.EventTypePermissionRequest EventTypeContextWindow = streams.EventTypeContextWindow + EventTypeSessionStatus = streams.EventTypeSessionStatus ) // StderrProvider provides access to recent stderr output for error context. diff --git a/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go b/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go new file mode 100644 index 0000000000..b7295da227 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go @@ -0,0 +1,730 @@ +package adapter + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/claudecode" + "go.uber.org/zap" +) + +// ClaudeCodeAdapter implements AgentAdapter for Claude Code CLI using stream-json protocol. +// Claude Code uses a streaming JSON format over stdin/stdout with control requests for permissions. +type ClaudeCodeAdapter struct { + cfg *Config + logger *logger.Logger + + // Subprocess stdin/stdout (set via Connect) + stdin io.Writer + stdout io.Reader + + // Claude Code client for protocol communication + client *claudecode.Client + + // Context for managing goroutine lifecycle + ctx context.Context + cancel context.CancelFunc + + // Session state + sessionID string + operationID string // Current prompt operation + + // Agent info + agentInfo *AgentInfo + + // Update channel + updatesCh chan AgentEvent + + // Permission handler + permissionHandler PermissionHandler + + // Stderr provider for error context + stderrProvider StderrProvider + + // Result completion signaling + resultCh chan resultComplete + + // Dynamic context window tracking + mainModelName string // Model name from assistant messages (excludes subagents) + mainModelContextWindow int64 // Context window size (updated from result's model_usage) + contextTokensUsed int64 // Total tokens used (input + output + cache) + + // Synchronization + mu sync.RWMutex + closed bool +} + +// defaultContextWindow is the fallback context window size for Claude models +const defaultContextWindow = 200000 + +// resultComplete holds the result of a completed prompt +type resultComplete struct { + success bool + err string +} + +// NewClaudeCodeAdapter creates a new Claude Code protocol adapter. +// Call Connect() after starting the subprocess to wire up stdin/stdout. +func NewClaudeCodeAdapter(cfg *Config, log *logger.Logger) *ClaudeCodeAdapter { + ctx, cancel := context.WithCancel(context.Background()) + return &ClaudeCodeAdapter{ + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "claude-code")), + ctx: ctx, + cancel: cancel, + updatesCh: make(chan AgentEvent, 100), + mainModelContextWindow: defaultContextWindow, + } +} + +// PrepareEnvironment performs protocol-specific setup before the agent process starts. +// Claude Code reads MCP configuration from settings files, but we handle MCP via kandev's +// built-in MCP server, so this is a no-op. +func (a *ClaudeCodeAdapter) PrepareEnvironment() error { + a.logger.Info("PrepareEnvironment called", + zap.Int("mcp_server_count", len(a.cfg.McpServers))) + // Claude Code MCP configuration is handled externally or via CLI flags + return nil +} + +// Connect wires up the stdin/stdout pipes from the running agent subprocess. +func (a *ClaudeCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.stdin != nil || a.stdout != nil { + return fmt.Errorf("adapter already connected") + } + + a.stdin = stdin + a.stdout = stdout + return nil +} + +// Initialize establishes the Claude Code connection with the agent subprocess. +func (a *ClaudeCodeAdapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing Claude Code adapter", + zap.String("workdir", a.cfg.WorkDir)) + + // Create Claude Code client + a.client = claudecode.NewClient(a.stdin, a.stdout, a.logger) + a.client.SetRequestHandler(a.handleControlRequest) + a.client.SetMessageHandler(a.handleMessage) + + // Start reading from stdout with the adapter's context + a.client.Start(a.ctx) + + // Store agent info (version will be populated from first message) + a.agentInfo = &AgentInfo{ + Name: "claude-code", + Version: "unknown", + } + + a.logger.Info("Claude Code adapter initialized") + + return nil +} + +// GetAgentInfo returns information about the connected agent. +func (a *ClaudeCodeAdapter) GetAgentInfo() *AgentInfo { + return a.agentInfo +} + +// NewSession creates a new Claude Code session. +// Note: Claude Code sessions are created implicitly with the first prompt. +// The mcpServers parameter is ignored as Claude Code handles MCP separately. +func (a *ClaudeCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + a.mu.Lock() + defer a.mu.Unlock() + + // Generate a session ID - Claude Code will return its own session ID + // which we'll update when we receive the system message + sessionID := uuid.New().String() + a.sessionID = sessionID + + a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) + + return sessionID, nil +} + +// LoadSession resumes an existing Claude Code session. +// The session ID will be passed to Claude Code via --resume flag (handled by process manager). +func (a *ClaudeCodeAdapter) LoadSession(ctx context.Context, sessionID string) error { + a.mu.Lock() + a.sessionID = sessionID + a.mu.Unlock() + + a.logger.Info("loaded session", zap.String("session_id", sessionID)) + + return nil +} + +// Prompt sends a prompt to Claude Code and waits for completion. +func (a *ClaudeCodeAdapter) Prompt(ctx context.Context, message string) error { + a.mu.Lock() + client := a.client + sessionID := a.sessionID + operationID := uuid.New().String() + a.operationID = operationID + // Create channel to wait for result + a.resultCh = make(chan resultComplete, 1) + a.mu.Unlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.logger.Info("sending prompt", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + + // Send user message + if err := client.SendUserMessage(message); err != nil { + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return fmt.Errorf("failed to send user message: %w", err) + } + + // Wait for result or context cancellation + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + select { + case <-ctx.Done(): + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return ctx.Err() + case result := <-resultCh: + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + if !result.success && result.err != "" { + return fmt.Errorf("prompt failed: %s", result.err) + } + a.logger.Info("prompt completed", + zap.String("operation_id", operationID), + zap.Bool("success", result.success)) + return nil + } +} + +// Cancel interrupts the current operation. +func (a *ClaudeCodeAdapter) Cancel(ctx context.Context) error { + a.mu.RLock() + client := a.client + a.mu.RUnlock() + + if client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.logger.Info("cancelling operation") + + // Send interrupt control request + return client.SendControlRequest(&claudecode.SDKControlRequest{ + Type: claudecode.MessageTypeControlRequest, + RequestID: uuid.New().String(), + Request: claudecode.SDKControlRequestBody{ + Subtype: claudecode.SubtypeInterrupt, + }, + }) +} + +// Updates returns the channel for agent events. +func (a *ClaudeCodeAdapter) Updates() <-chan AgentEvent { + return a.updatesCh +} + +// GetSessionID returns the current session ID. +func (a *ClaudeCodeAdapter) GetSessionID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.sessionID +} + +// GetOperationID returns the current operation ID. +func (a *ClaudeCodeAdapter) GetOperationID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.operationID +} + +// SetPermissionHandler sets the handler for permission requests. +func (a *ClaudeCodeAdapter) SetPermissionHandler(handler PermissionHandler) { + a.mu.Lock() + defer a.mu.Unlock() + a.permissionHandler = handler +} + +// SetStderrProvider sets the provider for recent stderr output. +func (a *ClaudeCodeAdapter) SetStderrProvider(provider StderrProvider) { + a.mu.Lock() + defer a.mu.Unlock() + a.stderrProvider = provider +} + +// Close releases resources held by the adapter. +func (a *ClaudeCodeAdapter) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.closed { + return nil + } + a.closed = true + + a.logger.Info("closing Claude Code adapter") + + // Cancel the context to stop the read loop + if a.cancel != nil { + a.cancel() + } + + // Stop the client + if a.client != nil { + a.client.Stop() + } + + // Close update channel + close(a.updatesCh) + + return nil +} + +// sendUpdate safely sends an event to the updates channel. +func (a *ClaudeCodeAdapter) sendUpdate(update AgentEvent) { + select { + case a.updatesCh <- update: + default: + a.logger.Warn("updates channel full, dropping event") + } +} + +// handleControlRequest processes control requests (permission requests) from Claude Code. +func (a *ClaudeCodeAdapter) handleControlRequest(requestID string, req *claudecode.ControlRequest) { + a.logger.Info("received control request", + zap.String("request_id", requestID), + zap.String("subtype", req.Subtype), + zap.String("tool_name", req.ToolName)) + + switch req.Subtype { + case claudecode.SubtypeCanUseTool: + a.handleToolPermission(requestID, req) + case claudecode.SubtypeHookCallback: + a.handleHookCallback(requestID, req) + default: + a.logger.Warn("unhandled control request subtype", + zap.String("subtype", req.Subtype)) + // Send error response + if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ + Type: claudecode.MessageTypeControlResponse, + RequestID: requestID, + Response: &claudecode.ControlResponse{ + Subtype: "error", + Error: fmt.Sprintf("unhandled subtype: %s", req.Subtype), + }, + }); err != nil { + a.logger.Warn("failed to send error response", zap.Error(err)) + } + } +} + +// handleToolPermission processes can_use_tool permission requests. +func (a *ClaudeCodeAdapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { + a.mu.RLock() + handler := a.permissionHandler + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + + // Determine action type based on tool name + actionType := types.ActionTypeOther + switch req.ToolName { + case claudecode.ToolBash: + actionType = types.ActionTypeCommand + case claudecode.ToolWrite, claudecode.ToolEdit, claudecode.ToolNotebookEdit: + actionType = types.ActionTypeFileWrite + case claudecode.ToolRead, claudecode.ToolGlob, claudecode.ToolGrep: + actionType = types.ActionTypeFileRead + case claudecode.ToolWebFetch, claudecode.ToolWebSearch: + actionType = types.ActionTypeNetwork + } + + // Build title from tool name and key input + title := req.ToolName + if cmd, ok := req.Input["command"].(string); ok && req.ToolName == claudecode.ToolBash { + title = cmd + } else if path, ok := req.Input["file_path"].(string); ok { + title = fmt.Sprintf("%s: %s", req.ToolName, path) + } + + // Build permission options + options := []PermissionOption{ + {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, + {OptionID: "allowAlways", Name: "Allow Always", Kind: "allow_always"}, + {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, + } + + permReq := &PermissionRequest{ + SessionID: sessionID, + ToolCallID: req.ToolUseID, + Title: title, + Options: options, + ActionType: actionType, + ActionDetails: req.Input, + } + + // If no handler, auto-allow + if handler == nil { + a.logger.Info("auto-allowing tool (no handler)", + zap.String("tool", req.ToolName)) + a.sendPermissionResponse(requestID, claudecode.BehaviorAllow) + return + } + + // Send permission notification through updates channel + a.sendUpdate(AgentEvent{ + Type: EventTypePermissionRequest, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: req.ToolUseID, + PendingID: requestID, + PermissionTitle: title, + PermissionOptions: options, + ActionType: actionType, + ActionDetails: req.Input, + }) + + // Call permission handler (blocking) + ctx := context.Background() + resp, err := handler(ctx, permReq) + if err != nil { + a.logger.Error("permission handler error", zap.Error(err)) + a.sendPermissionResponse(requestID, claudecode.BehaviorDeny) + return + } + + // Map response to behavior + behavior := claudecode.BehaviorAllow + if resp.Cancelled { + behavior = claudecode.BehaviorDeny + } else { + switch resp.OptionID { + case "allow", "allowAlways", "approve", "approveAlways": + behavior = claudecode.BehaviorAllow + case "deny", "reject", "decline": + behavior = claudecode.BehaviorDeny + } + } + + a.sendPermissionResponse(requestID, behavior) +} + +// sendPermissionResponse sends a permission response to Claude Code. +func (a *ClaudeCodeAdapter) sendPermissionResponse(requestID string, behavior string) { + resp := &claudecode.ControlResponseMessage{ + Type: claudecode.MessageTypeControlResponse, + RequestID: requestID, + Response: &claudecode.ControlResponse{ + Subtype: "success", + Result: &claudecode.PermissionResult{ + Behavior: behavior, + }, + }, + } + + if err := a.client.SendControlResponse(resp); err != nil { + a.logger.Warn("failed to send permission response", zap.Error(err)) + } +} + +// handleHookCallback processes hook callback requests. +func (a *ClaudeCodeAdapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { + a.logger.Info("received hook callback", + zap.String("request_id", requestID), + zap.String("hook_name", req.HookName)) + + // For now, acknowledge hook callbacks with success + if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ + Type: claudecode.MessageTypeControlResponse, + RequestID: requestID, + Response: &claudecode.ControlResponse{ + Subtype: "success", + }, + }); err != nil { + a.logger.Warn("failed to send hook callback response", zap.Error(err)) + } +} + +// handleMessage processes streaming messages from Claude Code. +func (a *ClaudeCodeAdapter) handleMessage(msg *claudecode.CLIMessage) { + a.mu.RLock() + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + + switch msg.Type { + case claudecode.MessageTypeSystem: + a.handleSystemMessage(msg) + + case claudecode.MessageTypeAssistant: + a.handleAssistantMessage(msg, sessionID, operationID) + + case claudecode.MessageTypeResult: + a.handleResultMessage(msg, sessionID, operationID) + + default: + a.logger.Debug("unhandled message type", zap.String("type", msg.Type)) + } +} + +// handleSystemMessage processes system init messages. +// Note: System messages are session initialization, NOT turn completion. +// Turn completion is signaled by result messages. +func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { + a.logger.Info("received system message", + zap.String("session_id", msg.SessionID), + zap.String("status", msg.SessionStatus)) + + // Update session ID if provided + if msg.SessionID != "" { + a.mu.Lock() + a.sessionID = msg.SessionID + a.mu.Unlock() + } + + // Send session status event (NOT complete - that's only for result messages) + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: msg.SessionID, + Data: map[string]any{ + "session_status": msg.SessionStatus, + "init": true, + }, + }) +} + +// handleAssistantMessage processes assistant messages (text, thinking, tool calls). +func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + if msg.Message == nil { + return + } + + // Update agent version and track main model name from model info + if msg.Message.Model != "" && a.agentInfo != nil { + a.agentInfo.Version = msg.Message.Model + + // Track the main model name for context window lookup + // Only set if not already set (first model we see is the main one) + a.mu.Lock() + if a.mainModelName == "" { + a.mainModelName = msg.Message.Model + a.logger.Debug("tracking main model", zap.String("model", msg.Message.Model)) + } + a.mu.Unlock() + } + + // Process content blocks + for _, block := range msg.Message.Content { + switch block.Type { + case "text": + if block.Text != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: block.Text, + }) + } + + case "thinking": + if block.Thinking != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: block.Thinking, + }) + } + + case "tool_use": + // Build a human-readable title for the tool call + toolTitle := block.Name + if cmd, ok := block.Input["command"].(string); ok && block.Name == claudecode.ToolBash { + toolTitle = cmd + } else if path, ok := block.Input["file_path"].(string); ok { + toolTitle = fmt.Sprintf("%s: %s", block.Name, path) + } + a.sendUpdate(AgentEvent{ + Type: EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ID, + ToolName: block.Name, + ToolTitle: toolTitle, + ToolArgs: block.Input, + ToolStatus: "running", + }) + + case "tool_result": + status := "complete" + if block.IsError { + status = "error" + } + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ToolUseID, + ToolResult: block.Content, + ToolStatus: status, + }) + } + } + + // Calculate and emit token usage as context window event + if msg.Message.Usage != nil { + usage := msg.Message.Usage + + // Calculate total tokens used (including cache tokens) + contextUsed := usage.InputTokens + usage.OutputTokens + + usage.CacheCreationInputTokens + usage.CacheReadInputTokens + + // Update tracked token usage + a.mu.Lock() + a.contextTokensUsed = contextUsed + contextSize := a.mainModelContextWindow + a.mu.Unlock() + + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) + } +} + +// handleResultMessage processes result (completion) messages. +func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + a.logger.Info("received result message", + zap.Bool("is_error", msg.IsError), + zap.Int("num_turns", msg.NumTurns)) + + // Update session ID from result if provided + if resultData := msg.GetResultData(); resultData != nil && resultData.SessionID != "" { + a.mu.Lock() + a.sessionID = resultData.SessionID + sessionID = resultData.SessionID + a.mu.Unlock() + } + + // Extract actual context window from model_usage if available + // This gives us the real context window size for the model + a.mu.Lock() + modelName := a.mainModelName + if msg.ModelUsage != nil && modelName != "" { + if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { + a.mainModelContextWindow = *modelStats.ContextWindow + a.logger.Debug("updated context window from model_usage", + zap.String("model", modelName), + zap.Int64("context_window", a.mainModelContextWindow)) + } + } + contextSize := a.mainModelContextWindow + contextUsed := a.contextTokensUsed + a.mu.Unlock() + + // Emit final accurate context window event + if contextUsed > 0 { + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) + } + + // Send completion event + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + OperationID: operationID, + Data: map[string]any{ + "cost_usd": msg.CostUSD, + "duration_ms": msg.DurationMS, + "num_turns": msg.NumTurns, + "input_tokens": msg.TotalInputTokens, + "output_tokens": msg.TotalOutputTokens, + "is_error": msg.IsError, + }, + }) + + // If there's result text, send it as a message chunk + // Result can be either a ResultData object or an error string + if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: resultData.Text, + }) + } + + // Signal completion + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + if resultCh != nil { + select { + case resultCh <- resultComplete{success: !msg.IsError}: + a.logger.Debug("signaled prompt completion") + default: + a.logger.Warn("result channel full, dropping signal") + } + } + + // Send error event if failed + if msg.IsError { + errorMsg := "prompt failed" + // Error result can be a string (API error) or ResultData with Text + if errStr := msg.GetResultString(); errStr != "" { + errorMsg = errStr + } else if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + errorMsg = resultData.Text + } + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + SessionID: sessionID, + OperationID: operationID, + Error: errorMsg, + }) + } +} + +// Verify interface implementation +var _ AgentAdapter = (*ClaudeCodeAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/factory.go b/apps/backend/internal/agentctl/server/adapter/factory.go new file mode 100644 index 0000000000..c277c01f99 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/factory.go @@ -0,0 +1,23 @@ +package adapter + +import ( + "fmt" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// NewAdapter creates a new protocol adapter based on the specified protocol type. +// It returns an error if the protocol is not supported. +func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (AgentAdapter, error) { + switch protocol { + case agent.ProtocolACP: + return NewACPAdapter(cfg, log), nil + case agent.ProtocolCodex: + return NewCodexAdapter(cfg, log), nil + case agent.ProtocolClaudeCode: + return NewClaudeCodeAdapter(cfg, log), nil + default: + return nil, fmt.Errorf("unsupported protocol: %s", protocol) + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index b834a0960d..eb299572e9 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -395,17 +395,19 @@ func (m *Manager) createAdapter() error { protocol = agent.ProtocolACP // Default to ACP } - // Use the adapter config built in Start() - switch protocol { - case agent.ProtocolACP: - m.adapter = adapter.NewACPAdapter(m.adapterCfg, m.logger) - case agent.ProtocolCodex: - codexAdapter := adapter.NewCodexAdapter(m.adapterCfg, m.logger) - // Set stderr provider so errors can include recent stderr output - codexAdapter.SetStderrProvider(m) - m.adapter = codexAdapter - default: - return fmt.Errorf("unsupported protocol: %s", protocol) + // Use the adapter factory to create the appropriate adapter + adpt, err := adapter.NewAdapter(protocol, m.adapterCfg, m.logger) + if err != nil { + return fmt.Errorf("failed to create adapter: %w", err) + } + m.adapter = adpt + + // Set stderr provider for adapters that support it + switch a := m.adapter.(type) { + case *adapter.CodexAdapter: + a.SetStderrProvider(m) + case *adapter.ClaudeCodeAdapter: + a.SetStderrProvider(m) } // Set the permission handler diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 47251740ff..5ec1a2d22f 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -21,12 +21,18 @@ func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPS if data.SessionID == "" || data.ACPSessionID == "" { return } + s.storeResumeToken(ctx, data.TaskID, data.SessionID, data.ACPSessionID) +} - session, err := s.repo.GetTaskSession(ctx, data.SessionID) +// storeResumeToken stores an agent's session ID as the resume token for session recovery. +// This is called both from handleACPSessionCreated (for ACP-based agents) and from +// handleAgentStreamEvent (for stream-based agents like Claude Code). +func (s *Service) storeResumeToken(ctx context.Context, taskID, sessionID, acpSessionID string) { + session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { - s.logger.Warn("failed to load task session for ACP session update", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), + s.logger.Warn("failed to load task session for resume token storage", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.Error(err)) return } @@ -45,7 +51,7 @@ func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPS ExecutorID: session.ExecutorID, Status: "ready", Resumable: resumable, - ResumeToken: data.ACPSessionID, + ResumeToken: acpSessionID, AgentExecutionID: session.AgentExecutionID, ContainerID: session.ContainerID, } @@ -57,16 +63,16 @@ func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPS if err := s.repo.UpsertExecutorRunning(ctx, running); err != nil { s.logger.Warn("failed to persist resume token for session", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), + zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.Error(err)) return } - s.logger.Debug("stored resume token for task session", - zap.String("task_id", data.TaskID), - zap.String("session_id", data.SessionID), - zap.String("resume_token", data.ACPSessionID)) + s.logger.Debug("stored resume token for session", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("resume_token", acpSessionID)) } // handleAgentCompleted handles agent completion events @@ -193,6 +199,11 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle case "session_status": // Handle session status events (resumed vs new session) + // Also store the agent's session ID as resume token for session recovery + if sessionID != "" && payload.Data.ACPSessionID != "" { + s.storeResumeToken(ctx, taskID, sessionID, payload.Data.ACPSessionID) + } + if sessionID != "" && s.messageCreator != nil { var statusMsg string if payload.Data.SessionStatus == "resumed" { diff --git a/apps/backend/pkg/agent/protocol.go b/apps/backend/pkg/agent/protocol.go index 9cd6bacf63..09ca5448f8 100644 --- a/apps/backend/pkg/agent/protocol.go +++ b/apps/backend/pkg/agent/protocol.go @@ -15,6 +15,10 @@ const ( // Codex uses a similar structure to ACP but with different message formats and // a Thread/Turn model instead of Session/Prompt. ProtocolCodex Protocol = "codex" + // ProtocolClaudeCode is the Claude Code CLI protocol (stream-json over stdin/stdout). + // Claude Code uses a streaming JSON format with control requests for permissions + // and user messages for prompts. + ProtocolClaudeCode Protocol = "claude-code" ) // String returns the string representation of the protocol. @@ -25,7 +29,7 @@ func (p Protocol) String() string { // IsValid returns true if the protocol is a known valid protocol. func (p Protocol) IsValid() bool { switch p { - case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex: + case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, ProtocolClaudeCode: return true default: return false diff --git a/apps/backend/pkg/claudecode/client.go b/apps/backend/pkg/claudecode/client.go new file mode 100644 index 0000000000..c196250348 --- /dev/null +++ b/apps/backend/pkg/claudecode/client.go @@ -0,0 +1,197 @@ +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// RequestHandler handles incoming control requests from Claude Code CLI. +// It receives the request ID and control request, and should call SendControlResponse. +type RequestHandler func(requestID string, req *ControlRequest) + +// MessageHandler handles streaming messages from Claude Code CLI. +type MessageHandler func(msg *CLIMessage) + +// Client handles Claude Code CLI communication over stdin/stdout streams. +// It reads streaming JSON from stdout and writes control messages to stdin. +type Client struct { + stdin io.Writer + stdout io.Reader + logger *logger.Logger + + // Handlers for incoming messages + requestHandler RequestHandler + messageHandler MessageHandler + + // Synchronization + mu sync.RWMutex + done chan struct{} +} + +// NewClient creates a new Claude Code CLI client. +func NewClient(stdin io.Writer, stdout io.Reader, log *logger.Logger) *Client { + return &Client{ + stdin: stdin, + stdout: stdout, + logger: log.WithFields(zap.String("component", "claudecode-client")), + done: make(chan struct{}), + } +} + +// SetRequestHandler sets the handler for incoming control requests. +func (c *Client) SetRequestHandler(handler RequestHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.requestHandler = handler +} + +// SetMessageHandler sets the handler for streaming messages. +func (c *Client) SetMessageHandler(handler MessageHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.messageHandler = handler +} + +// Start begins reading from stdout in a goroutine. +func (c *Client) Start(ctx context.Context) { + go c.readLoop(ctx) +} + +// Stop stops the client and closes the done channel. +func (c *Client) Stop() { + c.mu.Lock() + defer c.mu.Unlock() + + select { + case <-c.done: + // Already closed + default: + close(c.done) + } +} + +// SendControlRequest sends a control request to Claude Code CLI. +func (c *Client) SendControlRequest(req *SDKControlRequest) error { + return c.send(req) +} + +// SendControlResponse sends a control response to Claude Code CLI. +func (c *Client) SendControlResponse(resp *ControlResponseMessage) error { + return c.send(resp) +} + +// SendUserMessage sends a user message (prompt) to Claude Code CLI. +func (c *Client) SendUserMessage(content string) error { + msg := &UserMessage{ + Type: MessageTypeUser, + Message: UserMessageBody{ + Role: "user", + Content: content, + }, + } + return c.send(msg) +} + +func (c *Client) send(msg any) error { + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + data = append(data, '\n') + _, err = c.stdin.Write(data) + if err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + c.logger.Debug("claudecode: sent message", zap.String("data", string(data))) + return nil +} + +func (c *Client) readLoop(ctx context.Context) { + scanner := bufio.NewScanner(c.stdout) + // Allow for large JSON messages (up to 10MB) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return + case <-c.done: + return + default: + } + + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + c.handleLine(line) + } + + if err := scanner.Err(); err != nil { + c.logger.Error("read loop error", zap.Error(err)) + } +} + +func (c *Client) handleLine(line []byte) { + // First, parse the basic structure to determine message type + var msg CLIMessage + if err := json.Unmarshal(line, &msg); err != nil { + c.logger.Warn("failed to parse message", zap.Error(err), zap.String("line", string(line))) + return + } + + c.logger.Debug("claudecode: received message", + zap.String("type", msg.Type), + zap.String("request_id", msg.RequestID)) + + // Handle control requests specially - they need immediate response + if msg.Type == MessageTypeControlRequest && msg.Request != nil { + c.handleControlRequest(msg.RequestID, msg.Request) + return + } + + // Forward other messages to the message handler + c.mu.RLock() + handler := c.messageHandler + c.mu.RUnlock() + + if handler != nil { + // Store raw line for advanced parsing if needed + msg.RawContent = line + handler(&msg) + } +} + +func (c *Client) handleControlRequest(requestID string, req *ControlRequest) { + c.mu.RLock() + handler := c.requestHandler + c.mu.RUnlock() + + if handler != nil { + handler(requestID, req) + } else { + c.logger.Warn("received control request but no handler registered", + zap.String("request_id", requestID), + zap.String("subtype", req.Subtype)) + // Auto-deny if no handler + if err := c.SendControlResponse(&ControlResponseMessage{ + Type: MessageTypeControlResponse, + RequestID: requestID, + Response: &ControlResponse{ + Subtype: "error", + Error: "no handler registered", + }, + }); err != nil { + c.logger.Warn("failed to send error response", zap.Error(err)) + } + } +} diff --git a/apps/backend/pkg/claudecode/client_test.go b/apps/backend/pkg/claudecode/client_test.go new file mode 100644 index 0000000000..f743f3deeb --- /dev/null +++ b/apps/backend/pkg/claudecode/client_test.go @@ -0,0 +1,280 @@ +package claudecode + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + return log +} + +func TestClient_SendUserMessage(t *testing.T) { + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(""), newTestLogger()) + + err := client.SendUserMessage("Hello, Claude!") + if err != nil { + t.Fatalf("SendUserMessage() error = %v", err) + } + + // Parse what was written + var msg UserMessage + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &msg); err != nil { + t.Fatalf("failed to parse sent message: %v", err) + } + + if msg.Type != MessageTypeUser { + t.Errorf("Type = %q, want %q", msg.Type, MessageTypeUser) + } + if msg.Message.Role != "user" { + t.Errorf("Message.Role = %q, want %q", msg.Message.Role, "user") + } + if msg.Message.Content != "Hello, Claude!" { + t.Errorf("Message.Content = %q, want %q", msg.Message.Content, "Hello, Claude!") + } +} + +func TestClient_SendControlResponse(t *testing.T) { + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(""), newTestLogger()) + + resp := &ControlResponseMessage{ + Type: MessageTypeControlResponse, + RequestID: "req123", + Response: &ControlResponse{ + Subtype: "success", + Result: &PermissionResult{ + Behavior: BehaviorAllow, + }, + }, + } + + err := client.SendControlResponse(resp) + if err != nil { + t.Fatalf("SendControlResponse() error = %v", err) + } + + // Parse what was written + var parsed ControlResponseMessage + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil { + t.Fatalf("failed to parse sent message: %v", err) + } + + if parsed.RequestID != "req123" { + t.Errorf("RequestID = %q, want %q", parsed.RequestID, "req123") + } +} + +func TestClient_SendControlRequest(t *testing.T) { + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(""), newTestLogger()) + + req := &SDKControlRequest{ + Type: MessageTypeControlRequest, + RequestID: "init123", + Request: SDKControlRequestBody{ + Subtype: SubtypeInitialize, + }, + } + + err := client.SendControlRequest(req) + if err != nil { + t.Fatalf("SendControlRequest() error = %v", err) + } + + // Parse what was written + var parsed SDKControlRequest + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil { + t.Fatalf("failed to parse sent message: %v", err) + } + + if parsed.Request.Subtype != SubtypeInitialize { + t.Errorf("Request.Subtype = %q, want %q", parsed.Request.Subtype, SubtypeInitialize) + } +} + +func TestClient_HandleMessages(t *testing.T) { + // Create input with multiple messages + messages := []string{ + `{"type":"system","session_id":"sess123"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`, + } + input := strings.Join(messages, "\n") + "\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(input), newTestLogger()) + + var received []CLIMessage + var mu sync.Mutex + client.SetMessageHandler(func(msg *CLIMessage) { + mu.Lock() + received = append(received, *msg) + mu.Unlock() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(50 * time.Millisecond) // Give time for processing + + mu.Lock() + count := len(received) + mu.Unlock() + + if count != 2 { + t.Errorf("received %d messages, want 2", count) + } +} + +func TestClient_HandleControlRequest(t *testing.T) { + // Create a control request message + input := `{"type":"control_request","request_id":"req123","request":{"subtype":"can_use_tool","tool_name":"Bash"}}` + "\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(input), newTestLogger()) + + var receivedReq *ControlRequest + var receivedID string + var mu sync.Mutex + + client.SetRequestHandler(func(requestID string, req *ControlRequest) { + mu.Lock() + receivedID = requestID + receivedReq = req + mu.Unlock() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(50 * time.Millisecond) // Give time for processing + + mu.Lock() + defer mu.Unlock() + + if receivedID != "req123" { + t.Errorf("requestID = %q, want %q", receivedID, "req123") + } + if receivedReq == nil { + t.Fatal("receivedReq is nil") + } + if receivedReq.Subtype != SubtypeCanUseTool { + t.Errorf("Subtype = %q, want %q", receivedReq.Subtype, SubtypeCanUseTool) + } +} + +func TestClient_Stop(t *testing.T) { + // Use a pipe for continuous input + pr, _ := io.Pipe() + + var buf bytes.Buffer + client := NewClient(&buf, pr, newTestLogger()) + + ctx := context.Background() + client.Start(ctx) + + // Stop should not panic even if called multiple times + client.Stop() + client.Stop() +} + +func TestClient_NoHandlerAutoReject(t *testing.T) { + // Create a control request message + input := `{"type":"control_request","request_id":"req123","request":{"subtype":"can_use_tool","tool_name":"Bash"}}` + "\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(input), newTestLogger()) + + // No request handler set - should auto-reject + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(50 * time.Millisecond) // Give time for processing + + // Should have sent an error response + if buf.Len() == 0 { + t.Error("expected error response to be sent") + } + + var resp ControlResponseMessage + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Response == nil || resp.Response.Subtype != "error" { + t.Error("expected error response") + } +} + +func TestClient_EmptyLines(t *testing.T) { + // Test that empty lines are skipped + input := "\n\n{\"type\":\"system\",\"session_id\":\"abc\"}\n\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(input), newTestLogger()) + + var count int + var mu sync.Mutex + client.SetMessageHandler(func(msg *CLIMessage) { + mu.Lock() + count++ + mu.Unlock() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(50 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + + if count != 1 { + t.Errorf("count = %d, want 1", count) + } +} + +func TestClient_InvalidJSON(t *testing.T) { + // Test that invalid JSON is handled gracefully + input := "{invalid json}\n{\"type\":\"system\"}\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(input), newTestLogger()) + + var count int + var mu sync.Mutex + client.SetMessageHandler(func(msg *CLIMessage) { + mu.Lock() + count++ + mu.Unlock() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(50 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + + // Should still process the valid message + if count != 1 { + t.Errorf("count = %d, want 1", count) + } +} diff --git a/apps/backend/pkg/claudecode/types.go b/apps/backend/pkg/claudecode/types.go new file mode 100644 index 0000000000..48c08a1358 --- /dev/null +++ b/apps/backend/pkg/claudecode/types.go @@ -0,0 +1,293 @@ +// Package claudecode provides types and client for the Claude Code CLI stream-json protocol. +// Claude Code uses a streaming JSON format over stdin/stdout with control requests for permissions. +package claudecode + +import "encoding/json" + +// Message types from Claude Code CLI +const ( + // MessageTypeSystem is the initial system message with session info + MessageTypeSystem = "system" + // MessageTypeAssistant contains text or thinking from the assistant + MessageTypeAssistant = "assistant" + // MessageTypeResult is the final result message + MessageTypeResult = "result" + // MessageTypeControlRequest is a control request (permission, hook) + MessageTypeControlRequest = "control_request" + // MessageTypeControlResponse is a response to a control request + MessageTypeControlResponse = "control_response" + // MessageTypeUser is a user message (prompt) + MessageTypeUser = "user" +) + +// Control request subtypes +const ( + // SubtypeCanUseTool is a permission request for tool use + SubtypeCanUseTool = "can_use_tool" + // SubtypeHookCallback is a hook callback request + SubtypeHookCallback = "hook_callback" + // SubtypeInitialize initializes the session + SubtypeInitialize = "initialize" + // SubtypeInterrupt interrupts the current operation + SubtypeInterrupt = "interrupt" + // SubtypeSetPermissionMode sets the permission mode + SubtypeSetPermissionMode = "set_permission_mode" +) + +// Permission behaviors +const ( + // BehaviorAllow allows the tool use + BehaviorAllow = "allow" + // BehaviorDeny denies the tool use + BehaviorDeny = "deny" +) + +// CLIMessage represents messages from Claude Code CLI stdout. +// The message type determines which fields are populated. +type CLIMessage struct { + // Type is the message type (system, assistant, result, control_request, etc.) + Type string `json:"type"` + + // For control_request messages + RequestID string `json:"request_id,omitempty"` + Request *ControlRequest `json:"request,omitempty"` + + // For system messages + SessionID string `json:"session_id,omitempty"` + SessionStatus string `json:"session_status,omitempty"` + + // For assistant messages + Message *AssistantMessage `json:"message,omitempty"` + + // For result messages + // Result can be either a string (error message) or an object (ResultData) + Result json.RawMessage `json:"result,omitempty"` + Subtype string `json:"subtype,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + DurationAPIMS int64 `json:"duration_api_ms,omitempty"` + IsError bool `json:"is_error,omitempty"` + NumTurns int `json:"num_turns,omitempty"` + TotalInputTokens int64 `json:"total_input_tokens,omitempty"` + TotalOutputTokens int64 `json:"total_output_tokens,omitempty"` + ModelUsage map[string]ModelUsageStats `json:"model_usage,omitempty"` + + // Raw message for parsing content blocks + RawContent json.RawMessage `json:"-"` +} + +// AssistantMessage contains the assistant's response content. +type AssistantMessage struct { + Role string `json:"role"` + Content []ContentBlock `json:"content,omitempty"` + Model string `json:"model,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +// ContentBlock represents a block of content in an assistant message. +type ContentBlock struct { + Type string `json:"type"` + + // For text blocks + Text string `json:"text,omitempty"` + + // For thinking blocks + Thinking string `json:"thinking,omitempty"` + + // For tool_use blocks + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input map[string]any `json:"input,omitempty"` + + // For tool_result blocks + ToolUseID string `json:"tool_use_id,omitempty"` + Content string `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// Usage contains token usage information. +type Usage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` +} + +// ResultData contains the final result information. +type ResultData struct { + Text string `json:"text,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// GetResultData attempts to parse the Result field as a ResultData object. +// Returns nil if Result is empty, a string, or cannot be parsed as ResultData. +func (m *CLIMessage) GetResultData() *ResultData { + if len(m.Result) == 0 { + return nil + } + var data ResultData + if err := json.Unmarshal(m.Result, &data); err != nil { + return nil + } + return &data +} + +// GetResultString returns the Result field as a string. +// This is used when the result is an error message string. +func (m *CLIMessage) GetResultString() string { + if len(m.Result) == 0 { + return "" + } + var s string + if err := json.Unmarshal(m.Result, &s); err != nil { + // Not a string, return empty + return "" + } + return s +} + +// ModelUsageStats contains per-model usage statistics from result message. +// The context_window field provides the actual model context window size. +type ModelUsageStats struct { + ContextWindow *int64 `json:"context_window,omitempty"` +} + +// ControlRequest represents a control request from Claude Code CLI. +// This is used for permission requests (can_use_tool) and hook callbacks. +type ControlRequest struct { + // Subtype identifies the type of control request + Subtype string `json:"subtype"` + + // For can_use_tool requests + ToolName string `json:"tool_name,omitempty"` + Input map[string]any `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + + // For hook_callback requests + CallbackID string `json:"callback_id,omitempty"` + HookName string `json:"hook_name,omitempty"` + HookInput map[string]any `json:"hook_input,omitempty"` + + // Permission suggestions from Claude + PermissionSuggestions []PermissionUpdate `json:"permission_suggestions,omitempty"` +} + +// PermissionUpdate represents a permission rule update. +type PermissionUpdate struct { + Tool string `json:"tool"` + Pattern string `json:"pattern,omitempty"` + Allow bool `json:"allow"` +} + +// ControlResponseMessage is the message sent to respond to control requests. +type ControlResponseMessage struct { + Type string `json:"type"` // "control_response" + RequestID string `json:"request_id"` + Response *ControlResponse `json:"response"` +} + +// ControlResponse is the response to a control request. +type ControlResponse struct { + // Subtype is the response type (success, error) + Subtype string `json:"subtype"` + + // For success responses + Result *PermissionResult `json:"result,omitempty"` + + // For error responses + Error string `json:"error,omitempty"` +} + +// PermissionResult is the result for tool approval responses. +type PermissionResult struct { + // Behavior is "allow" or "deny" + Behavior string `json:"behavior"` + + // UpdatedInput allows modifying the tool input + UpdatedInput any `json:"updatedInput,omitempty"` + + // UpdatedPermissions adds permission rules for future requests + UpdatedPermissions []PermissionUpdate `json:"updatedPermissions,omitempty"` + + // Message provides feedback to the model + Message string `json:"message,omitempty"` + + // Interrupt stops the current operation (for deny) + Interrupt *bool `json:"interrupt,omitempty"` +} + +// SDKControlRequest is a control request sent to Claude Code CLI. +// Used for initialize, interrupt, and other control operations. +type SDKControlRequest struct { + Type string `json:"type"` // "control_request" + RequestID string `json:"request_id"` + Request SDKControlRequestBody `json:"request"` +} + +// SDKControlRequestBody contains the body of an SDK control request. +type SDKControlRequestBody struct { + // Subtype identifies the operation (initialize, interrupt, set_permission_mode) + Subtype string `json:"subtype"` + + // For initialize requests + Hooks map[string]any `json:"hooks,omitempty"` + + // For set_permission_mode requests + Mode string `json:"mode,omitempty"` +} + +// UserMessage is sent to provide a prompt to Claude Code. +type UserMessage struct { + Type string `json:"type"` // "user" + Message UserMessageBody `json:"message"` +} + +// UserMessageBody contains the user message content. +type UserMessageBody struct { + Role string `json:"role"` // "user" + Content string `json:"content"` +} + +// StreamEvent represents a streaming event from Claude Code. +// These are partial content updates during processing. +type StreamEvent struct { + Type string `json:"type"` + + // Content stream events + Index int `json:"index,omitempty"` + ContentType string `json:"content_type,omitempty"` + + // For text_delta events + Delta *TextDelta `json:"delta,omitempty"` + + // For thinking_delta events + ThinkingDelta string `json:"thinking_delta,omitempty"` +} + +// TextDelta contains a partial text update. +type TextDelta struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// ToolApprovalOptions represents the available options for tool approval. +type ToolApprovalOptions struct { + Allow bool `json:"allow"` + AlwaysAllow bool `json:"always_allow"` + Deny bool `json:"deny"` +} + +// Common tool names that require permission +const ( + ToolBash = "Bash" + ToolWrite = "Write" + ToolEdit = "Edit" + ToolNotebookEdit = "NotebookEdit" + ToolRead = "Read" + ToolGlob = "Glob" + ToolGrep = "Grep" + ToolTask = "Task" + ToolWebFetch = "WebFetch" + ToolWebSearch = "WebSearch" +) diff --git a/apps/backend/pkg/claudecode/types_test.go b/apps/backend/pkg/claudecode/types_test.go new file mode 100644 index 0000000000..39fa816aaa --- /dev/null +++ b/apps/backend/pkg/claudecode/types_test.go @@ -0,0 +1,291 @@ +package claudecode + +import ( + "encoding/json" + "testing" +) + +func TestCLIMessage_GetResultData(t *testing.T) { + tests := []struct { + name string + result json.RawMessage + wantNil bool + wantText string + }{ + { + name: "empty result", + result: nil, + wantNil: true, + }, + { + name: "string result (error)", + result: json.RawMessage(`"error message"`), + wantNil: true, // GetResultData returns nil for strings + }, + { + name: "object result with text", + result: json.RawMessage(`{"text":"success message","session_id":"abc123"}`), + wantNil: false, + wantText: "success message", + }, + { + name: "invalid JSON", + result: json.RawMessage(`{invalid`), + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := &CLIMessage{Result: tt.result} + got := msg.GetResultData() + if tt.wantNil { + if got != nil { + t.Errorf("GetResultData() = %v, want nil", got) + } + } else { + if got == nil { + t.Fatalf("GetResultData() = nil, want non-nil") + } + if got.Text != tt.wantText { + t.Errorf("GetResultData().Text = %q, want %q", got.Text, tt.wantText) + } + } + }) + } +} + +func TestCLIMessage_GetResultString(t *testing.T) { + tests := []struct { + name string + result json.RawMessage + want string + }{ + { + name: "empty result", + result: nil, + want: "", + }, + { + name: "string result", + result: json.RawMessage(`"error message"`), + want: "error message", + }, + { + name: "object result", + result: json.RawMessage(`{"text":"success"}`), + want: "", // GetResultString returns empty for objects + }, + { + name: "invalid JSON", + result: json.RawMessage(`{invalid`), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := &CLIMessage{Result: tt.result} + got := msg.GetResultString() + if got != tt.want { + t.Errorf("GetResultString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCLIMessage_JSONParsing(t *testing.T) { + // Test parsing a system message + systemJSON := `{"type":"system","session_id":"abc123","session_status":"active"}` + var systemMsg CLIMessage + if err := json.Unmarshal([]byte(systemJSON), &systemMsg); err != nil { + t.Fatalf("failed to parse system message: %v", err) + } + if systemMsg.Type != MessageTypeSystem { + t.Errorf("Type = %q, want %q", systemMsg.Type, MessageTypeSystem) + } + if systemMsg.SessionID != "abc123" { + t.Errorf("SessionID = %q, want %q", systemMsg.SessionID, "abc123") + } + + // Test parsing an assistant message + assistantJSON := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}],"model":"claude-3"}}` + var assistantMsg CLIMessage + if err := json.Unmarshal([]byte(assistantJSON), &assistantMsg); err != nil { + t.Fatalf("failed to parse assistant message: %v", err) + } + if assistantMsg.Type != MessageTypeAssistant { + t.Errorf("Type = %q, want %q", assistantMsg.Type, MessageTypeAssistant) + } + if assistantMsg.Message == nil { + t.Fatal("Message is nil") + } + if assistantMsg.Message.Model != "claude-3" { + t.Errorf("Message.Model = %q, want %q", assistantMsg.Message.Model, "claude-3") + } +} + +func TestControlRequest_JSONParsing(t *testing.T) { + // Test can_use_tool request + jsonStr := `{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"ls -la"},"tool_use_id":"tool123"}` + var req ControlRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + t.Fatalf("failed to parse control request: %v", err) + } + if req.Subtype != SubtypeCanUseTool { + t.Errorf("Subtype = %q, want %q", req.Subtype, SubtypeCanUseTool) + } + if req.ToolName != ToolBash { + t.Errorf("ToolName = %q, want %q", req.ToolName, ToolBash) + } + if req.Input["command"] != "ls -la" { + t.Errorf("Input[command] = %v, want %q", req.Input["command"], "ls -la") + } +} + +func TestControlResponseMessage_JSONMarshal(t *testing.T) { + resp := &ControlResponseMessage{ + Type: MessageTypeControlResponse, + RequestID: "req123", + Response: &ControlResponse{ + Subtype: "success", + Result: &PermissionResult{ + Behavior: BehaviorAllow, + }, + }, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if parsed["type"] != MessageTypeControlResponse { + t.Errorf("type = %v, want %q", parsed["type"], MessageTypeControlResponse) + } + if parsed["request_id"] != "req123" { + t.Errorf("request_id = %v, want %q", parsed["request_id"], "req123") + } +} + +func TestUserMessage_JSONMarshal(t *testing.T) { + msg := &UserMessage{ + Type: MessageTypeUser, + Message: UserMessageBody{ + Role: "user", + Content: "Hello, Claude!", + }, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + expected := `{"type":"user","message":{"role":"user","content":"Hello, Claude!"}}` + if string(data) != expected { + t.Errorf("Marshal() = %s, want %s", string(data), expected) + } +} + +func TestContentBlock_Types(t *testing.T) { + tests := []struct { + name string + json string + check func(t *testing.T, block ContentBlock) + }{ + { + name: "text block", + json: `{"type":"text","text":"Hello world"}`, + check: func(t *testing.T, block ContentBlock) { + if block.Type != "text" { + t.Errorf("Type = %q, want %q", block.Type, "text") + } + if block.Text != "Hello world" { + t.Errorf("Text = %q, want %q", block.Text, "Hello world") + } + }, + }, + { + name: "thinking block", + json: `{"type":"thinking","thinking":"Let me analyze..."}`, + check: func(t *testing.T, block ContentBlock) { + if block.Type != "thinking" { + t.Errorf("Type = %q, want %q", block.Type, "thinking") + } + if block.Thinking != "Let me analyze..." { + t.Errorf("Thinking = %q, want %q", block.Thinking, "Let me analyze...") + } + }, + }, + { + name: "tool_use block", + json: `{"type":"tool_use","id":"tool123","name":"Bash","input":{"command":"ls"}}`, + check: func(t *testing.T, block ContentBlock) { + if block.Type != "tool_use" { + t.Errorf("Type = %q, want %q", block.Type, "tool_use") + } + if block.ID != "tool123" { + t.Errorf("ID = %q, want %q", block.ID, "tool123") + } + if block.Name != "Bash" { + t.Errorf("Name = %q, want %q", block.Name, "Bash") + } + }, + }, + { + name: "tool_result block", + json: `{"type":"tool_result","tool_use_id":"tool123","content":"output","is_error":false}`, + check: func(t *testing.T, block ContentBlock) { + if block.Type != "tool_result" { + t.Errorf("Type = %q, want %q", block.Type, "tool_result") + } + if block.ToolUseID != "tool123" { + t.Errorf("ToolUseID = %q, want %q", block.ToolUseID, "tool123") + } + if block.Content != "output" { + t.Errorf("Content = %q, want %q", block.Content, "output") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var block ContentBlock + if err := json.Unmarshal([]byte(tt.json), &block); err != nil { + t.Fatalf("failed to parse: %v", err) + } + tt.check(t, block) + }) + } +} + +func TestModelUsageStats_ContextWindow(t *testing.T) { + jsonStr := `{"context_window": 200000}` + var stats ModelUsageStats + if err := json.Unmarshal([]byte(jsonStr), &stats); err != nil { + t.Fatalf("failed to parse: %v", err) + } + if stats.ContextWindow == nil { + t.Fatal("ContextWindow is nil") + } + if *stats.ContextWindow != 200000 { + t.Errorf("ContextWindow = %d, want %d", *stats.ContextWindow, 200000) + } + + // Test nil context window + jsonStr2 := `{}` + var stats2 ModelUsageStats + if err := json.Unmarshal([]byte(jsonStr2), &stats2); err != nil { + t.Fatalf("failed to parse: %v", err) + } + if stats2.ContextWindow != nil { + t.Errorf("ContextWindow = %v, want nil", stats2.ContextWindow) + } +}