feat: claude code support (#87)

This commit is contained in:
Carlos Florêncio
2026-01-24 13:41:45 +00:00
committed by GitHub
parent 39b6e89244
commit eec22dc3bb
16 changed files with 2538 additions and 32 deletions
+7 -3
View File
@@ -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,
+498
View File
@@ -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/<agentname>/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/<agentname>/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/<agentname>_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
@@ -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 {
@@ -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
}
]
}
}
]
}
@@ -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
}
@@ -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)
}
}
@@ -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.
@@ -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)
@@ -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)
}
}
@@ -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
@@ -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" {
+5 -1
View File
@@ -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
+197
View File
@@ -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))
}
}
}
+280
View File
@@ -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)
}
}
+293
View File
@@ -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"
)
+291
View File
@@ -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)
}
}