diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index f6283f304c..59f84f7ef0 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -286,9 +286,9 @@ func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskI return err } -// UpdateToolCallMessage updates a tool call message's status -func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID string) error { - return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result) +// UpdateToolCallMessage updates a tool call message's status and optionally updates title/args +func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, args map[string]interface{}) error { + return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, args) } // CreateSessionMessage creates a message for non-chat session updates (status/progress/error/etc). diff --git a/apps/backend/docs/opencode_gemini_plan.md b/apps/backend/docs/opencode_gemini_plan.md new file mode 100644 index 0000000000..f0c8ba2455 --- /dev/null +++ b/apps/backend/docs/opencode_gemini_plan.md @@ -0,0 +1,863 @@ +# Multi-Agent Support Plan: OpenCode and Gemini + +## Summary + +Add support for two additional AI coding agents to kandev-2: +1. **OpenCode** - REST/SSE-based protocol with HTTP server +2. **Gemini** - ACP-based protocol (reuses existing ACP adapter) + +## Architecture Overview + +``` +Protocol Layer Adapter Layer Normalized Events +------------------- ------------------- ------------------- +ACP (JSON-RPC) --> ACPAdapter --> +Codex (JSON-RPC) --> CodexAdapter --> AgentEvent +Claude (stream-json) --> ClaudeCodeAdapter --> (protocol-agnostic) +OpenCode (REST/SSE) --> OpenCodeAdapter --> (NEW) +Gemini (ACP) --> ACPAdapter (reuse) --> (uses existing) +``` + +--- + +## Agent 1: OpenCode + +### Protocol Overview + +OpenCode uses a **REST API + Server-Sent Events (SSE)** pattern: + +1. **Spawn HTTP Server**: Run `npx -y opencode-ai@ serve --hostname 127.0.0.1 --port 0` +2. **Wait for Server URL**: Parse stdout for `opencode server listening on ` +3. **Health Check**: Poll `GET /global/health` until healthy +4. **Session Management**: + - Create: `POST /session?directory=` + - Fork (resume): `POST /session/{id}/fork?directory=` +5. **Send Prompt**: `POST /session/{id}/message?directory=` +6. **Event Stream**: `GET /event?directory=` (SSE) +7. **Permission Handling**: `POST /permission/{id}/reply?directory=` +8. **Abort**: `POST /session/{id}/abort?directory=` + +### Key Features + +- **Password Auth**: Server uses `OPENCODE_SERVER_PASSWORD` env var for basic auth +- **Session Persistence**: Sessions can be forked for follow-up prompts +- **Permission System**: `permission.asked` events require `reply` with `once`/`reject` +- **Token Tracking**: Events include token usage info + +### Event Types from SSE Stream + +| Event Type | Description | +|------------|-------------| +| `message.updated` | Message metadata updated (tokens, model info) | +| `message.part.updated` | Text/reasoning/tool content streaming | +| `permission.asked` | Tool needs approval | +| `permission.replied` | Permission response sent | +| `session.idle` | Agent finished processing | +| `session.error` | Error occurred | +| `todo.updated` | Task list updated | + +### Event Mapping: OpenCode --> AgentEvent + +| OpenCode Event | AgentEvent Type | Field Mapping | +|----------------|-----------------|---------------| +| `session_start` | `session_status` | SessionID | +| `message.part.updated` (text) | `message_chunk` | Text | +| `message.part.updated` (reasoning) | `reasoning` | ReasoningText | +| `message.part.updated` (tool) | `tool_call` / `tool_update` | ToolCallID, ToolName, ToolStatus | +| `permission.asked` | `permission_request` | PendingID, PermissionTitle | +| `message.updated` (tokens) | `context_window` | ContextWindowUsed | +| `session.idle` + `done` | `complete` | - | +| `session.error` | `error` | Error | + +--- + +## Agent 2: Gemini (Google) + +### Protocol Overview + +Gemini uses the **ACP (Agent Communication Protocol)** - same as Auggie: + +1. **Spawn CLI**: `npx -y @google/gemini-cli@ --experimental-acp` +2. **ACP Initialization**: JSON-RPC over stdin/stdout +3. **Session Management**: Via ACP session/load protocol +4. **Prompts**: Via ACP prompt method + +### Key Insight + +Gemini CLI supports `--experimental-acp` flag, making it fully compatible with our existing `ACPAdapter`. No new adapter needed - just configuration. + +### Command Flags + +```bash +npx -y @google/gemini-cli@0.23.0 \ + --model \ + --experimental-acp \ + [--yolo] # Auto-approve (optional) +``` + +--- + +## Implementation Phases + +### Phase 1: Add Protocol Constants + +**File: `pkg/agent/protocol.go`** + +```go +const ( + // ... existing protocols + // ProtocolOpenCode is the OpenCode CLI protocol (REST/SSE over HTTP). + ProtocolOpenCode Protocol = "opencode" +) + +func (p Protocol) IsValid() bool { + switch p { + case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, + ProtocolClaudeCode, ProtocolOpenCode: + return true + } + return false +} +``` + +Note: Gemini uses `ProtocolACP` (existing). + +--- + +### Phase 2: OpenCode Protocol Types + +**New File: `pkg/opencode/types.go`** + +```go +package opencode + +// ExecutorEvent represents events written to stdout by the adapter +type ExecutorEvent struct { + Type string `json:"type"` + // Variants based on type +} + +type SessionStartEvent struct { + Type string `json:"type"` // "session_start" + SessionID string `json:"session_id"` +} + +type SDKEvent struct { + Type string `json:"type"` // "sdk_event" + Event json.RawMessage `json:"event"` +} + +type TokenUsageEvent struct { + Type string `json:"type"` // "token_usage" + TotalTokens int `json:"total_tokens"` + ModelContextWindow int `json:"model_context_window"` +} + +type ApprovalResponseEvent struct { + Type string `json:"type"` // "approval_response" + ToolCallID string `json:"tool_call_id"` + Status string `json:"status"` // "approved", "denied", etc. +} + +type DoneEvent struct { + Type string `json:"type"` // "done" +} + +type ErrorEvent struct { + Type string `json:"type"` // "error" + Message string `json:"message"` +} + +// SDK Event types (from /event SSE stream) +type MessageUpdatedEvent struct { + Type string `json:"type"` // "message.updated" + Properties MessageInfo `json:"properties"` +} + +type MessagePartUpdatedEvent struct { + Type string `json:"type"` // "message.part.updated" + Properties MessagePartInfo `json:"properties"` + Delta string `json:"delta,omitempty"` // For streaming text +} + +type MessageInfo struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` // "user", "assistant" + Model *MessageModelInfo `json:"model,omitempty"` + Tokens *MessageTokens `json:"tokens,omitempty"` +} + +type MessagePartInfo struct { + Type string `json:"type"` // "text", "reasoning", "tool" + MessageID string `json:"messageID"` + SessionID string `json:"sessionID"` + Text string `json:"text,omitempty"` + CallID string `json:"callID,omitempty"` // For tool + Tool string `json:"tool,omitempty"` + State *ToolStateUpdate `json:"state,omitempty"` +} + +type ToolStateUpdate struct { + Status string `json:"status"` // "pending", "running", "completed", "error" + Input json.RawMessage `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Title string `json:"title,omitempty"` + Error string `json:"error,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type PermissionAskedEvent struct { + Type string `json:"type"` // "permission.asked" + Properties struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Tool *PermissionToolInfo `json:"tool,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + } `json:"properties"` +} + +type PermissionToolInfo struct { + CallID string `json:"callID"` +} + +type SessionIdleEvent struct { + Type string `json:"type"` // "session.idle" + Properties struct { + SessionID string `json:"sessionID"` + } `json:"properties"` +} + +type SessionErrorEvent struct { + Type string `json:"type"` // "session.error" + Properties struct { + SessionID string `json:"sessionID"` + Error interface{} `json:"error"` + } `json:"properties"` +} +``` + +--- + +### Phase 3: OpenCode HTTP Client + +**New File: `pkg/opencode/client.go`** + +```go +package opencode + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Client manages HTTP communication with OpenCode server +type Client struct { + baseURL string + directory string + password string + httpClient *http.Client + logger *logger.Logger + + // Event handling + eventHandler EventHandler + controlCh chan ControlEvent + + // Session state + sessionID string + mu sync.RWMutex + + ctx context.Context + cancel context.CancelFunc +} + +type EventHandler func(event *SDKEvent) + +type ControlEvent struct { + Type string // "idle", "auth_required", "session_error", "disconnected" + Message string +} + +func NewClient(baseURL, directory, password string, log *logger.Logger) *Client + +// Core operations +func (c *Client) WaitForHealth(ctx context.Context) error +func (c *Client) CreateSession(ctx context.Context) (string, error) +func (c *Client) ForkSession(ctx context.Context, sessionID string) (string, error) +func (c *Client) SendPrompt(ctx context.Context, sessionID, prompt string, model *ModelSpec) error +func (c *Client) Abort(ctx context.Context, sessionID string) error +func (c *Client) ReplyPermission(ctx context.Context, requestID, reply string, message *string) error + +// Event stream +func (c *Client) StartEventStream(ctx context.Context) error +func (c *Client) SetEventHandler(handler EventHandler) +func (c *Client) ControlChannel() <-chan ControlEvent + +// Helpers +func (c *Client) buildAuthHeader() string +``` + +--- + +### Phase 4: OpenCode Adapter + +**New File: `internal/agentctl/server/adapter/opencode_adapter.go`** + +```go +package adapter + +import ( + "context" + "io" + "os/exec" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/opencode" +) + +type OpenCodeAdapter struct { + cfg *Config + logger *logger.Logger + + // Process management + cmd *exec.Cmd + stdout io.ReadCloser + + // HTTP client (created after server starts) + client *opencode.Client + + // Session state + sessionID string + password string + + // Event channel + updatesCh chan AgentEvent + + // Permission handling + permissionHandler PermissionHandler + + mu sync.RWMutex + closed bool + + ctx context.Context + cancel context.CancelFunc +} + +func NewOpenCodeAdapter(cfg *Config, log *logger.Logger) *OpenCodeAdapter + +// AgentAdapter interface implementation +func (a *OpenCodeAdapter) PrepareEnvironment() error +func (a *OpenCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error +func (a *OpenCodeAdapter) Initialize(ctx context.Context) error +func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error +func (a *OpenCodeAdapter) Cancel(ctx context.Context) error +func (a *OpenCodeAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) +func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) error +func (a *OpenCodeAdapter) Updates() <-chan AgentEvent +func (a *OpenCodeAdapter) Close() error +func (a *OpenCodeAdapter) GetAgentInfo() *AgentInfo +func (a *OpenCodeAdapter) SetPermissionHandler(handler PermissionHandler) +func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID string, response *PermissionResponse) error + +// Internal methods +func (a *OpenCodeAdapter) waitForServerURL(stdout io.Reader) (string, error) +func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEvent) +func (a *OpenCodeAdapter) parseAndEmitEvent(eventType string, data map[string]interface{}) +func (a *OpenCodeAdapter) sendUpdate(event AgentEvent) +``` + +### Key Implementation Details + +**Server Startup Flow:** +1. Spawn `npx -y opencode-ai@ serve --hostname 127.0.0.1 --port 0` +2. Parse stdout for `opencode server listening on ` +3. Create HTTP client with parsed URL +4. Call `WaitForHealth()` +5. Create session via `CreateSession()` or `ForkSession()` +6. Start SSE event stream in background goroutine + +**Event Processing:** +```go +func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEvent) { + switch event.Type { + case "message.part.updated": + part := parseMessagePart(event) + switch part.Type { + case "text": + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + Text: part.Delta, + }) + case "reasoning": + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + ReasoningText: part.Delta, + }) + case "tool": + a.handleToolUpdate(part) + } + case "permission.asked": + a.handlePermissionRequest(event) + case "session.idle": + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + }) + case "session.error": + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + Error: extractErrorMessage(event), + }) + } +} +``` + +--- + +### Phase 5: Update Adapter Factory + +**File: `internal/agentctl/server/adapter/factory.go`** + +```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.ProtocolOpenCode: + return NewOpenCodeAdapter(cfg, log), nil + default: + return nil, fmt.Errorf("unsupported protocol: %s", protocol) + } +} +``` + +--- + +### Phase 6: Update Registry Protocol Parser + +**File: `internal/agent/registry/registry.go`** + +```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 "opencode": + return agent.ProtocolOpenCode + default: + return agent.ProtocolACP + } +} +``` + +--- + +### Phase 7: Agent Configurations + +**File: `internal/agent/registry/agents.json`** + +Add these entries to the `agents` array: + +```json +{ + "id": "opencode", + "name": "OpenCode AI Agent", + "display_name": "OpenCode", + "description": "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "opencode-ai@1.1.25", + "serve", + "--hostname", + "127.0.0.1", + "--port", + "0" + ], + "working_dir": "{workspace}", + "required_env": [], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "opencode", + "protocol_config": {}, + "model_flag": "", + "workspace_flag": "", + "session_config": { + "resume_via_acp": false, + "resume_flag": "", + "can_recover": true, + "session_dir_template": "{home}/.opencode", + "session_dir_target": "", + "reports_status_via_stream": true + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": ["edit", "bash", "webfetch"] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": true, + "label": "Auto-approve", + "description": "Automatically approve tool calls", + "apply_method": "env", + "cli_flag": "" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": ["~/.config/opencode/opencode.json", "~/.config/opencode/opencode.jsonc"], + "windows": [], + "macos": ["~/Library/Application Support/opencode/opencode.json"] + }, + "installation_path": { + "linux": ["~/.opencode", "~/.config/opencode"], + "windows": [], + "macos": ["~/.opencode", "~/Library/Application Support/ai.opencode.desktop"] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "anthropic/claude-sonnet-4-20250514", + "available_models": [ + { + "id": "anthropic/claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "description": "Anthropic Claude Sonnet 4", + "provider": "anthropic", + "context_window": 200000, + "is_default": true + }, + { + "id": "anthropic/claude-opus-4-20250514", + "name": "Claude Opus 4", + "description": "Anthropic Claude Opus 4", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + }, + { + "id": "openai/gpt-5.2", + "name": "GPT-5.2", + "description": "OpenAI GPT-5.2", + "provider": "openai", + "context_window": 200000, + "is_default": false + }, + { + "id": "google/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "Google Gemini 2.5 Pro", + "provider": "google", + "context_window": 2000000, + "is_default": false + } + ] + } +}, +{ + "id": "gemini", + "name": "Google Gemini CLI Agent", + "display_name": "Gemini", + "description": "Google Gemini CLI-powered autonomous coding agent using ACP protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "@google/gemini-cli@0.23.0", + "--experimental-acp" + ], + "working_dir": "{workspace}", + "required_env": [], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "acp", + "protocol_config": {}, + "model_flag": "--model {model}", + "workspace_flag": "", + "session_config": { + "resume_via_acp": true, + "resume_flag": "", + "can_recover": true, + "session_dir_template": "{home}/.gemini", + "session_dir_target": "", + "reports_status_via_stream": false + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": ["run_shell_command", "write_file", "edit_file"] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": false, + "label": "Auto-approve (YOLO mode)", + "description": "Automatically approve all tool calls", + "apply_method": "cli_flag", + "cli_flag": "--yolo --allowed-tools run_shell_command" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": ["~/.gemini/settings.json"], + "windows": [], + "macos": ["~/.gemini/settings.json"] + }, + "installation_path": { + "linux": ["~/.gemini/oauth_creds.json", "~/.gemini/installation_id"], + "windows": [], + "macos": ["~/.gemini/oauth_creds.json", "~/.gemini/installation_id"] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "gemini-2.5-flash", + "available_models": [ + { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "Fast and efficient model", + "provider": "google", + "context_window": 1000000, + "is_default": true + }, + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "Most capable model with 2M context", + "provider": "google", + "context_window": 2000000, + "is_default": false + } + ] + } +} +``` + +--- + +## Files to Create + +| File | Description | +|------|-------------| +| `pkg/opencode/types.go` | OpenCode protocol type definitions | +| `pkg/opencode/client.go` | HTTP/SSE client for OpenCode server | +| `pkg/opencode/client_test.go` | Unit tests for client | +| `internal/agentctl/server/adapter/opencode_adapter.go` | Adapter implementation | +| `internal/agentctl/server/adapter/opencode_adapter_test.go` | Adapter tests | + +## Files to Modify + +| File | Change | +|------|--------| +| `pkg/agent/protocol.go` | Add `ProtocolOpenCode` constant | +| `internal/agent/registry/registry.go` | Add `"opencode"` case to `parseProtocol()` | +| `internal/agent/registry/agents.json` | Add opencode and gemini agent configs | +| `internal/agentctl/server/adapter/factory.go` | Add OpenCode case to factory | + +--- + +## Key Differences Between Agents + +| Aspect | OpenCode | Gemini | +|--------|----------|--------| +| Protocol | REST/SSE over HTTP | ACP (JSON-RPC over stdin/stdout) | +| Server Model | Spawns HTTP server | Uses ACP directly | +| Session Resume | HTTP fork endpoint | ACP session/load | +| Permission Handling | HTTP reply endpoint | ACP callback | +| New Adapter Needed | Yes (OpenCodeAdapter) | No (reuses ACPAdapter) | +| Auth | Password-based Basic Auth | OAuth (stored in ~/.gemini) | + +--- + +## Permission Handling + +### OpenCode +```go +// In opencode_adapter.go +func (a *OpenCodeAdapter) handlePermissionRequest(event *opencode.PermissionAskedEvent) { + requestID := event.Properties.ID + toolCallID := event.Properties.Tool.CallID + permission := event.Properties.Permission + + a.sendUpdate(AgentEvent{ + Type: EventTypePermissionRequest, + SessionID: a.sessionID, + PendingID: requestID, + ToolCallID: toolCallID, + PermissionTitle: formatPermissionTitle(permission, event.Properties.Metadata), + ActionType: permission, + ActionDetails: event.Properties.Metadata, + }) +} + +func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID string, response *PermissionResponse) error { + reply := "reject" + var message *string + + if response.Approved { + reply = "once" + } else { + msg := response.Message + if msg == "" { + msg = "User denied this tool use request" + } + message = &msg + } + + return a.client.ReplyPermission(ctx, requestID, reply, message) +} +``` + +### Gemini (uses existing ACPAdapter permission handling) + +--- + +## Session Management + +### OpenCode + +```go +func (a *OpenCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + sessionID, err := a.client.CreateSession(ctx) + if err != nil { + return "", err + } + + a.mu.Lock() + a.sessionID = sessionID + a.mu.Unlock() + + // Emit session status event for resume token storage + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: sessionID, + Data: map[string]interface{}{ + "session_status": "active", + "init": true, + }, + }) + + return sessionID, nil +} + +func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) error { + // Fork existing session (creates new session with history) + newSessionID, err := a.client.ForkSession(ctx, sessionID) + if err != nil { + return err + } + + a.mu.Lock() + a.sessionID = newSessionID + a.mu.Unlock() + + return nil +} +``` + +### Gemini (uses existing ACPAdapter session handling via ACP protocol) + +--- + +## Testing Checklist + +### OpenCode +- [ ] Server starts and health check passes +- [ ] Session creation works +- [ ] Prompt sends and response streams +- [ ] Text content displays correctly +- [ ] Reasoning content displays correctly +- [ ] Tool calls display with status updates +- [ ] Permission requests trigger UI +- [ ] Permission responses (approve/deny) work +- [ ] Session resume (fork) works +- [ ] Token usage tracked +- [ ] Abort works +- [ ] Error handling works + +### Gemini +- [ ] Agent appears when oauth_creds.json exists +- [ ] ACP initialization works +- [ ] Prompt sends and response streams +- [ ] Tool calls work +- [ ] Permission requests work (without --yolo) +- [ ] --yolo mode auto-approves +- [ ] Session resume works via ACP +- [ ] Model selection works (--model flag) + +--- + +## Implementation Priority + +1. **Gemini First** - Simpler, reuses ACPAdapter, just needs config +2. **OpenCode Second** - Requires new adapter with HTTP/SSE handling + +### Gemini Implementation Steps +1. Add agent config to agents.json +2. Test with existing ACPAdapter +3. Verify model flag and auto-approve work + +### OpenCode Implementation Steps +1. Add protocol constant +2. Create types package +3. Create HTTP/SSE client +4. Create adapter +5. Add to factory +6. Add agent config +7. Test end-to-end diff --git a/apps/backend/internal/agent/lifecycle/command.go b/apps/backend/internal/agent/lifecycle/command.go index 022abff819..a41d6c8b45 100644 --- a/apps/backend/internal/agent/lifecycle/command.go +++ b/apps/backend/internal/agent/lifecycle/command.go @@ -84,8 +84,10 @@ func (cb *CommandBuilder) BuildCommand(agentConfig *registry.AgentTypeConfig, op // Flag with value: "--flag value" cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) } else { - // Boolean flag: "--flag" - cmd = append(cmd, setting.CLIFlag) + // Boolean flag or multiple flags: "--flag" or "--flag1 --flag2 arg" + // Split on spaces to handle multiple flags in one setting + parts := strings.Fields(setting.CLIFlag) + cmd = append(cmd, parts...) } } } diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 8e11f761d8..61557ad2a3 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -42,12 +42,13 @@ type Manager struct { runtime Runtime // Refactored components for separation of concerns - executionStore *ExecutionStore // Thread-safe execution tracking - commandBuilder *CommandBuilder // Builds agent commands from registry config - sessionManager *SessionManager // Handles ACP session initialization - streamManager *StreamManager // Manages WebSocket streams - eventPublisher *EventPublisher // Publishes lifecycle events - containerManager *ContainerManager // Manages Docker containers (optional, nil for non-Docker runtimes) + executionStore *ExecutionStore // Thread-safe execution tracking + commandBuilder *CommandBuilder // Builds agent commands from registry config + sessionManager *SessionManager // Handles ACP session initialization + streamManager *StreamManager // Manages WebSocket streams + eventPublisher *EventPublisher // Publishes lifecycle events + containerManager *ContainerManager // Manages Docker containers (optional, nil for non-Docker runtimes) + historyManager *SessionHistoryManager // Stores session history for context injection (fork_session pattern) // Workspace info provider for on-demand instance creation workspaceInfoProvider WorkspaceInfoProvider @@ -88,6 +89,12 @@ func NewManager( // Initialize execution store executionStore := NewExecutionStore() + // Initialize session history manager for fork_session pattern (context injection) + historyManager, err := NewSessionHistoryManager("", log) + if err != nil { + log.Warn("failed to create session history manager, context injection disabled", zap.Error(err)) + } + mgr := &Manager{ registry: reg, eventBus: eventBus, @@ -101,6 +108,7 @@ func NewManager( sessionManager: sessionManager, eventPublisher: eventPublisher, containerManager: containerManager, + historyManager: historyManager, cleanupInterval: 30 * time.Second, stopCh: stopCh, } @@ -118,7 +126,7 @@ func NewManager( }) // Set session manager dependencies for full orchestration - sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore) + sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore, historyManager) if runtime != nil { mgr.logger.Info("initialized with runtime", zap.String("runtime", string(runtime.Name()))) @@ -999,6 +1007,19 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age // Include the flushed text in the event for the orchestrator to save if flushedText := m.flushMessageBuffer(execution); flushedText != "" { event.Text = flushedText + // Store flushed agent message to session history for context injection + if m.historyManager != nil && execution.SessionID != "" { + if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { + m.logger.Warn("failed to store agent message to history", zap.Error(err)) + } + } + } + + // Store tool call to session history for context injection + if m.historyManager != nil && execution.SessionID != "" { + if err := m.historyManager.AppendToolCall(execution.SessionID, event); err != nil { + m.logger.Warn("failed to store tool call to history", zap.Error(err)) + } } m.logger.Debug("tool call started", @@ -1008,6 +1029,12 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age // Tool call message creation is handled by orchestrator via AgentStreamEvent case "tool_update": + // Store tool result to session history when complete + if m.historyManager != nil && execution.SessionID != "" && event.ToolStatus == "complete" { + if err := m.historyManager.AppendToolResult(execution.SessionID, event); err != nil { + m.logger.Warn("failed to store tool result to history", zap.Error(err)) + } + } // Tool update handled by orchestrator via AgentStreamEvent case "plan": @@ -1034,6 +1061,12 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age // The orchestrator will save this as an agent message if flushedText := m.flushMessageBuffer(execution); flushedText != "" { event.Text = flushedText + // Store agent message to session history for context injection + if m.historyManager != nil && execution.SessionID != "" { + if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { + m.logger.Warn("failed to store agent message to history", zap.Error(err)) + } + } } // Mark agent as READY for follow-up prompts diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index e6fa453e6f..7f36f66bd6 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/common/appctx" "github.com/kandev/kandev/internal/common/logger" v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/agent" ) // SessionManager handles ACP session initialization and management @@ -23,6 +24,7 @@ type SessionManager struct { eventPublisher *EventPublisher streamManager *StreamManager executionStore *ExecutionStore + historyManager *SessionHistoryManager stopCh <-chan struct{} // For graceful shutdown coordination } @@ -36,10 +38,11 @@ func NewSessionManager(log *logger.Logger, stopCh <-chan struct{}) *SessionManag // SetDependencies sets the optional dependencies for full session orchestration. // These are set after construction to avoid circular dependencies. -func (sm *SessionManager) SetDependencies(ep *EventPublisher, strm *StreamManager, store *ExecutionStore) { +func (sm *SessionManager) SetDependencies(ep *EventPublisher, strm *StreamManager, store *ExecutionStore, history *SessionHistoryManager) { sm.eventPublisher = ep sm.streamManager = strm sm.executionStore = store + sm.historyManager = history } // InitializeResult contains the result of session initialization @@ -116,11 +119,68 @@ func (sm *SessionManager) createOrLoadSession( mcpServers []agentctltypes.McpServer, ) (string, error) { if agentConfig.SessionConfig.ResumeViaACP && existingSessionID != "" { - return sm.loadSession(ctx, client, agentConfig, existingSessionID) + sessionID, err := sm.loadSession(ctx, client, agentConfig, existingSessionID) + if err != nil { + // If session/load fails with "Method not found" or "LoadSession capability is false", + // fall back to creating a new session. This handles agents that don't support session/load. + if strings.Contains(err.Error(), "Method not found") || + strings.Contains(err.Error(), "LoadSession capability is false") { + sm.logger.Warn("agent does not support session loading, falling back to session/new", + zap.String("agent_type", agentConfig.ID), + zap.String("existing_session_id", existingSessionID), + zap.String("reason", err.Error())) + return sm.createNewSession(ctx, client, agentConfig, workspacePath, mcpServers) + } + return "", err + } + return sessionID, nil } return sm.createNewSession(ctx, client, agentConfig, workspacePath, mcpServers) } +// shouldInjectResumeContext determines if we should inject resume context for this session. +// Returns true if: +// 1. The agent uses ACP protocol +// 2. The agent doesn't support session/load (ResumeViaACP is false) +// 3. We have a task session ID (for history lookup) +// 4. There's existing history for this session +func (sm *SessionManager) shouldInjectResumeContext(agentConfig *registry.AgentTypeConfig, taskSessionID string) bool { + if sm.historyManager == nil { + return false + } + + // Only inject for ACP agents that don't support session/load + if agentConfig.Protocol != agent.ProtocolACP { + return false + } + + // If agent supports session/load via ACP, don't inject (it will restore context natively) + if agentConfig.SessionConfig.ResumeViaACP { + return false + } + + // Check if we have history for this session + return sm.historyManager.HasHistory(taskSessionID) +} + +// getResumeContextPrompt generates a prompt with resume context if available. +// If there's no history or context injection is disabled, returns the original prompt. +func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentTypeConfig, taskSessionID, originalPrompt string) string { + if !sm.shouldInjectResumeContext(agentConfig, taskSessionID) { + return originalPrompt + } + + resumePrompt, err := sm.historyManager.GenerateResumeContext(taskSessionID, originalPrompt) + if err != nil { + sm.logger.Warn("failed to generate resume context, using original prompt", + zap.String("session_id", taskSessionID), + zap.Error(err)) + return originalPrompt + } + + return resumePrompt +} + // loadSession loads an existing session via ACP session/load func (sm *SessionManager) loadSession( ctx context.Context, @@ -244,21 +304,47 @@ func (sm *SessionManager) InitializeAndPrompt( // Send the task prompt if provided - run asynchronously so orchestrator.start returns quickly. // The agent will process the prompt and emit events via the WebSocket stream. + // + // For resumed sessions (fork_session pattern): If we have session history but no new task description, + // we still need to send a resume context prompt so the agent knows about the prior conversation. if taskDescription != "" { + // Check if we need to inject resume context (fork_session pattern for ACP agents) + effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, taskDescription) + if effectivePrompt != taskDescription { + sm.logger.Info("injecting resume context into initial prompt", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.Int("original_length", len(taskDescription)), + zap.Int("effective_length", len(effectivePrompt))) + } + go func() { // Use detached context that respects stopCh for graceful shutdown promptCtx, cancel := appctx.Detached(ctx, sm.stopCh, 10*time.Minute) defer cancel() - _, err := sm.SendPrompt(promptCtx, execution, taskDescription, false, markReady) + _, err := sm.SendPrompt(promptCtx, execution, effectivePrompt, false, markReady) if err != nil { sm.logger.Error("initial prompt failed", zap.String("execution_id", execution.ID), zap.Error(err)) } }() + } else if sm.shouldInjectResumeContext(agentConfig, execution.SessionID) { + // No task description, but we have session history (resumed session) + // Don't send a prompt now - we'll inject context when the user sends their first message. + // This avoids triggering IN_PROGRESS state until the user actually interacts. + execution.needsResumeContext = true + sm.logger.Info("session has history for context injection, will inject on first user prompt", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID)) + if err := markReady(execution.ID); err != nil { + sm.logger.Error("failed to mark execution as ready", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } } else { - sm.logger.Warn("no task description provided, marking as ready", + sm.logger.Debug("no task description and no resume context needed, marking as ready", zap.String("execution_id", execution.ID)) if err := markReady(execution.ID); err != nil { sm.logger.Error("failed to mark execution as ready", @@ -303,12 +389,45 @@ func (sm *SessionManager) SendPrompt( execution.currentMessageID = "" // Clear streaming message ID for new turn execution.messageMu.Unlock() + // Check if we need to inject resume context (fork_session pattern) + // This happens on the first user prompt after resuming a session + effectivePrompt := prompt + if execution.needsResumeContext && !execution.resumeContextInjected { + if sm.historyManager != nil { + resumePrompt, err := sm.historyManager.GenerateResumeContext(execution.SessionID, prompt) + if err != nil { + sm.logger.Warn("failed to generate resume context for follow-up prompt", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } else if resumePrompt != prompt { + effectivePrompt = resumePrompt + execution.resumeContextInjected = true + sm.logger.Info("injecting resume context into follow-up prompt", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.Int("original_length", len(prompt)), + zap.Int("effective_length", len(effectivePrompt))) + // Log the full resume context for debugging (use Info for now so user can inspect) + sm.logger.Info("resume context prompt content", + zap.String("execution_id", execution.ID), + zap.String("resume_prompt", effectivePrompt)) + } + } + } + sm.logger.Info("sending prompt to agent", zap.String("execution_id", execution.ID), - zap.Int("prompt_length", len(prompt))) + zap.Int("prompt_length", len(effectivePrompt))) + + // Store user prompt to session history for context injection (store original, not with injected context) + if sm.historyManager != nil && execution.SessionID != "" { + if err := sm.historyManager.AppendUserMessage(execution.SessionID, prompt); err != nil { + sm.logger.Warn("failed to store user message to history", zap.Error(err)) + } + } // Prompt is synchronous - blocks until agent completes - resp, err := execution.agentctl.Prompt(ctx, prompt) + resp, err := execution.agentctl.Prompt(ctx, effectivePrompt) if err != nil { sm.logger.Error("ACP prompt failed", zap.String("execution_id", execution.ID), diff --git a/apps/backend/internal/agent/lifecycle/session_history.go b/apps/backend/internal/agent/lifecycle/session_history.go new file mode 100644 index 0000000000..43a5c20877 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/session_history.go @@ -0,0 +1,313 @@ +// Package lifecycle provides session history management for ACP agents. +// This enables session context injection for agents that don't support session/load. +package lifecycle + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" +) + +// SessionHistoryManager stores and retrieves session history for context injection. +// This enables the fork_session pattern for ACP agents that don't support session/load. +// +// Session history is stored as JSONL files at: {baseDir}/{sessionID}.jsonl +// Each line is a HistoryEntry containing the event type, timestamp, and relevant content. +type SessionHistoryManager struct { + baseDir string + logger *logger.Logger + mu sync.RWMutex +} + +// HistoryEntry represents a single entry in the session history. +type HistoryEntry struct { + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` // "user_message", "agent_message", "tool_call", "tool_result" + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolArgs map[string]any `json:"tool_args,omitempty"` + ToolStatus string `json:"tool_status,omitempty"` + SessionID string `json:"session_id,omitempty"` + OperationID string `json:"operation_id,omitempty"` +} + +// NewSessionHistoryManager creates a new SessionHistoryManager. +// The baseDir is the directory where session history files will be stored. +// If baseDir is empty, defaults to ~/.kandev/sessions/ +func NewSessionHistoryManager(baseDir string, log *logger.Logger) (*SessionHistoryManager, error) { + if baseDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + baseDir = filepath.Join(homeDir, ".kandev", "sessions") + } + + // Ensure directory exists + if err := os.MkdirAll(baseDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create session history directory: %w", err) + } + + return &SessionHistoryManager{ + baseDir: baseDir, + logger: log.WithFields(zap.String("component", "session-history")), + }, nil +} + +// historyFilePath returns the path to the history file for a session. +func (m *SessionHistoryManager) historyFilePath(sessionID string) string { + // Sanitize session ID for use as filename + safeID := strings.ReplaceAll(sessionID, "/", "_") + safeID = strings.ReplaceAll(safeID, "\\", "_") + return filepath.Join(m.baseDir, safeID+".jsonl") +} + +// AppendEntry appends a single history entry to the session's history file. +func (m *SessionHistoryManager) AppendEntry(sessionID string, entry HistoryEntry) error { + if sessionID == "" { + return fmt.Errorf("session ID is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + filePath := m.historyFilePath(sessionID) + f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open history file: %w", err) + } + defer func() { _ = f.Close() }() + + // Set timestamp if not already set + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } + entry.SessionID = sessionID + + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("failed to marshal history entry: %w", err) + } + + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("failed to write history entry: %w", err) + } + + m.logger.Debug("appended history entry", + zap.String("session_id", sessionID), + zap.String("type", entry.Type)) + + return nil +} + +// AppendUserMessage appends a user message to the session history. +func (m *SessionHistoryManager) AppendUserMessage(sessionID, message string) error { + return m.AppendEntry(sessionID, HistoryEntry{ + Type: "user_message", + Role: "user", + Content: message, + }) +} + +// AppendAgentMessage appends an agent message to the session history. +func (m *SessionHistoryManager) AppendAgentMessage(sessionID, message string) error { + return m.AppendEntry(sessionID, HistoryEntry{ + Type: "agent_message", + Role: "assistant", + Content: message, + }) +} + +// AppendToolCall appends a tool call to the session history. +func (m *SessionHistoryManager) AppendToolCall(sessionID string, event agentctl.AgentEvent) error { + return m.AppendEntry(sessionID, HistoryEntry{ + Type: "tool_call", + ToolCallID: event.ToolCallID, + ToolName: event.ToolName, + ToolArgs: event.ToolArgs, + ToolStatus: event.ToolStatus, + }) +} + +// AppendToolResult appends a tool result to the session history. +func (m *SessionHistoryManager) AppendToolResult(sessionID string, event agentctl.AgentEvent) error { + resultStr := "" + if event.ToolResult != nil { + if s, ok := event.ToolResult.(string); ok { + resultStr = s + } else if data, err := json.Marshal(event.ToolResult); err == nil { + resultStr = string(data) + } + } + + return m.AppendEntry(sessionID, HistoryEntry{ + Type: "tool_result", + ToolCallID: event.ToolCallID, + ToolName: event.ToolName, + ToolStatus: event.ToolStatus, + Content: resultStr, + }) +} + +// ReadHistory reads all history entries for a session. +func (m *SessionHistoryManager) ReadHistory(sessionID string) ([]HistoryEntry, error) { + if sessionID == "" { + return nil, fmt.Errorf("session ID is required") + } + + m.mu.RLock() + defer m.mu.RUnlock() + + filePath := m.historyFilePath(sessionID) + f, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No history yet + } + return nil, fmt.Errorf("failed to open history file: %w", err) + } + defer func() { _ = f.Close() }() + + var entries []HistoryEntry + scanner := bufio.NewScanner(f) + // Increase buffer size for long lines (tool results can be large) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) // 1MB max line size + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var entry HistoryEntry + if err := json.Unmarshal(line, &entry); err != nil { + m.logger.Warn("failed to parse history entry, skipping", + zap.String("session_id", sessionID), + zap.Error(err)) + continue + } + entries = append(entries, entry) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read history file: %w", err) + } + + return entries, nil +} + +// GenerateResumeContext generates a "RESUME CONTEXT" prompt from session history. +// This is injected into the first prompt when forking a session. +func (m *SessionHistoryManager) GenerateResumeContext(sessionID, newPrompt string) (string, error) { + entries, err := m.ReadHistory(sessionID) + if err != nil { + return newPrompt, err + } + + if len(entries) == 0 { + return newPrompt, nil // No history to inject + } + + // Format history as conversation context + var historyBuilder strings.Builder + for _, entry := range entries { + switch entry.Type { + case "user_message": + fmt.Fprintf(&historyBuilder, "\n[USER]: %s\n", truncateForContext(entry.Content, 2000)) + case "agent_message": + fmt.Fprintf(&historyBuilder, "\n[ASSISTANT]: %s\n", truncateForContext(entry.Content, 2000)) + case "tool_call": + argsStr := "" + if entry.ToolArgs != nil { + if data, err := json.Marshal(entry.ToolArgs); err == nil { + argsStr = truncateForContext(string(data), 500) + } + } + fmt.Fprintf(&historyBuilder, "\n[TOOL CALL: %s] Args: %s\n", entry.ToolName, argsStr) + case "tool_result": + fmt.Fprintf(&historyBuilder, "\n[TOOL RESULT: %s] %s\n", entry.ToolName, truncateForContext(entry.Content, 500)) + } + } + + history := historyBuilder.String() + if history == "" { + return newPrompt, nil + } + + // Build the resume context prompt + resumePrompt := fmt.Sprintf(`RESUME CONTEXT FOR CONTINUING TASK + +=== EXECUTION HISTORY === +The following is a summary of the previous conversation in this session: +%s + +=== CURRENT REQUEST === +%s + +=== INSTRUCTIONS === +You are continuing work on the above task. This is a continuation of a previous session. +Please continue from where the previous execution left off, taking into account all the context provided above. +Do not repeat work that was already completed. Build on the existing progress. +`, history, newPrompt) + + m.logger.Info("generated resume context", + zap.String("session_id", sessionID), + zap.Int("history_entries", len(entries)), + zap.Int("context_length", len(resumePrompt))) + + return resumePrompt, nil +} + +// HasHistory checks if a session has any stored history. +func (m *SessionHistoryManager) HasHistory(sessionID string) bool { + if sessionID == "" { + return false + } + + filePath := m.historyFilePath(sessionID) + info, err := os.Stat(filePath) + if err != nil { + return false + } + return info.Size() > 0 +} + +// DeleteHistory deletes the history file for a session. +func (m *SessionHistoryManager) DeleteHistory(sessionID string) error { + if sessionID == "" { + return fmt.Errorf("session ID is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + filePath := m.historyFilePath(sessionID) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete history file: %w", err) + } + + m.logger.Debug("deleted session history", zap.String("session_id", sessionID)) + return nil +} + +// truncateForContext truncates a string to a maximum length for context injection. +// This prevents the resume context from becoming too large. +func truncateForContext(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "... [truncated]" +} diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 2d14274e32..3eebb19f7c 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -54,6 +54,12 @@ type AgentExecution struct { // Streaming message tracking - ID of the current in-progress message being streamed // This is set when we create a streaming message and cleared on tool_call/complete currentMessageID string + + // Resume context tracking for fork_session pattern (ACP agents that don't support session/load) + // needsResumeContext is set to true when the session has history that should be injected + // resumeContextInjected is set to true after context has been injected into a prompt + needsResumeContext bool + resumeContextInjected bool } // GetAgentCtlClient returns the agentctl client for this execution diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index 5c93122ddd..adf149aa54 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -400,6 +400,250 @@ } ] } + }, + { + "id": "gemini", + "name": "Google Gemini CLI Agent", + "display_name": "Gemini", + "description": "Google Gemini CLI-powered autonomous coding agent using ACP protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "@google/gemini-cli@0.23.0", + "--experimental-acp" + ], + "working_dir": "{workspace}", + "required_env": [], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "acp", + "protocol_config": {}, + "model_flag": "--model {model}", + "workspace_flag": "", + "session_config": { + "resume_via_acp": false, + "resume_flag": "", + "can_recover": false, + "session_dir_template": "{home}/.gemini", + "session_dir_target": "", + "reports_status_via_stream": false + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": [ + "run_shell_command", + "write_file", + "edit_file" + ] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": true, + "label": "Auto-approve (YOLO mode)", + "description": "Automatically approve all tool calls", + "apply_method": "cli_flag", + "cli_flag": "--yolo --allowed-tools run_shell_command" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": [ + "~/.gemini/settings.json" + ], + "windows": [], + "macos": [ + "~/.gemini/settings.json" + ] + }, + "installation_path": { + "linux": [ + "~/.gemini/oauth_creds.json", + "~/.gemini/installation_id" + ], + "windows": [], + "macos": [ + "~/.gemini/oauth_creds.json", + "~/.gemini/installation_id" + ] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "gemini-3-flash-preview", + "available_models": [ + { + "id": "gemini-3-flash-preview", + "name": "Gemini 3 Flash", + "description": "Fast and efficient model", + "provider": "google", + "context_window": 1000000, + "is_default": true + }, + { + "id": "gemini-3-pro-preview", + "name": "Gemini 3 Pro", + "description": "Most capable model with 2M context", + "provider": "google", + "context_window": 2000000, + "is_default": false + } + ] + } + }, + { + "id": "opencode", + "name": "OpenCode AI Agent", + "display_name": "OpenCode", + "description": "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "opencode-ai@1.1.25", + "serve", + "--hostname", + "127.0.0.1", + "--port", + "0" + ], + "working_dir": "{workspace}", + "required_env": [], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "opencode", + "protocol_config": {}, + "model_flag": "", + "workspace_flag": "", + "session_config": { + "resume_via_acp": true, + "resume_flag": "", + "can_recover": true, + "session_dir_template": "{home}/.opencode", + "session_dir_target": "", + "reports_status_via_stream": true + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": [ + "edit", + "bash", + "webfetch" + ] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": true, + "label": "Auto-approve", + "description": "Automatically approve tool calls", + "apply_method": "env", + "cli_flag": "" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": [ + "~/.config/opencode/opencode.json", + "~/.config/opencode/opencode.jsonc" + ], + "windows": [], + "macos": [ + "~/.config/opencode/opencode.json", + "~/.config/opencode/opencode.jsonc", + "~/Library/Application Support/opencode/opencode.json" + ] + }, + "installation_path": { + "linux": [ + "~/.opencode", + "~/.config/opencode" + ], + "windows": [], + "macos": [ + "~/.opencode", + "~/.config/opencode", + "~/Library/Application Support/ai.opencode.desktop" + ] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "anthropic/claude-sonnet-4-20250514", + "available_models": [ + { + "id": "anthropic/claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "description": "Anthropic Claude Sonnet 4", + "provider": "anthropic", + "context_window": 200000, + "is_default": true + }, + { + "id": "anthropic/claude-opus-4-20250514", + "name": "Claude Opus 4", + "description": "Anthropic Claude Opus 4", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + }, + { + "id": "openai/gpt-4.1", + "name": "GPT-4.1", + "description": "OpenAI GPT-4.1", + "provider": "openai", + "context_window": 200000, + "is_default": false + }, + { + "id": "google/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "Google Gemini 2.5 Pro", + "provider": "google", + "context_window": 2000000, + "is_default": false + } + ] + } } ] } diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 64f9677a8c..1b8f769b90 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -445,6 +445,8 @@ func parseProtocol(p string) agent.Protocol { return agent.ProtocolCodex case "claude-code": return agent.ProtocolClaudeCode + case "opencode": + return agent.ProtocolOpenCode default: return agent.ProtocolACP // Default to ACP } diff --git a/apps/backend/internal/agentctl/server/acp/client.go b/apps/backend/internal/agentctl/server/acp/client.go index 599d1c2651..7afc206dfb 100644 --- a/apps/backend/internal/agentctl/server/acp/client.go +++ b/apps/backend/internal/agentctl/server/acp/client.go @@ -138,16 +138,47 @@ func (c *Client) forwardPermissionRequest(ctx context.Context, handler Permissio } } + // Extract title - prefer Kind as the title, use Title as description title := "" + description := "" if p.ToolCall.Title != nil { - title = *p.ToolCall.Title + description = *p.ToolCall.Title + } + + // Use Kind as the action type (e.g., "run_shell_command", "write_file") + actionType := "" + if p.ToolCall.Kind != nil { + actionType = string(*p.ToolCall.Kind) + title = actionType // Use kind as the title for cleaner display + } + + // If no Kind, try to extract a short title from the verbose title + // Gemini format: "pwd [current working directory /path] (Print the current working directory.)" + if title == "" && description != "" { + // Use the first word/command as the title + if idx := strings.Index(description, " "); idx > 0 { + title = description[:idx] + } else { + title = description + } + } + + // Build action details from raw input if available + actionDetails := make(map[string]any) + if p.ToolCall.RawInput != nil { + actionDetails["raw_input"] = p.ToolCall.RawInput + } + if description != "" && description != title { + actionDetails["description"] = description } req := &types.PermissionRequest{ - SessionID: string(p.SessionId), - ToolCallID: string(p.ToolCall.ToolCallId), - Title: title, - Options: options, + SessionID: string(p.SessionId), + ToolCallID: string(p.ToolCall.ToolCallId), + Title: title, + ActionType: actionType, + ActionDetails: actionDetails, + Options: options, } c.logger.Info("forwarding permission request to handler", diff --git a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go index 0e16b07e3e..9e3fc7caf2 100644 --- a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go @@ -32,7 +32,8 @@ type ACPAdapter struct { sessionID string // Agent info (populated after Initialize) - agentInfo *AgentInfo + agentInfo *AgentInfo + capabilities acp.AgentCapabilities // Update channel updatesCh chan AgentEvent @@ -40,6 +41,10 @@ type ACPAdapter struct { // Permission handler permissionHandler PermissionHandler + // Context injection for fork_session pattern (ACP agents that don't support session/load) + // When set, this context will be prepended to the first prompt sent to the session. + pendingContext string + // Synchronization mu sync.RWMutex closed bool @@ -57,8 +62,8 @@ func NewACPAdapter(cfg *Config, log *logger.Logger) *ACPAdapter { // PrepareEnvironment is a no-op for ACP. // ACP passes MCP servers through the protocol during session creation. -func (a *ACPAdapter) PrepareEnvironment() error { - return nil +func (a *ACPAdapter) PrepareEnvironment() (map[string]string, error) { + return nil, nil } // Connect wires up the stdin/stdout pipes from the running agent subprocess. @@ -105,7 +110,7 @@ func (a *ACPAdapter) Initialize(ctx context.Context) error { return fmt.Errorf("ACP initialize handshake failed: %w", err) } - // Store agent info + // Store agent info and capabilities a.agentInfo = &AgentInfo{ Name: "unknown", Version: "unknown", @@ -114,9 +119,11 @@ func (a *ACPAdapter) Initialize(ctx context.Context) error { a.agentInfo.Name = resp.AgentInfo.Name a.agentInfo.Version = resp.AgentInfo.Version } + a.capabilities = resp.AgentCapabilities a.logger.Info("ACP adapter initialized", zap.String("agent_name", a.agentInfo.Name), - zap.String("agent_version", a.agentInfo.Version)) + zap.String("agent_version", a.agentInfo.Version), + zap.Bool("supports_load_session", a.capabilities.LoadSession)) return nil } @@ -179,6 +186,7 @@ func toACPMcpServers(servers []types.McpServer) []acp.McpServer { } // LoadSession resumes an existing session. +// Returns an error if the agent does not support session loading (LoadSession capability). func (a *ACPAdapter) LoadSession(ctx context.Context, sessionID string) error { a.mu.Lock() defer a.mu.Unlock() @@ -187,6 +195,11 @@ func (a *ACPAdapter) LoadSession(ctx context.Context, sessionID string) error { return fmt.Errorf("adapter not initialized") } + // Check if the agent supports session loading + if !a.capabilities.LoadSession { + return fmt.Errorf("agent does not support session loading (LoadSession capability is false)") + } + _, err := a.acpConn.LoadSession(ctx, acp.LoadSessionRequest{ SessionId: acp.SessionId(sessionID), }) @@ -201,25 +214,46 @@ func (a *ACPAdapter) LoadSession(ctx context.Context, sessionID string) error { } // Prompt sends a prompt to the agent. +// If pending context is set (from SetPendingContext), it will be prepended to the message. func (a *ACPAdapter) Prompt(ctx context.Context, message string) error { - a.mu.RLock() + a.mu.Lock() conn := a.acpConn sessionID := a.sessionID - a.mu.RUnlock() + pendingContext := a.pendingContext + a.pendingContext = "" // Clear after use + a.mu.Unlock() if conn == nil { return fmt.Errorf("adapter not initialized") } + // Inject pending context if available (fork_session pattern) + finalMessage := message + if pendingContext != "" { + finalMessage = pendingContext + a.logger.Info("injecting resume context into prompt", + zap.String("session_id", sessionID), + zap.Int("context_length", len(pendingContext))) + } + a.logger.Info("sending prompt", zap.String("session_id", sessionID)) _, err := conn.Prompt(ctx, acp.PromptRequest{ SessionId: acp.SessionId(sessionID), - Prompt: []acp.ContentBlock{acp.TextBlock(message)}, + Prompt: []acp.ContentBlock{acp.TextBlock(finalMessage)}, }) return err } +// SetPendingContext sets the context to be injected into the next prompt. +// This is used by the fork_session pattern for ACP agents that don't support session/load. +// The context will be prepended to the first prompt sent to this session. +func (a *ACPAdapter) SetPendingContext(context string) { + a.mu.Lock() + defer a.mu.Unlock() + a.pendingContext = context +} + // Cancel cancels the current operation. func (a *ACPAdapter) Cancel(ctx context.Context) error { a.mu.RLock() @@ -423,8 +457,41 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent func (a *ACPAdapter) handlePermissionRequest(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error) { a.mu.RLock() handler := a.permissionHandler + sessionID := a.sessionID a.mu.RUnlock() + // Emit a tool_call event so a message is created in the database. + // This is needed because permission requests bypass the normal ToolCall notification flow. + // Without this, when the tool completes (ToolCallUpdate), there's no message to update. + toolCallEvent := AgentEvent{ + Type: EventTypeToolCall, + SessionID: sessionID, + ToolCallID: req.ToolCallID, + ToolName: req.ActionType, // Use action type as tool name (e.g., "run_shell_command") + ToolTitle: req.Title, + ToolStatus: "pending_permission", // Mark as pending permission + ToolArgs: map[string]interface{}{ + "permission_request": true, + "action_type": req.ActionType, + }, + } + + // Add action details if available + if req.ActionDetails != nil { + for k, v := range req.ActionDetails { + toolCallEvent.ToolArgs[k] = v + } + } + + // Emit the tool_call event + select { + case a.updatesCh <- toolCallEvent: + a.logger.Debug("emitted tool_call event for permission request", + zap.String("tool_call_id", req.ToolCallID)) + default: + a.logger.Warn("updates channel full, could not emit tool_call event for permission") + } + if handler == nil { // Auto-approve if no handler if len(req.Options) > 0 { diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 8a8bdfeef5..4cb305c641 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -70,8 +70,10 @@ type AgentAdapter interface { // PrepareEnvironment performs protocol-specific setup before the agent process starts. // For Codex, this writes MCP servers to ~/.codex/config.toml. // For ACP, this is a no-op (MCP servers are passed through the protocol). + // For OpenCode, this returns environment variables for server authentication. // Must be called before the agent subprocess is started. - PrepareEnvironment() error + // Returns a map of environment variables to add to the subprocess environment. + PrepareEnvironment() (map[string]string, error) // Connect wires up the stdin/stdout pipes from the running agent subprocess. // Must be called after the subprocess is started and before Initialize. diff --git a/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go b/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go index b7295da227..85c4111347 100644 --- a/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go @@ -31,8 +31,12 @@ type ClaudeCodeAdapter struct { cancel context.CancelFunc // Session state - sessionID string - operationID string // Current prompt operation + sessionID string + operationID string // Current prompt operation + sessionStatusSent bool // Whether we've sent the session status event + + // Track pending tool calls to auto-complete on result + pendingToolCalls map[string]bool // Agent info agentInfo *AgentInfo @@ -79,17 +83,18 @@ func NewClaudeCodeAdapter(cfg *Config, log *logger.Logger) *ClaudeCodeAdapter { cancel: cancel, updatesCh: make(chan AgentEvent, 100), mainModelContextWindow: defaultContextWindow, + pendingToolCalls: make(map[string]bool), } } // 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 { +func (a *ClaudeCodeAdapter) PrepareEnvironment() (map[string]string, 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 + return nil, nil } // Connect wires up the stdin/stdout pipes from the running agent subprocess. @@ -495,10 +500,19 @@ func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { zap.String("status", msg.SessionStatus)) // Update session ID if provided + a.mu.Lock() if msg.SessionID != "" { - a.mu.Lock() a.sessionID = msg.SessionID - a.mu.Unlock() + } + alreadySent := a.sessionStatusSent + a.sessionStatusSent = true + a.mu.Unlock() + + // Only send session status event once per session (on first prompt) + // Claude Code sends system messages on every prompt, but we only want to + // show "New session started" or "Session resumed" once + if alreadySent { + return } // Send session status event (NOT complete - that's only for result messages) @@ -518,6 +532,15 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s return } + // Log content block types for debugging + blockTypes := make([]string, 0, len(msg.Message.Content)) + for _, block := range msg.Message.Content { + blockTypes = append(blockTypes, block.Type) + } + a.logger.Info("processing assistant message", + zap.Int("num_blocks", len(msg.Message.Content)), + zap.Strings("block_types", blockTypes)) + // Update agent version and track main model name from model info if msg.Message.Model != "" && a.agentInfo != nil { a.agentInfo.Version = msg.Message.Model @@ -563,6 +586,16 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s } else if path, ok := block.Input["file_path"].(string); ok { toolTitle = fmt.Sprintf("%s: %s", block.Name, path) } + a.logger.Info("tool_use block received", + zap.String("tool_call_id", block.ID), + zap.String("tool_name", block.Name), + zap.String("title", toolTitle)) + + // Track this tool call as pending + a.mu.Lock() + a.pendingToolCalls[block.ID] = true + a.mu.Unlock() + a.sendUpdate(AgentEvent{ Type: EventTypeToolCall, SessionID: sessionID, @@ -579,6 +612,15 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s if block.IsError { status = "error" } + a.logger.Info("tool_result block received", + zap.String("tool_call_id", block.ToolUseID), + zap.String("status", status)) + + // Remove from pending + a.mu.Lock() + delete(a.pendingToolCalls, block.ToolUseID) + a.mu.Unlock() + a.sendUpdate(AgentEvent{ Type: EventTypeToolUpdate, SessionID: sessionID, @@ -635,6 +677,28 @@ func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sess a.mu.Unlock() } + // Auto-complete any pending tool calls that didn't receive explicit tool_result + // This can happen if the tool result is not sent as a separate assistant message + a.mu.Lock() + pendingTools := make([]string, 0, len(a.pendingToolCalls)) + for toolID := range a.pendingToolCalls { + pendingTools = append(pendingTools, toolID) + } + a.pendingToolCalls = make(map[string]bool) // Clear pending + a.mu.Unlock() + + for _, toolID := range pendingTools { + a.logger.Info("auto-completing pending tool call on result", + zap.String("tool_call_id", toolID)) + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolID, + ToolStatus: "complete", + }) + } + // Extract actual context window from model_usage if available // This gives us the real context window size for the model a.mu.Lock() diff --git a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go index 7e3f582b21..d290aab6ef 100644 --- a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go @@ -84,7 +84,7 @@ func NewCodexAdapter(cfg *Config, log *logger.Logger) *CodexAdapter { // PrepareEnvironment writes MCP servers to the Codex config file. // Codex reads MCP configuration from ~/.codex/config.toml at startup time. -func (a *CodexAdapter) PrepareEnvironment() error { +func (a *CodexAdapter) PrepareEnvironment() (map[string]string, error) { a.logger.Info("PrepareEnvironment called", zap.Int("mcp_server_count", len(a.cfg.McpServers))) for i, srv := range a.cfg.McpServers { @@ -95,7 +95,7 @@ func (a *CodexAdapter) PrepareEnvironment() error { zap.String("type", srv.Type), zap.String("command", srv.Command)) } - return WriteCodexMcpConfig(a.cfg.McpServers, "", a.logger) + return nil, WriteCodexMcpConfig(a.cfg.McpServers, "", a.logger) } // Connect wires up the stdin/stdout pipes from the running agent subprocess. diff --git a/apps/backend/internal/agentctl/server/adapter/factory.go b/apps/backend/internal/agentctl/server/adapter/factory.go index c277c01f99..1de74b78cb 100644 --- a/apps/backend/internal/agentctl/server/adapter/factory.go +++ b/apps/backend/internal/agentctl/server/adapter/factory.go @@ -17,6 +17,8 @@ func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (Agent return NewCodexAdapter(cfg, log), nil case agent.ProtocolClaudeCode: return NewClaudeCodeAdapter(cfg, log), nil + case agent.ProtocolOpenCode: + return NewOpenCodeAdapter(cfg, log), nil default: return nil, fmt.Errorf("unsupported protocol: %s", protocol) } diff --git a/apps/backend/internal/agentctl/server/adapter/opencode_adapter.go b/apps/backend/internal/agentctl/server/adapter/opencode_adapter.go new file mode 100644 index 0000000000..cb02a3a507 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/opencode_adapter.go @@ -0,0 +1,846 @@ +package adapter + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/opencode" + "go.uber.org/zap" +) + +// OpenCodeAdapter implements AgentAdapter for OpenCode CLI using REST/SSE protocol. +// OpenCode spawns an HTTP server and communicates via REST API calls and SSE for events. +type OpenCodeAdapter struct { + cfg *Config + logger *logger.Logger + + // Subprocess stdout (for parsing server URL) + stdout io.Reader + + // HTTP client (created after server starts) + client *opencode.Client + + // Server configuration + serverURL string + password string + directory string + + // Context for managing goroutine lifecycle + ctx context.Context + cancel context.CancelFunc + + // Session state + sessionID string + operationID string + + // Agent info + agentInfo *AgentInfo + + // Update channel + updatesCh chan AgentEvent + + // Permission handler + permissionHandler PermissionHandler + + // Pending permission requests + pendingPermissions map[string]*permissionRequest + + // Result completion signaling + resultCh chan resultComplete + + // Token tracking + totalTokens int + modelContextWindow int + + // Tool call deduplication - track which tool calls we've seen to avoid duplicates + seenToolCalls map[string]bool + + // Text part tracking - track text parts by part ID to compute incremental text + textParts map[string]*textPartState + + // Message role tracking - track message roles (user/assistant) by messageID + // Used to filter out user messages from being processed as agent output + messageRoles map[string]string + + // Synchronization + mu sync.RWMutex + closed bool +} + +// textPartState tracks the state of a text part for incremental text computation +type textPartState struct { + lastTextLen int // Length of text we've already sent +} + +// permissionRequest tracks a pending permission +type permissionRequest struct { + requestID string + toolCallID string + toolName string +} + +// NewOpenCodeAdapter creates a new OpenCode protocol adapter. +func NewOpenCodeAdapter(cfg *Config, log *logger.Logger) *OpenCodeAdapter { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenCodeAdapter{ + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "opencode")), + ctx: ctx, + cancel: cancel, + updatesCh: make(chan AgentEvent, 100), + pendingPermissions: make(map[string]*permissionRequest), + seenToolCalls: make(map[string]bool), + password: opencode.GenerateServerPassword(), + directory: cfg.WorkDir, + modelContextWindow: 200000, // Default + } +} + +// PrepareEnvironment performs protocol-specific setup before the agent process starts. +// For OpenCode, we return environment variables for server authentication. +func (a *OpenCodeAdapter) PrepareEnvironment() (map[string]string, error) { + a.logger.Info("PrepareEnvironment called", + zap.Int("mcp_server_count", len(a.cfg.McpServers))) + + env := map[string]string{ + "OPENCODE_SERVER_PASSWORD": a.password, + } + + // Set up permissions based on config + if a.cfg.AutoApprove { + env["OPENCODE_PERMISSION"] = `{"question":"deny"}` + } else { + env["OPENCODE_PERMISSION"] = `{"edit":"ask","bash":"ask","webfetch":"ask","doom_loop":"ask","external_directory":"ask","question":"deny"}` + } + + return env, nil +} + +// Connect wires up the stdout pipe from the running agent subprocess. +// For OpenCode, we only need stdout to parse the server URL. +func (a *OpenCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.stdout != nil { + return fmt.Errorf("adapter already connected") + } + + a.stdout = stdout + return nil +} + +// Initialize establishes the OpenCode connection by waiting for the server URL. +func (a *OpenCodeAdapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing OpenCode adapter", + zap.String("workdir", a.cfg.WorkDir)) + + // Wait for server to print listening URL + serverURL, err := a.waitForServerURL(ctx) + if err != nil { + return fmt.Errorf("failed to get server URL: %w", err) + } + + a.mu.Lock() + a.serverURL = serverURL + a.mu.Unlock() + + a.logger.Info("OpenCode server started", zap.String("url", serverURL)) + + // Create HTTP client + a.client = opencode.NewClient(serverURL, a.directory, a.password, a.logger) + + // Set up event handler + a.client.SetEventHandler(a.handleSDKEvent) + + // Wait for server to be healthy + healthCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := a.client.WaitForHealth(healthCtx); err != nil { + return fmt.Errorf("server health check failed: %w", err) + } + + // Store agent info + a.agentInfo = &AgentInfo{ + Name: "opencode", + Version: "unknown", + } + + a.logger.Info("OpenCode adapter initialized") + + return nil +} + +// waitForServerURL reads stdout until we find the server URL +func (a *OpenCodeAdapter) waitForServerURL(ctx context.Context) (string, error) { + deadline := time.Now().Add(180 * time.Second) + + scanner := bufio.NewScanner(a.stdout) + var capturedLines []string + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + // Try to scan with a short timeout + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read stdout: %w", err) + } + // EOF - server exited + tail := strings.Join(capturedLines, "\n") + return "", fmt.Errorf("server exited before printing URL\nOutput:\n%s", tail) + } + + line := scanner.Text() + + // Capture for error reporting + if len(capturedLines) < 64 { + capturedLines = append(capturedLines, line) + } + + // Check for the listening URL + if url, found := strings.CutPrefix(line, "opencode server listening on "); found { + url = strings.TrimSpace(url) + a.logger.Info("found server URL", zap.String("url", url)) + + // Continue draining stdout in background + go func() { + for scanner.Scan() { + // Just drain + } + }() + + return url, nil + } + } + + tail := strings.Join(capturedLines[max(0, len(capturedLines)-12):], "\n") + return "", fmt.Errorf("timeout waiting for server URL\nOutput tail:\n%s", tail) +} + +// GetAgentInfo returns information about the connected agent. +func (a *OpenCodeAdapter) GetAgentInfo() *AgentInfo { + return a.agentInfo +} + +// NewSession creates a new OpenCode session. +func (a *OpenCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + // Clear any stale state from previous sessions + a.clearSessionState() + + sessionID, err := a.client.CreateSession(ctx) + if err != nil { + return "", fmt.Errorf("create session: %w", err) + } + + a.mu.Lock() + a.sessionID = sessionID + a.mu.Unlock() + + a.logger.Info("created new session", zap.String("session_id", sessionID)) + + // Emit session status event for resume token storage + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: sessionID, + Data: map[string]any{ + "session_status": "active", + "init": true, + }, + }) + + return sessionID, nil +} + +// LoadSession resumes an existing OpenCode session by forking it. +func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) error { + // Clear any stale state from previous sessions + a.clearSessionState() + + // Fork the session to create a new one with the same history + newSessionID, err := a.client.ForkSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("fork session: %w", err) + } + + a.mu.Lock() + a.sessionID = newSessionID + a.mu.Unlock() + + a.logger.Info("loaded session (forked)", + zap.String("original_session_id", sessionID), + zap.String("new_session_id", newSessionID)) + + return nil +} + +// Prompt sends a prompt to OpenCode and waits for completion. +func (a *OpenCodeAdapter) 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)) + + // Start event stream before sending prompt + if err := client.StartEventStream(a.ctx, sessionID); err != nil { + a.logger.Warn("failed to start event stream", zap.Error(err)) + } + + // Send prompt + // TODO: Support model selection from config + if err := client.SendPrompt(ctx, sessionID, message, nil, "", ""); err != nil { + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return fmt.Errorf("failed to send prompt: %w", err) + } + + // Wait for result via control channel or context cancellation + controlCh := client.ControlChannel() + + for { + select { + case <-ctx.Done(): + // Request cancellation + _ = a.Cancel(context.Background()) + return ctx.Err() + + case control, ok := <-controlCh: + if !ok { + // Channel closed + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + }) + return nil + } + + switch control.Type { + case "idle": + a.logger.Info("session idle, prompt complete") + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + }) + return nil + + case "auth_required": + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + SessionID: sessionID, + Error: fmt.Sprintf("Authentication required: %s", control.Message), + }) + return fmt.Errorf("auth required: %s", control.Message) + + case "session_error": + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + SessionID: sessionID, + Error: control.Message, + }) + // Continue - might recover + + case "disconnected": + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + }) + return nil + } + } + } +} + +// Cancel cancels the current operation. +func (a *OpenCodeAdapter) Cancel(ctx context.Context) error { + a.mu.RLock() + client := a.client + sessionID := a.sessionID + a.mu.RUnlock() + + if client == nil { + return nil + } + + a.logger.Info("cancelling operation", zap.String("session_id", sessionID)) + + return client.Abort(ctx, sessionID) +} + +// Updates returns a channel that receives agent events. +func (a *OpenCodeAdapter) Updates() <-chan AgentEvent { + return a.updatesCh +} + +// GetSessionID returns the current session ID. +func (a *OpenCodeAdapter) GetSessionID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.sessionID +} + +// GetOperationID returns the current operation/turn ID. +func (a *OpenCodeAdapter) GetOperationID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.operationID +} + +// SetPermissionHandler sets the handler for permission requests. +func (a *OpenCodeAdapter) SetPermissionHandler(handler PermissionHandler) { + a.mu.Lock() + defer a.mu.Unlock() + a.permissionHandler = handler +} + +// Close releases resources held by the adapter. +func (a *OpenCodeAdapter) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.closed { + return nil + } + a.closed = true + + a.cancel() + + if a.client != nil { + a.client.Close() + } + + close(a.updatesCh) + + a.logger.Info("OpenCode adapter closed") + return nil +} + +// clearSessionState clears session-specific tracking state. +// This should be called when starting a new session to prevent memory leaks +// and stale data from previous sessions. +func (a *OpenCodeAdapter) clearSessionState() { + a.mu.Lock() + defer a.mu.Unlock() + + a.textParts = make(map[string]*textPartState) + a.messageRoles = make(map[string]string) + a.seenToolCalls = make(map[string]bool) +} + +// sendUpdate sends an event to the updates channel +func (a *OpenCodeAdapter) sendUpdate(event AgentEvent) { + a.mu.RLock() + closed := a.closed + a.mu.RUnlock() + + if closed { + return + } + + select { + case a.updatesCh <- event: + default: + a.logger.Warn("update channel full, dropping event", + zap.String("event_type", event.Type)) + } +} + +// handleSDKEvent processes events from the OpenCode SSE stream +func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { + a.mu.RLock() + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + + switch event.Type { + case opencode.SDKEventMessagePartUpdated: + a.handleMessagePartUpdated(event.Properties, sessionID, operationID) + + case opencode.SDKEventMessageUpdated: + a.handleMessageUpdated(event.Properties, sessionID) + + case opencode.SDKEventPermissionAsked: + a.handlePermissionAsked(event.Properties, sessionID) + + case opencode.SDKEventSessionError: + props, err := opencode.ParseSessionError(event.Properties) + if err != nil { + return + } + if props.Error != nil { + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + SessionID: sessionID, + Error: props.Error.GetMessage(), + }) + } + } +} + +// handleMessagePartUpdated processes message.part.updated events +func (a *OpenCodeAdapter) handleMessagePartUpdated(props json.RawMessage, sessionID, operationID string) { + parsed, err := opencode.ParseMessagePartUpdated(props) + if err != nil { + a.logger.Warn("failed to parse message.part.updated", zap.Error(err)) + return + } + + part := parsed.Part + + // Filter out user messages - only process assistant messages + // The role is tracked from message.updated events + if part.MessageID != "" { + a.mu.RLock() + role := a.messageRoles[part.MessageID] + a.mu.RUnlock() + if role == "user" { + // Skip user message parts - we don't want to echo user input as agent output + return + } + } + + switch part.Type { + case opencode.PartTypeText: + // Track text parts by ID to compute incremental text + // IMPORTANT: Always prefer cumulative part.Text over delta to avoid duplication + // The delta may be sent multiple times with the same content + partID := part.ID + if partID == "" { + // Fallback: use MessageID + type as unique key + partID = part.MessageID + ":text" + } + + a.mu.Lock() + if a.textParts == nil { + a.textParts = make(map[string]*textPartState) + } + state := a.textParts[partID] + if state == nil { + state = &textPartState{lastTextLen: 0} + a.textParts[partID] = state + } + + // Determine what text to send - prefer cumulative text to avoid duplication + var textToSend string + if part.Text != "" { + // Use cumulative text - compute what's new since last time + if len(part.Text) > state.lastTextLen { + textToSend = part.Text[state.lastTextLen:] + state.lastTextLen = len(part.Text) + } + } else if parsed.Delta != "" && state.lastTextLen == 0 { + // No cumulative text available, use delta only for first chunk + // This handles streaming before cumulative text is populated + textToSend = parsed.Delta + } + a.mu.Unlock() + + if textToSend != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: textToSend, + }) + } + + case opencode.PartTypeReasoning: + // Track reasoning parts by ID to compute incremental text + // IMPORTANT: Always prefer cumulative part.Text over delta to avoid duplication + partID := part.ID + if partID == "" { + // Fallback: use MessageID + type as unique key + partID = part.MessageID + ":reasoning" + } + + a.mu.Lock() + if a.textParts == nil { + a.textParts = make(map[string]*textPartState) + } + state := a.textParts[partID] + if state == nil { + state = &textPartState{lastTextLen: 0} + a.textParts[partID] = state + } + + // Determine what text to send - prefer cumulative text to avoid duplication + var textToSend string + if part.Text != "" { + // Use cumulative text - compute what's new since last time + if len(part.Text) > state.lastTextLen { + textToSend = part.Text[state.lastTextLen:] + state.lastTextLen = len(part.Text) + } + } else if parsed.Delta != "" && state.lastTextLen == 0 { + // No cumulative text available, use delta only for first chunk + textToSend = parsed.Delta + } + a.mu.Unlock() + + if textToSend != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: textToSend, + }) + } + + case opencode.PartTypeTool: + // Tool call + if part.State == nil { + return + } + + toolCallID := part.CallID + toolName := part.Tool + state := part.State + + // Determine status + var status string + switch state.Status { + case opencode.ToolStatusPending: + status = "pending" + case opencode.ToolStatusRunning: + status = "running" + case opencode.ToolStatusCompleted: + status = "complete" // Normalized to "complete" for frontend consistency + case opencode.ToolStatusError: + status = "error" + default: + status = state.Status + } + + title := state.Title + if title == "" { + title = toolName + } + + var args map[string]any + if state.Input != nil { + _ = json.Unmarshal(state.Input, &args) // Ignore error - args are optional + } + + // Track if we've seen this tool call before + a.mu.Lock() + if a.seenToolCalls == nil { + a.seenToolCalls = make(map[string]bool) + } + isFirstEvent := !a.seenToolCalls[toolCallID] + a.seenToolCalls[toolCallID] = true + a.mu.Unlock() + + // Emit tool_call for the first event we see for this tool call + // This creates the message in the database + if isFirstEvent { + a.sendUpdate(AgentEvent{ + Type: EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: title, + ToolArgs: args, + ToolStatus: status, + }) + } else { + // For subsequent events, emit tool_update to update the existing message + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: title, + ToolArgs: args, + ToolResult: state.Output, + ToolStatus: status, + Error: state.Error, + }) + } + } +} + +// handleMessageUpdated processes message.updated events (for token tracking) +func (a *OpenCodeAdapter) handleMessageUpdated(props json.RawMessage, sessionID string) { + parsed, err := opencode.ParseMessageUpdated(props) + if err != nil { + return + } + + info := parsed.Info + + // Track message role (user vs assistant) for filtering in handleMessagePartUpdated + if info.ID != "" && info.Role != "" { + a.mu.Lock() + if a.messageRoles == nil { + a.messageRoles = make(map[string]string) + } + a.messageRoles[info.ID] = info.Role + a.mu.Unlock() + } + + if info.Tokens == nil { + return + } + + // Calculate total tokens + totalTokens := info.Tokens.Input + info.Tokens.Output + if info.Tokens.Cache != nil { + totalTokens += info.Tokens.Cache.Read + } + + a.mu.Lock() + a.totalTokens = totalTokens + a.mu.Unlock() + + // Emit context window event + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + ContextWindowUsed: int64(totalTokens), + ContextWindowSize: int64(a.modelContextWindow), + }) +} + +// handlePermissionAsked processes permission.asked events +func (a *OpenCodeAdapter) handlePermissionAsked(props json.RawMessage, sessionID string) { + parsed, err := opencode.ParsePermissionAsked(props) + if err != nil { + a.logger.Warn("failed to parse permission.asked", zap.Error(err)) + return + } + + requestID := parsed.ID + permission := parsed.Permission + toolCallID := requestID + if parsed.Tool != nil { + toolCallID = parsed.Tool.CallID + } + + a.mu.RLock() + handler := a.permissionHandler + autoApprove := a.cfg.AutoApprove + a.mu.RUnlock() + + // If auto-approve is enabled, immediately approve + if autoApprove { + go func() { + if err := a.client.ReplyPermission(a.ctx, requestID, opencode.PermissionReplyOnce, nil); err != nil { + a.logger.Warn("failed to auto-approve permission", zap.Error(err)) + } + }() + return + } + + // Store pending permission + a.mu.Lock() + a.pendingPermissions[requestID] = &permissionRequest{ + requestID: requestID, + toolCallID: toolCallID, + toolName: permission, + } + a.mu.Unlock() + + // Format title from metadata if available + title := fmt.Sprintf("Permission: %s", permission) + if parsed.Metadata != nil { + if cmd, ok := parsed.Metadata["command"].(string); ok { + title = fmt.Sprintf("Execute: %s", cmd) + } else if path, ok := parsed.Metadata["path"].(string); ok { + title = fmt.Sprintf("%s: %s", permission, path) + } + } + + // Emit permission request event + a.sendUpdate(AgentEvent{ + Type: EventTypePermissionRequest, + SessionID: sessionID, + PendingID: requestID, + ToolCallID: toolCallID, + PermissionTitle: title, + PermissionOptions: []PermissionOption{ + {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, + {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, + }, + ActionType: permission, + ActionDetails: parsed.Metadata, + }) + + // If we have a handler, call it + if handler != nil { + go func() { + req := &PermissionRequest{ + SessionID: sessionID, + ToolCallID: toolCallID, + Title: title, + ActionType: permission, + ActionDetails: parsed.Metadata, + Options: []PermissionOption{ + {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, + {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, + }, + } + resp, err := handler(a.ctx, req) + if err != nil { + a.logger.Warn("permission handler error", zap.Error(err)) + return + } + if resp == nil { + return + } + + // Respond based on handler result + approved := resp.OptionID == "allow" + if err := a.RespondToPermission(a.ctx, requestID, approved, ""); err != nil { + a.logger.Warn("failed to respond to permission", zap.Error(err)) + } + }() + } +} + +// RespondToPermission responds to a permission request (called via handler) +func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID string, approved bool, message string) error { + a.mu.Lock() + delete(a.pendingPermissions, requestID) + a.mu.Unlock() + + reply := opencode.PermissionReplyOnce + var msg *string + + if !approved { + reply = opencode.PermissionReplyReject + if message != "" { + msg = &message + } + } + + return a.client.ReplyPermission(ctx, requestID, reply, msg) +} + diff --git a/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go b/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go new file mode 100644 index 0000000000..736aba2a1c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go @@ -0,0 +1,329 @@ +package adapter + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/opencode" +) + +func newTestOpenCodeAdapter() *OpenCodeAdapter { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + }) + cfg := &Config{ + WorkDir: "/tmp/test", + AutoApprove: true, + } + return NewOpenCodeAdapter(cfg, log) +} + +// drainEvents reads events from the updates channel with a timeout +func drainEvents(adapter *OpenCodeAdapter, timeout time.Duration) []AgentEvent { + var events []AgentEvent + deadline := time.After(timeout) + for { + select { + case evt := <-adapter.updatesCh: + events = append(events, evt) + case <-deadline: + return events + default: + // Small sleep to avoid busy loop + time.Sleep(10 * time.Millisecond) + select { + case evt := <-adapter.updatesCh: + events = append(events, evt) + case <-deadline: + return events + default: + return events + } + } + } +} + +func TestOpenCodeAdapter_TextPartDeduplication(t *testing.T) { + adapter := newTestOpenCodeAdapter() + + sessionID := "test-session" + operationID := "test-op" + + // Track message role first (assistant message) + adapter.mu.Lock() + adapter.messageRoles = make(map[string]string) + adapter.messageRoles["msg-1"] = "assistant" + adapter.mu.Unlock() + + // Simulate streaming events with cumulative text + // The key insight: part.Text is CUMULATIVE, not incremental + events := []struct { + partID string + text string // Cumulative text so far + delta string + }{ + {partID: "part-1", text: "Hello", delta: "Hello"}, + {partID: "part-1", text: "Hello world", delta: " world"}, + {partID: "part-1", text: "Hello world!", delta: "!"}, + } + + for _, evt := range events { + props := opencode.MessagePartUpdatedProperties{ + Part: opencode.Part{ + ID: evt.partID, + Type: opencode.PartTypeText, + MessageID: "msg-1", + SessionID: sessionID, + Text: evt.text, + }, + Delta: evt.delta, + } + propsJSON, _ := json.Marshal(props) + adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID) + } + + // Collect sent events + sentEvents := drainEvents(adapter, 100*time.Millisecond) + + // Should have received 3 events with incremental text + if len(sentEvents) != 3 { + t.Fatalf("expected 3 events, got %d", len(sentEvents)) + } + + // Verify text is incremental (computed from cumulative), not duplicated + expectedTexts := []string{"Hello", " world", "!"} + for i, evt := range sentEvents { + if evt.Text != expectedTexts[i] { + t.Errorf("event %d: expected text %q, got %q", i, expectedTexts[i], evt.Text) + } + } +} + +func TestOpenCodeAdapter_TextDeduplicationWithRepeatedDeltas(t *testing.T) { + adapter := newTestOpenCodeAdapter() + + sessionID := "test-session" + operationID := "test-op" + + // Track message role + adapter.mu.Lock() + adapter.messageRoles = make(map[string]string) + adapter.messageRoles["msg-1"] = "assistant" + adapter.mu.Unlock() + + // Simulate the problematic case: same delta sent multiple times + // This can happen when OpenCode sends multiple events with the same delta + // Our fix: we prefer cumulative text over delta + events := []struct { + text string + delta string + }{ + {text: "Done", delta: "Done"}, + {text: "Done", delta: "Done"}, // Duplicate event with same delta + {text: "Done.", delta: "."}, + } + + for _, evt := range events { + props := opencode.MessagePartUpdatedProperties{ + Part: opencode.Part{ + ID: "part-1", + Type: opencode.PartTypeText, + MessageID: "msg-1", + SessionID: sessionID, + Text: evt.text, + }, + Delta: evt.delta, + } + propsJSON, _ := json.Marshal(props) + adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID) + } + + sentEvents := drainEvents(adapter, 100*time.Millisecond) + + // Should only have 2 events (the duplicate should be filtered) + if len(sentEvents) != 2 { + t.Fatalf("expected 2 events (duplicate filtered), got %d", len(sentEvents)) + } + + // Verify final combined text + combinedText := "" + for _, evt := range sentEvents { + combinedText += evt.Text + } + if combinedText != "Done." { + t.Errorf("expected combined text 'Done.', got %q", combinedText) + } +} + +func TestOpenCodeAdapter_FilterUserMessages(t *testing.T) { + adapter := newTestOpenCodeAdapter() + + sessionID := "test-session" + operationID := "test-op" + + // Track message roles + adapter.mu.Lock() + adapter.messageRoles = make(map[string]string) + adapter.messageRoles["user-msg"] = "user" + adapter.messageRoles["assistant-msg"] = "assistant" + adapter.mu.Unlock() + + // Send user message part - should be filtered + userProps := opencode.MessagePartUpdatedProperties{ + Part: opencode.Part{ + ID: "user-part", + Type: opencode.PartTypeText, + MessageID: "user-msg", + SessionID: sessionID, + Text: "User input that should not appear", + }, + } + userJSON, _ := json.Marshal(userProps) + adapter.handleMessagePartUpdated(userJSON, sessionID, operationID) + + // Send assistant message part - should be processed + assistantProps := opencode.MessagePartUpdatedProperties{ + Part: opencode.Part{ + ID: "assistant-part", + Type: opencode.PartTypeText, + MessageID: "assistant-msg", + SessionID: sessionID, + Text: "Assistant response", + }, + } + assistantJSON, _ := json.Marshal(assistantProps) + adapter.handleMessagePartUpdated(assistantJSON, sessionID, operationID) + + sentEvents := drainEvents(adapter, 100*time.Millisecond) + + // Should only have 1 event (assistant message) + if len(sentEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(sentEvents)) + } + + if sentEvents[0].Text != "Assistant response" { + t.Errorf("expected assistant response, got %q", sentEvents[0].Text) + } +} + +func TestOpenCodeAdapter_MessageRoleTracking(t *testing.T) { + adapter := newTestOpenCodeAdapter() + + sessionID := "test-session" + + // Simulate message.updated event + props := opencode.MessageUpdatedProperties{ + Info: opencode.MessageInfo{ + ID: "msg-123", + SessionID: sessionID, + Role: "assistant", + }, + } + propsJSON, _ := json.Marshal(props) + + adapter.handleMessageUpdated(propsJSON, sessionID) + + // Check role was tracked + adapter.mu.RLock() + role := adapter.messageRoles["msg-123"] + adapter.mu.RUnlock() + + if role != "assistant" { + t.Errorf("expected role 'assistant', got %q", role) + } +} + +func TestOpenCodeAdapter_ClearSessionState(t *testing.T) { + adapter := newTestOpenCodeAdapter() + + // Add some state + adapter.mu.Lock() + adapter.textParts = map[string]*textPartState{ + "old-part": {lastTextLen: 100}, + } + adapter.messageRoles = map[string]string{ + "old-msg": "assistant", + } + adapter.seenToolCalls = map[string]bool{ + "old-call": true, + } + adapter.mu.Unlock() + + // Clear state + adapter.clearSessionState() + + // Verify state is cleared + adapter.mu.RLock() + defer adapter.mu.RUnlock() + + if len(adapter.textParts) != 0 { + t.Errorf("expected textParts to be empty, got %d entries", len(adapter.textParts)) + } + if len(adapter.messageRoles) != 0 { + t.Errorf("expected messageRoles to be empty, got %d entries", len(adapter.messageRoles)) + } + if len(adapter.seenToolCalls) != 0 { + t.Errorf("expected seenToolCalls to be empty, got %d entries", len(adapter.seenToolCalls)) + } +} + +func TestOpenCodeAdapter_ToolCallDeduplication(t *testing.T) { + adapter := newTestOpenCodeAdapter() + + sessionID := "test-session" + operationID := "test-op" + + // Simulate multiple tool call events for the same tool call ID + for i := range 3 { + var status string + switch i { + case 1: + status = "running" + case 2: + status = "completed" + default: + status = "pending" + } + + props := opencode.MessagePartUpdatedProperties{ + Part: opencode.Part{ + ID: "tool-part-1", + Type: opencode.PartTypeTool, + MessageID: "msg-1", + SessionID: sessionID, + CallID: "call-123", + Tool: "bash", + State: &opencode.ToolStateUpdate{ + Status: status, + Title: "Running command", + }, + }, + } + propsJSON, _ := json.Marshal(props) + adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID) + } + + sentEvents := drainEvents(adapter, 100*time.Millisecond) + + // Should have 1 tool_call event and 2 tool_update events + toolCallCount := 0 + toolUpdateCount := 0 + for _, evt := range sentEvents { + switch evt.Type { + case EventTypeToolCall: + toolCallCount++ + case EventTypeToolUpdate: + toolUpdateCount++ + } + } + + if toolCallCount != 1 { + t.Errorf("expected 1 tool_call event, got %d", toolCallCount) + } + if toolUpdateCount != 2 { + t.Errorf("expected 2 tool_update events, got %d", toolUpdateCount) + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index eb299572e9..401794df48 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -271,9 +271,15 @@ func (m *Manager) Start(ctx context.Context) error { } // Prepare protocol-specific environment before starting the process - if err := m.adapter.PrepareEnvironment(); err != nil { + // This may return additional environment variables that need to be passed to the subprocess + adapterEnv, err := m.adapter.PrepareEnvironment() + if err != nil { m.logger.Warn("failed to prepare protocol environment", zap.Error(err)) } + // Merge adapter-provided env vars into the subprocess environment + for k, v := range adapterEnv { + m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v)) + } // Start the process m.logger.Info("starting agent process", diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 5ec1a2d22f..67c0e6374f 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -342,30 +342,38 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. return } - // Only update message when tool call completes or errors - switch payload.Data.ToolStatus { - case "complete", "completed", "error", "failed": - if s.messageCreator != nil { - result := "" - if payload.Data.ToolResult != nil { - if str, ok := payload.Data.ToolResult.(string); ok { - result = str - } - } - if err := s.messageCreator.UpdateToolCallMessage( - ctx, - payload.TaskID, - payload.Data.ToolCallID, - payload.Data.ToolStatus, - result, - payload.SessionID, - ); err != nil { - s.logger.Warn("failed to update tool call message", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID), - zap.Error(err)) - } + if s.messageCreator == nil { + return + } + // Handle all status updates (running, complete, error) + switch payload.Data.ToolStatus { + case "running", "complete", "completed", "success", "error", "failed": + result := "" + if payload.Data.ToolResult != nil { + if str, ok := payload.Data.ToolResult.(string); ok { + result = str + } + } + if err := s.messageCreator.UpdateToolCallMessage( + ctx, + payload.TaskID, + payload.Data.ToolCallID, + payload.Data.ToolStatus, + result, + payload.SessionID, + payload.Data.ToolTitle, // Include title from update event + payload.Data.ToolArgs, // Include args from update event + ); err != nil { + s.logger.Warn("failed to update tool call message", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID), + zap.Error(err)) + } + + // Update session state for completion events + if payload.Data.ToolStatus == "complete" || payload.Data.ToolStatus == "completed" || + payload.Data.ToolStatus == "success" || payload.Data.ToolStatus == "error" || payload.Data.ToolStatus == "failed" { s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", false) } } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 4cdfbf763e..7483a63e97 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -278,22 +278,27 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP var agentProfileSnapshot map[string]interface{} if profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID); err == nil && profileInfo != nil { agentProfileSnapshot = map[string]interface{}{ - "id": profileInfo.ProfileID, - "name": profileInfo.ProfileName, - "agent_id": profileInfo.AgentID, - "agent_name": profileInfo.AgentName, - "model": profileInfo.Model, - "auto_approve": profileInfo.AutoApprove, + "id": profileInfo.ProfileID, + "name": profileInfo.ProfileName, + "agent_id": profileInfo.AgentID, + "agent_name": profileInfo.AgentName, + "model": profileInfo.Model, + "auto_approve": profileInfo.AutoApprove, "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, - "plan": profileInfo.Plan, + "plan": profileInfo.Plan, } e.logger.Debug("resolved agent profile for snapshot", zap.String("profile_id", profileInfo.ProfileID), zap.String("model", profileInfo.Model)) - } else if err != nil { - e.logger.Warn("failed to resolve agent profile for snapshot, session will have empty snapshot", + } else { + // Create minimal snapshot even on failure - ensures model switching works + e.logger.Warn("failed to resolve agent profile, using minimal snapshot", zap.String("agent_profile_id", agentProfileID), zap.Error(err)) + agentProfileSnapshot = map[string]interface{}{ + "id": agentProfileID, + "model": "", // Empty model allows any model switch + } } // Create agent session in database before launch so worktree associations can persist. @@ -437,6 +442,18 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP zap.String("session_id", sessionID), zap.Error(updateErr)) } + return + } + + // Agent started successfully - transition task from SCHEDULING to IN_PROGRESS + if updateErr := e.repo.UpdateTaskState(context.Background(), task.ID, v1.TaskStateInProgress); updateErr != nil { + e.logger.Warn("failed to update task state to IN_PROGRESS after agent start", + zap.String("task_id", task.ID), + zap.Error(updateErr)) + } else { + e.logger.Debug("task transitioned to IN_PROGRESS after agent started", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID)) } }() @@ -684,6 +701,27 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio zap.String("session_id", session.ID), zap.Error(updateErr)) } + return + } + + // Agent resumed successfully - sync task state with session state. + // If the session is waiting for input, the task should be in REVIEW state. + // This ensures the task state reflects the actual agent status. + if session.State == models.TaskSessionStateWaitingForInput { + if updateErr := e.repo.UpdateTaskState(context.Background(), task.ID, v1.TaskStateReview); updateErr != nil { + e.logger.Warn("failed to update task state to REVIEW after resume", + zap.String("task_id", task.ID), + zap.Error(updateErr)) + } else { + e.logger.Debug("task state synced to REVIEW after resume (session waiting for input)", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID)) + } + } else { + e.logger.Debug("agent resumed successfully, task state unchanged", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID), + zap.String("session_state", string(session.State))) } }() } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 1caf337bab..b94163ae31 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -55,7 +55,7 @@ type MessageCreator interface { CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, args map[string]interface{}) error - UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID string) error + UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, args map[string]interface{}) error CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 09110b7111..8755d7d56c 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -92,11 +92,8 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s } } - if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil { - s.logger.Warn("failed to update task state to IN_PROGRESS", - zap.String("task_id", taskID), - zap.Error(err)) - } + // Note: Task stays in SCHEDULING state until the agent is fully initialized. + // The executor will transition to IN_PROGRESS after StartAgentProcess() succeeds. return execution, nil } @@ -258,30 +255,36 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom return nil, fmt.Errorf("failed to get session for model switch: %w", err) } - // Get current model from agent profile snapshot + // Get current model - handle nil snapshot and missing key safely + currentModel := "" if session.AgentProfileSnapshot != nil { - if currentModel, ok := session.AgentProfileSnapshot["model"].(string); ok && currentModel != model { - // Model switch needed - delegate to executor - s.logger.Debug("model switch requested", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("current_model", currentModel), - zap.String("new_model", model)) - - // Start a new turn for this prompt (model switch case) - s.startTurnForSession(ctx, sessionID) - - // SwitchModel will stop agent, rebuild with new model, restart, and send prompt - switchResult, err := s.executor.SwitchModel(ctx, taskID, sessionID, model, prompt) - if err != nil { - return nil, err - } - return &PromptResult{ - StopReason: switchResult.StopReason, - AgentMessage: switchResult.AgentMessage, - }, nil + if m, ok := session.AgentProfileSnapshot["model"].(string); ok { + currentModel = m } } + + // Trigger switch if models differ OR if current model is unknown (empty string) + if currentModel != model { + s.logger.Info("model switch requested", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("current_model", currentModel), + zap.String("new_model", model)) + + // Start a new turn for this prompt (model switch case) + s.startTurnForSession(ctx, sessionID) + + // SwitchModel will stop agent, rebuild with new model, restart, and send prompt + switchResult, err := s.executor.SwitchModel(ctx, taskID, sessionID, model, prompt) + if err != nil { + return nil, fmt.Errorf("model switch failed: %w", err) + } + return &PromptResult{ + StopReason: switchResult.StopReason, + AgentMessage: switchResult.AgentMessage, + }, nil + } + // Model is the same, fall through to regular prompt } s.setSessionRunning(ctx, taskID, sessionID) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 9059d80314..92aceb8036 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -1802,10 +1802,10 @@ func (s *Service) AppendMessageContent(ctx context.Context, messageID, additiona return nil } -// UpdateToolCallMessage updates a tool call message's status. +// UpdateToolCallMessage updates a tool call message's status, and optionally title/args. // It includes retry logic to handle race conditions where the complete event // may arrive before the message has been created by the start event. -func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result string) error { +func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, args map[string]interface{}) error { const maxRetries = 5 const retryDelay = 100 * time.Millisecond @@ -1853,6 +1853,24 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall message.Metadata["result"] = result } + // Update title if provided and current title is just the tool name (not yet filled) + // This handles the case where the first event only had the tool name + if title != "" { + currentTitle, _ := message.Metadata["title"].(string) + if currentTitle == "" || currentTitle == message.Metadata["tool_name"] { + message.Content = title + message.Metadata["title"] = title + } + } + + // Update args if provided and current args are empty + if len(args) > 0 { + currentArgs, _ := message.Metadata["args"].(map[string]interface{}) + if len(currentArgs) == 0 { + message.Metadata["args"] = args + } + } + if err := s.repo.UpdateMessage(ctx, message); err != nil { s.logger.Error("failed to update tool call message", zap.String("message_id", message.ID), diff --git a/apps/backend/pkg/agent/protocol.go b/apps/backend/pkg/agent/protocol.go index 09ca5448f8..ba9cd73639 100644 --- a/apps/backend/pkg/agent/protocol.go +++ b/apps/backend/pkg/agent/protocol.go @@ -19,6 +19,10 @@ const ( // Claude Code uses a streaming JSON format with control requests for permissions // and user messages for prompts. ProtocolClaudeCode Protocol = "claude-code" + // ProtocolOpenCode is the OpenCode CLI protocol (REST/SSE over HTTP). + // OpenCode spawns an HTTP server and communicates via REST API calls + // with Server-Sent Events (SSE) for streaming responses. + ProtocolOpenCode Protocol = "opencode" ) // String returns the string representation of the protocol. @@ -29,7 +33,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, ProtocolClaudeCode: + case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, ProtocolClaudeCode, ProtocolOpenCode: return true default: return false diff --git a/apps/backend/pkg/opencode/client.go b/apps/backend/pkg/opencode/client.go new file mode 100644 index 0000000000..b028be15e0 --- /dev/null +++ b/apps/backend/pkg/opencode/client.go @@ -0,0 +1,516 @@ +package opencode + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Client manages HTTP communication with OpenCode server +type Client struct { + baseURL string + directory string + password string + httpClient *http.Client + logger *logger.Logger + + // Event handling + eventHandler EventHandler + controlCh chan ControlEvent + + mu sync.RWMutex + closed bool +} + +// EventHandler is called for each SDK event from the SSE stream +type EventHandler func(event *SDKEventEnvelope) + +// ControlEvent represents control flow events +type ControlEvent struct { + Type string // "idle", "auth_required", "session_error", "disconnected" + Message string +} + +// NewClient creates a new OpenCode HTTP client +func NewClient(baseURL, directory, password string, log *logger.Logger) *Client { + return &Client{ + baseURL: strings.TrimSuffix(baseURL, "/"), + directory: directory, + password: password, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + logger: log, + controlCh: make(chan ControlEvent, 10), + } +} + +// GenerateServerPassword generates a cryptographically secure random password +func GenerateServerPassword() string { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + // Fallback to a timestamp-based string if random fails + return fmt.Sprintf("opencode-%d", time.Now().UnixNano()) + } + return base64.RawURLEncoding.EncodeToString(bytes) +} + +// SetEventHandler sets the handler for SDK events +func (c *Client) SetEventHandler(handler EventHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.eventHandler = handler +} + +// ControlChannel returns the channel for control events +func (c *Client) ControlChannel() <-chan ControlEvent { + return c.controlCh +} + +// buildAuthHeader creates the Basic auth header value +func (c *Client) buildAuthHeader() string { + credentials := base64.StdEncoding.EncodeToString([]byte("opencode:" + c.password)) + return "Basic " + credentials +} + +// doRequest performs an HTTP request with auth headers +func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + url := c.baseURL + path + if strings.Contains(path, "?") { + url += "&directory=" + c.directory + } else { + url += "?directory=" + c.directory + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", c.buildAuthHeader()) + req.Header.Set("X-OpenCode-Directory", c.directory) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.httpClient.Do(req) +} + +// WaitForHealth waits for the OpenCode server to be healthy +func (c *Client) WaitForHealth(ctx context.Context) error { + deadline := time.Now().Add(20 * time.Second) + var lastErr error + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + resp, err := c.doRequest(ctx, http.MethodGet, "/global/health", nil) + if err != nil { + lastErr = err + c.logger.Debug("health check request failed", zap.Error(err)) + time.Sleep(150 * time.Millisecond) + continue + } + + // Read body for logging and parsing + bodyBytes, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + lastErr = fmt.Errorf("read health response: %w", err) + time.Sleep(150 * time.Millisecond) + continue + } + + c.logger.Debug("health check response", + zap.Int("status", resp.StatusCode), + zap.String("body", string(bodyBytes))) + + // Check HTTP status first + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("health check HTTP %d: %s", resp.StatusCode, string(bodyBytes)) + time.Sleep(150 * time.Millisecond) + continue + } + + var health HealthResponse + if err := json.Unmarshal(bodyBytes, &health); err != nil { + lastErr = fmt.Errorf("parse health response (got: %q): %w", string(bodyBytes), err) + time.Sleep(150 * time.Millisecond) + continue + } + + if health.Healthy { + c.logger.Info("OpenCode server healthy", zap.String("version", health.Version)) + return nil + } + + lastErr = fmt.Errorf("server unhealthy (version %s)", health.Version) + time.Sleep(150 * time.Millisecond) + } + + if lastErr != nil { + return fmt.Errorf("health check timeout: %w", lastErr) + } + return fmt.Errorf("health check timeout") +} + +// CreateSession creates a new OpenCode session +func (c *Client) CreateSession(ctx context.Context) (string, error) { + resp, err := c.doRequest(ctx, http.MethodPost, "/session", strings.NewReader("{}")) + if err != nil { + return "", fmt.Errorf("create session request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("create session failed: HTTP %d: %s", resp.StatusCode, string(body)) + } + + var session SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return "", fmt.Errorf("parse session response: %w", err) + } + + return session.ID, nil +} + +// ForkSession forks an existing session for follow-up prompts +func (c *Client) ForkSession(ctx context.Context, sessionID string) (string, error) { + path := fmt.Sprintf("/session/%s/fork", sessionID) + resp, err := c.doRequest(ctx, http.MethodPost, path, strings.NewReader("{}")) + if err != nil { + return "", fmt.Errorf("fork session request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("fork session failed: HTTP %d: %s", resp.StatusCode, string(body)) + } + + var session SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return "", fmt.Errorf("parse session response: %w", err) + } + + return session.ID, nil +} + +// SendPrompt sends a prompt to the session +func (c *Client) SendPrompt(ctx context.Context, sessionID, prompt string, model *ModelSpec, agent, variant string) error { + req := PromptRequest{ + Model: model, + Agent: agent, + Variant: variant, + Parts: []TextPartInput{ + {Type: "text", Text: prompt}, + }, + } + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("marshal prompt request: %w", err) + } + + path := fmt.Sprintf("/session/%s/message", sessionID) + resp, err := c.doRequest(ctx, http.MethodPost, path, strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("send prompt request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Read and validate response + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read prompt response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("prompt failed: HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + // Validate response structure + trimmed := strings.TrimSpace(string(respBody)) + if trimmed == "" { + return fmt.Errorf("prompt returned empty response") + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return fmt.Errorf("parse prompt response: %w", err) + } + + // Check for success response: { info, parts } + if _, hasInfo := parsed["info"]; hasInfo { + if _, hasParts := parsed["parts"]; hasParts { + return nil + } + } + + // Check for error response: { name, data } + if name, ok := parsed["name"].(string); ok { + message := "unknown error" + if data, ok := parsed["data"].(map[string]any); ok { + if msg, ok := data["message"].(string); ok { + message = msg + } + } + return fmt.Errorf("prompt error: %s: %s", name, message) + } + + return nil +} + +// Abort sends an abort request to stop the current operation +func (c *Client) Abort(ctx context.Context, sessionID string) error { + path := fmt.Sprintf("/session/%s/abort", sessionID) + + // Use a short timeout for abort + abortCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond) + defer cancel() + + resp, err := c.doRequest(abortCtx, http.MethodPost, path, nil) + if err != nil { + return nil // Ignore abort errors + } + defer func() { _ = resp.Body.Close() }() + + // Drain body + _, _ = io.ReadAll(resp.Body) + return nil +} + +// ReplyPermission sends a permission reply +func (c *Client) ReplyPermission(ctx context.Context, requestID, reply string, message *string) error { + payload := PermissionReplyRequest{ + Reply: reply, + } + if message != nil { + payload.Message = *message + } else if reply == PermissionReplyReject { + // If rejecting without message, provide default + payload.Message = "User denied this tool use request" + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal permission reply: %w", err) + } + + path := fmt.Sprintf("/permission/%s/reply", requestID) + resp, err := c.doRequest(ctx, http.MethodPost, path, strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("permission reply request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Drain body + _, _ = io.ReadAll(resp.Body) + return nil +} + +// StartEventStream starts the SSE event stream and processes events +func (c *Client) StartEventStream(ctx context.Context, sessionID string) error { + url := c.baseURL + "/event?directory=" + c.directory + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create event stream request: %w", err) + } + + req.Header.Set("Authorization", c.buildAuthHeader()) + req.Header.Set("X-OpenCode-Directory", c.directory) + req.Header.Set("Accept", "text/event-stream") + + // Use a client without timeout for SSE + sseClient := &http.Client{} + resp, err := sseClient.Do(req) + if err != nil { + return fmt.Errorf("connect event stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return fmt.Errorf("event stream failed: HTTP %d: %s", resp.StatusCode, string(body)) + } + + // Process events in background + go c.processEventStream(ctx, sessionID, resp.Body) + + return nil +} + +// processEventStream reads and processes SSE events +func (c *Client) processEventStream(ctx context.Context, sessionID string, body io.ReadCloser) { + defer func() { _ = body.Close() }() + + scanner := bufio.NewScanner(body) + // Increase buffer size for potentially large events + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + var dataBuffer strings.Builder + + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // SSE format: "data: {...}" + if strings.HasPrefix(line, "data: ") { + dataBuffer.WriteString(strings.TrimPrefix(line, "data: ")) + continue + } + + // Empty line signals end of event + if line == "" && dataBuffer.Len() > 0 { + data := strings.TrimSpace(dataBuffer.String()) + dataBuffer.Reset() + + if data == "" { + continue + } + + event, err := ParseSDKEvent([]byte(data)) + if err != nil { + c.logger.Warn("failed to parse SDK event", zap.Error(err)) + continue + } + + // Filter events for this session + if !c.eventMatchesSession(event, sessionID) { + continue + } + + // Process control events + c.processControlEvent(event, sessionID) + + // Call event handler + c.mu.RLock() + handler := c.eventHandler + c.mu.RUnlock() + + if handler != nil { + handler(event) + } + } + } + + if err := scanner.Err(); err != nil { + c.logger.Error("event stream error", zap.Error(err)) + } + + // Notify disconnection + select { + case c.controlCh <- ControlEvent{Type: "disconnected"}: + default: + } +} + +// eventMatchesSession checks if an event belongs to the specified session +func (c *Client) eventMatchesSession(event *SDKEventEnvelope, sessionID string) bool { + // Try to extract sessionID from properties + var props map[string]any + if event.Properties != nil { + if err := json.Unmarshal(event.Properties, &props); err != nil { + return true // If we can't parse, let it through + } + } + + // Check various paths where sessionID might be + extractedID := "" + + switch event.Type { + case SDKEventMessageUpdated: + if info, ok := props["info"].(map[string]any); ok { + if id, ok := info["sessionID"].(string); ok { + extractedID = id + } + } + case SDKEventMessagePartUpdated: + if part, ok := props["part"].(map[string]any); ok { + if id, ok := part["sessionID"].(string); ok { + extractedID = id + } + } + default: + if id, ok := props["sessionID"].(string); ok { + extractedID = id + } + } + + if extractedID == "" { + return true // No sessionID in event, let it through + } + + return extractedID == sessionID +} + +// processControlEvent handles control flow events +func (c *Client) processControlEvent(event *SDKEventEnvelope, sessionID string) { + switch event.Type { + case SDKEventSessionIdle: + select { + case c.controlCh <- ControlEvent{Type: "idle"}: + default: + } + + case SDKEventSessionError: + props, err := ParseSessionError(event.Properties) + if err != nil { + return + } + + if props.Error != nil { + kind := props.Error.GetKind() + message := props.Error.GetMessage() + + if kind == "ProviderAuthError" { + select { + case c.controlCh <- ControlEvent{Type: "auth_required", Message: message}: + default: + } + } else { + select { + case c.controlCh <- ControlEvent{Type: "session_error", Message: message}: + default: + } + } + } + } +} + +// Close closes the client +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + c.closed = true + + close(c.controlCh) +} diff --git a/apps/backend/pkg/opencode/client_test.go b/apps/backend/pkg/opencode/client_test.go new file mode 100644 index 0000000000..970c825c3b --- /dev/null +++ b/apps/backend/pkg/opencode/client_test.go @@ -0,0 +1,467 @@ +package opencode + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + }) + return log +} + +func TestGenerateServerPassword(t *testing.T) { + // Generate multiple passwords and ensure they're unique + passwords := make(map[string]bool) + for i := 0; i < 10; i++ { + pw := GenerateServerPassword() + if pw == "" { + t.Error("generated empty password") + } + if passwords[pw] { + t.Error("generated duplicate password") + } + passwords[pw] = true + } +} + +func TestClient_BuildAuthHeader(t *testing.T) { + client := NewClient("http://localhost:8080", "/workspace", "test-password", newTestLogger()) + + header := client.buildAuthHeader() + if !strings.HasPrefix(header, "Basic ") { + t.Errorf("expected header to start with 'Basic ', got %s", header) + } +} + +func TestClient_WaitForHealth(t *testing.T) { + tests := []struct { + name string + responses []HealthResponse + delays []time.Duration + wantError bool + }{ + { + name: "healthy immediately", + responses: []HealthResponse{{Healthy: true, Version: "1.0.0"}}, + delays: []time.Duration{0}, + wantError: false, + }, + { + name: "healthy after retry", + responses: []HealthResponse{ + {Healthy: false, Version: "1.0.0"}, + {Healthy: true, Version: "1.0.0"}, + }, + delays: []time.Duration{0, 0}, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/global/health") { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if callCount < len(tt.delays) { + time.Sleep(tt.delays[callCount]) + } + + resp := tt.responses[callCount%len(tt.responses)] + callCount++ + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := client.WaitForHealth(ctx) + if tt.wantError && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantError && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestClient_CreateSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !strings.Contains(r.URL.Path, "/session") { + http.Error(w, "not found", http.StatusNotFound) + return + } + + // Check auth header + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Basic ") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(SessionResponse{ID: "sess-123"}) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx := context.Background() + + sessionID, err := client.CreateSession(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionID != "sess-123" { + t.Errorf("expected session ID 'sess-123', got %s", sessionID) + } +} + +func TestClient_ForkSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !strings.Contains(r.URL.Path, "/session/sess-123/fork") { + http.Error(w, "not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(SessionResponse{ID: "sess-456"}) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx := context.Background() + + newSessionID, err := client.ForkSession(ctx, "sess-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if newSessionID != "sess-456" { + t.Errorf("expected session ID 'sess-456', got %s", newSessionID) + } +} + +func TestClient_SendPrompt(t *testing.T) { + tests := []struct { + name string + statusCode int + response string + wantError bool + }{ + { + name: "success", + statusCode: http.StatusOK, + response: `{"info":{},"parts":[]}`, + wantError: false, + }, + { + name: "error response", + statusCode: http.StatusOK, + response: `{"name":"SomeError","data":{"message":"something went wrong"}}`, + wantError: true, + }, + { + name: "http error", + statusCode: http.StatusInternalServerError, + response: `{"error":"internal error"}`, + wantError: true, + }, + { + name: "empty response", + statusCode: http.StatusOK, + response: ``, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _, _ = fmt.Fprint(w, tt.response) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx := context.Background() + + err := client.SendPrompt(ctx, "sess-123", "Hello", nil, "", "") + if tt.wantError && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantError && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestClient_SendPrompt_WithModel(t *testing.T) { + var receivedBody PromptRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"info":{},"parts":[]}`) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx := context.Background() + + model := &ModelSpec{ProviderID: "anthropic", ModelID: "claude-3-sonnet"} + err := client.SendPrompt(ctx, "sess-123", "Hello", model, "coder", "default") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedBody.Model == nil { + t.Error("expected model to be set") + } else { + if receivedBody.Model.ProviderID != "anthropic" { + t.Errorf("expected providerID 'anthropic', got %s", receivedBody.Model.ProviderID) + } + } + if receivedBody.Agent != "coder" { + t.Errorf("expected agent 'coder', got %s", receivedBody.Agent) + } + if receivedBody.Variant != "default" { + t.Errorf("expected variant 'default', got %s", receivedBody.Variant) + } +} + +func TestClient_Abort(t *testing.T) { + aborted := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/abort") { + aborted = true + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx := context.Background() + + err := client.Abort(ctx, "sess-123") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !aborted { + t.Error("expected abort endpoint to be called") + } +} + +func TestClient_ReplyPermission(t *testing.T) { + tests := []struct { + name string + reply string + message *string + }{ + { + name: "allow once", + reply: PermissionReplyOnce, + message: nil, + }, + { + name: "reject with message", + reply: PermissionReplyReject, + message: strPtr("User denied"), + }, + { + name: "reject without message", + reply: PermissionReplyReject, + message: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var receivedBody PermissionReplyRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "/workspace", "test-password", newTestLogger()) + ctx := context.Background() + + err := client.ReplyPermission(ctx, "perm-123", tt.reply, tt.message) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedBody.Reply != tt.reply { + t.Errorf("expected reply %s, got %s", tt.reply, receivedBody.Reply) + } + + if tt.message != nil { + if receivedBody.Message != *tt.message { + t.Errorf("expected message %s, got %s", *tt.message, receivedBody.Message) + } + } else if tt.reply == PermissionReplyReject { + // Should have default message + if receivedBody.Message == "" { + t.Error("expected default message for reject without message") + } + } + }) + } +} + +func TestClient_ControlChannel(t *testing.T) { + client := NewClient("http://localhost:8080", "/workspace", "test-password", newTestLogger()) + + ch := client.ControlChannel() + if ch == nil { + t.Error("expected control channel to be non-nil") + } +} + +func TestClient_SetEventHandler(t *testing.T) { + client := NewClient("http://localhost:8080", "/workspace", "test-password", newTestLogger()) + + handler := func(event *SDKEventEnvelope) { + // Handler logic + } + + client.SetEventHandler(handler) + + // Access handler through the client - this is internal but we can test it works + client.mu.RLock() + h := client.eventHandler + client.mu.RUnlock() + + if h == nil { + t.Error("expected event handler to be set") + } +} + +func TestClient_Close(t *testing.T) { + client := NewClient("http://localhost:8080", "/workspace", "test-password", newTestLogger()) + + // First close should succeed + client.Close() + + // Second close should be a no-op + client.Close() + + // Client should be marked as closed + client.mu.RLock() + closed := client.closed + client.mu.RUnlock() + + if !closed { + t.Error("expected client to be closed") + } +} + +func TestClient_EventMatchesSession(t *testing.T) { + client := NewClient("http://localhost:8080", "/workspace", "test-password", newTestLogger()) + + tests := []struct { + name string + eventType string + props string + sessionID string + want bool + }{ + { + name: "message.updated matches", + eventType: SDKEventMessageUpdated, + props: `{"info":{"sessionID":"sess-123"}}`, + sessionID: "sess-123", + want: true, + }, + { + name: "message.updated doesn't match", + eventType: SDKEventMessageUpdated, + props: `{"info":{"sessionID":"sess-456"}}`, + sessionID: "sess-123", + want: false, + }, + { + name: "message.part.updated matches", + eventType: SDKEventMessagePartUpdated, + props: `{"part":{"sessionID":"sess-123"}}`, + sessionID: "sess-123", + want: true, + }, + { + name: "other event matches", + eventType: SDKEventSessionIdle, + props: `{"sessionID":"sess-123"}`, + sessionID: "sess-123", + want: true, + }, + { + name: "no sessionID in event - matches", + eventType: SDKEventSessionIdle, + props: `{}`, + sessionID: "sess-123", + want: true, + }, + { + name: "nil properties - matches", + eventType: SDKEventSessionIdle, + props: "", + sessionID: "sess-123", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var props json.RawMessage + if tt.props != "" { + props = json.RawMessage(tt.props) + } + + event := &SDKEventEnvelope{ + Type: tt.eventType, + Properties: props, + } + + got := client.eventMatchesSession(event, tt.sessionID) + if got != tt.want { + t.Errorf("expected %v, got %v", tt.want, got) + } + }) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/apps/backend/pkg/opencode/types.go b/apps/backend/pkg/opencode/types.go new file mode 100644 index 0000000000..7d6a8bb9b5 --- /dev/null +++ b/apps/backend/pkg/opencode/types.go @@ -0,0 +1,329 @@ +// Package opencode provides types and client for the OpenCode CLI protocol. +// OpenCode uses a REST API + Server-Sent Events (SSE) pattern for communication. +package opencode + +import ( + "encoding/json" +) + +// ExecutorEvent types written to adapter output +const ( + EventTypeStartupLog = "startup_log" + EventTypeSessionStart = "session_start" + EventTypeSDKEvent = "sdk_event" + EventTypeTokenUsage = "token_usage" + EventTypeApprovalResponse = "approval_response" + EventTypeError = "error" + EventTypeDone = "done" +) + +// SDK Event types from /event SSE stream +const ( + SDKEventMessageUpdated = "message.updated" + SDKEventMessagePartUpdated = "message.part.updated" + SDKEventMessageRemoved = "message.removed" + SDKEventMessagePartRemoved = "message.part.removed" + SDKEventPermissionAsked = "permission.asked" + SDKEventPermissionReplied = "permission.replied" + SDKEventSessionIdle = "session.idle" + SDKEventSessionStatus = "session.status" + SDKEventSessionDiff = "session.diff" + SDKEventSessionCompacted = "session.compacted" + SDKEventSessionError = "session.error" + SDKEventTodoUpdated = "todo.updated" +) + +// Part types +const ( + PartTypeText = "text" + PartTypeReasoning = "reasoning" + PartTypeTool = "tool" +) + +// Tool status values +const ( + ToolStatusPending = "pending" + ToolStatusRunning = "running" + ToolStatusCompleted = "completed" + ToolStatusError = "error" +) + +// Permission reply values +const ( + PermissionReplyOnce = "once" + PermissionReplyReject = "reject" +) + +// ExecutorEvent represents events written to stdout by the adapter +type ExecutorEvent struct { + Type string `json:"type"` + // Variant fields based on type + Message string `json:"message,omitempty"` + SessionID string `json:"session_id,omitempty"` + Event json.RawMessage `json:"event,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + ModelContextWindow int `json:"model_context_window,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Status string `json:"status,omitempty"` +} + +// HealthResponse from GET /global/health +type HealthResponse struct { + Healthy bool `json:"healthy"` + Version string `json:"version"` +} + +// SessionResponse from POST /session or POST /session/{id}/fork +type SessionResponse struct { + ID string `json:"id"` +} + +// ModelSpec for prompt requests +type ModelSpec struct { + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` +} + +// TextPartInput for prompt request parts +type TextPartInput struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// PromptRequest for POST /session/{id}/message +type PromptRequest struct { + Model *ModelSpec `json:"model,omitempty"` + Agent string `json:"agent,omitempty"` + Variant string `json:"variant,omitempty"` + Parts []TextPartInput `json:"parts"` +} + +// PermissionReplyRequest for POST /permission/{id}/reply +type PermissionReplyRequest struct { + Reply string `json:"reply"` + Message string `json:"message,omitempty"` +} + +// SDKEventEnvelope is the base structure for all SSE events +type SDKEventEnvelope struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties,omitempty"` +} + +// MessageUpdatedProperties for message.updated events +type MessageUpdatedProperties struct { + Info MessageInfo `json:"info"` +} + +// MessageInfo contains message metadata +type MessageInfo struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` // "user", "assistant" + Model *MessageModelInfo `json:"model,omitempty"` + ProviderID string `json:"providerID,omitempty"` + ModelID string `json:"modelID,omitempty"` + Tokens *MessageTokensInfo `json:"tokens,omitempty"` +} + +// MessageModelInfo contains model information +type MessageModelInfo struct { + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` +} + +// MessageTokensInfo contains token usage information +type MessageTokensInfo struct { + Input int `json:"input"` + Output int `json:"output"` + Cache *MessageTokensCacheInfo `json:"cache,omitempty"` +} + +// MessageTokensCacheInfo contains cache token information +type MessageTokensCacheInfo struct { + Read int `json:"read"` +} + +// MessagePartUpdatedProperties for message.part.updated events +type MessagePartUpdatedProperties struct { + Part Part `json:"part"` + Delta string `json:"delta,omitempty"` +} + +// Part represents a message part (text, reasoning, or tool) +type Part struct { + ID string `json:"id"` // Unique part identifier for tracking updates + Type string `json:"type"` // "text", "reasoning", "tool" + MessageID string `json:"messageID"` + SessionID string `json:"sessionID"` + Text string `json:"text,omitempty"` // For text/reasoning + CallID string `json:"callID,omitempty"` // For tool + Tool string `json:"tool,omitempty"` // For tool + State *ToolStateUpdate `json:"state,omitempty"` // For tool +} + +// ToolStateUpdate represents tool execution state +type ToolStateUpdate struct { + Status string `json:"status"` // "pending", "running", "completed", "error" + Input json.RawMessage `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Title string `json:"title,omitempty"` + Error string `json:"error,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// PermissionAskedProperties for permission.asked events +type PermissionAskedProperties struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Patterns []string `json:"patterns,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Tool *PermissionToolInfo `json:"tool,omitempty"` +} + +// PermissionToolInfo contains tool information for permission requests +type PermissionToolInfo struct { + CallID string `json:"callID"` +} + +// SessionIdleProperties for session.idle events +type SessionIdleProperties struct { + SessionID string `json:"sessionID"` +} + +// SessionErrorProperties for session.error events +type SessionErrorProperties struct { + SessionID string `json:"sessionID"` + Error *SDKError `json:"error,omitempty"` +} + +// SDKError represents an error from the SDK +type SDKError struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + Data *struct { + Message string `json:"message,omitempty"` + } `json:"data,omitempty"` +} + +// GetMessage returns the error message +func (e *SDKError) GetMessage() string { + if e.Data != nil && e.Data.Message != "" { + return e.Data.Message + } + return e.Message +} + +// GetKind returns the error kind/type +func (e *SDKError) GetKind() string { + if e.Name != "" { + return e.Name + } + if e.Type != "" { + return e.Type + } + return "unknown" +} + +// SessionStatusProperties for session.status events +type SessionStatusProperties struct { + Status SessionStatus `json:"status"` +} + +// SessionStatus represents session status +type SessionStatus struct { + Type string `json:"type"` // "idle", "busy", "retry" + Attempt int `json:"attempt,omitempty"` + Message string `json:"message,omitempty"` + Next int64 `json:"next,omitempty"` +} + +// TodoUpdatedProperties for todo.updated events +type TodoUpdatedProperties struct { + Todos []SDKTodo `json:"todos"` +} + +// SDKTodo represents a todo item +type SDKTodo struct { + ID string `json:"id"` + Content string `json:"content"` + Status string `json:"status"` + Priority string `json:"priority"` +} + +// ProviderListResponse from GET /provider +type ProviderListResponse struct { + All []ProviderInfo `json:"all"` +} + +// ProviderInfo contains provider information +type ProviderInfo struct { + ID string `json:"id"` + Models map[string]ProviderModelInfo `json:"models,omitempty"` +} + +// ProviderModelInfo contains model information from provider +type ProviderModelInfo struct { + Limit ProviderModelLimit `json:"limit"` +} + +// ProviderModelLimit contains model limits +type ProviderModelLimit struct { + Context int `json:"context"` +} + +// ParseSDKEvent parses an SDK event from JSON +func ParseSDKEvent(data []byte) (*SDKEventEnvelope, error) { + var env SDKEventEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, err + } + return &env, nil +} + +// ParseMessageUpdated parses message.updated properties +func ParseMessageUpdated(data json.RawMessage) (*MessageUpdatedProperties, error) { + var props MessageUpdatedProperties + if err := json.Unmarshal(data, &props); err != nil { + return nil, err + } + return &props, nil +} + +// ParseMessagePartUpdated parses message.part.updated properties +func ParseMessagePartUpdated(data json.RawMessage) (*MessagePartUpdatedProperties, error) { + var props MessagePartUpdatedProperties + if err := json.Unmarshal(data, &props); err != nil { + return nil, err + } + return &props, nil +} + +// ParsePermissionAsked parses permission.asked properties +func ParsePermissionAsked(data json.RawMessage) (*PermissionAskedProperties, error) { + var props PermissionAskedProperties + if err := json.Unmarshal(data, &props); err != nil { + return nil, err + } + return &props, nil +} + +// ParseSessionIdle parses session.idle properties +func ParseSessionIdle(data json.RawMessage) (*SessionIdleProperties, error) { + var props SessionIdleProperties + if err := json.Unmarshal(data, &props); err != nil { + return nil, err + } + return &props, nil +} + +// ParseSessionError parses session.error properties +func ParseSessionError(data json.RawMessage) (*SessionErrorProperties, error) { + var props SessionErrorProperties + if err := json.Unmarshal(data, &props); err != nil { + return nil, err + } + return &props, nil +} diff --git a/apps/backend/pkg/opencode/types_test.go b/apps/backend/pkg/opencode/types_test.go new file mode 100644 index 0000000000..729dc92286 --- /dev/null +++ b/apps/backend/pkg/opencode/types_test.go @@ -0,0 +1,366 @@ +package opencode + +import ( + "encoding/json" + "testing" +) + +func TestSDKEventEnvelope_ParseSDKEvent(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantError bool + }{ + { + name: "message.updated event", + input: `{"type":"message.updated","properties":{"info":{"id":"123","sessionID":"sess-1","role":"assistant"}}}`, + wantType: SDKEventMessageUpdated, + }, + { + name: "message.part.updated event", + input: `{"type":"message.part.updated","properties":{"part":{"type":"text","text":"hello"}}}`, + wantType: SDKEventMessagePartUpdated, + }, + { + name: "permission.asked event", + input: `{"type":"permission.asked","properties":{"id":"perm-1","sessionID":"sess-1","permission":"edit"}}`, + wantType: SDKEventPermissionAsked, + }, + { + name: "session.idle event", + input: `{"type":"session.idle","properties":{"sessionID":"sess-1"}}`, + wantType: SDKEventSessionIdle, + }, + { + name: "session.error event", + input: `{"type":"session.error","properties":{"sessionID":"sess-1","error":{"message":"something went wrong"}}}`, + wantType: SDKEventSessionError, + }, + { + name: "invalid JSON", + input: `{invalid`, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := ParseSDKEvent([]byte(tt.input)) + if tt.wantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if event.Type != tt.wantType { + t.Errorf("expected type %s, got %s", tt.wantType, event.Type) + } + }) + } +} + +func TestParseMessageUpdated(t *testing.T) { + input := `{"info":{"id":"msg-123","sessionID":"sess-456","role":"assistant","model":{"providerID":"anthropic","modelID":"claude-3-sonnet"},"tokens":{"input":100,"output":50,"cache":{"read":20}}}}` + + props, err := ParseMessageUpdated(json.RawMessage(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if props.Info.ID != "msg-123" { + t.Errorf("expected ID 'msg-123', got %s", props.Info.ID) + } + if props.Info.SessionID != "sess-456" { + t.Errorf("expected sessionID 'sess-456', got %s", props.Info.SessionID) + } + if props.Info.Role != "assistant" { + t.Errorf("expected role 'assistant', got %s", props.Info.Role) + } + if props.Info.Model == nil { + t.Error("expected model to be set") + } else { + if props.Info.Model.ProviderID != "anthropic" { + t.Errorf("expected providerID 'anthropic', got %s", props.Info.Model.ProviderID) + } + } + if props.Info.Tokens == nil { + t.Error("expected tokens to be set") + } else { + if props.Info.Tokens.Input != 100 { + t.Errorf("expected input tokens 100, got %d", props.Info.Tokens.Input) + } + if props.Info.Tokens.Output != 50 { + t.Errorf("expected output tokens 50, got %d", props.Info.Tokens.Output) + } + if props.Info.Tokens.Cache == nil || props.Info.Tokens.Cache.Read != 20 { + t.Error("expected cache read 20") + } + } +} + +func TestParseMessagePartUpdated(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantText string + wantID string + }{ + { + name: "text part with ID", + input: `{"part":{"id":"part-123","type":"text","messageID":"msg-1","sessionID":"sess-1","text":"Hello world"},"delta":"Hello"}`, + wantType: PartTypeText, + wantText: "Hello world", + wantID: "part-123", + }, + { + name: "text part without ID (backwards compatibility)", + input: `{"part":{"type":"text","messageID":"msg-1","sessionID":"sess-1","text":"Hello world"},"delta":"Hello"}`, + wantType: PartTypeText, + wantText: "Hello world", + wantID: "", + }, + { + name: "reasoning part", + input: `{"part":{"id":"reason-1","type":"reasoning","messageID":"msg-1","sessionID":"sess-1","text":"Let me think..."}}`, + wantType: PartTypeReasoning, + wantText: "Let me think...", + wantID: "reason-1", + }, + { + name: "tool part", + input: `{"part":{"id":"tool-1","type":"tool","messageID":"msg-1","sessionID":"sess-1","callID":"call-1","tool":"bash","state":{"status":"running","title":"Running command"}}}`, + wantType: PartTypeTool, + wantID: "tool-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + props, err := ParseMessagePartUpdated(json.RawMessage(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if props.Part.Type != tt.wantType { + t.Errorf("expected type %s, got %s", tt.wantType, props.Part.Type) + } + if tt.wantText != "" && props.Part.Text != tt.wantText { + t.Errorf("expected text %q, got %q", tt.wantText, props.Part.Text) + } + if props.Part.ID != tt.wantID { + t.Errorf("expected ID %q, got %q", tt.wantID, props.Part.ID) + } + }) + } +} + +func TestParsePermissionAsked(t *testing.T) { + input := `{"id":"perm-123","sessionID":"sess-456","permission":"bash","patterns":["npm run *"],"metadata":{"command":"npm run test"},"tool":{"callID":"call-789"}}` + + props, err := ParsePermissionAsked(json.RawMessage(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if props.ID != "perm-123" { + t.Errorf("expected ID 'perm-123', got %s", props.ID) + } + if props.SessionID != "sess-456" { + t.Errorf("expected sessionID 'sess-456', got %s", props.SessionID) + } + if props.Permission != "bash" { + t.Errorf("expected permission 'bash', got %s", props.Permission) + } + if len(props.Patterns) != 1 || props.Patterns[0] != "npm run *" { + t.Errorf("expected patterns [npm run *], got %v", props.Patterns) + } + if props.Tool == nil || props.Tool.CallID != "call-789" { + t.Error("expected tool with callID 'call-789'") + } + if cmd, ok := props.Metadata["command"].(string); !ok || cmd != "npm run test" { + t.Errorf("expected metadata command 'npm run test', got %v", props.Metadata) + } +} + +func TestParseSessionIdle(t *testing.T) { + input := `{"sessionID":"sess-123"}` + + props, err := ParseSessionIdle(json.RawMessage(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if props.SessionID != "sess-123" { + t.Errorf("expected sessionID 'sess-123', got %s", props.SessionID) + } +} + +func TestParseSessionError(t *testing.T) { + tests := []struct { + name string + input string + wantKind string + wantMessage string + }{ + { + name: "error with name and data.message", + input: `{"sessionID":"sess-1","error":{"name":"ProviderAuthError","data":{"message":"API key invalid"}}}`, + wantKind: "ProviderAuthError", + wantMessage: "API key invalid", + }, + { + name: "error with type and message", + input: `{"sessionID":"sess-1","error":{"type":"RateLimitError","message":"Rate limit exceeded"}}`, + wantKind: "RateLimitError", + wantMessage: "Rate limit exceeded", + }, + { + name: "no error", + input: `{"sessionID":"sess-1"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + props, err := ParseSessionError(json.RawMessage(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantKind == "" { + if props.Error != nil { + t.Error("expected no error, but got one") + } + return + } + + if props.Error == nil { + t.Fatal("expected error, got nil") + } + + if props.Error.GetKind() != tt.wantKind { + t.Errorf("expected kind %s, got %s", tt.wantKind, props.Error.GetKind()) + } + if props.Error.GetMessage() != tt.wantMessage { + t.Errorf("expected message %s, got %s", tt.wantMessage, props.Error.GetMessage()) + } + }) + } +} + +func TestSDKError_GetKind(t *testing.T) { + tests := []struct { + name string + err SDKError + wantKind string + }{ + { + name: "name takes precedence", + err: SDKError{Name: "AuthError", Type: "SomeType"}, + wantKind: "AuthError", + }, + { + name: "falls back to type", + err: SDKError{Type: "SomeType"}, + wantKind: "SomeType", + }, + { + name: "returns unknown", + err: SDKError{}, + wantKind: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.err.GetKind(); got != tt.wantKind { + t.Errorf("expected kind %s, got %s", tt.wantKind, got) + } + }) + } +} + +func TestSDKError_GetMessage(t *testing.T) { + tests := []struct { + name string + err SDKError + wantMessage string + }{ + { + name: "data.message takes precedence", + err: SDKError{ + Message: "outer message", + Data: &struct{ Message string `json:"message,omitempty"` }{Message: "inner message"}, + }, + wantMessage: "inner message", + }, + { + name: "falls back to message", + err: SDKError{Message: "outer message"}, + wantMessage: "outer message", + }, + { + name: "returns empty", + err: SDKError{}, + wantMessage: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.err.GetMessage(); got != tt.wantMessage { + t.Errorf("expected message %s, got %s", tt.wantMessage, got) + } + }) + } +} + +func TestToolStateUpdate_Statuses(t *testing.T) { + tests := []struct { + status string + }{ + {ToolStatusPending}, + {ToolStatusRunning}, + {ToolStatusCompleted}, + {ToolStatusError}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + state := ToolStateUpdate{Status: tt.status} + if state.Status != tt.status { + t.Errorf("expected status %s, got %s", tt.status, state.Status) + } + }) + } +} + +func TestExecutorEvent_JSON(t *testing.T) { + event := ExecutorEvent{ + Type: EventTypeSessionStart, + SessionID: "sess-123", + } + + data, err := json.Marshal(event) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var parsed ExecutorEvent + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if parsed.Type != event.Type { + t.Errorf("expected type %s, got %s", event.Type, parsed.Type) + } + if parsed.SessionID != event.SessionID { + t.Errorf("expected sessionID %s, got %s", event.SessionID, parsed.SessionID) + } +} diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx index f0408be210..0f37117726 100644 --- a/apps/web/components/task/chat/messages/tool-call-message.tsx +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -9,6 +9,7 @@ import { IconEdit, IconEye, IconFile, + IconLoader2, IconSearch, IconTerminal2, IconX, @@ -73,6 +74,8 @@ function getStatusIcon(status?: string, permissionStatus?: string) { case 'error': return ; case 'running': + case 'in_progress': + return ; default: return null; } diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index a35779e92d..12d21314d0 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -6,7 +6,6 @@ import { IconArrowLeft, IconCopy, IconDots, - IconFolderOpen, IconGitBranch, IconGitMerge, IconGitPullRequest, diff --git a/apps/web/hooks/domains/session/use-cumulative-diff.ts b/apps/web/hooks/domains/session/use-cumulative-diff.ts index 6e791a02da..8a65a86cd2 100644 --- a/apps/web/hooks/domains/session/use-cumulative-diff.ts +++ b/apps/web/hooks/domains/session/use-cumulative-diff.ts @@ -1,5 +1,4 @@ import { useEffect, useCallback, useState } from 'react'; -import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; import type { CumulativeDiff } from '@/lib/state/slices/session-runtime/types';