feat: claude code support (#87)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user