From 122094fa5d289088787f865d8898798cca03caa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 21 Feb 2026 16:27:26 +0000 Subject: [PATCH] feat: e2e tests --- apps/backend/Makefile | 10 +- apps/backend/docs/add_agent.md | 497 ---------- apps/backend/docs/debugging_normalization.md | 325 ------- apps/backend/docs/opencode_gemini_plan.md | 863 ------------------ apps/backend/internal/agent/agents/codex.go | 10 +- .../agentctl/server/adapter/adapter.go | 1 + .../agentctl/server/adapter/amp_adapter.go | 110 ++- .../agentctl/server/adapter/e2e/amp_test.go | 30 + .../agentctl/server/adapter/e2e/assertions.go | 122 +++ .../server/adapter/e2e/auggie_test.go | 32 + .../server/adapter/e2e/claude_code_test.go | 68 ++ .../agentctl/server/adapter/e2e/codex_test.go | 32 + .../server/adapter/e2e/copilot_test.go | 32 + .../server/adapter/e2e/gemini_test.go | 32 + .../agentctl/server/adapter/e2e/harness.go | 329 +++++++ .../server/adapter/e2e/mock_agent_test.go | 29 + .../server/adapter/e2e/opencode_test.go | 32 + apps/backend/pkg/amp/client.go | 26 +- docs/test_e2e.md | 166 ++++ 19 files changed, 1034 insertions(+), 1712 deletions(-) delete mode 100644 apps/backend/docs/add_agent.md delete mode 100644 apps/backend/docs/debugging_normalization.md delete mode 100644 apps/backend/docs/opencode_gemini_plan.md create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/amp_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/assertions.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/claude_code_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/codex_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/copilot_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/harness.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/mock_agent_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/e2e/opencode_test.go create mode 100644 docs/test_e2e.md diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 0865d45fb4..8d996403fe 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -25,7 +25,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-mock-agent clean run dev test lint fmt vet help +.PHONY: all build build-all build-agentctl build-mock-agent clean run dev test test-e2e lint fmt vet help ## Default target all: build @@ -74,6 +74,13 @@ test: @echo "Running tests..." $(GO) test ./... +## Run E2E tests (real agents, costs money — manual only) +## Set agent commands via env vars: E2E_CLAUDE_CODE_COMMAND, E2E_AMP_COMMAND +## Enable debug: KANDEV_DEBUG_AGENT_MESSAGES=true KANDEV_DEBUG_LOG_DIR=/tmp/e2e-debug +test-e2e: + @echo "Running E2E adapter tests..." + $(GO) test -tags e2e -v -timeout 10m ./internal/agentctl/server/adapter/e2e/ + ## Run tests with coverage test-coverage: @echo "Running tests with coverage..." @@ -130,6 +137,7 @@ help: @echo " run Build and run the application" @echo " dev Run in development mode with a local database" @echo " test Run tests" + @echo " test-e2e Run E2E adapter tests (real agents, costs money)" @echo " test-coverage Run tests with coverage report" @echo " lint Run golangci-lint" @echo " fmt Format code" diff --git a/apps/backend/docs/add_agent.md b/apps/backend/docs/add_agent.md deleted file mode 100644 index 848ab95e9b..0000000000 --- a/apps/backend/docs/add_agent.md +++ /dev/null @@ -1,497 +0,0 @@ -# Adding New Agents to Kandev - -This guide explains how to add support for new AI coding agents (like Gemini, OpenCode, Amp, etc.) to the Kandev backend. - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Agent Architecture │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ agents.json Registry Adapter Factory │ -│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ -│ │ Agent │───────▶│ Registry │─────────▶│ NewAdapter() │ │ -│ │ Config │ │ │ │ │ │ -│ └──────────┘ └──────────┘ └──────┬───────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Protocol Adapters │ │ -│ ├─────────────┬─────────────┬─────────────┬─────────────┐ │ │ -│ │ ACP Adapter │Codex Adapter│Claude Adapter│ New Adapter │ │ │ -│ │ (JSON-RPC) │ (JSON-RPC) │(stream-json) │ (???) │ │ │ -│ └─────────────┴─────────────┴─────────────┴─────────────┘ │ │ -│ │ │ │ -│ ▼ │ │ -│ AgentEvent (normalized) │ │ -│ │ │ │ -│ ▼ │ │ -│ Lifecycle Manager │ │ -│ │ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -All agents communicate through a **protocol adapter** that normalizes their output into `AgentEvent` structs. This allows the rest of the system to work uniformly regardless of the underlying agent. - -## Files to Modify/Create - -### 1. Protocol Definition -**File:** `pkg/agent/protocol.go` - -Add a new protocol constant: - -```go -const ( - ProtocolACP Protocol = "acp" - ProtocolCodex Protocol = "codex" - ProtocolClaudeCode Protocol = "claude-code" - ProtocolGemini Protocol = "gemini" // New - ProtocolOpenCode Protocol = "opencode" // New -) - -func (p Protocol) IsValid() bool { - switch p { - case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, - ProtocolClaudeCode, ProtocolGemini, ProtocolOpenCode: - return true - } - return false -} -``` - -### 2. Protocol Types (if needed) -**File:** `pkg//types.go` - -If the agent uses a custom protocol, create a types package: - -```go -package gemini - -// Message types specific to this agent's protocol -type CLIMessage struct { - Type string `json:"type"` - // ... agent-specific fields -} - -// Request/response types for control flow -type ControlRequest struct { - // ... -} -``` - -### 3. Protocol Client (if needed) -**File:** `pkg//client.go` - -For agents with custom protocols over stdin/stdout: - -```go -package gemini - -type Client struct { - stdin io.Writer - stdout io.Reader - // ... -} - -func NewClient(stdin io.Writer, stdout io.Reader) *Client -func (c *Client) Start(ctx context.Context) -func (c *Client) SendMessage(msg *Message) error -func (c *Client) SetMessageHandler(handler MessageHandler) -``` - -### 4. Protocol Adapter -**File:** `internal/agentctl/server/adapter/_adapter.go` - -Implement the `AgentAdapter` interface: - -```go -package adapter - -type GeminiAdapter struct { - cfg *Config - logger *logger.Logger - stdin io.Writer - stdout io.Reader - client *gemini.Client - updatesCh chan AgentEvent - // ... -} - -// Required interface methods: -func (a *GeminiAdapter) PrepareEnvironment() error -func (a *GeminiAdapter) Connect(stdin io.Writer, stdout io.Reader) error -func (a *GeminiAdapter) Initialize(ctx context.Context) error -func (a *GeminiAdapter) Prompt(ctx context.Context, message string) error -func (a *GeminiAdapter) Cancel(ctx context.Context) error -func (a *GeminiAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) -func (a *GeminiAdapter) LoadSession(ctx context.Context, sessionID string) error -func (a *GeminiAdapter) Updates() <-chan AgentEvent -func (a *GeminiAdapter) Close() error -func (a *GeminiAdapter) GetAgentInfo() *AgentInfo -func (a *GeminiAdapter) SetPermissionHandler(handler PermissionHandler) -func (a *GeminiAdapter) RespondToPermission(ctx context.Context, requestID string, response *PermissionResponse) error -``` - -### 5. Adapter Factory -**File:** `internal/agentctl/server/adapter/factory.go` - -Register the new adapter: - -```go -func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (AgentAdapter, error) { - switch protocol { - case agent.ProtocolACP: - return NewACPAdapter(cfg, log), nil - case agent.ProtocolCodex: - return NewCodexAdapter(cfg, log), nil - case agent.ProtocolClaudeCode: - return NewClaudeCodeAdapter(cfg, log), nil - case agent.ProtocolGemini: - return NewGeminiAdapter(cfg, log), nil // New - default: - return nil, fmt.Errorf("unsupported protocol: %s", protocol) - } -} -``` - -### 6. Registry Protocol Parser -**File:** `internal/agent/registry/registry.go` - -Add protocol string parsing: - -```go -func parseProtocol(p string) agent.Protocol { - switch p { - case "acp": - return agent.ProtocolACP - case "codex": - return agent.ProtocolCodex - case "claude-code": - return agent.ProtocolClaudeCode - case "gemini": - return agent.ProtocolGemini // New - default: - return agent.ProtocolACP - } -} -``` - -### 7. Agent Configuration -**File:** `internal/agent/registry/agents.json` - -Add the agent definition: - -```json -{ - "id": "gemini", - "name": "Google Gemini Agent", - "display_name": "Gemini", - "description": "Google Gemini CLI-powered coding agent.", - "enabled": true, - "image": "", - "tag": "", - "cmd": [ - "gemini-cli", - "--output-format=json", - "--non-interactive" - ], - "working_dir": "{workspace}", - "required_env": ["GOOGLE_API_KEY"], - "env": {}, - "mounts": [], - "resource_limits": { - "memory_mb": 4096, - "cpu_cores": 2.0, - "timeout_seconds": 3600 - }, - "capabilities": [ - "code_generation", - "code_review", - "shell_execution" - ], - "protocol": "gemini", - "protocol_config": {}, - "model_flag": "--model {model}", - "workspace_flag": "--workspace", - "session_config": { - "native_session_resume": false, - "resume_flag": "--resume", - "can_recover": true, - "session_dir_template": "{home}/.gemini/sessions", - "session_dir_target": "" - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": ["shell", "write_file", "edit_file"] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": false, - "label": "Auto-approve", - "description": "Automatically approve tool calls", - "apply_method": "cli_flag", - "cli_flag": "--auto-approve" - } - }, - "discovery": { - "supports_mcp": false, - "mcp_config_path": { "linux": [], "windows": [], "macos": [] }, - "installation_path": { - "linux": ["~/.gemini/config.json"], - "windows": ["~/.gemini/config.json"], - "macos": ["~/.gemini/config.json"] - }, - "discovery_capabilities": { - "supports_session_resume": true, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "gemini-2.0-flash", - "available_models": [ - { - "id": "gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "description": "Fast and efficient model", - "provider": "google", - "context_window": 1000000, - "is_default": true - }, - { - "id": "gemini-2.0-pro", - "name": "Gemini 2.0 Pro", - "description": "Most capable model", - "provider": "google", - "context_window": 2000000, - "is_default": false - } - ] - } -} -``` - -## Key Considerations - -### Execution Modes - -Agents can run in two modes: - -1. **Docker-based**: Set `image` and `tag` fields. The agent runs in a container. -2. **Standalone**: Set `cmd` array, leave `image` empty. The agent runs directly on the host. - -The validation in `registry.go` requires either an `image` OR a `cmd`: -```go -if config.Image == "" && len(config.Cmd) == 0 { - return fmt.Errorf("agent type requires either image (Docker) or cmd (standalone)") -} -``` - -### Event Types - -All adapters must emit normalized `AgentEvent` structs. Key event types: - -| Event Type | Purpose | -|------------|---------| -| `message_chunk` | Streaming text from the agent | -| `reasoning` | Chain-of-thought/thinking content | -| `tool_call` | Agent is invoking a tool | -| `tool_update` | Tool execution result | -| `complete` | Agent turn finished (triggers state change to REVIEW) | -| `error` | Error occurred | -| `permission_request` | Agent needs permission for an action | -| `session_status` | Session state update (NOT turn completion) | -| `context_window` | Token usage information | - -**Important:** Only emit `complete` when the agent truly finishes a turn. Don't emit it for initialization messages. - -### Tool Call Events - -When emitting tool calls, set both `ToolName` and `ToolTitle`: - -```go -a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolID, - ToolName: "Bash", // Machine-readable name - ToolTitle: "ls -la /tmp", // Human-readable title for UI - ToolArgs: args, - ToolStatus: "running", -}) -``` - -### Permission Handling - -If the agent requires permission for certain actions: - -1. Set `tools_requiring_permission` in the agent config -2. Implement `SetPermissionHandler` and `RespondToPermission` in the adapter -3. Emit `permission_request` events when the agent needs approval: - -```go -a.sendUpdate(AgentEvent{ - Type: EventTypePermissionRequest, - SessionID: sessionID, - PendingID: requestID, - ToolCallID: toolID, - PermissionTitle: "Execute: rm -rf /tmp/test", - PermissionOptions: []PermissionOption{ - {ID: "allow", Label: "Allow"}, - {ID: "deny", Label: "Deny"}, - }, - ActionType: "shell", - ActionDetails: map[string]any{"command": "rm -rf /tmp/test"}, -}) -``` - -### Session Management - -Configure session handling in `session_config`: - -- `native_session_resume`: If true, sessions resume via the adapter's native session loading (works across protocols). If false, use CLI flag or context injection. -- `resume_flag`: CLI flag for resuming (e.g., `--resume`) -- `can_recover`: Whether sessions can survive backend restarts -- `session_dir_template`: Where session data is stored on host -- `session_dir_target`: Mount path inside container (if Docker-based) - -**Session ID Tracking for Stream-Based Protocols:** - -For agents that report their session ID via the stream (like Claude Code), you must: - -1. Capture the session ID from the protocol message (e.g., `system` message in Claude Code) -2. Emit a `session_status` event with the session ID in `SessionID` field -3. The orchestrator will automatically store this as the resume token - -Example from Claude Code adapter: -```go -func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { - // Update session ID if provided - if msg.SessionID != "" { - a.mu.Lock() - a.sessionID = msg.SessionID - a.mu.Unlock() - } - - // Send session status event - this triggers resume token storage - a.sendUpdate(AgentEvent{ - Type: EventTypeSessionStatus, - SessionID: msg.SessionID, // Agent's native session ID - Data: map[string]any{ - "session_status": msg.SessionStatus, - "init": true, - }, - }) -} -``` - -The `SessionID` field in the event is mapped to `ACPSessionID` in the event payload, which the orchestrator uses to store the resume token. - -### Model Configuration - -The `model_flag` field defines how to pass the model to the CLI: - -```json -"model_flag": "--model {model}" // Results in: --model gemini-2.0-flash -"model_flag": "-c model=\"{model}\"" // Results in: -c model="gemini-2.0-flash" -``` - -The `{model}` placeholder is replaced with the selected model ID. - -### Discovery - -The discovery system detects installed agents by checking for files: - -```json -"installation_path": { - "linux": ["~/.gemini/config.json"], - "windows": ["~/.gemini/config.json"], - "macos": ["~/.gemini/config.json"] -} -``` - -When a file exists at any of these paths, the agent is considered "available" and a default profile is auto-created. - -### Context Window Tracking - -Track token usage and emit context window events: - -```go -a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, - SessionID: sessionID, - ContextWindowSize: 200000, - ContextWindowUsed: 15000, - ContextWindowRemaining: 185000, - ContextEfficiency: 7.5, // percentage -}) -``` - -## Testing Checklist - -Before releasing a new agent integration: - -- [ ] Agent appears in settings when installed (discovery works) -- [ ] Default profile created with correct model -- [ ] Agent starts successfully with a simple prompt -- [ ] Streaming text appears in real-time -- [ ] Tool calls display with proper titles -- [ ] Tool results are shown correctly -- [ ] Permission requests work (if applicable) -- [ ] Session stays IN_PROGRESS while agent is working -- [ ] Session moves to REVIEW when agent finishes -- [ ] Follow-up prompts work in same session -- [ ] Session resume works after stopping agent -- [ ] Context window tracking shows accurate usage -- [ ] Errors are properly displayed to user -- [ ] Model switching works - -## Example: Existing Adapters - -### ACP Adapter (`acp_adapter.go`) -- Used by: Auggie -- Protocol: JSON-RPC over stdin/stdout -- Features: Full ACP protocol support, MCP server integration - -### Codex Adapter (`codex_adapter.go`) -- Used by: OpenAI Codex -- Protocol: Custom JSON-RPC (codex app-server) -- Features: Auto-approval via ACP, session resume - -### Claude Code Adapter (`claude_code_adapter.go`) -- Used by: Claude Code CLI -- Protocol: stream-json over stdin/stdout -- Features: Permission prompts via stdio, thinking/reasoning support - -## Troubleshooting - -### Agent not appearing in settings -- Check `installation_path` paths exist on system -- Verify `enabled: true` in agents.json -- Check backend logs for discovery errors - -### Protocol not recognized (falls back to ACP) -- Add protocol to `parseProtocol()` in `registry.go` -- Add to `Protocol.IsValid()` in `pkg/agent/protocol.go` - -### Session moves to REVIEW too quickly -- Don't emit `EventTypeComplete` for initialization messages -- Only emit `complete` when agent truly finishes turn - -### Tool titles not showing -- Set both `ToolName` and `ToolTitle` in tool_call events -- `ToolTitle` is the human-readable display text - -### Model flag not working -- Ensure `model_flag` includes `{model}` placeholder -- Check profile has model set (not empty) - -### Session resume not working -- For stream-based protocols: ensure adapter emits `session_status` event with agent's native session ID -- Check that `resume_flag` is set in agent config (e.g., `"--resume"`) -- Verify `native_session_resume` is `false` for CLI-based resume -- Check `executor_running` table has the correct `resume_token` value -- For Claude Code: the session ID from `system` message should be stored as resume token diff --git a/apps/backend/docs/debugging_normalization.md b/apps/backend/docs/debugging_normalization.md deleted file mode 100644 index 7fca9a0e62..0000000000 --- a/apps/backend/docs/debugging_normalization.md +++ /dev/null @@ -1,325 +0,0 @@ -# Debugging Tool Normalization Issues - -This guide explains how to debug and fix issues where tool calls from agents are not rendering correctly in the frontend (e.g., a shell command showing as generic `tool_call` instead of `tool_execute`). - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Tool Call Normalization Flow │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Agent CLI Adapter Lifecycle Orchestrator │ -│ ┌────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │ -│ │ Codex │──JSON──│ Codex │──Event──│ Manager │──Event──│ Event │ │ -│ │ Claude │ │ Adapter │ │ │ │ Handlers │ │ -│ │ etc. │ └────┬─────┘ └────┬────┘ └─────┬──────┘ │ -│ └────────┘ │ │ │ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ NormalizedPayload AgentEvent Message DB │ -│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ kind: ... │ ───▶ │Normalized│ ──────▶│type: ... │ │ -│ │ shell_exec │ │Payload │ │tool_exec │ │ -│ └─────────────┘ └──────────┘ └────┬─────┘ │ -│ │ │ -│ ▼ │ -│ WebSocket Event │ -│ ┌──────────────┐ │ -│ │type:tool_exec│ │ -│ └──────┬───────┘ │ -│ │ │ -│ ▼ │ -│ Frontend │ -│ ┌──────────────┐ │ -│ │ToolExecute │ │ -│ │ Message.tsx │ │ -│ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -## Key Files - -| Layer | File | Purpose | -|-------|------|---------| -| Types | `internal/agentctl/types/streams/tool_payload.go` | `NormalizedPayload` struct and factory functions | -| Codex Adapter | `internal/agentctl/server/adapter/transport/codex/adapter.go` | Converts Codex events to `AgentEvent` | -| Codex Normalizer | `internal/agentctl/server/adapter/transport/codex/normalize.go` | Creates `NormalizedPayload` for Codex tools | -| StreamJSON Adapter | `internal/agentctl/server/adapter/transport/streamjson/adapter.go` | Converts Claude Code events to `AgentEvent` | -| StreamJSON Normalizer | `internal/agentctl/server/adapter/transport/streamjson/normalize.go` | Creates `NormalizedPayload` for Claude tools | -| OpenCode Normalizer | `internal/agentctl/server/adapter/transport/opencode/normalize.go` | Creates `NormalizedPayload` for OpenCode tools | -| Lifecycle Events | `internal/agent/lifecycle/events.go` | Publishes `AgentStreamEvent` to event bus | -| Orchestrator Handlers | `internal/orchestrator/event_handlers.go` | Handles events and creates DB messages | -| Message Creator | `cmd/kandev/adapters.go` | `toolKindToMessageType()` maps kind to message type | -| Frontend Renderer | `apps/web/components/task/chat/message-renderer.tsx` | Routes message types to components | - -## Tool Kind to Message Type Mapping - -The `NormalizedPayload.Kind()` determines which frontend component renders the tool: - -| Tool Kind | Message Type | Frontend Component | -|-----------|-------------|-------------------| -| `read_file` | `tool_read` | `ToolReadMessage` | -| `code_search` | `tool_search` | `ToolSearchMessage` | -| `modify_file` | `tool_edit` | `ToolEditMessage` | -| `shell_exec` | `tool_execute` | `ToolExecuteMessage` | -| `manage_todos` | `todo` | `TodoMessage` | -| `subagent_task` | `tool_call` | `ToolSubagentMessage` | -| `generic` | `tool_call` | `ToolCallMessage` | -| (nil/unknown) | `tool_call` | `ToolCallMessage` | - -## Debugging Steps - -### 1. Check Raw Events - -Raw events are logged to `raw--.jsonl` files in the backend directory: - -```bash -# View raw Codex events -tail -f raw-codex-codex.jsonl | jq . - -# View raw Claude events -tail -f raw-streamjson-claude.jsonl | jq . -``` - -### 2. Check Normalized Events - -Normalized events show what the adapter produced: - -```bash -# View normalized events -tail -f normalized-codex-codex.jsonl | jq . -``` - -Look for the `normalized` field in `tool_call` events: -```json -{ - "event": { - "type": "tool_call", - "normalized": { - "kind": "shell_exec", - "shell_exec": { - "command": "ls -la", - "work_dir": "/path/to/dir" - } - } - } -} -``` - -If `normalized` is missing or `kind` is wrong, the issue is in the adapter. - -### 3. Check Database Message Type - -Query the database to see what message type was stored: - -```sql -SELECT id, type, metadata->>'normalized' as normalized -FROM task_session_messages -WHERE metadata->>'tool_call_id' = '' -ORDER BY created_at DESC LIMIT 1; -``` - -If the type is `tool_call` but should be `tool_execute`, the issue is in the message creation flow. - -### 4. Check WebSocket Events - -Use browser DevTools Network tab to inspect WebSocket messages. Look for `session.message.added` events and check the `type` field. - -## Common Issues - -### Issue 1: Missing NormalizedPayload in tool_update Events - -**Symptom:** Tool calls render as generic `tool_call` instead of specialized type. - -**Cause:** The adapter sends `tool_update` events (e.g., on completion) without the `NormalizedPayload`. When there's a race condition and the message doesn't exist yet, the fallback creation uses `tool_call` as default. - -**Example (Codex):** -```go -// BAD - tool_update without normalized payload -update := AgentEvent{ - Type: streams.EventTypeToolUpdate, - ToolCallID: p.Item.ID, - ToolStatus: "complete", -} - -// GOOD - tool_update with normalized payload -update := AgentEvent{ - Type: streams.EventTypeToolUpdate, - ToolCallID: p.Item.ID, - ToolStatus: "complete", - NormalizedPayload: a.normalizer.NormalizeToolCall("commandExecution", args), -} -``` - -**Fix Location:** Adapter's notification handler (e.g., `NotifyItemCompleted` in Codex adapter). - -### Issue 2: Wrong Tool Name Passed to Normalizer - -**Symptom:** Tool renders as `tool_call` (generic) instead of specialized type. - -**Cause:** The normalizer's switch statement doesn't match the tool name. - -**Example:** -```go -// Normalizer expects "commandExecution" but receives "command_execution" -switch toolName { -case CodexItemCommandExecution: // "commandExecution" - return n.normalizeCommand(args) -default: - return n.normalizeGeneric(toolName, args) // Falls through here -} -``` - -**Fix:** Check the exact tool name string in the protocol and match it in the normalizer. - -### Issue 3: Factory Function Not Setting Kind - -**Symptom:** `normalized.Kind()` returns empty or wrong value. - -**Cause:** Factory function doesn't set the `kind` field correctly. - -**Example:** -```go -// BAD - missing kind -func NewShellExec(...) *NormalizedPayload { - return &NormalizedPayload{ - shellExec: &ShellExecPayload{...}, - } -} - -// GOOD - kind is set -func NewShellExec(...) *NormalizedPayload { - return &NormalizedPayload{ - kind: ToolKindShellExec, - shellExec: &ShellExecPayload{...}, - } -} -``` - -**Fix Location:** `internal/agentctl/types/streams/tool_payload.go` - -### Issue 4: toolKindToMessageType Not Handling New Kind - -**Symptom:** New tool kind renders as generic `tool_call`. - -**Cause:** The `toolKindToMessageType` function doesn't have a case for the new kind. - -**Fix Location:** Two places need updating: -1. `internal/orchestrator/event_handlers.go` - `toolKindToMessageType()` -2. `cmd/kandev/adapters.go` - `toolKindToMessageType()` - -### Issue 5: Missing UnmarshalJSON for NormalizedPayload - -**Symptom:** All tool calls render as `tool_call` even though adapter logs show correct `kind`. - -**Cause:** The `NormalizedPayload` struct has unexported fields. Without a custom `UnmarshalJSON` method, the standard library cannot populate these fields when deserializing events from WebSocket/JSON. - -**Example:** -```go -// NormalizedPayload with unexported fields requires both methods: -func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { ... } -func (p *NormalizedPayload) UnmarshalJSON(data []byte) error { ... } // REQUIRED! -``` - -**Fix Location:** `internal/agentctl/types/streams/tool_payload.go` - -**How to verify:** Run the round-trip test: -```bash -go test ./internal/agentctl/types/streams/... -v -run MarshalRoundTrip -``` - -## Adding a New Tool Kind - -1. **Add kind constant** in `internal/agentctl/types/streams/tool_payload.go`: - ```go - const ( - ToolKindMyNewTool ToolKind = "my_new_tool" - ) - ``` - -2. **Add payload struct** with fields: - ```go - type MyNewToolPayload struct { - SomeField string `json:"some_field"` - } - ``` - -3. **Add unexported field** to `NormalizedPayload`: - ```go - type NormalizedPayload struct { - kind ToolKind - myNewTool *MyNewToolPayload - // ... - } - ``` - -4. **Add getter method**: - ```go - func (p *NormalizedPayload) MyNewTool() *MyNewToolPayload { return p.myNewTool } - ``` - -5. **Add to MarshalJSON**: - ```go - type jsonPayload struct { - MyNewTool *MyNewToolPayload `json:"my_new_tool,omitempty"` - // ... - } - ``` - -6. **Add factory function**: - ```go - func NewMyNewTool(someField string) *NormalizedPayload { - return &NormalizedPayload{ - kind: ToolKindMyNewTool, - myNewTool: &MyNewToolPayload{ - SomeField: someField, - }, - } - } - ``` - -7. **Update normalizers** in each adapter to call the factory function. - -8. **Update toolKindToMessageType** in both locations: - ```go - case streams.ToolKindMyNewTool: - return "tool_my_new" - ``` - -9. **Add frontend component** and update `message-renderer.tsx`: - ```tsx - { - matches: (comment) => comment.type === 'tool_my_new', - render: (comment) => , - }, - ``` - -## Testing - -### Unit Tests - -Run normalizer tests: -```bash -go test ./internal/agentctl/server/adapter/transport/streamjson/... -v -go test ./internal/agentctl/server/adapter/transport/codex/... -v -``` - -### Integration Testing - -1. Start a session with the agent -2. Trigger the tool you're testing -3. Check normalized events file -4. Check database message type -5. Verify frontend renders correct component - -## Checklist for Fixing Normalization Issues - -- [ ] Check raw events to confirm agent sends expected data -- [ ] Check normalized events to confirm adapter produces correct payload -- [ ] Verify `NormalizedPayload.Kind()` returns correct kind -- [ ] Verify `tool_call` event includes `NormalizedPayload` -- [ ] Verify `tool_update` event includes `NormalizedPayload` (for race condition handling) -- [ ] Check `toolKindToMessageType` handles the kind in both files -- [ ] Verify database stores correct message type -- [ ] Verify frontend `message-renderer.tsx` has matching adapter diff --git a/apps/backend/docs/opencode_gemini_plan.md b/apps/backend/docs/opencode_gemini_plan.md deleted file mode 100644 index ee4c2fa27d..0000000000 --- a/apps/backend/docs/opencode_gemini_plan.md +++ /dev/null @@ -1,863 +0,0 @@ -# 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": { - "native_session_resume": 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": { - "native_session_resume": 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/agents/codex.go b/apps/backend/internal/agent/agents/codex.go index 1df7fb81f8..d88497c983 100644 --- a/apps/backend/internal/agent/agents/codex.go +++ b/apps/backend/internal/agent/agents/codex.go @@ -73,7 +73,7 @@ func (a *Codex) ListModels(ctx context.Context) (*ModelList, error) { } func (a *Codex) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", "@openai/codex@0.98.0", "app-server"). + return Cmd("npx", "-y", "@openai/codex@0.104.0", "app-server"). Model(NewParam("-c", "model=\"{model}\""), opts.Model). Settings(codexPermSettings, opts.PermissionValues). Build() @@ -84,7 +84,7 @@ func (a *Codex) Runtime() *RuntimeConfig { return &RuntimeConfig{ Image: "kandev/multi-agent", Tag: "latest", - Cmd: Cmd("npx", "-y", "@openai/codex@0.98.0", "app-server").Build(), + Cmd: Cmd("npx", "-y", "@openai/codex@0.104.0", "app-server").Build(), WorkingDir: "/workspace", Env: map[string]string{}, Mounts: []MountTemplate{ @@ -106,8 +106,10 @@ func (a *Codex) PermissionSettings() map[string]PermissionSetting { } var codexPermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Skip approval requests from Codex", - ApplyMethod: "acp", CLIFlag: "--full-auto"}, + "auto_approve": { + Supported: true, Default: true, Label: "Auto-approve", Description: "Skip approval requests from Codex", + ApplyMethod: "acp", CLIFlag: "--full-auto", + }, } func codexStaticModels() []Model { diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 4e12de18ab..15b68b8e9f 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -48,6 +48,7 @@ const ( EventTypePermissionCancelled = streams.EventTypePermissionCancelled EventTypeContextWindow = streams.EventTypeContextWindow EventTypeSessionStatus = streams.EventTypeSessionStatus + EventTypeRateLimit = streams.EventTypeRateLimit ) // StderrProvider provides access to recent stderr output for error context. diff --git a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go index cfb5b67208..0e8a03aeb3 100644 --- a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go @@ -73,6 +73,9 @@ type AmpAdapter struct { // (to avoid duplicates from both stop_reason=end_turn and result message) completeSent bool + // Track whether text was streamed this turn to prevent duplicates from result.text + streamingTextSentThisTurn bool + // lastRawData holds the raw JSON of the current message being processed. // Set in handleMessage before dispatch; used by sendUpdate for OTel tracing. lastRawData json.RawMessage @@ -195,7 +198,8 @@ func (a *AmpAdapter) Prompt(ctx context.Context, message string, _ []v1.MessageA operationID := uuid.New().String() a.operationID = operationID a.resultCh = make(chan resultComplete, 1) - a.completeSent = false // Reset completion flag for new operation + a.completeSent = false // Reset completion flag for new operation + a.streamingTextSentThisTurn = false // Reset text dedup flag for new prompt a.mu.Unlock() if a.client == nil { @@ -348,6 +352,9 @@ func (a *AmpAdapter) handleMessage(msg *amp.Message) { case amp.MessageTypeResult: a.handleResultMessage(msg, sessionID, operationID) + case amp.MessageTypeRateLimit: + a.handleRateLimitMessage(msg, sessionID, operationID) + default: a.logger.Debug("unhandled message type", zap.String("type", msg.Type)) } @@ -447,13 +454,13 @@ func (a *AmpAdapter) trackUsageAndEmitContextWindow(usage *amp.TokenUsage, sessi } // processAssistantContentBlock processes a single content block from an assistant message. -func (a *AmpAdapter) processAssistantContentBlock(block *amp.ContentBlock, sessionID, operationID string) { +func (a *AmpAdapter) processAssistantContentBlock(block *amp.ContentBlock, sessionID, operationID, parentToolUseID string) { switch block.Type { case amp.ContentTypeText: if block.Text != "" { - // Accumulate text locally to include in complete event - // This bypasses the race condition with the manager's buffer + // Mark that text was streamed this turn (prevents duplicate from result.text) a.mu.Lock() + a.streamingTextSentThisTurn = true a.textAccumulator.WriteString(block.Text) a.mu.Unlock() @@ -476,7 +483,7 @@ func (a *AmpAdapter) processAssistantContentBlock(block *amp.ContentBlock, sessi } case amp.ContentTypeToolUse: - a.handleToolUse(block, sessionID, operationID) + a.handleToolUse(block, sessionID, operationID, parentToolUseID) case amp.ContentTypeToolResult: a.handleToolResult(block, sessionID, operationID) @@ -489,6 +496,9 @@ func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operati return } + // Extract parent tool use ID for subagent nesting + parentToolUseID := msg.ParentToolUseID + // Log content block types for debugging blockTypes := make([]string, 0, len(msg.Message.Content)) for _, block := range msg.Message.Content { @@ -496,14 +506,15 @@ func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operati } a.logger.Debug("processing assistant message", zap.Int("num_blocks", len(msg.Message.Content)), - zap.Strings("block_types", blockTypes)) + zap.Strings("block_types", blockTypes), + zap.String("parent_tool_use_id", parentToolUseID)) // Update agent version and track main model a.updateMainModel(msg.Message.Model) // Process content blocks for i := range msg.Message.Content { - a.processAssistantContentBlock(&msg.Message.Content[i], sessionID, operationID) + a.processAssistantContentBlock(&msg.Message.Content[i], sessionID, operationID, parentToolUseID) } // Calculate and emit token usage @@ -517,7 +528,7 @@ func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operati } // handleToolUse processes a tool_use content block. -func (a *AmpAdapter) handleToolUse(block *amp.ContentBlock, sessionID, operationID string) { +func (a *AmpAdapter) handleToolUse(block *amp.ContentBlock, sessionID, operationID, parentToolUseID string) { // Generate normalized payload using the normalizer normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) @@ -552,6 +563,7 @@ func (a *AmpAdapter) handleToolUse(block *amp.ContentBlock, sessionID, operation SessionID: sessionID, OperationID: operationID, ToolCallID: block.ID, + ParentToolCallID: parentToolUseID, ToolName: block.Name, ToolTitle: toolTitle, ToolStatus: "running", @@ -669,6 +681,10 @@ func (a *AmpAdapter) extractResultErrorMsg(msg *amp.Message) string { if !msg.IsError { return "" } + // Check errors array first (Claude Code format, most specific) + if len(msg.Errors) > 0 { + return strings.Join(msg.Errors, "; ") + } if msg.Error != "" { return msg.Error } @@ -681,6 +697,36 @@ func (a *AmpAdapter) extractResultErrorMsg(msg *amp.Message) string { return "prompt failed" } +// extractResultText extracts text from the result message for non-streamed responses. +func (a *AmpAdapter) extractResultText(msg *amp.Message) string { + if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + return resultData.Text + } + if resultStr := msg.GetResultString(); resultStr != "" { + return resultStr + } + return "" +} + +// handleRateLimitMessage processes rate limit notifications. +func (a *AmpAdapter) handleRateLimitMessage(msg *amp.Message, sessionID, operationID string) { + message := "Rate limited by API" + if len(msg.RateLimitInfo) > 0 { + message = string(msg.RateLimitInfo) + } + + a.logger.Warn("rate limit event received", + zap.String("session_id", sessionID), + zap.String("message", message)) + + a.sendUpdate(AgentEvent{ + Type: EventTypeRateLimit, + SessionID: sessionID, + OperationID: operationID, + RateLimitMessage: message, + }) +} + // signalResultCompletion sends the result to the result channel. func (a *AmpAdapter) signalResultCompletion(success bool, errMsg string) { a.mu.RLock() @@ -780,24 +826,52 @@ func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationI // Only send completion event if not already sent by handleTurnComplete if !alreadyCompleted { - // Clear the text accumulator (text was already sent via message_chunk events) + // Check if text was already streamed this turn + a.mu.RLock() + textWasStreamed := a.streamingTextSentThisTurn + a.mu.RUnlock() + + // Emit result.text as message_chunk if no text was streamed this turn + // (e.g. slash commands or very short responses) + if !textWasStreamed { + if resultText := a.extractResultText(msg); resultText != "" { + a.logger.Debug("sending result text as message_chunk (no streaming text this turn)", + zap.Int("text_length", len(resultText))) + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: resultText, + }) + } + } else { + a.logger.Debug("skipping result text (streaming text already sent this turn)") + } + + // Reset text dedup flag and accumulator a.mu.Lock() + a.streamingTextSentThisTurn = false a.textAccumulator.Reset() a.mu.Unlock() - // Send complete event WITHOUT text to avoid duplicates + // Use GetCostUSD() to handle both cost_usd and total_cost_usd field names + completeData := map[string]any{ + "cost_usd": msg.GetCostUSD(), + "duration_ms": msg.DurationMS, + "num_turns": msg.NumTurns, + "input_tokens": msg.TotalInputTokens, + "output_tokens": msg.TotalOutputTokens, + "is_error": msg.IsError, + } + if len(msg.Errors) > 0 { + completeData["errors"] = msg.Errors + } + a.sendUpdate(AgentEvent{ Type: EventTypeComplete, SessionID: sessionID, OperationID: operationID, - Data: map[string]any{ - "cost_usd": msg.CostUSD, - "duration_ms": msg.DurationMS, - "num_turns": msg.NumTurns, - "input_tokens": msg.TotalInputTokens, - "output_tokens": msg.TotalOutputTokens, - "is_error": msg.IsError, - }, + Data: completeData, }) } diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/amp_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/amp_test.go new file mode 100644 index 0000000000..4396cd2c3e --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/amp_test.go @@ -0,0 +1,30 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// ampCommand is the CLI command for Amp in stream-json mode. +// Derived from internal/agent/agents/amp.go. +const ampCommand = "amp --execute --stream-json --stream-json-input" + +func TestAmp_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "amp", + Command: ampCommand, + Protocol: agent.ProtocolAmp, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("amp completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/assertions.go b/apps/backend/internal/agentctl/server/adapter/e2e/assertions.go new file mode 100644 index 0000000000..f8cdbd3f6c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/assertions.go @@ -0,0 +1,122 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" +) + +// AssertTurnCompleted checks the core structural invariants: +// - At least 1 event was received +// - Session ID is non-empty +// - No error events +// - A complete event was received +// - At least 1 user-visible event (message_chunk, tool_call, reasoning) +func AssertTurnCompleted(t *testing.T, result *TestResult) { + t.Helper() + + if len(result.Events) == 0 { + t.Fatal("no events received from agent") + } + + if result.SessionID == "" { + t.Error("session ID is empty after turn") + } + + AssertNoErrors(t, result.Events) + + hasComplete := false + for _, ev := range result.Events { + if ev.Type == adapter.EventTypeComplete { + hasComplete = true + break + } + } + if !hasComplete { + t.Error("no complete event received") + } + + hasVisible := false + for _, ev := range result.Events { + switch ev.Type { + case adapter.EventTypeMessageChunk, + adapter.EventTypeToolCall, + adapter.EventTypeReasoning, + adapter.EventTypeToolUpdate: + hasVisible = true + } + } + if !hasVisible { + t.Error("no user-visible events (message_chunk, tool_call, reasoning) received") + } +} + +// AssertHasEventType checks that at least one event of the given type exists. +func AssertHasEventType(t *testing.T, events []adapter.AgentEvent, eventType string) { + t.Helper() + for _, ev := range events { + if ev.Type == eventType { + return + } + } + t.Errorf("expected at least one %q event, got none", eventType) +} + +// AssertNoErrors checks that no error events were received. +func AssertNoErrors(t *testing.T, events []adapter.AgentEvent) { + t.Helper() + for _, ev := range events { + if ev.Type == adapter.EventTypeError { + t.Errorf("unexpected error event: %s", ev.Error) + } + } +} + +// AssertSessionIDConsistent checks all events with a session_id have the same value. +func AssertSessionIDConsistent(t *testing.T, events []adapter.AgentEvent) { + t.Helper() + var sessionID string + for _, ev := range events { + if ev.SessionID == "" { + continue + } + if sessionID == "" { + sessionID = ev.SessionID + } else if ev.SessionID != sessionID { + t.Logf("warning: session ID changed mid-turn: %s -> %s", sessionID, ev.SessionID) + } + } +} + +// CountEventsByType returns a map of event type -> count for debugging. +func CountEventsByType(events []adapter.AgentEvent) map[string]int { + counts := make(map[string]int) + for _, ev := range events { + counts[ev.Type]++ + } + return counts +} + +// DumpEventsOnFailure logs all events and debug info when the test fails. +// Call with defer at the start of each test. +func DumpEventsOnFailure(t *testing.T, result *TestResult) { + t.Helper() + if !t.Failed() { + return + } + t.Logf("--- E2E Failure Dump ---") + t.Logf("duration: %s", result.Duration) + t.Logf("session_id: %s", result.SessionID) + t.Logf("operation_id: %s", result.OperationID) + t.Logf("total events: %d", len(result.Events)) + + counts := CountEventsByType(result.Events) + t.Logf("event counts: %v", counts) + + for i, ev := range result.Events { + t.Logf(" [%d] type=%-20s session=%-36s tool=%-10s text_len=%d error=%q", + i, ev.Type, ev.SessionID, ev.ToolName, len(ev.Text), ev.Error) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go new file mode 100644 index 0000000000..3a01305094 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/auggie_test.go @@ -0,0 +1,32 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// https://www.npmjs.com/package/@augmentcode/auggie + +// auggieCommand is the CLI command for Auggie in ACP mode. +// Derived from internal/agent/agents/auggie.go. +const auggieCommand = "npx -y @augmentcode/auggie@0.16.2 --acp" + +func TestAuggie_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "auggie", + Command: auggieCommand, + Protocol: agent.ProtocolACP, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("auggie completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/claude_code_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/claude_code_test.go new file mode 100644 index 0000000000..69c4d68fac --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/claude_code_test.go @@ -0,0 +1,68 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/pkg/agent" +) + +// https://www.npmjs.com/package/@anthropic-ai/claude-code + +// claudeCodeCommand is the CLI command for Claude Code in stream-json mode. +// Derived from internal/agent/agents/claude_code.go. +const claudeCodeCommand = "npx -y @anthropic-ai/claude-code@2.1.50 -p --output-format=stream-json --input-format=stream-json --verbose" + +func TestClaudeCode_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "claude-code", + Command: claudeCodeCommand, + Protocol: agent.ProtocolClaudeCode, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("claude-code completed in %s: %v", result.Duration, counts) +} + +func TestClaudeCode_ToolUse(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "claude-code", + Command: claudeCodeCommand, + Protocol: agent.ProtocolClaudeCode, + DefaultPrompt: "List the files in the current directory using ls", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertHasEventType(t, result.Events, adapter.EventTypeToolCall) + + counts := CountEventsByType(result.Events) + t.Logf("claude-code tool use completed in %s: %v", result.Duration, counts) +} + +func TestClaudeCode_SlashCost(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "claude-code", + Command: claudeCodeCommand, + Protocol: agent.ProtocolClaudeCode, + DefaultPrompt: "/cost", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + // /cost should complete without errors and produce at least a message chunk. + AssertNoErrors(t, result.Events) + AssertHasEventType(t, result.Events, adapter.EventTypeComplete) + + counts := CountEventsByType(result.Events) + t.Logf("claude-code /cost completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/codex_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/codex_test.go new file mode 100644 index 0000000000..1dec6769e0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/codex_test.go @@ -0,0 +1,32 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// https://www.npmjs.com/package/@openai/codex + +// codexCommand is the CLI command for OpenAI Codex in app-server mode. +// Derived from internal/agent/agents/codex.go. +const codexCommand = "npx -y @openai/codex@0.104.0 app-server" + +func TestCodex_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "codex", + Command: codexCommand, + Protocol: agent.ProtocolCodex, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("codex completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/copilot_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/copilot_test.go new file mode 100644 index 0000000000..1651a868bb --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/copilot_test.go @@ -0,0 +1,32 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// https://www.npmjs.com/package/@github/copilot + +// copilotCommand is the CLI command for GitHub Copilot. +// Derived from internal/agent/agents/copilot.go. +const copilotCommand = "npx -y @github/copilot@0.0.414 --server --log-level error" + +func TestCopilot_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "copilot", + Command: copilotCommand, + Protocol: agent.ProtocolCopilot, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("copilot completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go new file mode 100644 index 0000000000..38811a027d --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/gemini_test.go @@ -0,0 +1,32 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// https://www.npmjs.com/package/@google/gemini-cli + +// geminiCommand is the CLI command for Google Gemini in ACP mode. +// Derived from internal/agent/agents/gemini.go. +const geminiCommand = "npx -y @google/gemini-cli@0.29.5 --experimental-acp" + +func TestGemini_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "gemini", + Command: geminiCommand, + Protocol: agent.ProtocolACP, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("gemini completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/harness.go b/apps/backend/internal/agentctl/server/adapter/e2e/harness.go new file mode 100644 index 0000000000..55929ac32d --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/harness.go @@ -0,0 +1,329 @@ +//go:build e2e + +// Package e2e provides end-to-end tests that exercise the full adapter lifecycle +// against real agent binaries (Claude Code, Amp, etc.). +// +// These tests cost money (real API subscriptions) and are gated behind the "e2e" +// build tag so they never run in CI. Run them manually: +// +// go test -tags e2e -v -timeout 10m ./internal/agentctl/server/adapter/e2e/ +// +// Tests skip gracefully when an agent binary is not installed on PATH. +// +// Debug logging (set externally — read at package init time): +// +// KANDEV_DEBUG_AGENT_MESSAGES=true KANDEV_DEBUG_LOG_DIR=/tmp/e2e-debug ... +// +// OTel tracing: +// +// OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ... +package e2e + +import ( + "context" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// AgentSpec defines the configuration for an E2E agent test. +type AgentSpec struct { + // Name is a human-readable name for logging (e.g., "claude-code"). + Name string + + // Command is the full CLI command to run the agent. + // The first token must be a binary on PATH (or an absolute path). + // Tests skip if the binary is not found. + Command string + + // Protocol is the agent protocol constant. + Protocol agent.Protocol + + // DefaultPrompt is the prompt to send. + DefaultPrompt string + + // Timeout is the maximum time to wait for the full test lifecycle. + // Defaults to 2 minutes if zero. + Timeout time.Duration + + // AutoApprove enables auto-approval of permission requests. + AutoApprove bool +} + +// TestResult holds collected events and metadata from a test run. +type TestResult struct { + Events []adapter.AgentEvent + SessionID string + OperationID string + Duration time.Duration +} + +// RunAgent executes the full adapter lifecycle against an agent binary +// and returns collected events. Skips if the binary is not on PATH. +func RunAgent(t *testing.T, spec AgentSpec) *TestResult { + t.Helper() + requireBinary(t, spec.Command) + return runAgentLifecycle(t, spec, spec.Command) +} + +// requireBinary checks that the first token of the command is a binary +// on PATH. Skips the test if not found. +func requireBinary(t *testing.T, command string) { + t.Helper() + fields := strings.Fields(command) + if len(fields) == 0 { + t.Fatal("empty command") + } + if _, err := exec.LookPath(fields[0]); err != nil { + t.Skipf("skipping: %s not found on PATH", fields[0]) + } +} + +// runAgentLifecycle implements the full lifecycle: +// workspace setup → process manager → Start → Initialize → NewSession → Prompt → collect events. +func runAgentLifecycle(t *testing.T, spec AgentSpec, command string) *TestResult { + t.Helper() + + timeout := spec.Timeout + if timeout == 0 { + timeout = 2 * time.Minute + } + if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" { + if d, err := time.ParseDuration(envTimeout); err == nil { + timeout = d + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Set up workspace + workDir := setupWorkspace(t) + + // Build config and create process manager + cfg := buildInstanceConfig(command, spec.Protocol, workDir, spec.AutoApprove) + log := newTestLogger(t) + mgr := process.NewManager(cfg, log) + + // Start the subprocess + start := time.Now() + if err := mgr.Start(ctx); err != nil { + t.Fatalf("failed to start agent: %v", err) + } + t.Cleanup(func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer stopCancel() + _ = mgr.Stop(stopCtx) + }) + + // Initialize adapter + adpt := mgr.GetAdapter() + if adpt == nil { + t.Fatal("adapter is nil after Start") + } + if err := adpt.Initialize(ctx); err != nil { + t.Fatalf("failed to initialize adapter: %v", err) + } + + // Create session + if _, err := adpt.NewSession(ctx, nil); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Collect events while prompting + events := collectEventsUntilPromptDone(ctx, t, mgr, adpt, spec.DefaultPrompt) + duration := time.Since(start) + + return &TestResult{ + Events: events, + SessionID: adpt.GetSessionID(), + OperationID: adpt.GetOperationID(), + Duration: duration, + } +} + +// collectEventsUntilPromptDone drains the manager's updates channel while +// the adapter processes a prompt. Prompt() blocks until the turn completes. +func collectEventsUntilPromptDone( + ctx context.Context, + t *testing.T, + mgr *process.Manager, + adpt adapter.AgentAdapter, + prompt string, +) []adapter.AgentEvent { + t.Helper() + + var events []adapter.AgentEvent + var mu sync.Mutex + done := make(chan struct{}) + + // Start event collector goroutine + go func() { + defer close(done) + ch := mgr.GetUpdates() + for { + select { + case ev, ok := <-ch: + if !ok { + return + } + mu.Lock() + events = append(events, ev) + mu.Unlock() + case <-ctx.Done(): + return + } + } + }() + + // Send prompt (blocks until turn completes or context cancels) + if err := adpt.Prompt(ctx, prompt, nil); err != nil { + t.Fatalf("prompt failed: %v", err) + } + + // Grace period for remaining events to propagate through the channel + time.Sleep(500 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + result := make([]adapter.AgentEvent, len(events)) + copy(result, events) + return result +} + +// setupWorkspace creates a temp directory with git init for agents that require a repo. +func setupWorkspace(t *testing.T) string { + t.Helper() + + if envDir := os.Getenv("E2E_WORKDIR"); envDir != "" { + return envDir + } + + dir := t.TempDir() + cmd := exec.Command("git", "init") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init failed in %s: %v\n%s", dir, err, out) + } + + // Create a minimal file so the repo isn't completely empty + if err := os.WriteFile(dir+"/README.md", []byte("# E2E Test Workspace\n"), 0644); err != nil { + t.Fatalf("failed to create README.md: %v", err) + } + + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + _ = cmd.Run() + + cmd = exec.Command("git", "commit", "-m", "init", "--allow-empty") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + _ = cmd.Run() + + return dir +} + +// buildInstanceConfig creates a config.InstanceConfig from the test parameters. +func buildInstanceConfig(command string, protocol agent.Protocol, workDir string, autoApprove bool) *config.InstanceConfig { + args := config.ParseCommand(command) + env := config.CollectAgentEnv(nil) + + return &config.InstanceConfig{ + Protocol: protocol, + AgentCommand: command, + AgentArgs: args, + WorkDir: workDir, + AgentEnv: env, + AutoApprovePermissions: autoApprove, + ApprovalPolicy: "never", + ShellEnabled: false, + LogLevel: "debug", + LogFormat: "console", + AgentType: string(protocol), + } +} + +// newTestLogger creates a logger for e2e tests. Uses debug level so adapter +// internals are visible in test output via -v. +func newTestLogger(t *testing.T) *logger.Logger { + t.Helper() + level := "info" + if testing.Verbose() { + level = "debug" + } + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: level, + Format: "console", + OutputPath: "stderr", + }) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + return log +} + +// buildMockAgent compiles the mock-agent binary and returns its path. +func buildMockAgent(t *testing.T) string { + t.Helper() + + // Find the backend root relative to this test file + backendRoot := findBackendRoot(t) + + binary := t.TempDir() + "/mock-agent" + cmd := exec.Command("go", "build", "-o", binary, "./cmd/mock-agent") + cmd.Dir = backendRoot + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build mock-agent: %v\n%s", err, out) + } + return binary +} + +// findBackendRoot walks up from CWD to find apps/backend. +func findBackendRoot(t *testing.T) string { + t.Helper() + + // Try common relative paths from the e2e test directory + candidates := []string{ + "../../../../../..", // from internal/agentctl/server/adapter/e2e/ back to apps/backend + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + + for _, rel := range candidates { + abs := cwd + "/" + rel + if _, err := os.Stat(abs + "/cmd/mock-agent"); err == nil { + return abs + } + } + + // Fallback: walk up from cwd looking for cmd/mock-agent + dir := cwd + for i := 0; i < 10; i++ { + if _, err := os.Stat(dir + "/cmd/mock-agent"); err == nil { + return dir + } + parent := dir[:strings.LastIndex(dir, "/")] + if parent == dir { + break + } + dir = parent + } + + t.Fatalf("could not find backend root (cmd/mock-agent) from %s", cwd) + return "" +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/mock_agent_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/mock_agent_test.go new file mode 100644 index 0000000000..5e078b8867 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/mock_agent_test.go @@ -0,0 +1,29 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// TestMockAgent_BasicPrompt validates the harness works end-to-end using +// the mock agent. No API cost — can always run. +func TestMockAgent_BasicPrompt(t *testing.T) { + binary := buildMockAgent(t) + + result := RunAgent(t, AgentSpec{ + Name: "mock-agent", + Command: binary + " --model mock-fast", + Protocol: agent.ProtocolClaudeCode, + DefaultPrompt: "hello", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + t.Logf("mock agent completed in %s with %d events", result.Duration, len(result.Events)) +} diff --git a/apps/backend/internal/agentctl/server/adapter/e2e/opencode_test.go b/apps/backend/internal/agentctl/server/adapter/e2e/opencode_test.go new file mode 100644 index 0000000000..c694855727 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/e2e/opencode_test.go @@ -0,0 +1,32 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/kandev/kandev/pkg/agent" +) + +// https://www.npmjs.com/package/opencode-ai + +// openCodeCommand is the CLI command for OpenCode in serve mode. +// Derived from internal/agent/agents/opencode.go. +const openCodeCommand = "npx -y opencode-ai@1.2.10 serve --hostname 127.0.0.1 --port 0" + +func TestOpenCode_BasicPrompt(t *testing.T) { + result := RunAgent(t, AgentSpec{ + Name: "opencode", + Command: openCodeCommand, + Protocol: agent.ProtocolOpenCode, + DefaultPrompt: "What is 2 + 2? Reply with just the number.", + AutoApprove: true, + }) + defer DumpEventsOnFailure(t, result) + + AssertTurnCompleted(t, result) + AssertSessionIDConsistent(t, result.Events) + + counts := CountEventsByType(result.Events) + t.Logf("opencode completed in %s: %v", result.Duration, counts) +} diff --git a/apps/backend/pkg/amp/client.go b/apps/backend/pkg/amp/client.go index eec20dfe61..70a87e1e2b 100644 --- a/apps/backend/pkg/amp/client.go +++ b/apps/backend/pkg/amp/client.go @@ -15,11 +15,13 @@ import ( ) // Message types for Amp stream-json protocol. +// Amp claims Claude Code compatibility via --stream-json, so these match Claude Code's types. const ( MessageTypeSystem = "system" MessageTypeAssistant = "assistant" MessageTypeResult = "result" MessageTypeUser = "user" + MessageTypeRateLimit = "rate_limit_event" ) // Content block types. @@ -53,22 +55,38 @@ type Client struct { } // Message represents a message from Amp. +// Amp uses Claude Code-compatible stream-json format, so fields match both protocols. type Message struct { - Type string `json:"type"` - ThreadID string `json:"thread_id,omitempty"` - Message *Content `json:"message,omitempty"` + Type string `json:"type"` + ThreadID string `json:"thread_id,omitempty"` + Message *Content `json:"message,omitempty"` + ParentToolUseID string `json:"parent_tool_use_id,omitempty"` // Result fields (when Type == "result") IsError bool `json:"is_error,omitempty"` - Error string `json:"error,omitempty"` // Error message when is_error is true + Error string `json:"error,omitempty"` // Single error message + Errors []string `json:"errors,omitempty"` // Multiple errors (Claude Code format) Subtype string `json:"subtype,omitempty"` // e.g., "error_during_execution" CostUSD float64 `json:"cost_usd,omitempty"` + TotalCostUSD float64 `json:"total_cost_usd,omitempty"` // Claude Code uses this field name DurationMS int64 `json:"duration_ms,omitempty"` NumTurns int `json:"num_turns,omitempty"` TotalInputTokens int64 `json:"total_input_tokens,omitempty"` TotalOutputTokens int64 `json:"total_output_tokens,omitempty"` Result json.RawMessage `json:"result,omitempty"` ModelUsage map[string]*Usage `json:"model_usage,omitempty"` + + // Rate limit fields (when Type == "rate_limit_event") + RateLimitInfo json.RawMessage `json:"rate_limit_info,omitempty"` +} + +// GetCostUSD returns the cost, checking both total_cost_usd (Claude Code format) +// and cost_usd (Amp format). +func (m *Message) GetCostUSD() float64 { + if m.TotalCostUSD != 0 { + return m.TotalCostUSD + } + return m.CostUSD } // Content represents the message content. diff --git a/docs/test_e2e.md b/docs/test_e2e.md new file mode 100644 index 0000000000..2315d6c943 --- /dev/null +++ b/docs/test_e2e.md @@ -0,0 +1,166 @@ +# E2E Adapter Tests + +End-to-end tests that exercise the full adapter lifecycle against real agent binaries. +They confirm message parsing, turn detection, and normalization still work when agent versions change. + +**Location:** `internal/agentctl/server/adapter/e2e/` + +## Prerequisites + +- Agent binary installed and on PATH (`claude`, `amp`, etc.) +- Active API subscription for the agent you're testing +- Tests for agents not installed are skipped automatically + +## Running Tests + +All commands run from `apps/backend/`. + +### All agents at once + +```bash +make test-e2e +``` + +Agents not on PATH are skipped. The mock agent is always built and tested. + +### Single agent + +**Claude Code** (requires `claude` on PATH): + +```bash +go test -tags e2e -v -timeout 10m -run TestClaudeCode ./internal/agentctl/server/adapter/e2e/ +``` + +**Copilot** (requires `npx` + GitHub auth): + +```bash +go test -tags e2e -v -timeout 10m -run TestCopilot ./internal/agentctl/server/adapter/e2e/ +``` + +**Codex** (requires `npx` + OpenAI auth): + +```bash +go test -tags e2e -v -timeout 10m -run TestCodex ./internal/agentctl/server/adapter/e2e/ +``` + +**Auggie** (requires `npx` + Augment auth): + +```bash +go test -tags e2e -v -timeout 10m -run TestAuggie ./internal/agentctl/server/adapter/e2e/ +``` + +**OpenCode** (requires `npx` + OpenCode auth): + +```bash +go test -tags e2e -v -timeout 10m -run TestOpenCode ./internal/agentctl/server/adapter/e2e/ +``` + +**Gemini** (requires `npx` + Google auth): + +```bash +go test -tags e2e -v -timeout 10m -run TestGemini ./internal/agentctl/server/adapter/e2e/ +``` + +**Amp** (requires `amp` on PATH): + +```bash +go test -tags e2e -v -timeout 10m -run TestAmp ./internal/agentctl/server/adapter/e2e/ +``` + +**Mock Agent** (free, no API cost — binary is auto-built): + +```bash +go test -tags e2e -v -run TestMockAgent ./internal/agentctl/server/adapter/e2e/ +``` + +### Single test + +```bash +go test -tags e2e -v -timeout 5m -run TestClaudeCode_ToolUse ./internal/agentctl/server/adapter/e2e/ +``` + +## Available Tests + +| Test | Agent | Protocol | What it checks | +|------|-------|----------|----------------| +| `TestClaudeCode_BasicPrompt` | Claude Code | stream-json | Simple prompt completes a turn | +| `TestClaudeCode_ToolUse` | Claude Code | stream-json | Prompt triggers at least one tool call | +| `TestClaudeCode_SlashCost` | Claude Code | stream-json | `/cost` slash command completes | +| `TestCopilot_BasicPrompt` | GitHub Copilot | copilot-sdk | Simple prompt completes a turn | +| `TestCodex_BasicPrompt` | OpenAI Codex | codex | Simple prompt completes a turn | +| `TestAuggie_BasicPrompt` | Auggie | acp | Simple prompt completes a turn | +| `TestOpenCode_BasicPrompt` | OpenCode | opencode | Simple prompt completes a turn | +| `TestGemini_BasicPrompt` | Gemini | acp | Simple prompt completes a turn | +| `TestAmp_BasicPrompt` | Amp | stream-json | Simple prompt completes a turn | +| `TestMockAgent_BasicPrompt` | Mock Agent | stream-json | Harness smoke test (no API cost) | + +## Debugging Failures + +### Debug message logs + +Set `KANDEV_DEBUG_AGENT_MESSAGES` **before** running the test binary (the variable is read at package init time): + +```bash +KANDEV_DEBUG_AGENT_MESSAGES=true \ +KANDEV_DEBUG_LOG_DIR=/tmp/e2e-debug \ + go test -tags e2e -v -timeout 10m ./internal/agentctl/server/adapter/e2e/ +``` + +This writes two JSONL files per agent run into the log dir: + +- `raw-{protocol}-{agentId}.jsonl` — every message from the agent subprocess +- `normalized-{protocol}-{agentId}.jsonl` — normalized `AgentEvent` objects + +### OTel tracing + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +KANDEV_DEBUG_AGENT_MESSAGES=true \ + go test -tags e2e -v -timeout 10m ./internal/agentctl/server/adapter/e2e/ +``` + +### Test failure output + +When a test fails, `DumpEventsOnFailure` prints all collected events with type, session ID, tool name, text length, and any error messages. + +## Configuration + +| Env Var | Default | Description | +|---------|---------|-------------| +| `E2E_WORKDIR` | temp dir with `git init` | Workspace directory for the agent | +| `E2E_TIMEOUT` | `2m` | Timeout per test (Go duration format) | +| `KANDEV_DEBUG_AGENT_MESSAGES` | `false` | Write raw/normalized message logs | +| `KANDEV_DEBUG_LOG_DIR` | cwd | Directory for debug log files | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTel collector endpoint for tracing | + +## How It Works + +Each test defines an `AgentSpec` with a hardcoded command (e.g. `claude -p --output-format=stream-json ...`). +The harness runs the full production code path: + +1. Check binary on PATH via `exec.LookPath` — skip if not found +2. Create temp workspace with `git init` +3. Build `config.InstanceConfig` and create `process.Manager` +4. `Manager.Start()` — spawns subprocess, creates adapter via factory, wires stdin/stdout pipes +5. `Adapter.Initialize()` — protocol handshake +6. `Adapter.NewSession()` — create session +7. `Adapter.Prompt()` — send message, blocks until turn completes +8. Collect all `AgentEvent` objects from the manager's updates channel +9. Assert structural invariants (turn completed, no errors, at least one visible event) + +## What Is Asserted + +Tests assert **structural invariants only** — never specific text or tool names, since agent responses are non-deterministic: + +- At least one event was received +- Session ID is non-empty +- No error events +- A complete event was received (turn finished) +- At least one user-visible event (message chunk, tool call, or reasoning) + +## Adding a New Agent + +1. Create `{agent}_test.go` with `//go:build e2e` +2. Define the command as a const (derive from `internal/agent/agents/{agent}.go`) +3. Write test functions using `RunAgent(t, AgentSpec{...})` +4. Use `AssertTurnCompleted` for basic turn validation