diff --git a/.gitignore b/.gitignore index 600a3cb035..4ba36be831 100644 --- a/.gitignore +++ b/.gitignore @@ -53,8 +53,8 @@ temp/ dist/ build/ -# Debug files -debug +# Debug files (at root level only) +/debug *.pprof # Air live reload (if used) diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 461d4457e7..ed1721c90c 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/orchestrator" @@ -263,19 +264,16 @@ func (a *messageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, c return err } -// CreateToolCallMessage creates a message for a tool call with type="tool_call" -func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, args map[string]interface{}) error { +// CreateToolCallMessage creates a message for a tool call +func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { metadata := map[string]interface{}{ "tool_call_id": toolCallID, "title": title, "status": status, } - - if len(args) > 0 { - metadata["args"] = args - if kind, ok := args["kind"].(string); ok && kind != "" { - metadata["tool_name"] = kind - } + // Add normalized tool data to metadata for frontend consumption + if normalized != nil { + metadata["normalized"] = normalized } _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ @@ -290,9 +288,9 @@ func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskI return err } -// UpdateToolCallMessage updates a tool call message's status and optionally updates title/args -func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, args map[string]interface{}) error { - return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, args) +// UpdateToolCallMessage updates a tool call message's status +func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, normalized *streams.NormalizedPayload) error { + return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, normalized) } // CreateSessionMessage creates a message for non-chat session updates (status/progress/error/etc). diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 61a00e6206..0d4333d395 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -42,6 +42,9 @@ import ( "github.com/kandev/kandev/internal/clarification" + // Debug handlers + debughandlers "github.com/kandev/kandev/internal/debug" + // MCP handlers mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" @@ -561,6 +564,9 @@ func main() { mcpHandlers.RegisterHandlers(gateway.Dispatcher) log.Debug("Registered MCP handlers (WebSocket)") + debughandlers.RegisterRoutes(router, log) + log.Debug("Registered Debug handlers (HTTP)") + // Health check (simple HTTP for load balancers/monitoring) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index db1290772b..cb74244a5b 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -103,10 +103,15 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe } // Create an instance via the control API (same flow as standalone mode) + agentType := "" + if config.AgentConfig != nil { + agentType = config.AgentConfig.ID + } createReq := &agentctl.CreateInstanceRequest{ ID: config.InstanceID, WorkspacePath: "/workspace", AgentCommand: "", // Agent command set via Configure endpoint later + AgentType: agentType, Env: config.Credentials, AutoStart: false, McpServers: mcpServers, diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 5148fc2248..9641f843e8 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -1,7 +1,11 @@ // Package lifecycle provides event payload types for agent lifecycle events. package lifecycle -import "time" +import ( + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) // AgentEventPayload is the payload for agent lifecycle events (started, stopped, ready, completed, failed). type AgentEventPayload struct { @@ -35,18 +39,20 @@ type ACPSessionCreatedPayload struct { // AgentStreamEventData contains the nested event data within AgentStreamEventPayload. type AgentStreamEventData struct { - Type string `json:"type"` - ACPSessionID string `json:"acp_session_id,omitempty"` - Text string `json:"text,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolTitle string `json:"tool_title,omitempty"` - ToolStatus string `json:"tool_status,omitempty"` - ToolArgs map[string]interface{} `json:"tool_args,omitempty"` - ToolResult interface{} `json:"tool_result,omitempty"` - Error string `json:"error,omitempty"` - SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events - Data interface{} `json:"data,omitempty"` + Type string `json:"type"` + ACPSessionID string `json:"acp_session_id,omitempty"` + Text string `json:"text,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolTitle string `json:"tool_title,omitempty"` + ToolStatus string `json:"tool_status,omitempty"` + Error string `json:"error,omitempty"` + SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events + Data interface{} `json:"data,omitempty"` + + // Normalized contains the typed tool payload data. + // This is used to populate message metadata with structured tool information. + Normalized *streams.NormalizedPayload `json:"normalized,omitempty"` // Streaming message fields (for "message_streaming" and "thinking_streaming" types) // MessageID is the ID of the message being streamed (empty for first chunk, set for appends) diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 8dd7942ba2..7016194f6a 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -122,11 +122,10 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even ToolName: event.ToolName, ToolTitle: event.ToolTitle, ToolStatus: event.ToolStatus, - ToolArgs: event.ToolArgs, - ToolResult: event.ToolResult, Error: event.Error, SessionStatus: event.SessionStatus, Data: event.Data, + Normalized: event.NormalizedPayload, } // Build agent event message payload @@ -475,3 +474,4 @@ func (p *EventPublisher) PublishContextWindow(execution *AgentExecution, size, u zap.Error(err)) } } + diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index 180c20b6d2..8c82ec180d 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -95,11 +95,16 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea // Create instance via control API // Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client. + agentType := "" + if req.AgentConfig != nil { + agentType = req.AgentConfig.ID + } createReq := &agentctl.CreateInstanceRequest{ ID: req.InstanceID, WorkspacePath: req.WorkspacePath, AgentCommand: "", // Agent command set via Configure endpoint Protocol: req.Protocol, + AgentType: agentType, Env: env, AutoStart: false, McpServers: mcpServers, diff --git a/apps/backend/internal/agent/lifecycle/session_history.go b/apps/backend/internal/agent/lifecycle/session_history.go index 43a5c20877..ec3187d0e5 100644 --- a/apps/backend/internal/agent/lifecycle/session_history.go +++ b/apps/backend/internal/agent/lifecycle/session_history.go @@ -32,15 +32,14 @@ type SessionHistoryManager struct { // HistoryEntry represents a single entry in the session history. type HistoryEntry struct { Timestamp time.Time `json:"timestamp"` - Type string `json:"type"` // "user_message", "agent_message", "tool_call", "tool_result" - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolArgs map[string]any `json:"tool_args,omitempty"` - ToolStatus string `json:"tool_status,omitempty"` - SessionID string `json:"session_id,omitempty"` - OperationID string `json:"operation_id,omitempty"` + Type string `json:"type"` // "user_message", "agent_message", "tool_call", "tool_result" + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolStatus string `json:"tool_status,omitempty"` + SessionID string `json:"session_id,omitempty"` + OperationID string `json:"operation_id,omitempty"` } // NewSessionHistoryManager creates a new SessionHistoryManager. @@ -136,28 +135,17 @@ func (m *SessionHistoryManager) AppendToolCall(sessionID string, event agentctl. Type: "tool_call", ToolCallID: event.ToolCallID, ToolName: event.ToolName, - ToolArgs: event.ToolArgs, ToolStatus: event.ToolStatus, }) } // AppendToolResult appends a tool result to the session history. func (m *SessionHistoryManager) AppendToolResult(sessionID string, event agentctl.AgentEvent) error { - resultStr := "" - if event.ToolResult != nil { - if s, ok := event.ToolResult.(string); ok { - resultStr = s - } else if data, err := json.Marshal(event.ToolResult); err == nil { - resultStr = string(data) - } - } - return m.AppendEntry(sessionID, HistoryEntry{ Type: "tool_result", ToolCallID: event.ToolCallID, ToolName: event.ToolName, ToolStatus: event.ToolStatus, - Content: resultStr, }) } @@ -230,13 +218,7 @@ func (m *SessionHistoryManager) GenerateResumeContext(sessionID, newPrompt strin case "agent_message": fmt.Fprintf(&historyBuilder, "\n[ASSISTANT]: %s\n", truncateForContext(entry.Content, 2000)) case "tool_call": - argsStr := "" - if entry.ToolArgs != nil { - if data, err := json.Marshal(entry.ToolArgs); err == nil { - argsStr = truncateForContext(string(data), 500) - } - } - fmt.Fprintf(&historyBuilder, "\n[TOOL CALL: %s] Args: %s\n", entry.ToolName, argsStr) + fmt.Fprintf(&historyBuilder, "\n[TOOL CALL: %s]\n", entry.ToolName) case "tool_result": fmt.Fprintf(&historyBuilder, "\n[TOOL RESULT: %s] %s\n", entry.ToolName, truncateForContext(entry.Content, 500)) } diff --git a/apps/backend/internal/agentctl/client/control.go b/apps/backend/internal/agentctl/client/control.go index 0db132fa49..6aee9dd253 100644 --- a/apps/backend/internal/agentctl/client/control.go +++ b/apps/backend/internal/agentctl/client/control.go @@ -38,6 +38,7 @@ type CreateInstanceRequest struct { WorkspacePath string `json:"workspace_path"` AgentCommand string `json:"agent_command,omitempty"` Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp, codex) + AgentType string `json:"agent_type,omitempty"` // Agent type ID for debug file naming (e.g., "codex", "auggie") WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root") Env map[string]string `json:"env,omitempty"` AutoStart bool `json:"auto_start,omitempty"` diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index c2b8612817..8bc063efe3 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -1,11 +1,19 @@ // Package adapter provides protocol adapters for different agent communication protocols. // This abstraction allows agentctl to work with agents using ACP, REST, MCP, or custom protocols. +// +// Architecture: +// - Transport layer (transport/*): Handles protocol-level communication (ACP, stream-json, codex, opencode) +// - Factory: Creates the appropriate transport adapter based on agent's protocol +// +// Agent configuration (commands, discovery, models) is handled by the agent/registry package +// using agents.json as the source of truth. This package only handles protocol communication. package adapter import ( "context" "io" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" ) @@ -48,6 +56,13 @@ type StderrProvider interface { GetRecentStderr() []string } +// StderrProviderSetter is an optional interface implemented by adapters that can use +// stderr output for error context. The process manager checks for this interface +// and calls SetStderrProvider if available. +type StderrProviderSetter interface { + SetStderrProvider(provider StderrProvider) +} + // AgentInfo contains information about the connected agent. type AgentInfo struct { Name string `json:"name"` @@ -157,6 +172,14 @@ type Config struct { // McpServers is a list of MCP servers to configure for the agent McpServers []McpServerConfig + // AgentID is the agent identifier from the registry (e.g., "auggie", "amp", "claude-code"). + // Used for logging and debug capture. Adapters should use this instead of hardcoded names. + AgentID string + + // AgentName is the human-readable agent name (e.g., "Auggie", "AMP", "Claude Code"). + // Used for display purposes. + AgentName string + // For HTTP-based adapters (REST) BaseURL string // Base URL of the agent's HTTP API AuthHeader string // Optional auth header name @@ -166,3 +189,30 @@ type Config struct { // Protocol-specific configuration Extra map[string]string } + +// ToSharedConfig converts this Config to the shared.Config used by transport adapters. +func (c *Config) ToSharedConfig() *shared.Config { + mcpServers := make([]shared.McpServerConfig, len(c.McpServers)) + for i, srv := range c.McpServers { + mcpServers[i] = shared.McpServerConfig{ + Name: srv.Name, + URL: srv.URL, + Type: srv.Type, + Command: srv.Command, + Args: srv.Args, + } + } + return &shared.Config{ + WorkDir: c.WorkDir, + AutoApprove: c.AutoApprove, + ApprovalPolicy: c.ApprovalPolicy, + McpServers: mcpServers, + AgentID: c.AgentID, + AgentName: c.AgentName, + BaseURL: c.BaseURL, + AuthHeader: c.AuthHeader, + AuthValue: c.AuthValue, + Headers: c.Headers, + Extra: c.Extra, + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/adapter_test.go b/apps/backend/internal/agentctl/server/adapter/adapter_test.go index 8c656ce54b..0928572843 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter_test.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter_test.go @@ -14,8 +14,6 @@ func TestSessionUpdate_Fields(t *testing.T) { ToolName: "file_read", ToolTitle: "Reading file", ToolStatus: "running", - ToolArgs: map[string]interface{}{"path": "/test/file.txt"}, - ToolResult: map[string]interface{}{"content": "file contents"}, PlanEntries: []PlanEntry{ {Description: "Step 1", Status: "completed", Priority: "high"}, }, @@ -44,9 +42,6 @@ func TestSessionUpdate_Fields(t *testing.T) { if update.ToolStatus != "running" { t.Errorf("expected ToolStatus 'running', got %q", update.ToolStatus) } - if update.ToolArgs["path"] != "/test/file.txt" { - t.Errorf("expected ToolArgs[path] '/test/file.txt', got %v", update.ToolArgs["path"]) - } if len(update.PlanEntries) != 1 { t.Errorf("expected 1 PlanEntry, got %d", len(update.PlanEntries)) } @@ -286,84 +281,15 @@ func TestPlanEntry_StatusValues(t *testing.T) { } } -func TestConvertSSEToStreamableHTTP(t *testing.T) { - testCases := []struct { - name string - input string - expected string - }{ - { - name: "standard SSE endpoint", - input: "http://localhost:9090/sse", - expected: "http://localhost:9090/mcp", - }, - { - name: "SSE endpoint with different port", - input: "http://example.com:8080/sse", - expected: "http://example.com:8080/mcp", - }, - { - name: "trailing slash not converted", - input: "http://localhost:9090/sse/", - expected: "http://localhost:9090/sse/", - }, - { - name: "non-SSE endpoint unchanged", - input: "http://localhost:9090/other", - expected: "http://localhost:9090/other", - }, - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "already streamable HTTP endpoint", - input: "http://localhost:9090/mcp", - expected: "http://localhost:9090/mcp", - }, - { - name: "sse in path but not at end", - input: "http://localhost:9090/sse/something", - expected: "http://localhost:9090/sse/something", - }, - { - name: "https endpoint", - input: "https://secure.example.com/sse", - expected: "https://secure.example.com/mcp", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := convertSSEToStreamableHTTP(tc.input) - if result != tc.expected { - t.Errorf("convertSSEToStreamableHTTP(%q) = %q, want %q", tc.input, result, tc.expected) - } - }) - } -} - -func TestSessionUpdate_WithComplexToolArgs(t *testing.T) { - // Test with complex nested ToolArgs +func TestSessionUpdate_WithNormalizedPayload(t *testing.T) { + // Test with NormalizedPayload update := AgentEvent{ Type: "tool_call", ToolCallID: "call-123", ToolName: "file_edit", - ToolArgs: map[string]interface{}{ - "path": "/src/main.go", - "locations": []map[string]interface{}{ - {"path": "/src/main.go", "line": 42}, - {"path": "/src/util.go", "line": 10}, - }, - "raw_input": map[string]interface{}{ - "old_text": "foo", - "new_text": "bar", - }, - }, } - // Marshal and unmarshal to verify complex structures work + // Marshal and unmarshal to verify serialization works data, err := json.Marshal(update) if err != nil { t.Fatalf("failed to marshal: %v", err) @@ -374,15 +300,10 @@ func TestSessionUpdate_WithComplexToolArgs(t *testing.T) { t.Fatalf("failed to unmarshal: %v", err) } - if decoded.ToolArgs["path"] != "/src/main.go" { - t.Errorf("expected path '/src/main.go', got %v", decoded.ToolArgs["path"]) + if decoded.ToolCallID != "call-123" { + t.Errorf("expected ToolCallID 'call-123', got %v", decoded.ToolCallID) } - - locations, ok := decoded.ToolArgs["locations"].([]interface{}) - if !ok { - t.Fatal("expected locations to be a slice") - } - if len(locations) != 2 { - t.Errorf("expected 2 locations, got %d", len(locations)) + if decoded.ToolName != "file_edit" { + t.Errorf("expected ToolName 'file_edit', got %v", decoded.ToolName) } } diff --git a/apps/backend/internal/agentctl/server/adapter/factory.go b/apps/backend/internal/agentctl/server/adapter/factory.go index 1de74b78cb..8d54c8cb94 100644 --- a/apps/backend/internal/agentctl/server/adapter/factory.go +++ b/apps/backend/internal/agentctl/server/adapter/factory.go @@ -3,23 +3,118 @@ package adapter import ( "fmt" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/codex" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/opencode" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" ) // NewAdapter creates a new protocol adapter based on the specified protocol type. // It returns an error if the protocol is not supported. +// +// The protocol determines which transport adapter to use: +// - ProtocolACP: ACP adapter (JSON-RPC 2.0 over stdin/stdout) +// - ProtocolClaudeCode: Stream-json adapter (streaming JSON over stdin/stdout) +// - ProtocolCodex: Codex adapter (JSON-RPC variant over stdin/stdout) +// - ProtocolOpenCode: OpenCode adapter (REST/SSE over HTTP) func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (AgentAdapter, error) { + // Convert to shared config for transport adapters + sharedCfg := cfg.ToSharedConfig() + switch protocol { case agent.ProtocolACP: - return NewACPAdapter(cfg, log), nil - case agent.ProtocolCodex: - return NewCodexAdapter(cfg, log), nil + return newACPAdapterWrapper(acp.NewAdapter(sharedCfg, log)), nil case agent.ProtocolClaudeCode: - return NewClaudeCodeAdapter(cfg, log), nil + return newStreamJSONAdapterWrapper(streamjson.NewAdapter(sharedCfg, log)), nil + case agent.ProtocolCodex: + return newCodexAdapterWrapper(codex.NewAdapter(sharedCfg, log)), nil case agent.ProtocolOpenCode: - return NewOpenCodeAdapter(cfg, log), nil + return newOpenCodeAdapterWrapper(opencode.NewAdapter(sharedCfg, log)), nil default: return nil, fmt.Errorf("unsupported protocol: %s", protocol) } } + +// Adapter wrappers to convert transport-specific adapters to the common AgentAdapter interface. +// These wrappers handle the type conversion between transport-specific AgentInfo and the +// common adapter.AgentInfo type. + +// acpAdapterWrapper wraps acp.Adapter to implement AgentAdapter +type acpAdapterWrapper struct { + *acp.Adapter +} + +func newACPAdapterWrapper(a *acp.Adapter) *acpAdapterWrapper { + return &acpAdapterWrapper{Adapter: a} +} + +func (w *acpAdapterWrapper) GetAgentInfo() *AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &AgentInfo{Name: info.Name, Version: info.Version} +} + +// streamJSONAdapterWrapper wraps streamjson.Adapter to implement AgentAdapter +type streamJSONAdapterWrapper struct { + *streamjson.Adapter +} + +func newStreamJSONAdapterWrapper(a *streamjson.Adapter) *streamJSONAdapterWrapper { + return &streamJSONAdapterWrapper{Adapter: a} +} + +func (w *streamJSONAdapterWrapper) GetAgentInfo() *AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &AgentInfo{Name: info.Name, Version: info.Version} +} + +// SetStderrProvider implements StderrProviderSetter +func (w *streamJSONAdapterWrapper) SetStderrProvider(provider StderrProvider) { + w.Adapter.SetStderrProvider(provider) +} + +// codexAdapterWrapper wraps codex.Adapter to implement AgentAdapter +type codexAdapterWrapper struct { + *codex.Adapter +} + +func newCodexAdapterWrapper(a *codex.Adapter) *codexAdapterWrapper { + return &codexAdapterWrapper{Adapter: a} +} + +func (w *codexAdapterWrapper) GetAgentInfo() *AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &AgentInfo{Name: info.Name, Version: info.Version} +} + +// SetStderrProvider implements StderrProviderSetter +func (w *codexAdapterWrapper) SetStderrProvider(provider StderrProvider) { + w.Adapter.SetStderrProvider(provider) +} + +// openCodeAdapterWrapper wraps opencode.Adapter to implement AgentAdapter +type openCodeAdapterWrapper struct { + *opencode.Adapter +} + +func newOpenCodeAdapterWrapper(a *opencode.Adapter) *openCodeAdapterWrapper { + return &openCodeAdapterWrapper{Adapter: a} +} + +func (w *openCodeAdapterWrapper) GetAgentInfo() *AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &AgentInfo{Name: info.Name, Version: info.Version} +} diff --git a/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go b/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go deleted file mode 100644 index 736aba2a1c..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/opencode_adapter_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package adapter - -import ( - "encoding/json" - "testing" - "time" - - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/opencode" -) - -func newTestOpenCodeAdapter() *OpenCodeAdapter { - log, _ := logger.NewLogger(logger.LoggingConfig{ - Level: "error", - Format: "json", - }) - cfg := &Config{ - WorkDir: "/tmp/test", - AutoApprove: true, - } - return NewOpenCodeAdapter(cfg, log) -} - -// drainEvents reads events from the updates channel with a timeout -func drainEvents(adapter *OpenCodeAdapter, timeout time.Duration) []AgentEvent { - var events []AgentEvent - deadline := time.After(timeout) - for { - select { - case evt := <-adapter.updatesCh: - events = append(events, evt) - case <-deadline: - return events - default: - // Small sleep to avoid busy loop - time.Sleep(10 * time.Millisecond) - select { - case evt := <-adapter.updatesCh: - events = append(events, evt) - case <-deadline: - return events - default: - return events - } - } - } -} - -func TestOpenCodeAdapter_TextPartDeduplication(t *testing.T) { - adapter := newTestOpenCodeAdapter() - - sessionID := "test-session" - operationID := "test-op" - - // Track message role first (assistant message) - adapter.mu.Lock() - adapter.messageRoles = make(map[string]string) - adapter.messageRoles["msg-1"] = "assistant" - adapter.mu.Unlock() - - // Simulate streaming events with cumulative text - // The key insight: part.Text is CUMULATIVE, not incremental - events := []struct { - partID string - text string // Cumulative text so far - delta string - }{ - {partID: "part-1", text: "Hello", delta: "Hello"}, - {partID: "part-1", text: "Hello world", delta: " world"}, - {partID: "part-1", text: "Hello world!", delta: "!"}, - } - - for _, evt := range events { - props := opencode.MessagePartUpdatedProperties{ - Part: opencode.Part{ - ID: evt.partID, - Type: opencode.PartTypeText, - MessageID: "msg-1", - SessionID: sessionID, - Text: evt.text, - }, - Delta: evt.delta, - } - propsJSON, _ := json.Marshal(props) - adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID) - } - - // Collect sent events - sentEvents := drainEvents(adapter, 100*time.Millisecond) - - // Should have received 3 events with incremental text - if len(sentEvents) != 3 { - t.Fatalf("expected 3 events, got %d", len(sentEvents)) - } - - // Verify text is incremental (computed from cumulative), not duplicated - expectedTexts := []string{"Hello", " world", "!"} - for i, evt := range sentEvents { - if evt.Text != expectedTexts[i] { - t.Errorf("event %d: expected text %q, got %q", i, expectedTexts[i], evt.Text) - } - } -} - -func TestOpenCodeAdapter_TextDeduplicationWithRepeatedDeltas(t *testing.T) { - adapter := newTestOpenCodeAdapter() - - sessionID := "test-session" - operationID := "test-op" - - // Track message role - adapter.mu.Lock() - adapter.messageRoles = make(map[string]string) - adapter.messageRoles["msg-1"] = "assistant" - adapter.mu.Unlock() - - // Simulate the problematic case: same delta sent multiple times - // This can happen when OpenCode sends multiple events with the same delta - // Our fix: we prefer cumulative text over delta - events := []struct { - text string - delta string - }{ - {text: "Done", delta: "Done"}, - {text: "Done", delta: "Done"}, // Duplicate event with same delta - {text: "Done.", delta: "."}, - } - - for _, evt := range events { - props := opencode.MessagePartUpdatedProperties{ - Part: opencode.Part{ - ID: "part-1", - Type: opencode.PartTypeText, - MessageID: "msg-1", - SessionID: sessionID, - Text: evt.text, - }, - Delta: evt.delta, - } - propsJSON, _ := json.Marshal(props) - adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID) - } - - sentEvents := drainEvents(adapter, 100*time.Millisecond) - - // Should only have 2 events (the duplicate should be filtered) - if len(sentEvents) != 2 { - t.Fatalf("expected 2 events (duplicate filtered), got %d", len(sentEvents)) - } - - // Verify final combined text - combinedText := "" - for _, evt := range sentEvents { - combinedText += evt.Text - } - if combinedText != "Done." { - t.Errorf("expected combined text 'Done.', got %q", combinedText) - } -} - -func TestOpenCodeAdapter_FilterUserMessages(t *testing.T) { - adapter := newTestOpenCodeAdapter() - - sessionID := "test-session" - operationID := "test-op" - - // Track message roles - adapter.mu.Lock() - adapter.messageRoles = make(map[string]string) - adapter.messageRoles["user-msg"] = "user" - adapter.messageRoles["assistant-msg"] = "assistant" - adapter.mu.Unlock() - - // Send user message part - should be filtered - userProps := opencode.MessagePartUpdatedProperties{ - Part: opencode.Part{ - ID: "user-part", - Type: opencode.PartTypeText, - MessageID: "user-msg", - SessionID: sessionID, - Text: "User input that should not appear", - }, - } - userJSON, _ := json.Marshal(userProps) - adapter.handleMessagePartUpdated(userJSON, sessionID, operationID) - - // Send assistant message part - should be processed - assistantProps := opencode.MessagePartUpdatedProperties{ - Part: opencode.Part{ - ID: "assistant-part", - Type: opencode.PartTypeText, - MessageID: "assistant-msg", - SessionID: sessionID, - Text: "Assistant response", - }, - } - assistantJSON, _ := json.Marshal(assistantProps) - adapter.handleMessagePartUpdated(assistantJSON, sessionID, operationID) - - sentEvents := drainEvents(adapter, 100*time.Millisecond) - - // Should only have 1 event (assistant message) - if len(sentEvents) != 1 { - t.Fatalf("expected 1 event, got %d", len(sentEvents)) - } - - if sentEvents[0].Text != "Assistant response" { - t.Errorf("expected assistant response, got %q", sentEvents[0].Text) - } -} - -func TestOpenCodeAdapter_MessageRoleTracking(t *testing.T) { - adapter := newTestOpenCodeAdapter() - - sessionID := "test-session" - - // Simulate message.updated event - props := opencode.MessageUpdatedProperties{ - Info: opencode.MessageInfo{ - ID: "msg-123", - SessionID: sessionID, - Role: "assistant", - }, - } - propsJSON, _ := json.Marshal(props) - - adapter.handleMessageUpdated(propsJSON, sessionID) - - // Check role was tracked - adapter.mu.RLock() - role := adapter.messageRoles["msg-123"] - adapter.mu.RUnlock() - - if role != "assistant" { - t.Errorf("expected role 'assistant', got %q", role) - } -} - -func TestOpenCodeAdapter_ClearSessionState(t *testing.T) { - adapter := newTestOpenCodeAdapter() - - // Add some state - adapter.mu.Lock() - adapter.textParts = map[string]*textPartState{ - "old-part": {lastTextLen: 100}, - } - adapter.messageRoles = map[string]string{ - "old-msg": "assistant", - } - adapter.seenToolCalls = map[string]bool{ - "old-call": true, - } - adapter.mu.Unlock() - - // Clear state - adapter.clearSessionState() - - // Verify state is cleared - adapter.mu.RLock() - defer adapter.mu.RUnlock() - - if len(adapter.textParts) != 0 { - t.Errorf("expected textParts to be empty, got %d entries", len(adapter.textParts)) - } - if len(adapter.messageRoles) != 0 { - t.Errorf("expected messageRoles to be empty, got %d entries", len(adapter.messageRoles)) - } - if len(adapter.seenToolCalls) != 0 { - t.Errorf("expected seenToolCalls to be empty, got %d entries", len(adapter.seenToolCalls)) - } -} - -func TestOpenCodeAdapter_ToolCallDeduplication(t *testing.T) { - adapter := newTestOpenCodeAdapter() - - sessionID := "test-session" - operationID := "test-op" - - // Simulate multiple tool call events for the same tool call ID - for i := range 3 { - var status string - switch i { - case 1: - status = "running" - case 2: - status = "completed" - default: - status = "pending" - } - - props := opencode.MessagePartUpdatedProperties{ - Part: opencode.Part{ - ID: "tool-part-1", - Type: opencode.PartTypeTool, - MessageID: "msg-1", - SessionID: sessionID, - CallID: "call-123", - Tool: "bash", - State: &opencode.ToolStateUpdate{ - Status: status, - Title: "Running command", - }, - }, - } - propsJSON, _ := json.Marshal(props) - adapter.handleMessagePartUpdated(propsJSON, sessionID, operationID) - } - - sentEvents := drainEvents(adapter, 100*time.Millisecond) - - // Should have 1 tool_call event and 2 tool_update events - toolCallCount := 0 - toolUpdateCount := 0 - for _, evt := range sentEvents { - switch evt.Type { - case EventTypeToolCall: - toolCallCount++ - case EventTypeToolUpdate: - toolUpdateCount++ - } - } - - if toolCallCount != 1 { - t.Errorf("expected 1 tool_call event, got %d", toolCallCount) - } - if toolUpdateCount != 2 { - t.Errorf("expected 2 tool_update events, got %d", toolUpdateCount) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go similarity index 73% rename from apps/backend/internal/agentctl/server/adapter/acp_adapter.go rename to apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 8542b5121d..39a2504efe 100644 --- a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -1,7 +1,10 @@ -package adapter +// Package acp implements the ACP (Agent Communication Protocol) transport adapter. +// ACP uses JSON-RPC 2.0 over stdin/stdout for agent communication. +package acp import ( "context" + "encoding/json" "fmt" "io" "log/slog" @@ -9,19 +12,43 @@ import ( "github.com/coder/acp-go-sdk" acpclient "github.com/kandev/kandev/internal/agentctl/server/acp" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) -// ACPAdapter implements AgentAdapter for agents using the ACP protocol. +// Re-export types needed by external packages +type ( + PermissionRequest = types.PermissionRequest + PermissionResponse = types.PermissionResponse + PermissionOption = streams.PermissionOption + PermissionHandler = types.PermissionHandler + AgentEvent = streams.AgentEvent + PlanEntry = streams.PlanEntry +) + +// AgentInfo contains information about the connected agent. +type AgentInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Adapter implements the transport adapter for agents using the ACP protocol. // ACP (Agent Communication Protocol) uses JSON-RPC 2.0 over stdin/stdout. // The subprocess is managed externally (by process.Manager) and stdin/stdout // are connected via the Connect method after the process starts. -type ACPAdapter struct { - cfg *Config +type Adapter struct { + cfg *shared.Config logger *logger.Logger + // Agent identity (from config, for logging) + agentID string + + // Normalizer for converting tool data to NormalizedPayload + normalizer *Normalizer + // Subprocess stdin/stdout (set via Connect) stdin io.Writer stdout io.Reader @@ -50,24 +77,27 @@ type ACPAdapter struct { closed bool } -// NewACPAdapter creates a new ACP protocol adapter. +// NewAdapter creates a new ACP protocol adapter. // Call Connect() after starting the subprocess to wire up stdin/stdout. -func NewACPAdapter(cfg *Config, log *logger.Logger) *ACPAdapter { - return &ACPAdapter{ - cfg: cfg, - logger: log.WithFields(zap.String("adapter", "acp")), - updatesCh: make(chan AgentEvent, 100), +// cfg.AgentID is required for debug file naming. +func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { + return &Adapter{ + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "acp"), zap.String("agent_id", cfg.AgentID)), + agentID: cfg.AgentID, + normalizer: NewNormalizer(), + updatesCh: make(chan AgentEvent, 100), } } // PrepareEnvironment is a no-op for ACP. // ACP passes MCP servers through the protocol during session creation. -func (a *ACPAdapter) PrepareEnvironment() (map[string]string, error) { +func (a *Adapter) PrepareEnvironment() (map[string]string, error) { return nil, nil } // Connect wires up the stdin/stdout pipes from the running agent subprocess. -func (a *ACPAdapter) Connect(stdin io.Writer, stdout io.Reader) error { +func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() defer a.mu.Unlock() @@ -82,7 +112,7 @@ func (a *ACPAdapter) Connect(stdin io.Writer, stdout io.Reader) error { // Initialize establishes the ACP connection with the agent subprocess. // The subprocess should already be running (started by process.Manager). -func (a *ACPAdapter) Initialize(ctx context.Context) error { +func (a *Adapter) Initialize(ctx context.Context) error { a.logger.Info("initializing ACP adapter", zap.String("workdir", a.cfg.WorkDir)) @@ -129,12 +159,12 @@ func (a *ACPAdapter) Initialize(ctx context.Context) error { } // GetAgentInfo returns information about the connected agent. -func (a *ACPAdapter) GetAgentInfo() *AgentInfo { +func (a *Adapter) GetAgentInfo() *AgentInfo { return a.agentInfo } // NewSession creates a new agent session. -func (a *ACPAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) { +func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) { a.mu.Lock() defer a.mu.Unlock() @@ -187,7 +217,7 @@ func toACPMcpServers(servers []types.McpServer) []acp.McpServer { // LoadSession resumes an existing session. // Returns an error if the agent does not support session loading (LoadSession capability). -func (a *ACPAdapter) LoadSession(ctx context.Context, sessionID string) error { +func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { a.mu.Lock() defer a.mu.Unlock() @@ -215,7 +245,7 @@ func (a *ACPAdapter) LoadSession(ctx context.Context, sessionID string) error { // Prompt sends a prompt to the agent. // If pending context is set (from SetPendingContext), it will be prepended to the message. -func (a *ACPAdapter) Prompt(ctx context.Context, message string) error { +func (a *Adapter) Prompt(ctx context.Context, message string) error { a.mu.Lock() conn := a.acpConn sessionID := a.sessionID @@ -248,14 +278,14 @@ func (a *ACPAdapter) Prompt(ctx context.Context, message string) error { // SetPendingContext sets the context to be injected into the next prompt. // This is used by the fork_session pattern for ACP agents that don't support session/load. // The context will be prepended to the first prompt sent to this session. -func (a *ACPAdapter) SetPendingContext(context string) { +func (a *Adapter) SetPendingContext(context string) { a.mu.Lock() defer a.mu.Unlock() a.pendingContext = context } // Cancel cancels the current operation. -func (a *ACPAdapter) Cancel(ctx context.Context) error { +func (a *Adapter) Cancel(ctx context.Context) error { a.mu.RLock() conn := a.acpConn sessionID := a.sessionID @@ -273,12 +303,12 @@ func (a *ACPAdapter) Cancel(ctx context.Context) error { } // Updates returns the channel for agent events. -func (a *ACPAdapter) Updates() <-chan AgentEvent { +func (a *Adapter) Updates() <-chan AgentEvent { return a.updatesCh } // GetSessionID returns the current session ID. -func (a *ACPAdapter) GetSessionID() string { +func (a *Adapter) GetSessionID() string { a.mu.RLock() defer a.mu.RUnlock() return a.sessionID @@ -286,20 +316,20 @@ func (a *ACPAdapter) GetSessionID() string { // GetOperationID returns the current operation/turn ID. // ACP protocol doesn't have explicit turn/operation IDs, so this returns empty string. -func (a *ACPAdapter) GetOperationID() string { +func (a *Adapter) GetOperationID() string { // ACP doesn't have explicit operation/turn IDs return "" } // SetPermissionHandler sets the handler for permission requests. -func (a *ACPAdapter) SetPermissionHandler(handler PermissionHandler) { +func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { a.mu.Lock() defer a.mu.Unlock() a.permissionHandler = handler } // Close releases resources held by the adapter. -func (a *ACPAdapter) Close() error { +func (a *Adapter) Close() error { a.mu.Lock() defer a.mu.Unlock() @@ -320,21 +350,27 @@ func (a *ACPAdapter) Close() error { } // RequiresProcessKill returns false because ACP agents exit when stdin is closed. -func (a *ACPAdapter) RequiresProcessKill() bool { +func (a *Adapter) RequiresProcessKill() bool { return false } // GetACPConnection returns the underlying ACP connection for advanced usage. -func (a *ACPAdapter) GetACPConnection() *acp.ClientSideConnection { +func (a *Adapter) GetACPConnection() *acp.ClientSideConnection { a.mu.RLock() defer a.mu.RUnlock() return a.acpConn } // handleACPUpdate converts ACP SessionNotification to protocol-agnostic AgentEvent. -func (a *ACPAdapter) handleACPUpdate(n acp.SessionNotification) { +func (a *Adapter) handleACPUpdate(n acp.SessionNotification) { + // Log raw event for debugging + if rawData, err := json.Marshal(n); err == nil { + shared.LogRawEvent(shared.ProtocolACP, a.agentID, "session_notification", rawData) + } + event := a.convertNotification(n) if event != nil { + shared.LogNormalizedEvent(shared.ProtocolACP, a.agentID, event) select { case a.updatesCh <- *event: default: @@ -344,16 +380,17 @@ func (a *ACPAdapter) handleACPUpdate(n acp.SessionNotification) { } // convertNotification converts an ACP SessionNotification to an AgentEvent. -func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent { +func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { u := n.Update switch { case u.AgentMessageChunk != nil: if u.AgentMessageChunk.Content.Text != nil { + text := u.AgentMessageChunk.Content.Text.Text return &AgentEvent{ - Type: EventTypeMessageChunk, + Type: streams.EventTypeMessageChunk, SessionID: string(n.SessionId), - Text: u.AgentMessageChunk.Content.Text.Text, + Text: text, } } @@ -361,16 +398,17 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent // Agent thinking/reasoning - map to the reasoning type // Note: Only models with extended thinking (e.g., Opus 4.5) send agent_thought_chunk if u.AgentThoughtChunk.Content.Text != nil { + text := u.AgentThoughtChunk.Content.Text.Text return &AgentEvent{ - Type: EventTypeReasoning, + Type: streams.EventTypeReasoning, SessionID: string(n.SessionId), - ReasoningText: u.AgentThoughtChunk.Content.Text.Text, + ReasoningText: text, } } case u.ToolCall != nil: // Extract rich tool call information - args := map[string]interface{}{} + args := map[string]any{} // Add tool kind if u.ToolCall.Kind != "" { @@ -379,9 +417,9 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent // Add locations (file paths with line numbers) if len(u.ToolCall.Locations) > 0 { - locations := make([]map[string]interface{}, len(u.ToolCall.Locations)) + locations := make([]map[string]any, len(u.ToolCall.Locations)) for i, loc := range u.ToolCall.Locations { - locMap := map[string]interface{}{"path": loc.Path} + locMap := map[string]any{"path": loc.Path} if loc.Line != nil { locMap["line"] = *loc.Line } @@ -398,6 +436,14 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent args["raw_input"] = u.ToolCall.RawInput } + // Generate normalized payload using the normalizer + toolKind := string(u.ToolCall.Kind) + normalizedPayload := a.normalizer.NormalizeToolCall(toolKind, args) + + // Detect tool type for logging + toolType := DetectToolOperationType(toolKind, args) + _ = toolType // Used for normalization + // Normalize status - if empty, default to "running" for tool_call start status := string(u.ToolCall.Status) if status == "" { @@ -405,13 +451,13 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent } return &AgentEvent{ - Type: EventTypeToolCall, - SessionID: string(n.SessionId), - ToolCallID: string(u.ToolCall.ToolCallId), - ToolName: string(u.ToolCall.Kind), // Kind is effectively the tool name - ToolTitle: u.ToolCall.Title, - ToolStatus: status, - ToolArgs: args, + Type: streams.EventTypeToolCall, + SessionID: string(n.SessionId), + ToolCallID: string(u.ToolCall.ToolCallId), + ToolName: string(u.ToolCall.Kind), // Kind is effectively the tool name + ToolTitle: u.ToolCall.Title, + ToolStatus: status, + NormalizedPayload: normalizedPayload, } case u.ToolCallUpdate != nil: @@ -419,12 +465,12 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent if u.ToolCallUpdate.Status != nil { status = string(*u.ToolCallUpdate.Status) } - // Normalize status - "completed" → "complete" for frontend consistency + // Normalize status - "completed" -> "complete" for frontend consistency if status == "completed" { status = "complete" } return &AgentEvent{ - Type: EventTypeToolUpdate, + Type: streams.EventTypeToolUpdate, SessionID: string(n.SessionId), ToolCallID: string(u.ToolCallUpdate.ToolCallId), ToolStatus: status, @@ -440,7 +486,7 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent } } return &AgentEvent{ - Type: EventTypePlan, + Type: streams.EventTypePlan, SessionID: string(n.SessionId), PlanEntries: entries, } @@ -452,7 +498,7 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent // handlePermissionRequest handles permission requests from the agent. // Since both acpclient and adapter now use the shared types package, // no conversion is needed - we just forward to the handler. -func (a *ACPAdapter) handlePermissionRequest(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error) { +func (a *Adapter) handlePermissionRequest(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error) { a.mu.RLock() handler := a.permissionHandler sessionID := a.sessionID @@ -462,23 +508,12 @@ func (a *ACPAdapter) handlePermissionRequest(ctx context.Context, req *Permissio // This is needed because permission requests bypass the normal ToolCall notification flow. // Without this, when the tool completes (ToolCallUpdate), there's no message to update. toolCallEvent := AgentEvent{ - Type: EventTypeToolCall, + Type: streams.EventTypeToolCall, SessionID: sessionID, ToolCallID: req.ToolCallID, ToolName: req.ActionType, // Use action type as tool name (e.g., "run_shell_command") ToolTitle: req.Title, ToolStatus: "pending_permission", // Mark as pending permission - ToolArgs: map[string]interface{}{ - "permission_request": true, - "action_type": req.ActionType, - }, - } - - // Add action details if available - if req.ActionDetails != nil { - for k, v := range req.ActionDetails { - toolCallEvent.ToolArgs[k] = v - } } // Emit the tool_call event @@ -501,6 +536,3 @@ func (a *ACPAdapter) handlePermissionRequest(ctx context.Context, req *Permissio // Forward directly to handler - types are already compatible return handler(ctx, req) } - -// Verify interface implementation -var _ AgentAdapter = (*ACPAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go new file mode 100644 index 0000000000..1c00a54ab6 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -0,0 +1,213 @@ +package acp + +import ( + "strings" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// DetectToolOperationType determines the specific tool operation type from ACP tool data. +// Used for logging and backwards compatibility. +func DetectToolOperationType(toolKind string, args map[string]any) string { + // Check Auggie's "kind" field first + if kind, ok := args["kind"].(string); ok { + switch kind { + case "edit": + return "tool_edit" + case "read": + return "tool_read" + case "execute": + return "tool_execute" + } + } + + // Fallback to tool kind/name matching + switch strings.ToLower(toolKind) { + case "edit": + return "tool_edit" + case "read", "view": + return "tool_read" + case "execute", "bash", "run": + return "tool_execute" + default: + return "tool_call" // Generic fallback + } +} + +// Normalizer converts ACP protocol tool data to NormalizedPayload. +type Normalizer struct{} + +// NewNormalizer creates a new ACP normalizer. +func NewNormalizer() *Normalizer { + return &Normalizer{} +} + +// NormalizeToolCall converts ACP tool call data to NormalizedPayload. +func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { + // ACP uses "kind" field to identify tool type + kind, _ := args["kind"].(string) + if kind == "" { + kind = toolName + } + + switch strings.ToLower(kind) { + case "edit": + return n.normalizeEdit(args) + case "read", "view": + return n.normalizeRead(args) + case "execute", "bash", "run", "shell": + return n.normalizeExecute(args) + case "glob", "grep", "search": + return n.normalizeCodeSearch(toolName, args) + default: + return n.normalizeGeneric(toolName, args) + } +} + +// NormalizeToolResult updates the payload with tool result data. +func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { + switch payload.Kind { + case streams.ToolKindShellExec: + if payload.ShellExec != nil { + shared.NormalizeShellResult(payload.ShellExec, result) + } + case streams.ToolKindGeneric: + if payload.Generic != nil { + payload.Generic.Output = result + } + } +} + +// normalizeEdit converts ACP edit tool data. +func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { + rawInput, _ := args["raw_input"].(map[string]any) + if rawInput == nil { + rawInput = args + } + + // Get path from raw_input or locations + path := shared.GetString(rawInput, "path") + if path == "" { + path = extractPathFromLocations(args) + } + + payload := &streams.NormalizedPayload{ + Kind: streams.ToolKindModifyFile, + ModifyFile: &streams.ModifyFilePayload{ + FilePath: path, + Mutations: []streams.FileMutation{}, + }, + } + + // Check if this is file creation (has file_content) vs str_replace + if fileContent, ok := rawInput["file_content"].(string); ok { + payload.ModifyFile.Mutations = append(payload.ModifyFile.Mutations, streams.FileMutation{ + Type: streams.MutationCreate, + Content: fileContent, + }) + } else { + // str_replace operation + oldStr, _ := rawInput["old_str_1"].(string) + newStr, _ := rawInput["new_str_1"].(string) + + mutation := streams.FileMutation{ + Type: streams.MutationPatch, + OldContent: oldStr, + NewContent: newStr, + } + + // Add line numbers if available + if startLine, ok := rawInput["old_str_start_line_number_1"].(float64); ok { + mutation.StartLine = int(startLine) + } + if endLine, ok := rawInput["old_str_end_line_number_1"].(float64); ok { + mutation.EndLine = int(endLine) + } + + // Generate unified diff + if oldStr != "" && newStr != "" { + mutation.Diff = shared.GenerateUnifiedDiff(oldStr, newStr, path, mutation.StartLine) + } + + payload.ModifyFile.Mutations = append(payload.ModifyFile.Mutations, mutation) + } + + return payload +} + +// normalizeRead converts ACP read tool data. +func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { + rawInput, _ := args["raw_input"].(map[string]any) + if rawInput == nil { + rawInput = args + } + + path := shared.GetString(rawInput, "path") + if path == "" { + path = extractPathFromLocations(args) + } + + return streams.NewReadFile(path, 0, 0) +} + +// normalizeExecute converts ACP execute/bash tool data. +func (n *Normalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { + rawInput, _ := args["raw_input"].(map[string]any) + if rawInput == nil { + rawInput = args + } + + command := shared.GetString(rawInput, "command") + workDir := shared.GetString(rawInput, "cwd") + timeout := shared.GetInt(rawInput, "max_wait_seconds") + + // Background is true if wait is explicitly false + background := false + if wait, ok := rawInput["wait"].(bool); ok && !wait { + background = true + } + + return streams.NewShellExec(command, workDir, "", timeout, background) +} + +// normalizeCodeSearch converts ACP search tool data. +func (n *Normalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload { + rawInput, _ := args["raw_input"].(map[string]any) + if rawInput == nil { + rawInput = args + } + + path := shared.GetString(rawInput, "path") + pattern := shared.GetString(rawInput, "pattern") + + var query, glob string + switch strings.ToLower(toolName) { + case "glob": + glob = pattern + case "grep", "search": + query = shared.GetString(rawInput, "query") + } + + return streams.NewCodeSearch(query, pattern, path, glob) +} + +// normalizeGeneric wraps unknown tools as generic. +func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { + return streams.NewGeneric(toolName, args) +} + +// --- Helper functions --- + +func extractPathFromLocations(args map[string]any) string { + locations, ok := args["locations"].([]any) + if !ok || len(locations) == 0 { + return "" + } + loc, ok := locations[0].(map[string]any) + if !ok { + return "" + } + path, _ := loc["path"].(string) + return path +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go new file mode 100644 index 0000000000..963cee6828 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go @@ -0,0 +1,405 @@ +package acp + +import ( + "fmt" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// TestACPNormalization runs JSONL-driven tests for ACP protocol normalization. +func TestACPNormalization(t *testing.T) { + testCases := shared.LoadTestCases(t, "acp-messages.jsonl") + normalizer := NewNormalizer() + + for i, tc := range testCases { + t.Run(fmt.Sprintf("line_%d", i+1), func(t *testing.T) { + args, _ := tc.Input["args"].(map[string]any) + + // Detect tool type using the kind field (as the ACP adapter does) + kind, _ := args["kind"].(string) + toolType := DetectToolOperationType(kind, args) + expectedToolType, _ := tc.Expected["tool_type"].(string) + if toolType != expectedToolType { + t.Errorf("tool type mismatch: got %q, want %q", toolType, expectedToolType) + } + + // Normalize using the typed normalizer + payload := normalizer.NormalizeToolCall(kind, args) + + // Verify the Kind is set correctly based on tool type + switch toolType { + case "tool_edit": + if payload.Kind != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind) + } + case "tool_read": + if payload.Kind != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, payload.Kind) + } + case "tool_execute": + if payload.Kind != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind) + } + } + }) + } +} + +// TestDetectToolOperationType tests the ACP tool type detection function. +func TestDetectToolOperationType(t *testing.T) { + tests := []struct { + name string + toolKind string + args map[string]any + want string + }{ + { + name: "edit kind from args", + toolKind: "", + args: map[string]any{"kind": "edit"}, + want: "tool_edit", + }, + { + name: "read kind from args", + toolKind: "", + args: map[string]any{"kind": "read"}, + want: "tool_read", + }, + { + name: "execute kind from args", + toolKind: "", + args: map[string]any{"kind": "execute"}, + want: "tool_execute", + }, + { + name: "edit from toolKind parameter", + toolKind: "edit", + args: map[string]any{}, + want: "tool_edit", + }, + { + name: "read from toolKind parameter", + toolKind: "read", + args: map[string]any{}, + want: "tool_read", + }, + { + name: "view from toolKind parameter", + toolKind: "view", + args: map[string]any{}, + want: "tool_read", + }, + { + name: "bash from toolKind parameter", + toolKind: "bash", + args: map[string]any{}, + want: "tool_execute", + }, + { + name: "run from toolKind parameter", + toolKind: "run", + args: map[string]any{}, + want: "tool_execute", + }, + { + name: "unknown kind falls back to tool_call", + toolKind: "search", + args: map[string]any{"kind": "search"}, + want: "tool_call", + }, + { + name: "empty kind and args falls back to tool_call", + toolKind: "", + args: map[string]any{}, + want: "tool_call", + }, + { + name: "args kind takes priority over toolKind", + toolKind: "read", + args: map[string]any{"kind": "edit"}, + want: "tool_edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectToolOperationType(tt.toolKind, tt.args) + if got != tt.want { + t.Errorf("DetectToolOperationType(%q, %v) = %q, want %q", tt.toolKind, tt.args, got, tt.want) + } + }) + } +} + +// TestDetectLanguage tests the language detection from file extensions. +func TestDetectLanguage(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"file.ts", "typescript"}, + {"file.tsx", "typescript"}, + {"file.js", "javascript"}, + {"file.jsx", "javascript"}, + {"file.py", "python"}, + {"file.go", "go"}, + {"file.rs", "rust"}, + {"file.java", "java"}, + {"file.cpp", "cpp"}, + {"file.c", "c"}, + {"file.h", "c"}, + {"file.hpp", "cpp"}, + {"file.css", "css"}, + {"file.html", "html"}, + {"file.json", "json"}, + {"file.md", "markdown"}, + {"file.yaml", "yaml"}, + {"file.yml", "yaml"}, + {"file.sh", "bash"}, + {"file.bash", "bash"}, + {"file.unknown", "plaintext"}, + {"file", "plaintext"}, + {"", "plaintext"}, + {"/path/to/deep/file.ts", "typescript"}, + {"src/main.go", "go"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := shared.DetectLanguage(tt.path) + if got != tt.want { + t.Errorf("DetectLanguage(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +// TestGenerateUnifiedDiff tests the unified diff generation. +func TestGenerateUnifiedDiff(t *testing.T) { + t.Run("returns empty when old is empty", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1) + if result != "" { + t.Errorf("expected empty string for empty old string, got %q", result) + } + }) + + t.Run("returns empty when new is empty", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1) + if result != "" { + t.Errorf("expected empty string for empty new string, got %q", result) + } + }) + + t.Run("generates diff with header and hunks", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("const x = 1;", "const x = 2;", "file.ts", 5) + if result == "" { + t.Fatal("expected non-empty diff") + } + + // Check it contains expected elements + if !strings.Contains(result, "diff --git a/file.ts b/file.ts") { + t.Error("expected diff header") + } + if !strings.Contains(result, "--- a/file.ts") { + t.Error("expected old file marker") + } + if !strings.Contains(result, "+++ b/file.ts") { + t.Error("expected new file marker") + } + if !strings.Contains(result, "-const x = 1;") { + t.Error("expected removed line") + } + if !strings.Contains(result, "+const x = 2;") { + t.Error("expected added line") + } + }) + + t.Run("defaults startLine to 1 when 0", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("a", "b", "file.go", 0) + if result == "" { + t.Fatal("expected non-empty diff") + } + // Verify it contains @@ -1 (defaulted from 0) + if !strings.Contains(result, "@@ -1,") { + t.Error("expected startLine to default to 1") + } + }) +} + +// TestNormalizerEdit tests the normalizer's edit handling. +func TestNormalizerEdit(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts fields from raw_input", func(t *testing.T) { + args := map[string]any{ + "kind": "edit", + "raw_input": map[string]any{ + "old_str_1": "old code", + "new_str_1": "new code", + "path": "file.ts", + "old_str_start_line_number_1": float64(10), + "old_str_end_line_number_1": float64(15), + }, + } + + result := normalizer.NormalizeToolCall("edit", args) + if result.Kind != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind) + } + if result.ModifyFile == nil { + t.Fatal("expected ModifyFile to be set") + } + if result.ModifyFile.FilePath != "file.ts" { + t.Errorf("expected FilePath 'file.ts', got %q", result.ModifyFile.FilePath) + } + if len(result.ModifyFile.Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile.Mutations)) + } + mutation := result.ModifyFile.Mutations[0] + if mutation.OldContent != "old code" { + t.Errorf("expected OldContent 'old code', got %q", mutation.OldContent) + } + if mutation.NewContent != "new code" { + t.Errorf("expected NewContent 'new code', got %q", mutation.NewContent) + } + if mutation.StartLine != 10 { + t.Errorf("expected StartLine 10, got %d", mutation.StartLine) + } + if mutation.EndLine != 15 { + t.Errorf("expected EndLine 15, got %d", mutation.EndLine) + } + }) + + t.Run("falls back to locations for path", func(t *testing.T) { + args := map[string]any{ + "kind": "edit", + "locations": []any{ + map[string]any{"path": "/workspace/fallback.ts"}, + }, + "raw_input": map[string]any{ + "old_str_1": "a", + "new_str_1": "b", + "path": "", + }, + } + + result := normalizer.NormalizeToolCall("edit", args) + if result.ModifyFile.FilePath != "/workspace/fallback.ts" { + t.Errorf("expected FilePath '/workspace/fallback.ts', got %q", result.ModifyFile.FilePath) + } + }) +} + +// TestNormalizerRead tests the normalizer's read handling. +func TestNormalizerRead(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts path from raw_input", func(t *testing.T) { + args := map[string]any{ + "kind": "read", + "raw_input": map[string]any{ + "path": "config.json", + }, + } + result := normalizer.NormalizeToolCall("read", args) + if result.Kind != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind) + } + if result.ReadFile == nil { + t.Fatal("expected ReadFile to be set") + } + if result.ReadFile.FilePath != "config.json" { + t.Errorf("expected FilePath 'config.json', got %q", result.ReadFile.FilePath) + } + }) + + t.Run("falls back to locations for path", func(t *testing.T) { + args := map[string]any{ + "kind": "read", + "locations": []any{ + map[string]any{"path": "/workspace/README.md"}, + }, + "raw_input": map[string]any{ + "path": "", + }, + } + result := normalizer.NormalizeToolCall("read", args) + if result.ReadFile.FilePath != "/workspace/README.md" { + t.Errorf("expected FilePath '/workspace/README.md', got %q", result.ReadFile.FilePath) + } + }) +} + +// TestNormalizerExecute tests the normalizer's execute handling. +func TestNormalizerExecute(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts command, cwd, timeout", func(t *testing.T) { + args := map[string]any{ + "kind": "execute", + "raw_input": map[string]any{ + "command": "npm test", + "cwd": "/workspace", + "max_wait_seconds": float64(30), + }, + } + result := normalizer.NormalizeToolCall("execute", args) + if result.Kind != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind) + } + if result.ShellExec == nil { + t.Fatal("expected ShellExec to be set") + } + if result.ShellExec.Command != "npm test" { + t.Errorf("expected Command 'npm test', got %q", result.ShellExec.Command) + } + if result.ShellExec.WorkDir != "/workspace" { + t.Errorf("expected WorkDir '/workspace', got %q", result.ShellExec.WorkDir) + } + if result.ShellExec.Timeout != 30 { + t.Errorf("expected Timeout 30, got %d", result.ShellExec.Timeout) + } + }) + + t.Run("handles background flag", func(t *testing.T) { + args := map[string]any{ + "kind": "execute", + "raw_input": map[string]any{ + "command": "npm start", + "wait": false, + }, + } + result := normalizer.NormalizeToolCall("execute", args) + if !result.ShellExec.Background { + t.Error("expected Background to be true when wait is false") + } + }) +} + +// TestSplitLines tests the line splitting utility. +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty string", "", 0}, + {"single line no newline", "hello", 1}, + {"single line with newline", "hello\n", 2}, // splits to ["hello", ""] + {"two lines", "hello\nworld", 2}, + {"crlf line endings", "hello\r\nworld", 2}, + {"multiple lines", "a\nb\nc\nd", 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shared.SplitLines(tt.input) + if len(got) != tt.want { + t.Errorf("SplitLines(%q) returned %d lines, want %d", tt.input, len(got), tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl new file mode 100644 index 0000000000..c2e2632f70 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl @@ -0,0 +1,10 @@ +{"key":"edit","input":{"tool_name":"edit","args":{"kind":"edit","locations":[{"path":"/Users/test/file.ts"}],"path":"/Users/test/file.ts","raw_input":{"command":"str_replace","old_str_1":"const x = 1;","new_str_1":"const x = 2;","old_str_start_line_number_1":5,"old_str_end_line_number_1":5,"path":"file.ts"}}},"expected":{"tool_type":"tool_edit","file_path":"file.ts","old_content":"const x = 1;","new_content":"const x = 2;","start_line":5,"end_line":5}} +{"key":"edit:2","input":{"tool_name":"edit","args":{"kind":"edit","raw_input":{"command":"str_replace","old_str_1":"function hello() {\n return \"world\";\n}","new_str_1":"function hello() {\n return \"universe\";\n}","old_str_start_line_number_1":10,"old_str_end_line_number_1":12,"path":"src/main.go"}}},"expected":{"tool_type":"tool_edit","file_path":"src/main.go","old_content":"function hello() {\n return \"world\";\n}","new_content":"function hello() {\n return \"universe\";\n}"}} +{"key":"edit:3","input":{"tool_name":"edit","args":{"kind":"edit","locations":[{"path":"/workspace/app.py"}],"raw_input":{"command":"str_replace","old_str_1":"import os","new_str_1":"import os\nimport sys","path":""}}},"expected":{"tool_type":"tool_edit","file_path":"/workspace/app.py","old_content":"import os","new_content":"import os\nimport sys"}} +{"key":"read","input":{"tool_name":"read","args":{"kind":"read","raw_input":{"path":"config.json"}}},"expected":{"tool_type":"tool_read","file_path":"config.json"}} +{"key":"read:2","input":{"tool_name":"read","args":{"kind":"read","locations":[{"path":"/workspace/README.md"}],"raw_input":{"path":""}}},"expected":{"tool_type":"tool_read","file_path":"/workspace/README.md"}} +{"key":"read:3","input":{"tool_name":"read","args":{"kind":"read","raw_input":{"path":"src/utils.ts"}}},"expected":{"tool_type":"tool_read","file_path":"src/utils.ts"}} +{"key":"execute","input":{"tool_name":"execute","args":{"kind":"execute","raw_input":{"command":"npm test","cwd":"/workspace","max_wait_seconds":30}}},"expected":{"tool_type":"tool_execute","command":"npm test","cwd":"/workspace","timeout":30}} +{"key":"execute:2","input":{"tool_name":"execute","args":{"kind":"execute","raw_input":{"command":"go build ./...","cwd":"","max_wait_seconds":0}}},"expected":{"tool_type":"tool_execute","command":"go build ./..."}} +{"key":"execute:3","input":{"tool_name":"execute","args":{"kind":"execute","raw_input":{"command":"ls -la","cwd":"/tmp","max_wait_seconds":10}}},"expected":{"tool_type":"tool_execute","command":"ls -la","cwd":"/tmp","timeout":10}} +{"key":"search","input":{"tool_name":"search","args":{"kind":"search","raw_input":{"query":"TODO"}}},"expected":{"tool_type":"tool_call"}} diff --git a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go similarity index 84% rename from apps/backend/internal/agentctl/server/adapter/codex_adapter.go rename to apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go index b2d24cdfd3..f3aebc7e9b 100644 --- a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go @@ -1,4 +1,6 @@ -package adapter +// Package codex implements the Codex transport adapter. +// Codex uses a JSON-RPC 2.0 variant over stdio (omitting the jsonrpc field). +package codex import ( "context" @@ -10,19 +12,48 @@ import ( "strings" "sync" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/codex" "github.com/pelletier/go-toml/v2" "go.uber.org/zap" ) -// CodexAdapter implements AgentAdapter for agents using the OpenAI Codex app-server protocol. +// Re-export types needed by external packages +type ( + PermissionRequest = types.PermissionRequest + PermissionResponse = types.PermissionResponse + PermissionOption = streams.PermissionOption + PermissionHandler = types.PermissionHandler + AgentEvent = streams.AgentEvent + PlanEntry = streams.PlanEntry +) + +// AgentInfo contains information about the connected agent. +type AgentInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// StderrProvider provides access to recent stderr output for error context. +type StderrProvider interface { + GetRecentStderr() []string +} + +// Adapter implements the transport adapter for agents using the Codex protocol. // Codex uses a JSON-RPC 2.0 variant over stdio (omitting the jsonrpc field). -type CodexAdapter struct { - cfg *Config +type Adapter struct { + cfg *shared.Config logger *logger.Logger + // Agent identity (from config, for logging) + agentID string + + // Normalizer for converting tool data to NormalizedPayload + normalizer *Normalizer + // Subprocess stdin/stdout (set via Connect) stdin io.Writer stdout io.Reader @@ -68,22 +99,24 @@ type turnCompleteResult struct { err string } -// NewCodexAdapter creates a new Codex protocol adapter. +// NewAdapter creates a new Codex protocol adapter. // Call Connect() after starting the subprocess to wire up stdin/stdout. -func NewCodexAdapter(cfg *Config, log *logger.Logger) *CodexAdapter { +func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { ctx, cancel := context.WithCancel(context.Background()) - return &CodexAdapter{ - cfg: cfg, - logger: log.WithFields(zap.String("adapter", "codex")), - ctx: ctx, - cancel: cancel, - updatesCh: make(chan AgentEvent, 100), + return &Adapter{ + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "codex"), zap.String("agent_id", cfg.AgentID)), + agentID: cfg.AgentID, + normalizer: NewNormalizer(), + ctx: ctx, + cancel: cancel, + updatesCh: make(chan AgentEvent, 100), } } // PrepareEnvironment writes MCP servers to the Codex config file. // Codex reads MCP configuration from ~/.codex/config.toml at startup time. -func (a *CodexAdapter) PrepareEnvironment() (map[string]string, error) { +func (a *Adapter) PrepareEnvironment() (map[string]string, error) { a.logger.Info("PrepareEnvironment called", zap.Int("mcp_server_count", len(a.cfg.McpServers))) for i, srv := range a.cfg.McpServers { @@ -98,7 +131,7 @@ func (a *CodexAdapter) PrepareEnvironment() (map[string]string, error) { } // Connect wires up the stdin/stdout pipes from the running agent subprocess. -func (a *CodexAdapter) Connect(stdin io.Writer, stdout io.Reader) error { +func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() defer a.mu.Unlock() @@ -116,7 +149,7 @@ func (a *CodexAdapter) Connect(stdin io.Writer, stdout io.Reader) error { // Codex reads MCP servers from ~/.codex/config.toml at startup time, not through the protocol. // This function should be called before starting the Codex process. // If homeDir is empty, it uses os.UserHomeDir(). -func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logger.Logger) error { +func WriteCodexMcpConfig(mcpServers []shared.McpServerConfig, homeDir string, log *logger.Logger) error { if len(mcpServers) == 0 { return nil // Nothing to configure } @@ -133,7 +166,7 @@ func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logg configPath := filepath.Join(configDir, "config.toml") // Create config directory if it doesn't exist - if err := os.MkdirAll(configDir, 0755); err != nil { + if err := os.MkdirAll(configDir, 0o755); err != nil { return fmt.Errorf("failed to create codex config directory: %w", err) } @@ -144,7 +177,7 @@ func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logg } // Parse existing config into a generic map to preserve all fields - var rawConfig map[string]interface{} + var rawConfig map[string]any if len(existingData) > 0 { if err := toml.Unmarshal(existingData, &rawConfig); err != nil { if log != nil { @@ -152,22 +185,22 @@ func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logg zap.String("path", configPath), zap.Error(err)) } - rawConfig = make(map[string]interface{}) + rawConfig = make(map[string]any) } } else { - rawConfig = make(map[string]interface{}) + rawConfig = make(map[string]any) } // Get or create mcp_servers section - mcpServersSection, ok := rawConfig["mcp_servers"].(map[string]interface{}) + mcpServersSection, ok := rawConfig["mcp_servers"].(map[string]any) if !ok { - mcpServersSection = make(map[string]interface{}) + mcpServersSection = make(map[string]any) } // Add/update our MCP servers for _, server := range mcpServers { safeName := sanitizeCodexServerName(server.Name) - serverConfig := make(map[string]interface{}) + serverConfig := make(map[string]any) if server.Type == "sse" || server.Type == "http" { // HTTP/SSE transport - use url field @@ -201,7 +234,7 @@ func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logg } // Write config file - if err := os.WriteFile(configPath, output, 0644); err != nil { + if err := os.WriteFile(configPath, output, 0o644); err != nil { return fmt.Errorf("failed to write codex config: %w", err) } @@ -245,7 +278,7 @@ func convertSSEToStreamableHTTP(url string) string { } // Initialize establishes the Codex connection with the agent subprocess. -func (a *CodexAdapter) Initialize(ctx context.Context) error { +func (a *Adapter) Initialize(ctx context.Context) error { a.logger.Info("initializing Codex adapter", zap.String("workdir", a.cfg.WorkDir)) @@ -289,7 +322,7 @@ func (a *CodexAdapter) Initialize(ctx context.Context) error { // Store agent info a.agentInfo = &AgentInfo{ - Name: "codex", + Name: a.agentID, Version: initResult.UserAgent, } @@ -300,7 +333,7 @@ func (a *CodexAdapter) Initialize(ctx context.Context) error { } // GetAgentInfo returns information about the connected agent. -func (a *CodexAdapter) GetAgentInfo() *AgentInfo { +func (a *Adapter) GetAgentInfo() *AgentInfo { return a.agentInfo } @@ -308,7 +341,7 @@ func (a *CodexAdapter) GetAgentInfo() *AgentInfo { // Note: The mcpServers parameter is ignored because Codex reads MCP configuration from // ~/.codex/config.toml at startup time, not through the protocol. MCP servers are written // to the config file by PrepareEnvironment() before the Codex process starts. -func (a *CodexAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { +func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { // Check client under lock, but don't hold lock during Call() to avoid deadlock // with handleNotification which also needs the lock a.mu.RLock() @@ -364,7 +397,7 @@ func (a *CodexAdapter) NewSession(ctx context.Context, _ []types.McpServer) (str // LoadSession resumes an existing Codex thread. // It passes the same approval policy and sandbox settings as NewSession to ensure // permission requirements are preserved across resume (see openai/codex#5322). -func (a *CodexAdapter) LoadSession(ctx context.Context, sessionID string) error { +func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { // Check client under lock, but don't hold lock during Call() to avoid deadlock // with handleNotification which also needs the lock a.mu.RLock() @@ -415,7 +448,7 @@ func (a *CodexAdapter) LoadSession(ctx context.Context, sessionID string) error // Prompt sends a prompt to the agent, starting a new turn. // This method blocks until the turn completes (turn/completed notification received). -func (a *CodexAdapter) Prompt(ctx context.Context, message string) error { +func (a *Adapter) Prompt(ctx context.Context, message string) error { a.mu.Lock() client := a.client threadID := a.threadID @@ -495,7 +528,7 @@ func (a *CodexAdapter) Prompt(ctx context.Context, message string) error { } // Cancel interrupts the current turn. -func (a *CodexAdapter) Cancel(ctx context.Context) error { +func (a *Adapter) Cancel(ctx context.Context) error { a.mu.RLock() client := a.client threadID := a.threadID @@ -517,26 +550,26 @@ func (a *CodexAdapter) Cancel(ctx context.Context) error { } // Updates returns the channel for agent events. -func (a *CodexAdapter) Updates() <-chan AgentEvent { +func (a *Adapter) Updates() <-chan AgentEvent { return a.updatesCh } // GetSessionID returns the current thread ID (session). -func (a *CodexAdapter) GetSessionID() string { +func (a *Adapter) GetSessionID() string { a.mu.RLock() defer a.mu.RUnlock() return a.threadID } // GetOperationID returns the current turn ID (operation). -func (a *CodexAdapter) GetOperationID() string { +func (a *Adapter) GetOperationID() string { a.mu.RLock() defer a.mu.RUnlock() return a.turnID } // SetPermissionHandler sets the handler for permission requests. -func (a *CodexAdapter) SetPermissionHandler(handler PermissionHandler) { +func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { a.mu.Lock() defer a.mu.Unlock() a.permissionHandler = handler @@ -545,14 +578,14 @@ func (a *CodexAdapter) SetPermissionHandler(handler PermissionHandler) { // SetStderrProvider sets the provider for recent stderr output. // This is used to include stderr in error events when the agent // reports an error without a detailed message. -func (a *CodexAdapter) SetStderrProvider(provider StderrProvider) { +func (a *Adapter) SetStderrProvider(provider StderrProvider) { a.mu.Lock() defer a.mu.Unlock() a.stderrProvider = provider } // Close releases resources held by the adapter. -func (a *CodexAdapter) Close() error { +func (a *Adapter) Close() error { a.mu.Lock() defer a.mu.Unlock() @@ -580,12 +613,13 @@ func (a *CodexAdapter) Close() error { } // RequiresProcessKill returns false because Codex agents exit when stdin is closed. -func (a *CodexAdapter) RequiresProcessKill() bool { +func (a *Adapter) RequiresProcessKill() bool { return false } // sendUpdate safely sends an event to the updates channel. -func (a *CodexAdapter) sendUpdate(update AgentEvent) { +func (a *Adapter) sendUpdate(update AgentEvent) { + shared.LogNormalizedEvent(shared.ProtocolCodex, a.agentID, &update) select { case a.updatesCh <- update: default: @@ -594,7 +628,10 @@ func (a *CodexAdapter) sendUpdate(update AgentEvent) { } // handleNotification processes Codex notifications and emits AgentEvents. -func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) { +func (a *Adapter) handleNotification(method string, params json.RawMessage) { + // Log raw event for debugging + shared.LogRawEvent(shared.ProtocolCodex, a.agentID, method, params) + a.mu.RLock() threadID := a.threadID turnID := a.turnID @@ -612,7 +649,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) a.messageBuffer += p.Delta a.mu.Unlock() a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, + Type: streams.EventTypeMessageChunk, SessionID: threadID, OperationID: turnID, Text: p.Delta, @@ -628,7 +665,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) a.reasoningBuffer += p.Delta a.mu.Unlock() a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, + Type: streams.EventTypeReasoning, SessionID: threadID, OperationID: turnID, ReasoningText: p.Delta, @@ -646,7 +683,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) a.reasoningBuffer += p.Delta a.mu.Unlock() a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, + Type: streams.EventTypeReasoning, SessionID: threadID, OperationID: turnID, ReasoningText: p.Delta, @@ -687,7 +724,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) // (NotifyError handles the case when error details come separately) if p.Error != "" { a.sendUpdate(AgentEvent{ - Type: EventTypeError, + Type: streams.EventTypeError, SessionID: threadID, OperationID: p.TurnID, Error: p.Error, @@ -702,7 +739,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) return } a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, + Type: streams.EventTypeMessageChunk, SessionID: threadID, OperationID: p.TurnID, Diff: p.Diff, @@ -722,7 +759,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) } } a.sendUpdate(AgentEvent{ - Type: EventTypePlan, + Type: streams.EventTypePlan, SessionID: threadID, OperationID: p.TurnID, PlanEntries: entries, @@ -745,7 +782,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) // Try to parse stderr into structured error info // This handles Codex-specific error formats (e.g., rate limits) - parsedError := parseCodexStderrLines(stderrLines) + parsedError := ParseCodexStderrLines(stderrLines) var parsedMsg string if parsedError != nil { @@ -761,7 +798,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) zap.Int("stderr_lines", len(stderrLines))) // Build error data with all available context - errorData := map[string]interface{}{ + errorData := map[string]any{ "code": p.Code, "data": p.Data, "stderr": stderrLines, @@ -780,7 +817,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) } a.sendUpdate(AgentEvent{ - Type: EventTypeError, + Type: streams.EventTypeError, SessionID: threadID, Error: p.Message, Text: parsedMsg, // User-friendly parsed message @@ -800,18 +837,20 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) // Item types: "userMessage", "agentMessage", "commandExecution", "fileChange", "reasoning" switch p.Item.Type { case "commandExecution": + args := map[string]any{ + "command": p.Item.Command, + "cwd": p.Item.Cwd, + } + normalizedPayload := a.normalizer.NormalizeToolCall("commandExecution", args) a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolName: "commandExecution", - ToolTitle: p.Item.Command, - ToolStatus: "running", - ToolArgs: map[string]interface{}{ - "command": p.Item.Command, - "cwd": p.Item.Cwd, - }, + Type: streams.EventTypeToolCall, + SessionID: threadID, + OperationID: turnID, + ToolCallID: p.Item.ID, + ToolName: "commandExecution", + ToolTitle: p.Item.Command, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, }) case "fileChange": // Build title from file paths @@ -822,14 +861,27 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) title += fmt.Sprintf(" (+%d more)", len(p.Item.Changes)-1) } } + // Build args with changes info + changesArgs := make([]any, 0, len(p.Item.Changes)) + for _, c := range p.Item.Changes { + changesArgs = append(changesArgs, map[string]any{ + "path": c.Path, + "diff": c.Diff, + }) + } + args := map[string]any{ + "changes": changesArgs, + } + normalizedPayload := a.normalizer.NormalizeToolCall("fileChange", args) a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolName: "fileChange", - ToolTitle: title, - ToolStatus: "running", + Type: streams.EventTypeToolCall, + SessionID: threadID, + OperationID: turnID, + ToolCallID: p.Item.ID, + ToolName: "fileChange", + ToolTitle: title, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, }) } @@ -849,16 +901,12 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) status = "error" } update := AgentEvent{ - Type: EventTypeToolUpdate, + Type: streams.EventTypeToolUpdate, SessionID: threadID, OperationID: turnID, ToolCallID: p.Item.ID, ToolStatus: status, } - // Include output for commands - if p.Item.Type == "commandExecution" && p.Item.AggregatedOutput != "" { - update.ToolResult = p.Item.AggregatedOutput - } // Include diff for file changes if p.Item.Type == "fileChange" && len(p.Item.Changes) > 0 { var diffs []string @@ -881,11 +929,10 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) return } a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, + Type: streams.EventTypeToolUpdate, SessionID: threadID, OperationID: turnID, ToolCallID: p.ItemID, - ToolResult: p.Delta, }) case codex.NotifyThreadTokenUsageUpdated: @@ -912,7 +959,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) zap.Float64("efficiency", efficiency)) a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, + Type: streams.EventTypeContextWindow, SessionID: threadID, OperationID: turnID, ContextWindowSize: contextWindowSize, @@ -944,7 +991,7 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) } // handleRequest processes Codex requests (approval requests) and calls permissionHandler. -func (a *CodexAdapter) handleRequest(id interface{}, method string, params json.RawMessage) { +func (a *Adapter) handleRequest(id any, method string, params json.RawMessage) { a.logger.Debug("codex: received request", zap.Any("id", id), zap.String("method", method)) @@ -963,7 +1010,7 @@ func (a *CodexAdapter) handleRequest(id interface{}, method string, params json. } return } - a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeCommand, p.Command, map[string]interface{}{ + a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeCommand, p.Command, map[string]any{ "command": p.Command, "cwd": p.Cwd, "reasoning": p.Reasoning, @@ -978,7 +1025,7 @@ func (a *CodexAdapter) handleRequest(id interface{}, method string, params json. } return } - a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeFileWrite, p.Path, map[string]interface{}{ + a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeFileWrite, p.Path, map[string]any{ "path": p.Path, "diff": p.Diff, "reasoning": p.Reasoning, @@ -993,14 +1040,14 @@ func (a *CodexAdapter) handleRequest(id interface{}, method string, params json. } // handleApprovalRequest handles permission request logic for both command and file change approvals. -func (a *CodexAdapter) handleApprovalRequest( - id interface{}, +func (a *Adapter) handleApprovalRequest( + id any, handler PermissionHandler, threadID string, itemID string, actionType string, title string, - details map[string]interface{}, + details map[string]any, optionStrings []string, ) { // Build permission options from Codex options @@ -1087,6 +1134,3 @@ func (a *CodexAdapter) handleApprovalRequest( a.logger.Warn("failed to send approval response", zap.Error(err)) } } - -// Verify interface implementation -var _ AgentAdapter = (*CodexAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/codex_errors.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go similarity index 92% rename from apps/backend/internal/agentctl/server/adapter/codex_errors.go rename to apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go index 291f8f1bda..ad8fbd8197 100644 --- a/apps/backend/internal/agentctl/server/adapter/codex_errors.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go @@ -1,4 +1,4 @@ -package adapter +package codex import ( "encoding/json" @@ -26,13 +26,13 @@ type CodexParsedError struct { ResetsInSeconds int64 } -// parseCodexStderrError attempts to parse a Codex stderr error line and extract +// ParseCodexStderrError attempts to parse a Codex stderr error line and extract // error information. Returns nil if parsing fails. // // Example input: // // 2026-01-23T22:57:08.953223Z ERROR codex_api::endpoint::responses: error=http 429 Too Many Requests: Some("{\"error\":{...}}") -func parseCodexStderrError(line string) *CodexParsedError { +func ParseCodexStderrError(line string) *CodexParsedError { matches := codexErrorRegex.FindStringSubmatch(line) if len(matches) < 3 { return nil @@ -122,12 +122,12 @@ func parseCodexStderrError(line string) *CodexParsedError { return result } -// parseCodexStderrLines attempts to parse Codex stderr lines and extract +// ParseCodexStderrLines attempts to parse Codex stderr lines and extract // error information. Searches from the end (most recent) first. // Returns nil if no parseable error is found. -func parseCodexStderrLines(lines []string) *CodexParsedError { +func ParseCodexStderrLines(lines []string) *CodexParsedError { for i := len(lines) - 1; i >= 0; i-- { - if parsed := parseCodexStderrError(lines[i]); parsed != nil { + if parsed := ParseCodexStderrError(lines[i]); parsed != nil { return parsed } } diff --git a/apps/backend/internal/agentctl/server/adapter/codex_errors_test.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors_test.go similarity index 98% rename from apps/backend/internal/agentctl/server/adapter/codex_errors_test.go rename to apps/backend/internal/agentctl/server/adapter/transport/codex/errors_test.go index 44491951b1..57f4d31d99 100644 --- a/apps/backend/internal/agentctl/server/adapter/codex_errors_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors_test.go @@ -1,4 +1,4 @@ -package adapter +package codex import ( "strings" @@ -118,7 +118,7 @@ func TestParseCodexStderrError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := parseCodexStderrError(tt.input) + result := ParseCodexStderrError(tt.input) if tt.wantNil { if result != nil { @@ -210,7 +210,7 @@ func TestParseCodexStderrLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := parseCodexStderrLines(tt.lines) + result := ParseCodexStderrLines(tt.lines) if tt.wantNil { if result != nil { @@ -234,7 +234,7 @@ func TestParseCodexStderrError_ResetsInSeconds(t *testing.T) { // Test that ResetsInSeconds is correctly extracted input := `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":3600}}")` - result := parseCodexStderrError(input) + result := ParseCodexStderrError(input) if result == nil { t.Fatal("expected non-nil result") } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go new file mode 100644 index 0000000000..dc5ccec939 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go @@ -0,0 +1,150 @@ +package codex + +import ( + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// Codex item type constants +const ( + CodexItemCommandExecution = "commandExecution" + CodexItemFileChange = "fileChange" + CodexItemReasoning = "reasoning" + CodexItemUserMessage = "userMessage" + CodexItemAgentMessage = "agentMessage" +) + +// Normalizer converts Codex protocol tool data to NormalizedPayload. +type Normalizer struct{} + +// NewNormalizer creates a new Codex normalizer. +func NewNormalizer() *Normalizer { + return &Normalizer{} +} + +// NormalizeToolCall converts Codex tool call data to NormalizedPayload. +// Codex uses item types rather than explicit tool names. +func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { + // Codex toolName is actually the item type + switch toolName { + case CodexItemCommandExecution: + return n.normalizeCommand(args) + case CodexItemFileChange: + return n.normalizeFileChange(args) + default: + return n.normalizeGeneric(toolName, args) + } +} + +// NormalizeToolResult updates the payload with tool result data. +func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { + switch payload.Kind { + case streams.ToolKindShellExec: + if payload.ShellExec != nil { + n.normalizeCommandResult(payload.ShellExec, result) + } + case streams.ToolKindModifyFile: + // File changes are typically completed with a diff in the update + if payload.ModifyFile != nil && len(payload.ModifyFile.Mutations) > 0 { + if diffStr, ok := result.(string); ok && diffStr != "" { + payload.ModifyFile.Mutations[0].Diff = diffStr + } + } + case streams.ToolKindGeneric: + if payload.Generic != nil { + payload.Generic.Output = result + } + } +} + +// normalizeCommand converts Codex commandExecution item data. +func (n *Normalizer) normalizeCommand(args map[string]any) *streams.NormalizedPayload { + command := shared.GetString(args, "command") + workDir := shared.GetString(args, "cwd") + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindShellExec, + ShellExec: &streams.ShellExecPayload{ + Command: command, + WorkDir: workDir, + }, + } +} + +// normalizeFileChange converts Codex fileChange item data. +func (n *Normalizer) normalizeFileChange(args map[string]any) *streams.NormalizedPayload { + // Codex sends changes as an array in the item + changes, _ := args["changes"].([]any) + + payload := &streams.ModifyFilePayload{ + Mutations: []streams.FileMutation{}, + } + + for _, change := range changes { + changeMap, ok := change.(map[string]any) + if !ok { + continue + } + + path := shared.GetString(changeMap, "path") + if payload.FilePath == "" { + payload.FilePath = path + } + + mutation := streams.FileMutation{ + Type: streams.MutationPatch, + } + + // Extract diff if available + if diff, ok := changeMap["diff"].(string); ok { + mutation.Diff = diff + } + + payload.Mutations = append(payload.Mutations, mutation) + } + + // If no changes array, try single file fields + if len(payload.Mutations) == 0 { + path := shared.GetString(args, "path") + payload.FilePath = path + payload.Mutations = append(payload.Mutations, streams.FileMutation{ + Type: streams.MutationPatch, + }) + } + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindModifyFile, + ModifyFile: payload, + } +} + +// normalizeGeneric wraps unknown items as generic. +func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { + return &streams.NormalizedPayload{ + Kind: streams.ToolKindGeneric, + Generic: &streams.GenericPayload{ + Name: toolName, + Input: args, + }, + } +} + +// normalizeCommandResult updates command payload with result data. +func (n *Normalizer) normalizeCommandResult(payload *streams.ShellExecPayload, result any) { + if payload.Output == nil { + payload.Output = &streams.ShellExecOutput{} + } + + switch r := result.(type) { + case string: + payload.Output.Stdout = r + case map[string]any: + if output, ok := r["aggregatedOutput"].(string); ok { + payload.Output.Stdout = output + } + if status, ok := r["status"].(string); ok && status == "failed" { + payload.Output.ExitCode = 1 + } + } +} + diff --git a/apps/backend/internal/agentctl/server/adapter/opencode_adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go similarity index 83% rename from apps/backend/internal/agentctl/server/adapter/opencode_adapter.go rename to apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go index 5989db8f5a..c4b7325f2f 100644 --- a/apps/backend/internal/agentctl/server/adapter/opencode_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go @@ -1,4 +1,6 @@ -package adapter +// Package opencode implements the OpenCode transport adapter. +// OpenCode uses a REST/SSE protocol over HTTP, spawning its own HTTP server. +package opencode import ( "bufio" @@ -13,18 +15,41 @@ import ( "time" "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/opencode" "go.uber.org/zap" ) -// OpenCodeAdapter implements AgentAdapter for OpenCode CLI using REST/SSE protocol. +// Re-export types needed by external packages +type ( + PermissionRequest = types.PermissionRequest + PermissionResponse = types.PermissionResponse + PermissionOption = streams.PermissionOption + PermissionHandler = types.PermissionHandler + AgentEvent = streams.AgentEvent +) + +// AgentInfo contains information about the connected agent. +type AgentInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Adapter implements the transport adapter for OpenCode. // OpenCode spawns an HTTP server and communicates via REST API calls and SSE for events. -type OpenCodeAdapter struct { - cfg *Config +type Adapter struct { + cfg *shared.Config logger *logger.Logger + // Agent identity (from config, for logging) + agentID string + + // Normalizer for converting tool data to NormalizedPayload + normalizer *Normalizer + // Subprocess stdout (for parsing server URL) stdout io.Reader @@ -32,9 +57,9 @@ type OpenCodeAdapter struct { client *opencode.Client // Server configuration - serverURL string - password string - directory string + serverURL string + password string + directory string // Context for managing goroutine lifecycle ctx context.Context @@ -90,12 +115,25 @@ type permissionRequest struct { toolName string } -// NewOpenCodeAdapter creates a new OpenCode protocol adapter. -func NewOpenCodeAdapter(cfg *Config, log *logger.Logger) *OpenCodeAdapter { +// resultComplete holds the result of a completed prompt +// Note: Fields are currently unused but kept for future implementation +type resultComplete struct { + _ bool // success placeholder + _ string // err placeholder +} + +// NewAdapter creates a new OpenCode protocol adapter. +func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { ctx, cancel := context.WithCancel(context.Background()) - return &OpenCodeAdapter{ + agentID := cfg.AgentID + if agentID == "" { + agentID = "opencode" // Fallback for unknown agents using this protocol + } + return &Adapter{ cfg: cfg, - logger: log.WithFields(zap.String("adapter", "opencode")), + logger: log.WithFields(zap.String("adapter", "opencode"), zap.String("agent_id", agentID)), + agentID: agentID, + normalizer: NewNormalizer(), ctx: ctx, cancel: cancel, updatesCh: make(chan AgentEvent, 100), @@ -110,7 +148,7 @@ func NewOpenCodeAdapter(cfg *Config, log *logger.Logger) *OpenCodeAdapter { // PrepareEnvironment performs protocol-specific setup before the agent process starts. // For OpenCode, we write MCP servers to ~/.config/opencode/opencode.json and return // environment variables for server authentication. -func (a *OpenCodeAdapter) PrepareEnvironment() (map[string]string, error) { +func (a *Adapter) PrepareEnvironment() (map[string]string, error) { a.logger.Info("PrepareEnvironment called", zap.Int("mcp_server_count", len(a.cfg.McpServers))) for i, srv := range a.cfg.McpServers { @@ -144,7 +182,7 @@ func (a *OpenCodeAdapter) PrepareEnvironment() (map[string]string, error) { // Connect wires up the stdout pipe from the running agent subprocess. // For OpenCode, we only need stdout to parse the server URL. -func (a *OpenCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error { +func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() defer a.mu.Unlock() @@ -157,7 +195,7 @@ func (a *OpenCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error { } // Initialize establishes the OpenCode connection by waiting for the server URL. -func (a *OpenCodeAdapter) Initialize(ctx context.Context) error { +func (a *Adapter) Initialize(ctx context.Context) error { a.logger.Info("initializing OpenCode adapter", zap.String("workdir", a.cfg.WorkDir)) @@ -189,7 +227,7 @@ func (a *OpenCodeAdapter) Initialize(ctx context.Context) error { // Store agent info a.agentInfo = &AgentInfo{ - Name: "opencode", + Name: a.agentID, Version: "unknown", } @@ -199,7 +237,7 @@ func (a *OpenCodeAdapter) Initialize(ctx context.Context) error { } // waitForServerURL reads stdout until we find the server URL -func (a *OpenCodeAdapter) waitForServerURL(ctx context.Context) (string, error) { +func (a *Adapter) waitForServerURL(ctx context.Context) (string, error) { deadline := time.Now().Add(180 * time.Second) scanner := bufio.NewScanner(a.stdout) @@ -250,12 +288,12 @@ func (a *OpenCodeAdapter) waitForServerURL(ctx context.Context) (string, error) } // GetAgentInfo returns information about the connected agent. -func (a *OpenCodeAdapter) GetAgentInfo() *AgentInfo { +func (a *Adapter) GetAgentInfo() *AgentInfo { return a.agentInfo } // NewSession creates a new OpenCode session. -func (a *OpenCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { +func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { // Clear any stale state from previous sessions a.clearSessionState() @@ -272,7 +310,7 @@ func (a *OpenCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) ( // Emit session status event for resume token storage a.sendUpdate(AgentEvent{ - Type: EventTypeSessionStatus, + Type: streams.EventTypeSessionStatus, SessionID: sessionID, Data: map[string]any{ "session_status": "active", @@ -284,7 +322,7 @@ func (a *OpenCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) ( } // LoadSession resumes an existing OpenCode session by forking it. -func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) error { +func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { // Clear any stale state from previous sessions a.clearSessionState() @@ -306,7 +344,7 @@ func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) err } // Prompt sends a prompt to OpenCode and waits for completion. -func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error { +func (a *Adapter) Prompt(ctx context.Context, message string) error { a.mu.Lock() client := a.client sessionID := a.sessionID @@ -352,7 +390,7 @@ func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error { if !ok { // Channel closed a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, + Type: streams.EventTypeComplete, SessionID: sessionID, }) return nil @@ -362,14 +400,14 @@ func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error { case "idle": a.logger.Info("session idle, prompt complete") a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, + Type: streams.EventTypeComplete, SessionID: sessionID, }) return nil case "auth_required": a.sendUpdate(AgentEvent{ - Type: EventTypeError, + Type: streams.EventTypeError, SessionID: sessionID, Error: fmt.Sprintf("Authentication required: %s", control.Message), }) @@ -377,7 +415,7 @@ func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error { case "session_error": a.sendUpdate(AgentEvent{ - Type: EventTypeError, + Type: streams.EventTypeError, SessionID: sessionID, Error: control.Message, }) @@ -385,7 +423,7 @@ func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error { case "disconnected": a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, + Type: streams.EventTypeComplete, SessionID: sessionID, }) return nil @@ -395,7 +433,7 @@ func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error { } // Cancel cancels the current operation. -func (a *OpenCodeAdapter) Cancel(ctx context.Context) error { +func (a *Adapter) Cancel(ctx context.Context) error { a.mu.RLock() client := a.client sessionID := a.sessionID @@ -411,33 +449,33 @@ func (a *OpenCodeAdapter) Cancel(ctx context.Context) error { } // Updates returns a channel that receives agent events. -func (a *OpenCodeAdapter) Updates() <-chan AgentEvent { +func (a *Adapter) Updates() <-chan AgentEvent { return a.updatesCh } // GetSessionID returns the current session ID. -func (a *OpenCodeAdapter) GetSessionID() string { +func (a *Adapter) GetSessionID() string { a.mu.RLock() defer a.mu.RUnlock() return a.sessionID } // GetOperationID returns the current operation/turn ID. -func (a *OpenCodeAdapter) GetOperationID() string { +func (a *Adapter) GetOperationID() string { a.mu.RLock() defer a.mu.RUnlock() return a.operationID } // SetPermissionHandler sets the handler for permission requests. -func (a *OpenCodeAdapter) SetPermissionHandler(handler PermissionHandler) { +func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { a.mu.Lock() defer a.mu.Unlock() a.permissionHandler = handler } // Close releases resources held by the adapter. -func (a *OpenCodeAdapter) Close() error { +func (a *Adapter) Close() error { a.logger.Info("OpenCode adapter Close() called") a.mu.Lock() @@ -466,14 +504,14 @@ func (a *OpenCodeAdapter) Close() error { // RequiresProcessKill returns true because OpenCode runs as an HTTP server // and does not exit when stdin is closed. -func (a *OpenCodeAdapter) RequiresProcessKill() bool { +func (a *Adapter) RequiresProcessKill() bool { return true } // clearSessionState clears session-specific tracking state. // This should be called when starting a new session to prevent memory leaks // and stale data from previous sessions. -func (a *OpenCodeAdapter) clearSessionState() { +func (a *Adapter) clearSessionState() { a.mu.Lock() defer a.mu.Unlock() @@ -483,7 +521,7 @@ func (a *OpenCodeAdapter) clearSessionState() { } // sendUpdate sends an event to the updates channel -func (a *OpenCodeAdapter) sendUpdate(event AgentEvent) { +func (a *Adapter) sendUpdate(event AgentEvent) { a.mu.RLock() closed := a.closed a.mu.RUnlock() @@ -492,6 +530,7 @@ func (a *OpenCodeAdapter) sendUpdate(event AgentEvent) { return } + shared.LogNormalizedEvent(shared.ProtocolOpenCode, a.agentID, &event) select { case a.updatesCh <- event: default: @@ -501,7 +540,10 @@ func (a *OpenCodeAdapter) sendUpdate(event AgentEvent) { } // handleSDKEvent processes events from the OpenCode SSE stream -func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { +func (a *Adapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { + // Log raw event for debugging + shared.LogRawEvent(shared.ProtocolOpenCode, a.agentID, event.Type, event.Properties) + a.mu.RLock() sessionID := a.sessionID operationID := a.operationID @@ -524,7 +566,7 @@ func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { } if props.Error != nil { a.sendUpdate(AgentEvent{ - Type: EventTypeError, + Type: streams.EventTypeError, SessionID: sessionID, Error: props.Error.GetMessage(), }) @@ -533,7 +575,7 @@ func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { } // handleMessagePartUpdated processes message.part.updated events -func (a *OpenCodeAdapter) handleMessagePartUpdated(props json.RawMessage, sessionID, operationID string) { +func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, operationID string) { parsed, err := opencode.ParseMessagePartUpdated(props) if err != nil { a.logger.Warn("failed to parse message.part.updated", zap.Error(err)) @@ -592,7 +634,7 @@ func (a *OpenCodeAdapter) handleMessagePartUpdated(props json.RawMessage, sessio if textToSend != "" { a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, + Type: streams.EventTypeMessageChunk, SessionID: sessionID, OperationID: operationID, Text: textToSend, @@ -634,7 +676,7 @@ func (a *OpenCodeAdapter) handleMessagePartUpdated(props json.RawMessage, sessio if textToSend != "" { a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, + Type: streams.EventTypeReasoning, SessionID: sessionID, OperationID: operationID, ReasoningText: textToSend, @@ -676,6 +718,9 @@ func (a *OpenCodeAdapter) handleMessagePartUpdated(props json.RawMessage, sessio _ = json.Unmarshal(state.Input, &args) // Ignore error - args are optional } + // Generate normalized payload + normalizedPayload := a.normalizer.NormalizeToolCall(toolName, args) + // Track if we've seen this tool call before a.mu.Lock() if a.seenToolCalls == nil { @@ -689,35 +734,34 @@ func (a *OpenCodeAdapter) handleMessagePartUpdated(props json.RawMessage, sessio // This creates the message in the database if isFirstEvent { a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: title, - ToolArgs: args, - ToolStatus: status, + Type: streams.EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: title, + ToolStatus: status, + NormalizedPayload: normalizedPayload, }) } else { // For subsequent events, emit tool_update to update the existing message a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: title, - ToolArgs: args, - ToolResult: state.Output, - ToolStatus: status, - Error: state.Error, + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: title, + ToolStatus: status, + Error: state.Error, + NormalizedPayload: normalizedPayload, }) } } } // handleMessageUpdated processes message.updated events (for token tracking) -func (a *OpenCodeAdapter) handleMessageUpdated(props json.RawMessage, sessionID string) { +func (a *Adapter) handleMessageUpdated(props json.RawMessage, sessionID string) { parsed, err := opencode.ParseMessageUpdated(props) if err != nil { return @@ -751,7 +795,7 @@ func (a *OpenCodeAdapter) handleMessageUpdated(props json.RawMessage, sessionID // Emit context window event a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, + Type: streams.EventTypeContextWindow, SessionID: sessionID, ContextWindowUsed: int64(totalTokens), ContextWindowSize: int64(a.modelContextWindow), @@ -759,7 +803,7 @@ func (a *OpenCodeAdapter) handleMessageUpdated(props json.RawMessage, sessionID } // handlePermissionAsked processes permission.asked events -func (a *OpenCodeAdapter) handlePermissionAsked(props json.RawMessage, sessionID string) { +func (a *Adapter) handlePermissionAsked(props json.RawMessage, sessionID string) { parsed, err := opencode.ParsePermissionAsked(props) if err != nil { a.logger.Warn("failed to parse permission.asked", zap.Error(err)) @@ -809,7 +853,7 @@ func (a *OpenCodeAdapter) handlePermissionAsked(props json.RawMessage, sessionID // Emit permission request event a.sendUpdate(AgentEvent{ - Type: EventTypePermissionRequest, + Type: streams.EventTypePermissionRequest, SessionID: sessionID, PendingID: requestID, ToolCallID: toolCallID, @@ -855,7 +899,7 @@ func (a *OpenCodeAdapter) handlePermissionAsked(props json.RawMessage, sessionID } // RespondToPermission responds to a permission request (called via handler) -func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID string, approved bool, message string) error { +func (a *Adapter) RespondToPermission(ctx context.Context, requestID string, approved bool, message string) error { a.mu.Lock() delete(a.pendingPermissions, requestID) a.mu.Unlock() @@ -878,7 +922,7 @@ func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID str // OpenCode reads MCP servers from ~/.config/opencode/opencode.json at startup time. // This function should be called before starting the OpenCode process. // If homeDir is empty, it uses os.UserHomeDir(). -func WriteOpenCodeMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logger.Logger) error { +func WriteOpenCodeMcpConfig(mcpServers []shared.McpServerConfig, homeDir string, log *logger.Logger) error { if len(mcpServers) == 0 { return nil // Nothing to configure } @@ -975,4 +1019,3 @@ func WriteOpenCodeMcpConfig(mcpServers []McpServerConfig, homeDir string, log *l return nil } - diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go new file mode 100644 index 0000000000..0195057956 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go @@ -0,0 +1,162 @@ +package opencode + +import ( + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// OpenCode part type constants +const ( + OpenCodePartText = "text" + OpenCodePartReasoning = "reasoning" + OpenCodePartTool = "tool" +) + +// OpenCode tool name constants +const ( + OpenCodeToolBash = "bash" + OpenCodeToolEdit = "edit" + OpenCodeToolWebFetch = "webfetch" +) + +// Normalizer converts OpenCode protocol tool data to NormalizedPayload. +type Normalizer struct{} + +// NewNormalizer creates a new OpenCode normalizer. +func NewNormalizer() *Normalizer { + return &Normalizer{} +} + +// NormalizeToolCall converts OpenCode tool call data to NormalizedPayload. +func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { + // OpenCode tool names are typically lowercase + switch toolName { + case OpenCodeToolBash: + return n.normalizeBash(args) + case OpenCodeToolEdit: + return n.normalizeEdit(args) + case OpenCodeToolWebFetch: + return n.normalizeWebFetch(args) + default: + return n.normalizeGeneric(toolName, args) + } +} + +// NormalizeToolResult updates the payload with tool result data. +func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { + switch payload.Kind { + case streams.ToolKindShellExec: + if payload.ShellExec != nil { + n.normalizeBashResult(payload.ShellExec, result) + } + case streams.ToolKindHttpRequest: + if payload.HttpRequest != nil { + if r, ok := result.(string); ok { + payload.HttpRequest.Response = r + } + } + case streams.ToolKindGeneric: + if payload.Generic != nil { + payload.Generic.Output = result + } + } +} + +// normalizeBash converts OpenCode bash tool data. +func (n *Normalizer) normalizeBash(args map[string]any) *streams.NormalizedPayload { + command := shared.GetString(args, "command") + + // OpenCode tool state might have the command + if command == "" { + if state, ok := args["state"].(map[string]any); ok { + command = shared.GetString(state, "command") + } + } + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindShellExec, + ShellExec: &streams.ShellExecPayload{ + Command: command, + }, + } +} + +// normalizeEdit converts OpenCode edit tool data. +func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { + // OpenCode edit args might be in "input" or directly in args + input := args + if inputMap, ok := args["input"].(map[string]any); ok { + input = inputMap + } + + filePath := shared.GetString(input, "path") + if filePath == "" { + filePath = shared.GetString(input, "file_path") + } + + payload := &streams.ModifyFilePayload{ + FilePath: filePath, + Mutations: []streams.FileMutation{}, + } + + // OpenCode might have diff or content + if diff, ok := input["diff"].(string); ok && diff != "" { + payload.Mutations = append(payload.Mutations, streams.FileMutation{ + Type: streams.MutationPatch, + Diff: diff, + }) + } else { + payload.Mutations = append(payload.Mutations, streams.FileMutation{ + Type: streams.MutationPatch, + }) + } + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindModifyFile, + ModifyFile: payload, + } +} + +// normalizeWebFetch converts OpenCode webfetch tool data. +func (n *Normalizer) normalizeWebFetch(args map[string]any) *streams.NormalizedPayload { + url := shared.GetString(args, "url") + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindHttpRequest, + HttpRequest: &streams.HttpRequestPayload{ + URL: url, + Method: "GET", + }, + } +} + +// normalizeGeneric wraps unknown tools as generic. +func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { + return &streams.NormalizedPayload{ + Kind: streams.ToolKindGeneric, + Generic: &streams.GenericPayload{ + Name: toolName, + Input: args, + }, + } +} + +// normalizeBashResult updates bash payload with result data. +func (n *Normalizer) normalizeBashResult(payload *streams.ShellExecPayload, result any) { + if payload.Output == nil { + payload.Output = &streams.ShellExecOutput{} + } + + switch r := result.(type) { + case string: + payload.Output.Stdout = r + case map[string]any: + if output, ok := r["output"].(string); ok { + payload.Output.Stdout = output + } + if errStr, ok := r["error"].(string); ok { + payload.Output.Stderr = errStr + } + } +} + diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go new file mode 100644 index 0000000000..11b94498e6 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go @@ -0,0 +1,50 @@ +package shared + +// Config holds configuration for creating transport adapters. +// This is passed to transport adapters by the factory. +type Config struct { + // WorkDir is the working directory for the agent + WorkDir string + + // AutoApprove automatically approves permission requests + AutoApprove bool + + // ApprovalPolicy controls when the agent requests approval. + // Valid values: "untrusted" (always), "on-failure", "on-request", "never". + // Defaults to "on-request" if empty. + ApprovalPolicy string + + // McpServers is a list of MCP servers to configure for the agent + McpServers []McpServerConfig + + // AgentID is the agent identifier from the registry (e.g., "auggie", "amp", "claude-code"). + // Used for logging and debug capture. Adapters should use this instead of hardcoded names. + AgentID string + + // AgentName is the human-readable agent name (e.g., "Auggie", "AMP", "Claude Code"). + // Used for display purposes. + AgentName string + + // For HTTP-based adapters (REST) + BaseURL string // Base URL of the agent's HTTP API + AuthHeader string // Optional auth header name + AuthValue string // Optional auth header value + Headers map[string]string // Additional headers + + // Protocol-specific configuration + Extra map[string]string +} + +// McpServerConfig holds configuration for an MCP server. +type McpServerConfig struct { + // Name is the human-readable name of the MCP server + Name string `json:"name"` + // URL is the URL for HTTP/SSE transport + URL string `json:"url,omitempty"` + // Type is the transport type: "sse" or "http" + Type string `json:"type,omitempty"` + // Command is the command for stdio transport + Command string `json:"command,omitempty"` + // Args are the arguments for stdio transport + Args []string `json:"args,omitempty"` +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go new file mode 100644 index 0000000000..b9cc50c7c0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go @@ -0,0 +1,99 @@ +// Package shared provides common utilities for transport adapters. +package shared + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// debugMode controls whether agent messages are logged. +// Enable via KANDEV_DEBUG_AGENT_MESSAGES=true environment variable. +var debugMode = os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES") == "true" + +// debugLogDir is the directory where debug log files are written. +// Defaults to the process CWD; override with KANDEV_DEBUG_LOG_DIR. +var debugLogDir = resolveDebugLogDir() + +// debugLogMu protects concurrent writes to debug log files. +var debugLogMu sync.Mutex + +// Protocol constants for debug file naming +const ( + ProtocolACP = "acp" + ProtocolStreamJSON = "streamjson" + ProtocolCodex = "codex" + ProtocolOpenCode = "opencode" +) + +func resolveDebugLogDir() string { + if dir := os.Getenv("KANDEV_DEBUG_LOG_DIR"); dir != "" { + return dir + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "." +} + +// LogRawEvent logs a raw protocol event without transformation. +// File: raw-{protocol}-{agentId}.jsonl +func LogRawEvent(protocol, agentID, eventType string, rawData json.RawMessage) { + if !debugMode { + return + } + + entry := map[string]any{ + "ts": time.Now().UnixMilli(), + "protocol": protocol, + "agent": agentID, + "event": eventType, + "data": json.RawMessage(rawData), + } + + logFile := filepath.Join(debugLogDir, fmt.Sprintf("raw-%s-%s.jsonl", protocol, agentID)) + writeJSONLine(logFile, entry) +} + +// LogNormalizedEvent logs a normalized AgentEvent. +// File: normalized-{protocol}-{agentId}.jsonl +func LogNormalizedEvent(protocol, agentID string, event *streams.AgentEvent) { + if !debugMode { + return + } + + entry := map[string]any{ + "ts": time.Now().UnixMilli(), + "event": event, + } + + logFile := filepath.Join(debugLogDir, fmt.Sprintf("normalized-%s-%s.jsonl", protocol, agentID)) + writeJSONLine(logFile, entry) +} + +// writeJSONLine writes a JSON entry as a line to the specified file. +func writeJSONLine(logFile string, entry any) { + entryJSON, err := json.Marshal(entry) + if err != nil { + log.Printf("[DEBUG] Failed to marshal entry: %v", err) + return + } + + debugLogMu.Lock() + defer debugLogMu.Unlock() + + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("[DEBUG] Failed to open log file %s: %v", logFile, err) + return + } + defer func() { _ = f.Close() }() + + _, _ = f.WriteString(string(entryJSON) + "\n") +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/maputil.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/maputil.go new file mode 100644 index 0000000000..e99dcb1e6d --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/maputil.go @@ -0,0 +1,42 @@ +package shared + +// GetString extracts a string value from a map, returning empty string if not found or wrong type. +func GetString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// GetInt extracts an int value from a map, returning 0 if not found or wrong type. +// Handles JSON numbers which are decoded as float64. +func GetInt(m map[string]any, key string) int { + if v, ok := m[key].(float64); ok { + return int(v) + } + return 0 +} + +// GetBool extracts a bool value from a map, returning false if not found or wrong type. +func GetBool(m map[string]any, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} + +// GetMap extracts a nested map from a map, returning nil if not found or wrong type. +func GetMap(m map[string]any, key string) map[string]any { + if v, ok := m[key].(map[string]any); ok { + return v + } + return nil +} + +// GetSlice extracts a slice from a map, returning nil if not found or wrong type. +func GetSlice(m map[string]any, key string) []any { + if v, ok := m[key].([]any); ok { + return v + } + return nil +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go new file mode 100644 index 0000000000..c33aa544b8 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go @@ -0,0 +1,130 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// GenerateUnifiedDiff creates a unified diff string from old and new content. +func GenerateUnifiedDiff(oldStr, newStr, path string, startLine int) string { + if oldStr == "" || newStr == "" { + return "" + } + + oldLines := SplitLines(oldStr) + newLines := SplitLines(newStr) + + if startLine == 0 { + startLine = 1 + } + + // Build diff header + var sb strings.Builder + fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", path, path) + sb.WriteString("index 0000000..0000000 100644\n") + fmt.Fprintf(&sb, "--- a/%s\n", path) + fmt.Fprintf(&sb, "+++ b/%s\n", path) + fmt.Fprintf(&sb, "@@ -%d,%d +%d,%d @@\n", startLine, len(oldLines), startLine, len(newLines)) + + // Add removed lines + for _, line := range oldLines { + sb.WriteString("-") + sb.WriteString(line) + sb.WriteString("\n") + } + + // Add added lines + for _, line := range newLines { + sb.WriteString("+") + sb.WriteString(line) + sb.WriteString("\n") + } + + return sb.String() +} + +// SplitLines splits a string into lines, normalizing line endings. +func SplitLines(s string) []string { + if s == "" { + return []string{} + } + return strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") +} + +// NormalizeShellResult updates a ShellExecPayload with result data. +func NormalizeShellResult(payload *streams.ShellExecPayload, result any) { + if payload.Output == nil { + payload.Output = &streams.ShellExecOutput{} + } + + switch r := result.(type) { + case string: + payload.Output.Stdout = r + case map[string]any: + if stdout, ok := r["stdout"].(string); ok { + payload.Output.Stdout = stdout + } + if stderr, ok := r["stderr"].(string); ok { + payload.Output.Stderr = stderr + } + if exitCode, ok := r["exit_code"].(float64); ok { + payload.Output.ExitCode = int(exitCode) + } + } +} + +// DetectLanguage maps file extension to language identifier. +// Used for syntax highlighting in diffs. +func DetectLanguage(path string) string { + if path == "" { + return "plaintext" + } + + // Find last dot + lastDot := -1 + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '.' { + lastDot = i + break + } + if path[i] == '/' { + break // No extension found + } + } + + if lastDot == -1 || lastDot == len(path)-1 { + return "plaintext" + } + + ext := path[lastDot+1:] + + langMap := map[string]string{ + "ts": "typescript", + "tsx": "typescript", + "js": "javascript", + "jsx": "javascript", + "py": "python", + "go": "go", + "rs": "rust", + "java": "java", + "cpp": "cpp", + "c": "c", + "h": "c", + "hpp": "cpp", + "css": "css", + "html": "html", + "json": "json", + "md": "markdown", + "yaml": "yaml", + "yml": "yaml", + "sh": "bash", + "bash": "bash", + } + + if lang, ok := langMap[ext]; ok { + return lang + } + return "plaintext" +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/testutil.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/testutil.go new file mode 100644 index 0000000000..15b318e3cd --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/testutil.go @@ -0,0 +1,47 @@ +package shared + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestCase represents a single JSONL test fixture line for normalization tests. +type TestCase struct { + Key string `json:"key,omitempty"` // Dedup key for auto-capture + Input map[string]any `json:"input"` + Expected map[string]any `json:"expected"` +} + +// LoadTestCases loads test cases from a JSONL fixture file. +// The filename should be relative to the calling package's testdata directory. +func LoadTestCases(t *testing.T, filename string) []TestCase { + t.Helper() + + path := filepath.Join("testdata", filename) + file, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open test file %s: %v", path, err) + } + defer func() { _ = file.Close() }() + + var cases []TestCase + scanner := bufio.NewScanner(file) + // Increase buffer for long lines + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + lineNum := 0 + for scanner.Scan() { + lineNum++ + var tc TestCase + if err := json.Unmarshal(scanner.Bytes(), &tc); err != nil { + t.Fatalf("failed to parse line %d in %s: %v", lineNum, filename, err) + } + cases = append(cases, tc) + } + if err := scanner.Err(); err != nil { + t.Fatalf("error reading %s: %v", filename, err) + } + return cases +} diff --git a/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go similarity index 76% rename from apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go rename to apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go index 537ee98de6..dc3a21aa4f 100644 --- a/apps/backend/internal/agentctl/server/adapter/claude_code_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -1,24 +1,55 @@ -package adapter +// Package streamjson implements the stream-json transport adapter. +// This is the protocol used by Claude Code CLI (--output-format stream-json). +package streamjson import ( "context" + "encoding/json" "fmt" "io" "sync" "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/claudecode" "go.uber.org/zap" ) -// ClaudeCodeAdapter implements AgentAdapter for Claude Code CLI using stream-json protocol. +// Re-export types needed by external packages +type ( + PermissionRequest = types.PermissionRequest + PermissionResponse = types.PermissionResponse + PermissionOption = streams.PermissionOption + PermissionHandler = types.PermissionHandler + AgentEvent = streams.AgentEvent +) + +// AgentInfo contains information about the connected agent. +type AgentInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// StderrProvider provides access to recent stderr output for error context. +type StderrProvider interface { + GetRecentStderr() []string +} + +// Adapter implements the transport adapter for agents using the stream-json protocol. // Claude Code uses a streaming JSON format over stdin/stdout with control requests for permissions. -type ClaudeCodeAdapter struct { - cfg *Config +type Adapter struct { + cfg *shared.Config logger *logger.Logger + // Agent identity (from config, for logging) + agentID string + + // Normalizer for converting tool data to NormalizedPayload + normalizer *Normalizer + // Subprocess stdin/stdout (set via Connect) stdin io.Writer stdout io.Reader @@ -72,13 +103,16 @@ type resultComplete struct { err string } -// NewClaudeCodeAdapter creates a new Claude Code protocol adapter. +// NewAdapter creates a new stream-json protocol adapter. // Call Connect() after starting the subprocess to wire up stdin/stdout. -func NewClaudeCodeAdapter(cfg *Config, log *logger.Logger) *ClaudeCodeAdapter { +// cfg.AgentID is required for debug file naming. +func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { ctx, cancel := context.WithCancel(context.Background()) - return &ClaudeCodeAdapter{ + return &Adapter{ cfg: cfg, - logger: log.WithFields(zap.String("adapter", "claude-code")), + logger: log.WithFields(zap.String("adapter", "stream-json"), zap.String("agent_id", cfg.AgentID)), + agentID: cfg.AgentID, + normalizer: NewNormalizer(), ctx: ctx, cancel: cancel, updatesCh: make(chan AgentEvent, 100), @@ -88,17 +122,17 @@ func NewClaudeCodeAdapter(cfg *Config, log *logger.Logger) *ClaudeCodeAdapter { } // PrepareEnvironment performs protocol-specific setup before the agent process starts. -// Claude Code reads MCP configuration from settings files, but we handle MCP via kandev's +// Stream-json protocol reads MCP configuration from settings files, but we handle MCP via kandev's // built-in MCP server, so this is a no-op. -func (a *ClaudeCodeAdapter) PrepareEnvironment() (map[string]string, error) { +func (a *Adapter) PrepareEnvironment() (map[string]string, error) { a.logger.Info("PrepareEnvironment called", zap.Int("mcp_server_count", len(a.cfg.McpServers))) - // Claude Code MCP configuration is handled externally or via CLI flags + // MCP configuration is handled externally or via CLI flags return nil, nil } // Connect wires up the stdin/stdout pipes from the running agent subprocess. -func (a *ClaudeCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error { +func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() defer a.mu.Unlock() @@ -111,9 +145,9 @@ func (a *ClaudeCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error { return nil } -// Initialize establishes the Claude Code connection with the agent subprocess. -func (a *ClaudeCodeAdapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing Claude Code adapter", +// Initialize establishes the stream-json connection with the agent subprocess. +func (a *Adapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing stream-json adapter", zap.String("workdir", a.cfg.WorkDir)) // Create Claude Code client @@ -126,28 +160,28 @@ func (a *ClaudeCodeAdapter) Initialize(ctx context.Context) error { // Store agent info (version will be populated from first message) a.agentInfo = &AgentInfo{ - Name: "claude-code", + Name: a.agentID, Version: "unknown", } - a.logger.Info("Claude Code adapter initialized") + a.logger.Info("stream-json adapter initialized") return nil } // GetAgentInfo returns information about the connected agent. -func (a *ClaudeCodeAdapter) GetAgentInfo() *AgentInfo { +func (a *Adapter) GetAgentInfo() *AgentInfo { return a.agentInfo } -// NewSession creates a new Claude Code session. -// Note: Claude Code sessions are created implicitly with the first prompt. -// The mcpServers parameter is ignored as Claude Code handles MCP separately. -func (a *ClaudeCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { +// NewSession creates a new stream-json session. +// Note: Sessions are created implicitly with the first prompt. +// The mcpServers parameter is ignored as this protocol handles MCP separately. +func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { a.mu.Lock() defer a.mu.Unlock() - // Generate a session ID - Claude Code will return its own session ID + // Generate a session ID - the agent will return its own session ID // which we'll update when we receive the system message sessionID := uuid.New().String() a.sessionID = sessionID @@ -157,9 +191,9 @@ func (a *ClaudeCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) return sessionID, nil } -// LoadSession resumes an existing Claude Code session. -// The session ID will be passed to Claude Code via --resume flag (handled by process manager). -func (a *ClaudeCodeAdapter) LoadSession(ctx context.Context, sessionID string) error { +// LoadSession resumes an existing stream-json session. +// The session ID will be passed to the agent via --resume flag (handled by process manager). +func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { a.mu.Lock() a.sessionID = sessionID a.mu.Unlock() @@ -169,8 +203,8 @@ func (a *ClaudeCodeAdapter) LoadSession(ctx context.Context, sessionID string) e return nil } -// Prompt sends a prompt to Claude Code and waits for completion. -func (a *ClaudeCodeAdapter) Prompt(ctx context.Context, message string) error { +// Prompt sends a prompt and waits for completion. +func (a *Adapter) Prompt(ctx context.Context, message string) error { a.mu.Lock() client := a.client sessionID := a.sessionID @@ -222,7 +256,7 @@ func (a *ClaudeCodeAdapter) Prompt(ctx context.Context, message string) error { } // Cancel interrupts the current operation. -func (a *ClaudeCodeAdapter) Cancel(ctx context.Context) error { +func (a *Adapter) Cancel(ctx context.Context) error { a.mu.RLock() client := a.client a.mu.RUnlock() @@ -244,40 +278,40 @@ func (a *ClaudeCodeAdapter) Cancel(ctx context.Context) error { } // Updates returns the channel for agent events. -func (a *ClaudeCodeAdapter) Updates() <-chan AgentEvent { +func (a *Adapter) Updates() <-chan AgentEvent { return a.updatesCh } // GetSessionID returns the current session ID. -func (a *ClaudeCodeAdapter) GetSessionID() string { +func (a *Adapter) GetSessionID() string { a.mu.RLock() defer a.mu.RUnlock() return a.sessionID } // GetOperationID returns the current operation ID. -func (a *ClaudeCodeAdapter) GetOperationID() string { +func (a *Adapter) GetOperationID() string { a.mu.RLock() defer a.mu.RUnlock() return a.operationID } // SetPermissionHandler sets the handler for permission requests. -func (a *ClaudeCodeAdapter) SetPermissionHandler(handler PermissionHandler) { +func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { a.mu.Lock() defer a.mu.Unlock() a.permissionHandler = handler } // SetStderrProvider sets the provider for recent stderr output. -func (a *ClaudeCodeAdapter) SetStderrProvider(provider StderrProvider) { +func (a *Adapter) SetStderrProvider(provider StderrProvider) { a.mu.Lock() defer a.mu.Unlock() a.stderrProvider = provider } // Close releases resources held by the adapter. -func (a *ClaudeCodeAdapter) Close() error { +func (a *Adapter) Close() error { a.mu.Lock() defer a.mu.Unlock() @@ -286,7 +320,7 @@ func (a *ClaudeCodeAdapter) Close() error { } a.closed = true - a.logger.Info("closing Claude Code adapter") + a.logger.Info("closing stream-json adapter") // Cancel the context to stop the read loop if a.cancel != nil { @@ -305,12 +339,13 @@ func (a *ClaudeCodeAdapter) Close() error { } // RequiresProcessKill returns false because Claude Code agents exit when stdin is closed. -func (a *ClaudeCodeAdapter) RequiresProcessKill() bool { +func (a *Adapter) RequiresProcessKill() bool { return false } // sendUpdate safely sends an event to the updates channel. -func (a *ClaudeCodeAdapter) sendUpdate(update AgentEvent) { +func (a *Adapter) sendUpdate(update AgentEvent) { + shared.LogNormalizedEvent(shared.ProtocolStreamJSON, a.agentID, &update) select { case a.updatesCh <- update: default: @@ -318,8 +353,8 @@ func (a *ClaudeCodeAdapter) sendUpdate(update AgentEvent) { } } -// handleControlRequest processes control requests (permission requests) from Claude Code. -func (a *ClaudeCodeAdapter) handleControlRequest(requestID string, req *claudecode.ControlRequest) { +// handleControlRequest processes control requests (permission requests) from the agent. +func (a *Adapter) handleControlRequest(requestID string, req *claudecode.ControlRequest) { a.logger.Info("received control request", zap.String("request_id", requestID), zap.String("subtype", req.Subtype), @@ -348,7 +383,7 @@ func (a *ClaudeCodeAdapter) handleControlRequest(requestID string, req *claudeco } // handleToolPermission processes can_use_tool permission requests. -func (a *ClaudeCodeAdapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { +func (a *Adapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { a.mu.RLock() handler := a.permissionHandler sessionID := a.sessionID @@ -402,7 +437,7 @@ func (a *ClaudeCodeAdapter) handleToolPermission(requestID string, req *claudeco // Send permission notification through updates channel a.sendUpdate(AgentEvent{ - Type: EventTypePermissionRequest, + Type: streams.EventTypePermissionRequest, SessionID: sessionID, OperationID: operationID, ToolCallID: req.ToolUseID, @@ -438,8 +473,8 @@ func (a *ClaudeCodeAdapter) handleToolPermission(requestID string, req *claudeco a.sendPermissionResponse(requestID, behavior) } -// sendPermissionResponse sends a permission response to Claude Code. -func (a *ClaudeCodeAdapter) sendPermissionResponse(requestID string, behavior string) { +// sendPermissionResponse sends a permission response to the agent. +func (a *Adapter) sendPermissionResponse(requestID string, behavior string) { resp := &claudecode.ControlResponseMessage{ Type: claudecode.MessageTypeControlResponse, RequestID: requestID, @@ -457,7 +492,7 @@ func (a *ClaudeCodeAdapter) sendPermissionResponse(requestID string, behavior st } // handleHookCallback processes hook callback requests. -func (a *ClaudeCodeAdapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { +func (a *Adapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { a.logger.Info("received hook callback", zap.String("request_id", requestID), zap.String("hook_name", req.HookName)) @@ -474,8 +509,13 @@ func (a *ClaudeCodeAdapter) handleHookCallback(requestID string, req *claudecode } } -// handleMessage processes streaming messages from Claude Code. -func (a *ClaudeCodeAdapter) handleMessage(msg *claudecode.CLIMessage) { +// handleMessage processes streaming messages from the agent. +func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { + // Log raw event for debugging + if rawData, err := json.Marshal(msg); err == nil { + shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, msg.Type, rawData) + } + a.mu.RLock() sessionID := a.sessionID operationID := a.operationID @@ -499,7 +539,7 @@ func (a *ClaudeCodeAdapter) handleMessage(msg *claudecode.CLIMessage) { // handleSystemMessage processes system init messages. // Note: System messages are session initialization, NOT turn completion. // Turn completion is signaled by result messages. -func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { +func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { a.logger.Info("received system message", zap.String("session_id", msg.SessionID), zap.String("status", msg.SessionStatus)) @@ -514,7 +554,7 @@ func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { a.mu.Unlock() // Only send session status event once per session (on first prompt) - // Claude Code sends system messages on every prompt, but we only want to + // The agent sends system messages on every prompt, but we only want to // show "New session started" or "Session resumed" once if alreadySent { return @@ -522,7 +562,7 @@ func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { // Send session status event (NOT complete - that's only for result messages) a.sendUpdate(AgentEvent{ - Type: EventTypeSessionStatus, + Type: streams.EventTypeSessionStatus, SessionID: msg.SessionID, Data: map[string]any{ "session_status": msg.SessionStatus, @@ -532,7 +572,7 @@ func (a *ClaudeCodeAdapter) handleSystemMessage(msg *claudecode.CLIMessage) { } // handleAssistantMessage processes assistant messages (text, thinking, tool calls). -func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { +func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { if msg.Message == nil { return } @@ -566,7 +606,7 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s case "text": if block.Text != "" { a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, + Type: streams.EventTypeMessageChunk, SessionID: sessionID, OperationID: operationID, Text: block.Text, @@ -576,7 +616,7 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s case "thinking": if block.Thinking != "" { a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, + Type: streams.EventTypeReasoning, SessionID: sessionID, OperationID: operationID, ReasoningText: block.Thinking, @@ -584,6 +624,12 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s } case "tool_use": + // Generate normalized payload using the normalizer + normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) + + // Detect specific tool operation type for logging + toolType := DetectStreamJSONToolType(block.Name) + // Build a human-readable title for the tool call toolTitle := block.Name if cmd, ok := block.Input["command"].(string); ok && block.Name == claudecode.ToolBash { @@ -594,6 +640,7 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s a.logger.Info("tool_use block received", zap.String("tool_call_id", block.ID), zap.String("tool_name", block.Name), + zap.String("tool_type", toolType), zap.String("title", toolTitle)) // Track this tool call as pending @@ -602,14 +649,14 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s a.mu.Unlock() a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ID, - ToolName: block.Name, - ToolTitle: toolTitle, - ToolArgs: block.Input, - ToolStatus: "running", + Type: streams.EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ID, + ToolName: block.Name, + ToolTitle: toolTitle, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, }) case "tool_result": @@ -627,11 +674,10 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s a.mu.Unlock() a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, + Type: streams.EventTypeToolUpdate, SessionID: sessionID, OperationID: operationID, ToolCallID: block.ToolUseID, - ToolResult: block.Content, ToolStatus: status, }) } @@ -657,7 +703,7 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s } a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, + Type: streams.EventTypeContextWindow, SessionID: sessionID, OperationID: operationID, ContextWindowSize: contextSize, @@ -669,7 +715,7 @@ func (a *ClaudeCodeAdapter) handleAssistantMessage(msg *claudecode.CLIMessage, s } // handleResultMessage processes result (completion) messages. -func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { +func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { a.logger.Info("received result message", zap.Bool("is_error", msg.IsError), zap.Int("num_turns", msg.NumTurns)) @@ -696,7 +742,7 @@ func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sess a.logger.Info("auto-completing pending tool call on result", zap.String("tool_call_id", toolID)) a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, + Type: streams.EventTypeToolUpdate, SessionID: sessionID, OperationID: operationID, ToolCallID: toolID, @@ -727,7 +773,7 @@ func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sess remaining = 0 } a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, + Type: streams.EventTypeContextWindow, SessionID: sessionID, OperationID: operationID, ContextWindowSize: contextSize, @@ -739,16 +785,16 @@ func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sess // Send completion event a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, + Type: streams.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, + "cost_usd": msg.CostUSD, + "duration_ms": msg.DurationMS, + "num_turns": msg.NumTurns, + "input_tokens": msg.TotalInputTokens, + "output_tokens": msg.TotalOutputTokens, + "is_error": msg.IsError, }, }) @@ -756,7 +802,7 @@ func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sess // Result can be either a ResultData object or an error string if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, + Type: streams.EventTypeMessageChunk, SessionID: sessionID, OperationID: operationID, Text: resultData.Text, @@ -787,13 +833,10 @@ func (a *ClaudeCodeAdapter) handleResultMessage(msg *claudecode.CLIMessage, sess errorMsg = resultData.Text } a.sendUpdate(AgentEvent{ - Type: EventTypeError, + Type: streams.EventTypeError, SessionID: sessionID, OperationID: operationID, Error: errorMsg, }) } } - -// Verify interface implementation -var _ AgentAdapter = (*ClaudeCodeAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go new file mode 100644 index 0000000000..478ec736d2 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go @@ -0,0 +1,221 @@ +package streamjson + +import ( + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/claudecode" +) + +// DetectStreamJSONToolType determines tool type from stream-json/Claude Code tool names. +// Used for logging and backwards compatibility. +func DetectStreamJSONToolType(toolName string) string { + switch toolName { + case claudecode.ToolEdit, claudecode.ToolWrite, claudecode.ToolNotebookEdit: + return "tool_edit" + case claudecode.ToolRead, claudecode.ToolGlob, claudecode.ToolGrep: + return "tool_read" + case claudecode.ToolBash: + return "tool_execute" + default: + return "tool_call" + } +} + +// Normalizer converts stream-json protocol tool data to NormalizedPayload. +type Normalizer struct{} + +// NewNormalizer creates a new stream-json normalizer. +func NewNormalizer() *Normalizer { + return &Normalizer{} +} + +// NormalizeToolCall converts stream-json tool call data to NormalizedPayload. +func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { + switch toolName { + case claudecode.ToolEdit, claudecode.ToolWrite, claudecode.ToolNotebookEdit: + return n.normalizeEdit(toolName, args) + case claudecode.ToolRead: + return n.normalizeRead(args) + case claudecode.ToolGlob, claudecode.ToolGrep: + return n.normalizeCodeSearch(toolName, args) + case claudecode.ToolBash: + return n.normalizeExecute(args) + case claudecode.ToolWebFetch, claudecode.ToolWebSearch: + return n.normalizeHttpRequest(toolName, args) + case claudecode.ToolTask: + return n.normalizeSubagentTask(args) + case claudecode.ToolTaskCreate: + return n.normalizeCreateTask(args) + case claudecode.ToolTaskUpdate, claudecode.ToolTaskList: + return n.normalizeManageTodos(toolName, args) + case claudecode.ToolTodoWrite: + return n.normalizeManageTodos(toolName, args) + default: + return n.normalizeGeneric(toolName, args) + } +} + +// NormalizeToolResult updates the payload with tool result data. +func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { + switch payload.Kind { + case streams.ToolKindShellExec: + if payload.ShellExec != nil { + shared.NormalizeShellResult(payload.ShellExec, result) + } + case streams.ToolKindHttpRequest: + if payload.HttpRequest != nil { + if r, ok := result.(string); ok { + payload.HttpRequest.Response = r + } + } + case streams.ToolKindGeneric: + if payload.Generic != nil { + payload.Generic.Output = result + } + } +} + +// normalizeEdit converts stream-json Edit/Write tool data. +func (n *Normalizer) normalizeEdit(toolName string, args map[string]any) *streams.NormalizedPayload { + filePath := shared.GetString(args, "file_path") + oldString := shared.GetString(args, "old_string") + newString := shared.GetString(args, "new_string") + content := shared.GetString(args, "content") + + payload := &streams.ModifyFilePayload{ + FilePath: filePath, + Mutations: []streams.FileMutation{}, + } + + if toolName == claudecode.ToolWrite { + // Write is a full file creation/replacement + payload.Mutations = append(payload.Mutations, streams.FileMutation{ + Type: streams.MutationCreate, + Content: content, + }) + } else { + // Edit is a patch operation + mutation := streams.FileMutation{ + Type: streams.MutationPatch, + OldContent: oldString, + NewContent: newString, + } + + // Generate unified diff + if oldString != "" && newString != "" { + mutation.Diff = shared.GenerateUnifiedDiff(oldString, newString, filePath, 0) + } + + payload.Mutations = append(payload.Mutations, mutation) + } + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindModifyFile, + ModifyFile: payload, + } +} + +// normalizeRead converts stream-json Read tool data. +func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { + filePath := shared.GetString(args, "file_path") + offset := shared.GetInt(args, "offset") + limit := shared.GetInt(args, "limit") + + return streams.NewReadFile(filePath, offset, limit) +} + +// normalizeCodeSearch converts stream-json Glob/Grep tool data. +func (n *Normalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload { + path := shared.GetString(args, "path") + pattern := shared.GetString(args, "pattern") + + var query, glob string + switch toolName { + case claudecode.ToolGlob: + glob = pattern + case claudecode.ToolGrep: + query = shared.GetString(args, "query") + } + + return streams.NewCodeSearch(query, pattern, path, glob) +} + +// normalizeExecute converts stream-json Bash tool data. +func (n *Normalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { + command := shared.GetString(args, "command") + description := shared.GetString(args, "description") + timeout := shared.GetInt(args, "timeout") + background := shared.GetBool(args, "run_in_background") + + return streams.NewShellExec(command, "", description, timeout, background) +} + +// normalizeHttpRequest converts stream-json WebFetch/WebSearch tool data. +func (n *Normalizer) normalizeHttpRequest(toolName string, args map[string]any) *streams.NormalizedPayload { + url := shared.GetString(args, "url") + if url == "" { + url = shared.GetString(args, "query") + } + + method := "GET" + if toolName == claudecode.ToolWebSearch { + method = "SEARCH" + } + + return streams.NewHttpRequest(url, method) +} + +// normalizeSubagentTask converts stream-json Task tool data (subagent invocation). +func (n *Normalizer) normalizeSubagentTask(args map[string]any) *streams.NormalizedPayload { + description := shared.GetString(args, "description") + prompt := shared.GetString(args, "prompt") + subagentType := shared.GetString(args, "subagent_type") + + return streams.NewSubagentTask(description, prompt, subagentType) +} + +// normalizeCreateTask converts stream-json TaskCreate tool data. +func (n *Normalizer) normalizeCreateTask(args map[string]any) *streams.NormalizedPayload { + title := shared.GetString(args, "subject") + description := shared.GetString(args, "description") + + return streams.NewCreateTask(title, description) +} + +// normalizeManageTodos converts stream-json TaskUpdate/TaskList/TodoWrite tool data. +func (n *Normalizer) normalizeManageTodos(toolName string, args map[string]any) *streams.NormalizedPayload { + operation := "update" + switch toolName { + case claudecode.ToolTaskList: + operation = "list" + case claudecode.ToolTodoWrite: + operation = "write" + } + + payload := &streams.ManageTodosPayload{ + Operation: operation, + } + + // Extract items if present + if items, ok := args["items"].([]any); ok { + for _, item := range items { + if itemMap, ok := item.(map[string]any); ok { + payload.Items = append(payload.Items, streams.TodoItem{ + ID: shared.GetString(itemMap, "id"), + Description: shared.GetString(itemMap, "description"), + Status: shared.GetString(itemMap, "status"), + }) + } + } + } + + return &streams.NormalizedPayload{ + Kind: streams.ToolKindManageTodos, + ManageTodos: payload, + } +} + +// normalizeGeneric wraps unknown tools as generic. +func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { + return streams.NewGeneric(toolName, args) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go new file mode 100644 index 0000000000..08ae7d7596 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go @@ -0,0 +1,650 @@ +package streamjson + +import ( + "fmt" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/pkg/claudecode" +) + +// TestStreamJSONNormalization runs JSONL-driven tests for stream-json protocol normalization. +func TestStreamJSONNormalization(t *testing.T) { + testCases := shared.LoadTestCases(t, "streamjson-messages.jsonl") + normalizer := NewNormalizer() + + for i, tc := range testCases { + t.Run(fmt.Sprintf("line_%d", i+1), func(t *testing.T) { + toolName, _ := tc.Input["tool_name"].(string) + args, _ := tc.Input["args"].(map[string]any) + + // Detect tool type + toolType := DetectStreamJSONToolType(toolName) + expectedToolType, _ := tc.Expected["tool_type"].(string) + if toolType != expectedToolType { + t.Errorf("tool type mismatch: got %q, want %q", toolType, expectedToolType) + } + + // Normalize using the typed normalizer + payload := normalizer.NormalizeToolCall(toolName, args) + + // Verify the Kind is set correctly based on tool type + switch toolType { + case "tool_edit": + if payload.Kind != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind) + } + case "tool_read": + if payload.Kind != streams.ToolKindReadFile && payload.Kind != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q or %q, got %q", streams.ToolKindReadFile, streams.ToolKindCodeSearch, payload.Kind) + } + case "tool_execute": + if payload.Kind != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind) + } + } + }) + } +} + +// TestDetectStreamJSONToolType tests the stream-json protocol tool type detection function. +func TestDetectStreamJSONToolType(t *testing.T) { + tests := []struct { + toolName string + want string + }{ + {"Edit", "tool_edit"}, + {"Read", "tool_read"}, + {"Bash", "tool_execute"}, + {"Glob", "tool_read"}, // Glob is a read operation + {"Grep", "tool_read"}, // Grep is a read operation + {"Write", "tool_edit"}, // Write is an edit operation + {"WebSearch", "tool_call"}, + {"WebFetch", "tool_call"}, + {"Task", "tool_call"}, + {"", "tool_call"}, + } + + for _, tt := range tests { + t.Run(tt.toolName, func(t *testing.T) { + got := DetectStreamJSONToolType(tt.toolName) + if got != tt.want { + t.Errorf("DetectStreamJSONToolType(%q) = %q, want %q", tt.toolName, got, tt.want) + } + }) + } +} + +// TestDetectLanguage tests the language detection from file extensions. +func TestDetectLanguage(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"file.ts", "typescript"}, + {"file.tsx", "typescript"}, + {"file.js", "javascript"}, + {"file.jsx", "javascript"}, + {"file.py", "python"}, + {"file.go", "go"}, + {"file.rs", "rust"}, + {"file.java", "java"}, + {"file.cpp", "cpp"}, + {"file.c", "c"}, + {"file.h", "c"}, + {"file.hpp", "cpp"}, + {"file.css", "css"}, + {"file.html", "html"}, + {"file.json", "json"}, + {"file.md", "markdown"}, + {"file.yaml", "yaml"}, + {"file.yml", "yaml"}, + {"file.sh", "bash"}, + {"file.bash", "bash"}, + {"file.unknown", "plaintext"}, + {"file", "plaintext"}, + {"", "plaintext"}, + {"/path/to/deep/file.ts", "typescript"}, + {"src/main.go", "go"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := shared.DetectLanguage(tt.path) + if got != tt.want { + t.Errorf("DetectLanguage(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +// TestGenerateUnifiedDiff tests the unified diff generation. +func TestGenerateUnifiedDiff(t *testing.T) { + t.Run("returns empty when old is empty", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1) + if result != "" { + t.Errorf("expected empty string for empty old string, got %q", result) + } + }) + + t.Run("returns empty when new is empty", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1) + if result != "" { + t.Errorf("expected empty string for empty new string, got %q", result) + } + }) + + t.Run("generates diff with header and hunks", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("const x = 1;", "const x = 2;", "file.ts", 5) + if result == "" { + t.Fatal("expected non-empty diff") + } + + // Check it contains expected elements + if !strings.Contains(result, "diff --git a/file.ts b/file.ts") { + t.Error("expected diff header") + } + if !strings.Contains(result, "--- a/file.ts") { + t.Error("expected old file marker") + } + if !strings.Contains(result, "+++ b/file.ts") { + t.Error("expected new file marker") + } + if !strings.Contains(result, "-const x = 1;") { + t.Error("expected removed line") + } + if !strings.Contains(result, "+const x = 2;") { + t.Error("expected added line") + } + }) + + t.Run("defaults startLine to 1 when 0", func(t *testing.T) { + result := shared.GenerateUnifiedDiff("a", "b", "file.go", 0) + if result == "" { + t.Fatal("expected non-empty diff") + } + // Verify it contains @@ -1 (defaulted from 0) + if !strings.Contains(result, "@@ -1,") { + t.Error("expected startLine to default to 1") + } + }) +} + +// TestNormalizerEdit tests the normalizer's edit handling. +func TestNormalizerEdit(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts file_path and content for Edit", func(t *testing.T) { + args := map[string]any{ + "file_path": "/workspace/app.ts", + "old_string": "const x = 1;", + "new_string": "const x = 2;", + } + result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) + if result.Kind != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind) + } + if result.ModifyFile == nil { + t.Fatal("expected ModifyFile to be set") + } + if result.ModifyFile.FilePath != "/workspace/app.ts" { + t.Errorf("expected FilePath '/workspace/app.ts', got %q", result.ModifyFile.FilePath) + } + if len(result.ModifyFile.Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile.Mutations)) + } + mutation := result.ModifyFile.Mutations[0] + if mutation.OldContent != "const x = 1;" { + t.Errorf("expected OldContent 'const x = 1;', got %q", mutation.OldContent) + } + if mutation.NewContent != "const x = 2;" { + t.Errorf("expected NewContent 'const x = 2;', got %q", mutation.NewContent) + } + }) + + t.Run("generates diff when both strings present", func(t *testing.T) { + args := map[string]any{ + "file_path": "/workspace/app.ts", + "old_string": "const x = 1;", + "new_string": "const x = 2;", + } + result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) + if result.ModifyFile == nil || len(result.ModifyFile.Mutations) == 0 { + t.Fatal("expected mutation") + } + if result.ModifyFile.Mutations[0].Diff == "" { + t.Error("expected diff to be generated") + } + }) + + t.Run("no diff when old_string is empty", func(t *testing.T) { + args := map[string]any{ + "file_path": "/workspace/new.ts", + "old_string": "", + "new_string": "new content", + } + result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) + if result.ModifyFile == nil || len(result.ModifyFile.Mutations) == 0 { + t.Fatal("expected mutation") + } + if result.ModifyFile.Mutations[0].Diff != "" { + t.Error("expected no diff when old_string is empty") + } + }) + + t.Run("Write tool uses content field", func(t *testing.T) { + args := map[string]any{ + "file_path": "/workspace/CONTRIBUTING.md", + "content": "# Contributing\n\nThank you for your interest!", + } + result := normalizer.NormalizeToolCall(claudecode.ToolWrite, args) + if result.Kind != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind) + } + if result.ModifyFile == nil { + t.Fatal("expected ModifyFile to be set") + } + if result.ModifyFile.FilePath != "/workspace/CONTRIBUTING.md" { + t.Errorf("expected FilePath '/workspace/CONTRIBUTING.md', got %q", result.ModifyFile.FilePath) + } + if len(result.ModifyFile.Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile.Mutations)) + } + mutation := result.ModifyFile.Mutations[0] + if mutation.Type != streams.MutationCreate { + t.Errorf("expected MutationType %q, got %q", streams.MutationCreate, mutation.Type) + } + if mutation.Content != "# Contributing\n\nThank you for your interest!" { + t.Errorf("expected Content from content field, got %q", mutation.Content) + } + }) +} + +// TestNormalizerRead tests the normalizer's read handling. +func TestNormalizerRead(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts file_path, offset, limit", func(t *testing.T) { + args := map[string]any{ + "file_path": "/workspace/config.json", + "offset": float64(10), + "limit": float64(50), + } + result := normalizer.NormalizeToolCall(claudecode.ToolRead, args) + if result.Kind != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind) + } + if result.ReadFile == nil { + t.Fatal("expected ReadFile to be set") + } + if result.ReadFile.FilePath != "/workspace/config.json" { + t.Errorf("expected FilePath '/workspace/config.json', got %q", result.ReadFile.FilePath) + } + if result.ReadFile.Offset != 10 { + t.Errorf("expected Offset 10, got %d", result.ReadFile.Offset) + } + if result.ReadFile.Limit != 50 { + t.Errorf("expected Limit 50, got %d", result.ReadFile.Limit) + } + }) +} + +// TestNormalizerExecute tests the normalizer's execute handling. +func TestNormalizerExecute(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts command and description", func(t *testing.T) { + args := map[string]any{ + "command": "ls -la", + "description": "List files", + } + result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) + if result.Kind != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind) + } + if result.ShellExec == nil { + t.Fatal("expected ShellExec to be set") + } + if result.ShellExec.Command != "ls -la" { + t.Errorf("expected Command 'ls -la', got %q", result.ShellExec.Command) + } + if result.ShellExec.Description != "List files" { + t.Errorf("expected Description 'List files', got %q", result.ShellExec.Description) + } + }) + + t.Run("handles timeout", func(t *testing.T) { + args := map[string]any{ + "command": "npm test", + "timeout": float64(30000), + } + result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) + if result.ShellExec == nil { + t.Fatal("expected ShellExec to be set") + } + if result.ShellExec.Timeout != 30000 { + t.Errorf("expected Timeout 30000, got %d", result.ShellExec.Timeout) + } + }) + + t.Run("handles background flag", func(t *testing.T) { + args := map[string]any{ + "command": "npm start", + "run_in_background": true, + } + result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) + if result.ShellExec == nil { + t.Fatal("expected ShellExec to be set") + } + if !result.ShellExec.Background { + t.Error("expected Background to be true") + } + }) +} + +// TestNormalizerCodeSearch tests the normalizer's code search handling. +func TestNormalizerCodeSearch(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("Glob extracts pattern as glob", func(t *testing.T) { + args := map[string]any{ + "pattern": "**/*.ts", + "path": "/workspace", + } + result := normalizer.NormalizeToolCall(claudecode.ToolGlob, args) + if result.Kind != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind) + } + if result.CodeSearch == nil { + t.Fatal("expected CodeSearch to be set") + } + if result.CodeSearch.Glob != "**/*.ts" { + t.Errorf("expected Glob '**/*.ts', got %q", result.CodeSearch.Glob) + } + if result.CodeSearch.Path != "/workspace" { + t.Errorf("expected Path '/workspace', got %q", result.CodeSearch.Path) + } + }) + + t.Run("Grep extracts query", func(t *testing.T) { + args := map[string]any{ + "pattern": "func.*Error", + "path": "/workspace", + } + result := normalizer.NormalizeToolCall(claudecode.ToolGrep, args) + if result.Kind != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind) + } + if result.CodeSearch == nil { + t.Fatal("expected CodeSearch to be set") + } + if result.CodeSearch.Pattern != "func.*Error" { + t.Errorf("expected Pattern 'func.*Error', got %q", result.CodeSearch.Pattern) + } + }) +} + +// TestNormalizerHttpRequest tests the normalizer's HTTP request handling. +func TestNormalizerHttpRequest(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("WebFetch uses GET method", func(t *testing.T) { + args := map[string]any{ + "url": "https://example.com/api", + } + result := normalizer.NormalizeToolCall(claudecode.ToolWebFetch, args) + if result.Kind != streams.ToolKindHttpRequest { + t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind) + } + if result.HttpRequest == nil { + t.Fatal("expected HttpRequest to be set") + } + if result.HttpRequest.URL != "https://example.com/api" { + t.Errorf("expected URL 'https://example.com/api', got %q", result.HttpRequest.URL) + } + if result.HttpRequest.Method != "GET" { + t.Errorf("expected Method 'GET', got %q", result.HttpRequest.Method) + } + }) + + t.Run("WebSearch uses SEARCH method", func(t *testing.T) { + args := map[string]any{ + "query": "golang testing best practices", + } + result := normalizer.NormalizeToolCall(claudecode.ToolWebSearch, args) + if result.Kind != streams.ToolKindHttpRequest { + t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind) + } + if result.HttpRequest == nil { + t.Fatal("expected HttpRequest to be set") + } + if result.HttpRequest.URL != "golang testing best practices" { + t.Errorf("expected URL 'golang testing best practices', got %q", result.HttpRequest.URL) + } + if result.HttpRequest.Method != "SEARCH" { + t.Errorf("expected Method 'SEARCH', got %q", result.HttpRequest.Method) + } + }) +} + +// TestNormalizerSubagentTask tests the normalizer's subagent task handling. +func TestNormalizerSubagentTask(t *testing.T) { + normalizer := NewNormalizer() + + args := map[string]any{ + "description": "Find all test files", + "prompt": "Search for test files in the codebase", + "subagent_type": "Explore", + } + result := normalizer.NormalizeToolCall(claudecode.ToolTask, args) + if result.Kind != streams.ToolKindSubagentTask { + t.Errorf("expected Kind %q, got %q", streams.ToolKindSubagentTask, result.Kind) + } + if result.SubagentTask == nil { + t.Fatal("expected SubagentTask to be set") + } + if result.SubagentTask.Description != "Find all test files" { + t.Errorf("expected Description 'Find all test files', got %q", result.SubagentTask.Description) + } + if result.SubagentTask.Prompt != "Search for test files in the codebase" { + t.Errorf("expected Prompt 'Search for test files in the codebase', got %q", result.SubagentTask.Prompt) + } + if result.SubagentTask.SubagentType != "Explore" { + t.Errorf("expected SubagentType 'Explore', got %q", result.SubagentTask.SubagentType) + } +} + +// TestNormalizerCreateTask tests the normalizer's create task handling. +func TestNormalizerCreateTask(t *testing.T) { + normalizer := NewNormalizer() + + args := map[string]any{ + "subject": "Fix authentication bug", + "description": "The login flow fails when...", + } + result := normalizer.NormalizeToolCall(claudecode.ToolTaskCreate, args) + if result.Kind != streams.ToolKindCreateTask { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCreateTask, result.Kind) + } + if result.CreateTask == nil { + t.Fatal("expected CreateTask to be set") + } + if result.CreateTask.Title != "Fix authentication bug" { + t.Errorf("expected Title 'Fix authentication bug', got %q", result.CreateTask.Title) + } + if result.CreateTask.Description != "The login flow fails when..." { + t.Errorf("expected Description 'The login flow fails when...', got %q", result.CreateTask.Description) + } +} + +// TestNormalizerManageTodos tests the normalizer's manage todos handling. +func TestNormalizerManageTodos(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("TaskUpdate operation", func(t *testing.T) { + args := map[string]any{ + "taskId": "task-123", + "status": "completed", + } + result := normalizer.NormalizeToolCall(claudecode.ToolTaskUpdate, args) + if result.Kind != streams.ToolKindManageTodos { + t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind) + } + if result.ManageTodos == nil { + t.Fatal("expected ManageTodos to be set") + } + if result.ManageTodos.Operation != "update" { + t.Errorf("expected Operation 'update', got %q", result.ManageTodos.Operation) + } + }) + + t.Run("TaskList operation", func(t *testing.T) { + args := map[string]any{} + result := normalizer.NormalizeToolCall(claudecode.ToolTaskList, args) + if result.Kind != streams.ToolKindManageTodos { + t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind) + } + if result.ManageTodos == nil { + t.Fatal("expected ManageTodos to be set") + } + if result.ManageTodos.Operation != "list" { + t.Errorf("expected Operation 'list', got %q", result.ManageTodos.Operation) + } + }) + + t.Run("TodoWrite operation with items", func(t *testing.T) { + args := map[string]any{ + "items": []any{ + map[string]any{ + "id": "todo-1", + "description": "Write tests", + "status": "pending", + }, + }, + } + result := normalizer.NormalizeToolCall(claudecode.ToolTodoWrite, args) + if result.Kind != streams.ToolKindManageTodos { + t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind) + } + if result.ManageTodos == nil { + t.Fatal("expected ManageTodos to be set") + } + if result.ManageTodos.Operation != "write" { + t.Errorf("expected Operation 'write', got %q", result.ManageTodos.Operation) + } + if len(result.ManageTodos.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(result.ManageTodos.Items)) + } + if result.ManageTodos.Items[0].ID != "todo-1" { + t.Errorf("expected item ID 'todo-1', got %q", result.ManageTodos.Items[0].ID) + } + }) +} + +// TestNormalizerGeneric tests the normalizer's generic handling. +func TestNormalizerGeneric(t *testing.T) { + normalizer := NewNormalizer() + + args := map[string]any{ + "custom_field": "custom_value", + } + result := normalizer.NormalizeToolCall("UnknownTool", args) + if result.Kind != streams.ToolKindGeneric { + t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, result.Kind) + } + if result.Generic == nil { + t.Fatal("expected Generic to be set") + } + if result.Generic.Name != "UnknownTool" { + t.Errorf("expected Name 'UnknownTool', got %q", result.Generic.Name) + } +} + +// TestNormalizerToolResult tests the normalizer's tool result handling. +func TestNormalizerToolResult(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("shell exec result from string", func(t *testing.T) { + payload := &streams.NormalizedPayload{ + Kind: streams.ToolKindShellExec, + ShellExec: &streams.ShellExecPayload{Command: "ls"}, + } + normalizer.NormalizeToolResult(payload, "file1.txt\nfile2.txt") + if payload.ShellExec.Output == nil { + t.Fatal("expected Output to be set") + } + if payload.ShellExec.Output.Stdout != "file1.txt\nfile2.txt" { + t.Errorf("expected Stdout 'file1.txt\\nfile2.txt', got %q", payload.ShellExec.Output.Stdout) + } + }) + + t.Run("shell exec result from map", func(t *testing.T) { + payload := &streams.NormalizedPayload{ + Kind: streams.ToolKindShellExec, + ShellExec: &streams.ShellExecPayload{Command: "npm test"}, + } + normalizer.NormalizeToolResult(payload, map[string]any{ + "stdout": "All tests passed", + "stderr": "", + "exit_code": float64(0), + }) + if payload.ShellExec.Output == nil { + t.Fatal("expected Output to be set") + } + if payload.ShellExec.Output.Stdout != "All tests passed" { + t.Errorf("expected Stdout 'All tests passed', got %q", payload.ShellExec.Output.Stdout) + } + if payload.ShellExec.Output.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", payload.ShellExec.Output.ExitCode) + } + }) + + t.Run("http request result", func(t *testing.T) { + payload := &streams.NormalizedPayload{ + Kind: streams.ToolKindHttpRequest, + HttpRequest: &streams.HttpRequestPayload{URL: "https://example.com"}, + } + normalizer.NormalizeToolResult(payload, "Response body here") + if payload.HttpRequest.Response != "Response body here" { + t.Errorf("expected Response 'Response body here', got %q", payload.HttpRequest.Response) + } + }) + + t.Run("generic result", func(t *testing.T) { + payload := &streams.NormalizedPayload{ + Kind: streams.ToolKindGeneric, + Generic: &streams.GenericPayload{Name: "CustomTool"}, + } + normalizer.NormalizeToolResult(payload, map[string]any{"data": "value"}) + if payload.Generic.Output == nil { + t.Fatal("expected Output to be set") + } + }) +} + +// TestSplitLines tests the line splitting utility. +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty string", "", 0}, + {"single line no newline", "hello", 1}, + {"single line with newline", "hello\n", 2}, // splits to ["hello", ""] + {"two lines", "hello\nworld", 2}, + {"crlf line endings", "hello\r\nworld", 2}, + {"multiple lines", "a\nb\nc\nd", 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shared.SplitLines(tt.input) + if len(got) != tt.want { + t.Errorf("shared.SplitLines(%q) returned %d lines, want %d", tt.input, len(got), tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/testdata/streamjson-messages.jsonl b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/testdata/streamjson-messages.jsonl new file mode 100644 index 0000000000..4034a4632c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/testdata/streamjson-messages.jsonl @@ -0,0 +1,12 @@ +{"key":"Edit","input":{"tool_name":"Edit","args":{"file_path":"/workspace/app.ts","old_string":"const x = 1;","new_string":"const x = 2;","replace_all":false}},"expected":{"tool_type":"tool_edit","file_path":"/workspace/app.ts","old_content":"const x = 1;","new_content":"const x = 2;"}} +{"key":"Edit:2","input":{"tool_name":"Edit","args":{"file_path":"/workspace/src/main.go","old_string":"func main() {\n\tfmt.Println(\"hello\")\n}","new_string":"func main() {\n\tfmt.Println(\"hello world\")\n}","replace_all":false}},"expected":{"tool_type":"tool_edit","file_path":"/workspace/src/main.go","old_content":"func main() {\n\tfmt.Println(\"hello\")\n}","new_content":"func main() {\n\tfmt.Println(\"hello world\")\n}"}} +{"key":"Edit:3","input":{"tool_name":"Edit","args":{"file_path":"","old_string":"a","new_string":"b"}},"expected":{"tool_type":"tool_edit","file_path":"","old_content":"a","new_content":"b"}} +{"key":"Read","input":{"tool_name":"Read","args":{"file_path":"/workspace/config.json"}},"expected":{"tool_type":"tool_read","file_path":"/workspace/config.json"}} +{"key":"Read:2","input":{"tool_name":"Read","args":{"file_path":"/workspace/src/index.ts","offset":10,"limit":50}},"expected":{"tool_type":"tool_read","file_path":"/workspace/src/index.ts"}} +{"key":"Read:3","input":{"tool_name":"Read","args":{"file_path":""}},"expected":{"tool_type":"tool_read","file_path":""}} +{"key":"Bash","input":{"tool_name":"Bash","args":{"command":"ls -la","description":"List all files and directories"}},"expected":{"tool_type":"tool_execute","command":"ls -la","description":"List all files and directories"}} +{"key":"Bash:2","input":{"tool_name":"Bash","args":{"command":"npm test -- --coverage","description":""}},"expected":{"tool_type":"tool_execute","command":"npm test -- --coverage"}} +{"key":"Bash:3","input":{"tool_name":"Bash","args":{"command":"go build ./..."}},"expected":{"tool_type":"tool_execute","command":"go build ./..."}} +{"key":"Glob","input":{"tool_name":"Glob","args":{"pattern":"**/*.ts"}},"expected":{"tool_type":"tool_read"}} +{"key":"Grep","input":{"tool_name":"Grep","args":{"pattern":"TODO","path":"/workspace"}},"expected":{"tool_type":"tool_read"}} +{"key":"WebSearch","input":{"tool_name":"WebSearch","args":{"query":"golang testing"}},"expected":{"tool_type":"tool_call"}} diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index 5d55d42bcc..42f1ca2c8a 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -64,6 +64,10 @@ type CreateRequest struct { // Protocol is the protocol adapter to use (acp, codex, auggie). If empty, default is used. Protocol string `json:"protocol,omitempty"` + // AgentType identifies the agent (e.g., "auggie", "codex", "claude-code"). + // Required for debug file naming. Typically matches the agent ID from the registry. + AgentType string `json:"agent_type,omitempty"` + // WorkspaceFlag is the CLI flag for workspace path (e.g., "--workspace-root"). // If empty, only cwd is used for workspace path. WorkspaceFlag string `json:"workspace_flag,omitempty"` diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 809a5bf547..3965bb1e11 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -131,6 +131,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea WorkDir: req.WorkspacePath, AutoStart: &autoStart, Env: config.CollectAgentEnv(req.Env), + AgentType: req.AgentType, McpServers: mcpServers, BackendWsURL: req.BackendWsURL, SessionID: req.SessionID, diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 9a6a0204a7..a7e7540eeb 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -18,7 +18,6 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/shell" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" ) @@ -266,6 +265,7 @@ func (m *Manager) Start(ctx context.Context) error { AutoApprove: m.cfg.AutoApprovePermissions, ApprovalPolicy: m.cfg.ApprovalPolicy, McpServers: mcpServers, + AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") } // Create adapter before starting the process so we can call PrepareEnvironment @@ -402,7 +402,7 @@ func (m *Manager) Configure(command string, env map[string]string, approvalPolic func (m *Manager) createAdapter() error { protocol := m.cfg.Protocol if protocol == "" { - protocol = agent.ProtocolACP // Default to ACP + return fmt.Errorf("protocol not specified in configuration") } // Use the adapter factory to create the appropriate adapter @@ -412,12 +412,9 @@ func (m *Manager) createAdapter() error { } m.adapter = adpt - // Set stderr provider for adapters that support it - switch a := m.adapter.(type) { - case *adapter.CodexAdapter: - a.SetStderrProvider(m) - case *adapter.ClaudeCodeAdapter: - a.SetStderrProvider(m) + // Set stderr provider for adapters that support it (Codex, StreamJSON) + if setter, ok := m.adapter.(adapter.StderrProviderSetter); ok { + setter.SetStderrProvider(m) } // Set the permission handler diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index c1d0acee5a..6544aacb69 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -84,11 +84,9 @@ type AgentEvent struct { // ToolStatus indicates the current status: "started", "running", "completed", "error". ToolStatus string `json:"tool_status,omitempty"` - // ToolArgs contains the arguments passed to the tool. - ToolArgs map[string]interface{} `json:"tool_args,omitempty"` - - // ToolResult contains the result from the tool execution. - ToolResult interface{} `json:"tool_result,omitempty"` + // NormalizedPayload contains the normalized tool data as a typed discriminated union. + // Provides typed access to tool parameters based on the Kind field. + NormalizedPayload *NormalizedPayload `json:"normalized,omitempty"` // Diff contains unified diff content for file changes. // Populated when tools modify files, providing the aggregated diff. @@ -120,7 +118,7 @@ type AgentEvent struct { ActionType string `json:"action_type,omitempty"` // ActionDetails contains structured details about the action. - ActionDetails map[string]interface{} `json:"action_details,omitempty"` + ActionDetails map[string]any `json:"action_details,omitempty"` // --- Session status fields (for "session_status" type) --- @@ -131,7 +129,7 @@ type AgentEvent struct { // --- Extension fields --- // Data contains raw protocol-specific extensions. - Data map[string]interface{} `json:"data,omitempty"` + Data map[string]any `json:"data,omitempty"` // --- Context window fields --- diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go new file mode 100644 index 0000000000..e2eec4d2dd --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go @@ -0,0 +1,302 @@ +package streams + +// ToolKind categorizes the normalized tool operation +type ToolKind string + +const ( + ToolKindReadFile ToolKind = "read_file" + ToolKindModifyFile ToolKind = "modify_file" + ToolKindShellExec ToolKind = "shell_exec" + ToolKindCodeSearch ToolKind = "code_search" + ToolKindHttpRequest ToolKind = "http_request" + ToolKindGeneric ToolKind = "generic" + ToolKindCreateTask ToolKind = "create_task" + ToolKindSubagentTask ToolKind = "subagent_task" + ToolKindShowPlan ToolKind = "show_plan" + ToolKindManageTodos ToolKind = "manage_todos" + ToolKindMisc ToolKind = "misc" +) + +// NormalizedPayload is the normalized tool data (discriminated union). +// Exactly one of the kind-specific fields will be set based on Kind. +type NormalizedPayload struct { + Kind ToolKind `json:"kind"` + + // read_file + ReadFile *ReadFilePayload `json:"read_file,omitempty"` + + // modify_file + ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` + + // shell_exec + ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` + + // code_search + CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` + + // http_request + HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` + + // generic (fallback) + Generic *GenericPayload `json:"generic,omitempty"` + + // create_task + CreateTask *CreateTaskPayload `json:"create_task,omitempty"` + + // subagent_task + SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` + + // show_plan + ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` + + // manage_todos + ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` + + // misc + Misc *MiscPayload `json:"misc,omitempty"` +} + +// --- Kind-specific payloads --- + +// ReadFilePayload contains normalized data for file read operations. +type ReadFilePayload struct { + FilePath string `json:"file_path"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// ModifyFilePayload contains normalized data for file modification operations. +type ModifyFilePayload struct { + FilePath string `json:"file_path"` + Mutations []FileMutation `json:"mutations"` +} + +// MutationType describes the type of file mutation. +type MutationType string + +const ( + MutationCreate MutationType = "create" + MutationReplace MutationType = "replace" + MutationPatch MutationType = "patch" + MutationDelete MutationType = "delete" + MutationRename MutationType = "rename" +) + +// FileMutation represents a single change to a file. +type FileMutation struct { + Type MutationType `json:"type"` + Content string `json:"content,omitempty"` // for create/replace + OldContent string `json:"old_content,omitempty"` // for patch + NewContent string `json:"new_content,omitempty"` // for patch + Diff string `json:"diff,omitempty"` // unified diff + NewPath string `json:"new_path,omitempty"` // for rename + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` +} + +// ShellExecPayload contains normalized data for shell command execution. +type ShellExecPayload struct { + Command string `json:"command"` + WorkDir string `json:"work_dir,omitempty"` + Description string `json:"description,omitempty"` + Timeout int `json:"timeout,omitempty"` + Background bool `json:"background,omitempty"` + Output *ShellExecOutput `json:"output,omitempty"` +} + +// ShellExecOutput contains the result of a shell command execution. +type ShellExecOutput struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` +} + +// CodeSearchPayload contains normalized data for code search operations. +type CodeSearchPayload struct { + Query string `json:"query,omitempty"` + Pattern string `json:"pattern,omitempty"` + Path string `json:"path,omitempty"` + Glob string `json:"glob,omitempty"` +} + +// HttpRequestPayload contains normalized data for HTTP request operations. +type HttpRequestPayload struct { + URL string `json:"url"` + Method string `json:"method,omitempty"` + Response string `json:"response,omitempty"` +} + +// GenericPayload is the fallback for unrecognized tools. +type GenericPayload struct { + Name string `json:"name"` + Input any `json:"input,omitempty"` + Output any `json:"output,omitempty"` +} + +// CreateTaskPayload contains normalized data for task creation operations. +type CreateTaskPayload struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` +} + +// SubagentTaskPayload contains normalized data for subagent task invocations. +type SubagentTaskPayload struct { + Description string `json:"description"` + Prompt string `json:"prompt"` + SubagentType string `json:"subagent_type"` +} + +// ShowPlanPayload contains normalized data for plan display operations. +type ShowPlanPayload struct { + Summary string `json:"summary"` + Steps []string `json:"steps,omitempty"` +} + +// ManageTodosPayload contains normalized data for todo management operations. +type ManageTodosPayload struct { + Operation string `json:"operation"` // "add", "update", "remove", "list" + Items []TodoItem `json:"items,omitempty"` +} + +// TodoItem represents a single todo item. +type TodoItem struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + Status string `json:"status,omitempty"` +} + +// MiscPayload is for miscellaneous operations that don't fit other categories. +type MiscPayload struct { + Label string `json:"label"` + Details any `json:"details,omitempty"` +} + +// --- Constructor functions for NormalizedPayload --- + +// NewReadFile creates a NormalizedPayload for file read operations. +func NewReadFile(filePath string, offset, limit int) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindReadFile, + ReadFile: &ReadFilePayload{ + FilePath: filePath, + Offset: offset, + Limit: limit, + }, + } +} + +// NewModifyFile creates a NormalizedPayload for file modification operations. +func NewModifyFile(filePath string, mutations []FileMutation) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindModifyFile, + ModifyFile: &ModifyFilePayload{ + FilePath: filePath, + Mutations: mutations, + }, + } +} + +// NewShellExec creates a NormalizedPayload for shell command execution. +func NewShellExec(command, workDir, description string, timeout int, background bool) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindShellExec, + ShellExec: &ShellExecPayload{ + Command: command, + WorkDir: workDir, + Description: description, + Timeout: timeout, + Background: background, + }, + } +} + +// NewCodeSearch creates a NormalizedPayload for code search operations. +func NewCodeSearch(query, pattern, path, glob string) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindCodeSearch, + CodeSearch: &CodeSearchPayload{ + Query: query, + Pattern: pattern, + Path: path, + Glob: glob, + }, + } +} + +// NewHttpRequest creates a NormalizedPayload for HTTP request operations. +func NewHttpRequest(url, method string) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindHttpRequest, + HttpRequest: &HttpRequestPayload{ + URL: url, + Method: method, + }, + } +} + +// NewGeneric creates a NormalizedPayload for unrecognized tools. +func NewGeneric(name string, input any) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindGeneric, + Generic: &GenericPayload{ + Name: name, + Input: input, + }, + } +} + +// NewCreateTask creates a NormalizedPayload for task creation operations. +func NewCreateTask(title, description string) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindCreateTask, + CreateTask: &CreateTaskPayload{ + Title: title, + Description: description, + }, + } +} + +// NewSubagentTask creates a NormalizedPayload for subagent task invocations. +func NewSubagentTask(description, prompt, subagentType string) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindSubagentTask, + SubagentTask: &SubagentTaskPayload{ + Description: description, + Prompt: prompt, + SubagentType: subagentType, + }, + } +} + +// NewShowPlan creates a NormalizedPayload for plan display operations. +func NewShowPlan(summary string, steps []string) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindShowPlan, + ShowPlan: &ShowPlanPayload{ + Summary: summary, + Steps: steps, + }, + } +} + +// NewManageTodos creates a NormalizedPayload for todo management operations. +func NewManageTodos(operation string, items []TodoItem) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindManageTodos, + ManageTodos: &ManageTodosPayload{ + Operation: operation, + Items: items, + }, + } +} + +// NewMisc creates a NormalizedPayload for miscellaneous operations. +func NewMisc(label string, details any) *NormalizedPayload { + return &NormalizedPayload{ + Kind: ToolKindMisc, + Misc: &MiscPayload{ + Label: label, + Details: details, + }, + } +} diff --git a/apps/backend/internal/debug/fixture.go b/apps/backend/internal/debug/fixture.go new file mode 100644 index 0000000000..b9b2ae17b1 --- /dev/null +++ b/apps/backend/internal/debug/fixture.go @@ -0,0 +1,441 @@ +package debug + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" + "github.com/kandev/kandev/internal/agentctl/types/streams" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// DiscoveredFile represents a discovered fixture file. +type DiscoveredFile struct { + Path string `json:"path"` + Protocol string `json:"protocol"` + Agent string `json:"agent,omitempty"` + MessageCount int `json:"message_count"` + FileType string `json:"file_type"` // "raw", "normalized", or "testdata" +} + +// NormalizedFixture represents a test fixture with its normalized payload. +type NormalizedFixture struct { + Protocol string `json:"protocol"` + ToolName string `json:"tool_name"` + ToolType string `json:"tool_type"` + Input map[string]any `json:"input"` + Payload *streams.NormalizedPayload `json:"payload"` +} + +// normalizedEventEntry represents a single entry in a normalized events file. +type normalizedEventEntry struct { + Ts int64 `json:"ts"` + Event *streams.AgentEvent `json:"event"` +} + +// fixtureInput represents a raw fixture from JSONL files. +type fixtureInput struct { + Input map[string]any `json:"input"` + Expected map[string]any `json:"expected"` +} + +// normalizer defines the interface for protocol-specific normalizers. +type normalizer interface { + NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload +} + +// discoverFixtureFiles finds all fixture JSONL files in standard and custom locations. +func discoverFixtureFiles(baseDir string) ([]DiscoveredFile, error) { + var allMatches []string + + // Pattern 1: Standard location - transport/*/testdata/*-messages.jsonl + pattern1 := filepath.Join(baseDir, "transport", "*", "testdata", "*-messages.jsonl") + if matches, err := filepath.Glob(pattern1); err == nil { + allMatches = append(allMatches, matches...) + } + + // Pattern 2: Backend root - any *.jsonl files (for debug files) + backendRoot := filepath.Join(baseDir, "..", "..", "..", "..") + pattern2 := filepath.Join(backendRoot, "*.jsonl") + if matches, err := filepath.Glob(pattern2); err == nil { + allMatches = append(allMatches, matches...) + } + + var files []DiscoveredFile + seen := make(map[string]bool) + + for _, fullPath := range allMatches { + absPath, _ := filepath.Abs(fullPath) + if seen[absPath] { + continue + } + seen[absPath] = true + + filename := filepath.Base(fullPath) + fileType, protocol, agent := parseDebugFilename(filename) + count := countLines(fullPath) + relPath, err := filepath.Rel(baseDir, fullPath) + if err != nil { + relPath = fullPath + } + files = append(files, DiscoveredFile{ + Path: relPath, + Protocol: protocol, + Agent: agent, + MessageCount: count, + FileType: fileType, + }) + } + return files, nil +} + +// discoverNormalizedFiles finds all normalized event files (normalized-*.jsonl). +func discoverNormalizedFiles(baseDir string) ([]DiscoveredFile, error) { + backendRoot := filepath.Join(baseDir, "..", "..", "..", "..") + pattern := filepath.Join(backendRoot, "normalized-*.jsonl") + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + + var files []DiscoveredFile + for _, fullPath := range matches { + filename := filepath.Base(fullPath) + _, protocol, agent := parseDebugFilename(filename) + count := countLines(fullPath) + relPath, err := filepath.Rel(baseDir, fullPath) + if err != nil { + relPath = fullPath + } + files = append(files, DiscoveredFile{ + Path: relPath, + Protocol: protocol, + Agent: agent, + MessageCount: count, + FileType: "normalized", + }) + } + return files, nil +} + +// readNormalizedEventsAsMessages reads a normalized events file and returns v1.Message objects. +func readNormalizedEventsAsMessages(filePath string) ([]*v1.Message, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", filePath, err) + } + defer func() { _ = file.Close() }() + + var messages []*v1.Message + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + index := 0 + for scanner.Scan() { + var entry normalizedEventEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue // Skip malformed lines + } + if entry.Event != nil { + msg := agentEventToMessage(entry.Event, entry.Ts, index) + if msg != nil { + messages = append(messages, msg) + } + index++ + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading %s: %w", filePath, err) + } + + return messages, nil +} + +// agentEventToMessage converts an AgentEvent to a v1.Message. +func agentEventToMessage(event *streams.AgentEvent, ts int64, index int) *v1.Message { + if event == nil { + return nil + } + + // Map AgentEvent type to Message type + msgType := mapEventTypeToMessageType(event.Type) + if msgType == "" { + return nil // Skip events we don't want to display + } + + // Build content based on event type + content := buildMessageContent(event) + + // Build metadata + metadata := buildMessageMetadata(event) + + // Use timestamp from entry, or current time as fallback + createdAt := time.Now() + if ts > 0 { + createdAt = time.UnixMilli(ts) + } + + // Generate a unique ID for the message using index to ensure uniqueness + msgID := fmt.Sprintf("msg-%d-%d", ts, index) + + return &v1.Message{ + ID: msgID, + TaskSessionID: event.SessionID, + AuthorType: "agent", + Type: msgType, + Content: content, + Metadata: metadata, + CreatedAt: createdAt, + } +} + +// mapEventTypeToMessageType converts AgentEvent type to Message type. +func mapEventTypeToMessageType(eventType string) string { + switch eventType { + case streams.EventTypeMessageChunk: + return "message" + case streams.EventTypeReasoning: + return "thinking" + case streams.EventTypeToolCall, streams.EventTypeToolUpdate: + return "tool_call" + case streams.EventTypePlan: + return "todo" + case streams.EventTypeError: + return "error" + case streams.EventTypePermissionRequest: + return "permission_request" + case streams.EventTypeComplete: + return "status" + case streams.EventTypeSessionStatus, streams.EventTypeContextWindow: + return "" // Skip these events + default: + return "message" + } +} + +// buildMessageContent extracts the appropriate content from an AgentEvent. +func buildMessageContent(event *streams.AgentEvent) string { + switch event.Type { + case streams.EventTypeMessageChunk: + return event.Text + case streams.EventTypeReasoning: + return event.ReasoningText + case streams.EventTypeToolCall, streams.EventTypeToolUpdate: + if event.ToolTitle != "" { + return event.ToolTitle + } + return event.ToolName + case streams.EventTypeError: + return event.Error + case streams.EventTypeComplete: + return "Turn completed" + case streams.EventTypePermissionRequest: + return event.PermissionTitle + default: + return event.Text + } +} + +// buildMessageMetadata builds the metadata map for a Message from an AgentEvent. +func buildMessageMetadata(event *streams.AgentEvent) map[string]any { + metadata := make(map[string]any) + + // Add tool-related metadata + if event.ToolCallID != "" { + metadata["tool_call_id"] = event.ToolCallID + } + if event.ToolName != "" { + metadata["tool_name"] = event.ToolName + } + if event.ToolStatus != "" { + metadata["status"] = event.ToolStatus + } + if event.Diff != "" { + metadata["diff"] = event.Diff + } + + // Add normalized payload if present + if event.NormalizedPayload != nil { + metadata["normalized"] = event.NormalizedPayload + } + + // Add plan entries if present + if len(event.PlanEntries) > 0 { + metadata["plan_entries"] = event.PlanEntries + } + + // Add permission request details + if event.PendingID != "" { + metadata["pending_id"] = event.PendingID + } + if len(event.PermissionOptions) > 0 { + metadata["permission_options"] = event.PermissionOptions + } + if event.ActionType != "" { + metadata["action_type"] = event.ActionType + } + if event.ActionDetails != nil { + metadata["action_details"] = event.ActionDetails + } + + // Add context window info if present + if event.ContextWindowSize > 0 { + metadata["context_window_size"] = event.ContextWindowSize + metadata["context_window_used"] = event.ContextWindowUsed + metadata["context_window_remaining"] = event.ContextWindowRemaining + } + + return metadata +} + +// parseDebugFilename extracts file type, protocol, and agent from debug filenames. +// Patterns: +// - "raw-{protocol}-{agent}.jsonl" → ("raw", protocol, agent) +// - "normalized-{protocol}-{agent}.jsonl" → ("normalized", protocol, agent) +// - "{protocol}-messages.jsonl" → ("testdata", protocol, "") +// - "{protocol}-{agent}.jsonl" → ("unknown", protocol, agent) [legacy] +func parseDebugFilename(filename string) (fileType, protocol, agent string) { + name := strings.TrimSuffix(filename, ".jsonl") + + // Check for raw- prefix + if rest, found := strings.CutPrefix(name, "raw-"); found { + protocol, agent = splitProtocolAgent(rest) + return "raw", protocol, agent + } + + // Check for normalized- prefix + if rest, found := strings.CutPrefix(name, "normalized-"); found { + protocol, agent = splitProtocolAgent(rest) + return "normalized", protocol, agent + } + + // Check for testdata pattern: {protocol}-messages + if protocol, found := strings.CutSuffix(name, "-messages"); found { + return "testdata", protocol, "" + } + + // Legacy pattern: {protocol}-{agent} + protocol, agent = splitProtocolAgent(name) + if isKnownProtocol(protocol) { + return "unknown", protocol, agent + } + + return "unknown", "unknown", "" +} + +// splitProtocolAgent splits "{protocol}-{agent}" into protocol and agent. +func splitProtocolAgent(s string) (protocol, agent string) { + idx := strings.Index(s, "-") + if idx <= 0 { + return s, "" + } + return s[:idx], s[idx+1:] +} + +// parseProtocolFromFilename extracts protocol from filename patterns (legacy). +func parseProtocolFromFilename(filename string) string { + _, protocol, _ := parseDebugFilename(filename) + return protocol +} + +// isKnownProtocol checks if the protocol is one of the known protocols. +func isKnownProtocol(protocol string) bool { + switch protocol { + case "acp", "streamjson", "codex", "opencode": + return true + default: + return false + } +} + +// countLines counts the number of lines in a file. +func countLines(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer func() { _ = file.Close() }() + + count := 0 + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + count++ + } + return count +} + +// getNormalizerForProtocol returns the appropriate normalizer for the protocol. +func getNormalizerForProtocol(protocol string) normalizer { + switch protocol { + case "acp": + return acp.NewNormalizer() + case "streamjson": + return streamjson.NewNormalizer() + default: + return nil + } +} + +// normalizeFixtureFile normalizes a specific fixture file. +func normalizeFixtureFile(filePath string) ([]NormalizedFixture, error) { + protocol := parseProtocolFromFilename(filepath.Base(filePath)) + norm := getNormalizerForProtocol(protocol) + if norm == nil { + return nil, fmt.Errorf("unknown protocol: %s", protocol) + } + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", filePath, err) + } + defer func() { _ = file.Close() }() + + var fixtures []NormalizedFixture + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + var input fixtureInput + if err := json.Unmarshal(scanner.Bytes(), &input); err != nil { + continue // Skip malformed lines + } + + fixture := NormalizedFixture{ + Protocol: protocol, + Input: input.Input, + } + + // Extract tool info and normalize based on protocol + switch protocol { + case "acp": + args, _ := input.Input["args"].(map[string]any) + kind, _ := args["kind"].(string) + fixture.ToolName = kind + fixture.ToolType = acp.DetectToolOperationType(kind, args) + fixture.Payload = norm.NormalizeToolCall(kind, args) + + case "streamjson": + toolName, _ := input.Input["tool_name"].(string) + args, _ := input.Input["args"].(map[string]any) + fixture.ToolName = toolName + fixture.ToolType = streamjson.DetectStreamJSONToolType(toolName) + fixture.Payload = norm.NormalizeToolCall(toolName, args) + } + + fixtures = append(fixtures, fixture) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading %s: %w", filePath, err) + } + + return fixtures, nil +} diff --git a/apps/backend/internal/debug/handlers.go b/apps/backend/internal/debug/handlers.go new file mode 100644 index 0000000000..62a223928c --- /dev/null +++ b/apps/backend/internal/debug/handlers.go @@ -0,0 +1,205 @@ +package debug + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// getAdapterBaseDir returns the absolute path to the adapter directory. +// It handles different CWD scenarios (running from git root or apps/backend). +func getAdapterBaseDir() (string, error) { + // Try multiple possible paths + candidates := []string{ + "apps/backend/internal/agentctl/server/adapter", // From git root + "internal/agentctl/server/adapter", // From apps/backend + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + for _, candidate := range candidates { + absPath := filepath.Join(cwd, candidate) + if info, err := os.Stat(absPath); err == nil && info.IsDir() { + return absPath, nil + } + } + + // Fallback: try the first candidate + return filepath.Abs(candidates[0]) +} + +// RegisterRoutes registers debug API routes. +func RegisterRoutes(router *gin.Engine, log *logger.Logger) { + api := router.Group("/api/v1/debug") + api.GET("/fixture-files", handleListFixtureFiles(log)) + api.GET("/normalize-messages", handleNormalizeMessages(log)) + api.GET("/normalized-files", handleListNormalizedFiles(log)) + api.GET("/normalized-events", handleReadNormalizedEvents(log)) +} + +// handleListFixtureFiles handles GET /api/v1/debug/fixture-files +// Returns a list of discovered fixture files with their protocols. +func handleListFixtureFiles(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + baseDir, err := getAdapterBaseDir() + if err != nil { + log.Error("failed to resolve adapter directory", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to resolve adapter directory", + }) + return + } + + files, err := discoverFixtureFiles(baseDir) + if err != nil { + log.Error("failed to discover fixture files", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"files": files}) + } +} + +// handleNormalizeMessages handles GET /api/v1/debug/normalize-messages +// Query params: +// - file: relative path to a fixture file (e.g., "transport/acp/testdata/acp-messages.jsonl") +func handleNormalizeMessages(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + filePath := c.Query("file") + if filePath == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "file parameter required", + }) + return + } + + // Resolve base directory + baseDir, err := getAdapterBaseDir() + if err != nil { + log.Error("failed to resolve adapter directory", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to resolve adapter directory", + }) + return + } + + // Construct full path and validate it's within the allowed directory + fullPath := filepath.Join(baseDir, filePath) + fullPath = filepath.Clean(fullPath) + + if !isPathAllowed(fullPath, baseDir) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid file path", + }) + return + } + + fixtures, err := normalizeFixtureFile(fullPath) + if err != nil { + log.Error("failed to normalize fixtures", + zap.String("file", filePath), + zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, fixtures) + } +} + +// isPathAllowed validates that the path is within the allowed base directory. +func isPathAllowed(fullPath, baseDir string) bool { + // Ensure the path starts with the base directory (prevent path traversal) + return strings.HasPrefix(fullPath, baseDir+string(filepath.Separator)) || + fullPath == baseDir +} + +// handleListNormalizedFiles handles GET /api/v1/debug/normalized-files +// Returns a list of discovered normalized event files (normalized-*.jsonl). +func handleListNormalizedFiles(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + baseDir, err := getAdapterBaseDir() + if err != nil { + log.Error("failed to resolve adapter directory", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to resolve adapter directory", + }) + return + } + + files, err := discoverNormalizedFiles(baseDir) + if err != nil { + log.Error("failed to discover normalized files", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"files": files}) + } +} + +// handleReadNormalizedEvents handles GET /api/v1/debug/normalized-events +// Query params: +// - file: relative path to a normalized file (e.g., "../../../../normalized-acp-auggie.jsonl") +func handleReadNormalizedEvents(log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + filePath := c.Query("file") + if filePath == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "file parameter required", + }) + return + } + + // Resolve base directory + baseDir, err := getAdapterBaseDir() + if err != nil { + log.Error("failed to resolve adapter directory", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to resolve adapter directory", + }) + return + } + + // Construct full path + fullPath := filepath.Join(baseDir, filePath) + fullPath = filepath.Clean(fullPath) + + // Validate the file is a normalized file (security check) + filename := filepath.Base(fullPath) + if !strings.HasPrefix(filename, "normalized-") || !strings.HasSuffix(filename, ".jsonl") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid file: must be a normalized-*.jsonl file", + }) + return + } + + messages, err := readNormalizedEventsAsMessages(fullPath) + if err != nil { + log.Error("failed to read normalized events", + zap.String("file", filePath), + zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"messages": messages}) + } +} diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 8145e4d35f..45148f6a95 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events" @@ -485,17 +486,14 @@ func (a *testMessageCreatorAdapter) CreateUserMessage(ctx context.Context, taskI return err } -func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, args map[string]interface{}) error { +func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { metadata := map[string]interface{}{ "tool_call_id": toolCallID, "title": title, "status": status, } - if len(args) > 0 { - metadata["args"] = args - if kind, ok := args["kind"].(string); ok && kind != "" { - metadata["tool_name"] = kind - } + if normalized != nil { + metadata["normalized"] = normalized } _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, @@ -509,8 +507,8 @@ func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, t return err } -func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, args map[string]interface{}) error { - return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, args) +func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, normalized *streams.NormalizedPayload) error { + return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, normalized) } func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error { diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index a79305818e..05dad157a5 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -683,7 +683,7 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag payload.Data.ToolStatus, payload.SessionID, s.getActiveTurnID(payload.SessionID), - payload.Data.ToolArgs, + payload.Data.Normalized, // Pass normalized tool data for message metadata ); err != nil { s.logger.Error("failed to create tool call message", zap.String("task_id", payload.TaskID), @@ -834,21 +834,15 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. // Handle all status updates (running, complete, error) switch payload.Data.ToolStatus { case "running", "complete", "completed", "success", "error", "failed": - result := "" - if payload.Data.ToolResult != nil { - if str, ok := payload.Data.ToolResult.(string); ok { - result = str - } - } if err := s.messageCreator.UpdateToolCallMessage( ctx, payload.TaskID, payload.Data.ToolCallID, payload.Data.ToolStatus, - result, + "", // result - no longer used, tool results in NormalizedPayload payload.SessionID, - payload.Data.ToolTitle, // Include title from update event - payload.Data.ToolArgs, // Include args from update event + payload.Data.ToolTitle, // Include title from update event + payload.Data.Normalized, // Pass normalized tool data for message metadata ); err != nil { s.logger.Warn("failed to update tool call message", zap.String("task_id", payload.TaskID), diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 99514ce7bf..6b5742ecc6 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -18,6 +18,7 @@ import ( "go.uber.org/zap" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/executor" @@ -54,8 +55,12 @@ func DefaultServiceConfig() ServiceConfig { type MessageCreator interface { CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error - CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, args map[string]interface{}) error - UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, args map[string]interface{}) error + // CreateToolCallMessage creates a message for a tool call. + // normalized contains the typed tool payload data. + CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error + // UpdateToolCallMessage updates a tool call message's status and optionally its normalized data. + // normalized contains the typed tool payload data. + UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, normalized *streams.NormalizedPayload) error CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index c76761d2e3..b61d87432c 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -98,6 +98,12 @@ const ( MessageTypeContent MessageType = "content" // MessageTypeToolCall is when agent uses a tool MessageTypeToolCall MessageType = "tool_call" + // MessageTypeToolEdit is for file edit operations with diff visualization + MessageTypeToolEdit MessageType = "tool_edit" + // MessageTypeToolRead is for file read operations + MessageTypeToolRead MessageType = "tool_read" + // MessageTypeToolExecute is for command execution operations + MessageTypeToolExecute MessageType = "tool_execute" // MessageTypeProgress is for progress updates MessageTypeProgress MessageType = "progress" // MessageTypeLog is for agent log messages (info, debug, warning, etc.) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 1423b099b8..e528cbb1cd 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -1998,10 +1999,11 @@ func (s *Service) AppendThinkingContent(ctx context.Context, messageID, addition return nil } -// UpdateToolCallMessage updates a tool call message's status, and optionally title/args. +// UpdateToolCallMessage updates a tool call message's status, optionally title and normalized data. // It includes retry logic to handle race conditions where the complete event // may arrive before the message has been created by the start event. -func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, args map[string]interface{}) error { +// The normalized parameter contains typed tool payload data that gets added to metadata. +func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, normalized *streams.NormalizedPayload) error { const maxRetries = 5 const retryDelay = 100 * time.Millisecond @@ -2049,6 +2051,11 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall message.Metadata["result"] = result } + // Add normalized tool data to metadata for frontend consumption + if normalized != nil { + message.Metadata["normalized"] = normalized + } + // Update title if provided and current title is just the tool name (not yet filled) // This handles the case where the first event only had the tool name if title != "" { @@ -2059,14 +2066,6 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall } } - // Update args if provided and current args are empty - if len(args) > 0 { - currentArgs, _ := message.Metadata["args"].(map[string]interface{}) - if len(currentArgs) == 0 { - message.Metadata["args"] = args - } - } - if err := s.repo.UpdateMessage(ctx, message); err != nil { s.logger.Error("failed to update tool call message", zap.String("message_id", message.ID), diff --git a/apps/backend/pkg/claudecode/types.go b/apps/backend/pkg/claudecode/types.go index 48c08a1358..0b8d6816a4 100644 --- a/apps/backend/pkg/claudecode/types.go +++ b/apps/backend/pkg/claudecode/types.go @@ -278,7 +278,7 @@ type ToolApprovalOptions struct { Deny bool `json:"deny"` } -// Common tool names that require permission +// Common tool names used by Claude Code const ( ToolBash = "Bash" ToolWrite = "Write" @@ -288,6 +288,11 @@ const ( ToolGlob = "Glob" ToolGrep = "Grep" ToolTask = "Task" + ToolTaskCreate = "TaskCreate" + ToolTaskUpdate = "TaskUpdate" + ToolTaskList = "TaskList" + ToolTaskGet = "TaskGet" + ToolTodoWrite = "TodoWrite" ToolWebFetch = "WebFetch" ToolWebSearch = "WebSearch" ) diff --git a/apps/web/app/demo/agent-messages/page.tsx b/apps/web/app/demo/agent-messages/page.tsx new file mode 100644 index 0000000000..ab4e2b6b67 --- /dev/null +++ b/apps/web/app/demo/agent-messages/page.tsx @@ -0,0 +1,399 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import type { Message, MessageType } from '@/lib/types/http'; +import { MessageRenderer } from '@/components/task/chat/message-renderer'; +import { + fetchFixtureFiles, + fetchNormalizedMessages, + fetchNormalizedFiles, + fetchNormalizedEventsAsMessages, + type NormalizedFixture, + type DiscoveredFile, +} from '@/lib/api/domains/debug-api'; +import { IconChevronDown, IconChevronRight, IconRefresh } from '@tabler/icons-react'; + +type ToolFilter = 'all' | 'tool_edit' | 'tool_read' | 'tool_execute' | 'tool_call'; +type ViewMode = 'fixtures' | 'events'; + +function fixtureToMessage(fixture: NormalizedFixture, index: number): Message { + const payload = fixture.payload as Record; + const toolType = fixture.tool_type as MessageType; + + // Build content based on tool type + let content = ''; + if (payload.file_path) { + content = `${fixture.tool_type}: ${payload.file_path}`; + } else if (payload.command) { + content = `Execute: ${payload.command}`; + } else { + content = fixture.tool_type; + } + + return { + id: `fixture-${index}`, + session_id: 'demo', + task_id: 'demo-task', + author_type: 'agent', + type: toolType, + content, + metadata: { + ...payload, + tool_call_id: `fixture-${index}`, + status: 'complete', + }, + created_at: new Date().toISOString(), + }; +} + +function JsonPanel({ title, data, defaultExpanded = false }: { title: string; data: unknown; defaultExpanded?: boolean }) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+ + {expanded && ( +
+          {JSON.stringify(data, null, 2)}
+        
+ )} +
+ ); +} + +function FixtureCard({ fixture, index }: { fixture: NormalizedFixture; index: number }) { + const message = fixtureToMessage(fixture, index); + + return ( +
+ {/* Header */} +
+
+ + {fixture.protocol} + + + {fixture.tool_type} + + + {fixture.tool_name} + +
+
+ + {/* Rendered Message */} +
+
Rendered Output:
+ +
+ + {/* JSON Panels */} +
+
+ + +
+
+
+ ); +} + +function MessageCard({ message }: { message: Message }) { + const toolName = message.metadata?.tool_name as string | undefined; + const status = message.metadata?.status as string | undefined; + + return ( +
+ {/* Header */} +
+
+ + {message.type} + + {toolName && ( + + {toolName} + + )} + {status && ( + + {status} + + )} +
+
+ + {/* Rendered Message */} +
+
Rendered Output:
+ +
+ + {/* JSON Panel */} +
+ +
+
+ ); +} + +export default function AgentMessagesPage() { + const [viewMode, setViewMode] = useState('events'); + + // Fixture mode state + const [fixtureFiles, setFixtureFiles] = useState([]); + const [selectedFixtureFile, setSelectedFixtureFile] = useState(''); + const [fixtures, setFixtures] = useState([]); + + // Events mode state + const [eventFiles, setEventFiles] = useState([]); + const [selectedEventFile, setSelectedEventFile] = useState(''); + const [messages, setMessages] = useState([]); + + // Shared state + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [toolFilter, setToolFilter] = useState('all'); + + // Load available files on mount + useEffect(() => { + async function loadFiles() { + try { + // Load both fixture files and normalized event files + const [fixtureFilesData, eventFilesData] = await Promise.all([ + fetchFixtureFiles(), + fetchNormalizedFiles(), + ]); + setFixtureFiles(fixtureFilesData); + setEventFiles(eventFilesData); + + // Auto-select first file based on current mode + if (eventFilesData.length > 0 && !selectedEventFile) { + setSelectedEventFile(eventFilesData[0].path); + } + if (fixtureFilesData.length > 0 && !selectedFixtureFile) { + setSelectedFixtureFile(fixtureFilesData[0].path); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load files'); + } finally { + setLoading(false); + } + } + loadFiles(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadFixtures = useCallback(async () => { + if (!selectedFixtureFile) return; + + setLoading(true); + setError(null); + try { + const data = await fetchNormalizedMessages(selectedFixtureFile); + setFixtures(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load fixtures'); + } finally { + setLoading(false); + } + }, [selectedFixtureFile]); + + const loadMessages = useCallback(async () => { + if (!selectedEventFile) return; + + setLoading(true); + setError(null); + try { + const data = await fetchNormalizedEventsAsMessages(selectedEventFile); + setMessages(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load messages'); + } finally { + setLoading(false); + } + }, [selectedEventFile]); + + // Load content when selected file changes + useEffect(() => { + if (viewMode === 'fixtures' && selectedFixtureFile) { + loadFixtures(); + } else if (viewMode === 'events' && selectedEventFile) { + loadMessages(); + } + }, [viewMode, selectedFixtureFile, selectedEventFile, loadFixtures, loadMessages]); + + const filteredFixtures = fixtures.filter((f) => { + if (toolFilter === 'all') return true; + return f.tool_type === toolFilter; + }); + + const currentFiles = viewMode === 'fixtures' ? fixtureFiles : eventFiles; + const selectedFile = viewMode === 'fixtures' ? selectedFixtureFile : selectedEventFile; + const setSelectedFile = viewMode === 'fixtures' ? setSelectedFixtureFile : setSelectedEventFile; + const loadContent = viewMode === 'fixtures' ? loadFixtures : loadMessages; + + const toolTabs: { value: ToolFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'tool_edit', label: 'Edit' }, + { value: 'tool_read', label: 'Read' }, + { value: 'tool_execute', label: 'Execute' }, + { value: 'tool_call', label: 'Call' }, + ]; + + const itemCount = viewMode === 'fixtures' ? filteredFixtures.length : messages.length; + const totalCount = viewMode === 'fixtures' ? fixtures.length : messages.length; + + return ( +
+
+ {/* Header */} +
+
+

Agent Messages QA

+ +
+

+ Visual inspection of agent message normalization. Each card shows the rendered output alongside raw JSON. +

+
+ + {/* View Mode Tabs */} +
+
+ + +
+
+ + {/* Filters */} +
+ {/* File Selector */} +
+ + {viewMode === 'events' ? 'Event File:' : 'Fixture File:'} + + +
+ + {/* Tool Filter - only show for fixtures mode */} + {viewMode === 'fixtures' && ( +
+ Tool Type: +
+ {toolTabs.map((tab) => ( + + ))} +
+
+ )} +
+ + {/* Stats */} +
+ Showing {itemCount} of {totalCount} {viewMode === 'events' ? 'messages' : 'fixtures'} +
+ + {/* Content */} + {loading && ( +
+ +
+ )} + + {error && ( +
+
Error loading {viewMode}
+
{error}
+
+ )} + + {!loading && !error && itemCount === 0 && ( +
+ {viewMode === 'events' + ? 'No normalized event files found. Run an agent with KANDEV_DEBUG_AGENT_MESSAGES=1 to generate event logs.' + : 'No fixtures found. Select a fixture file from the dropdown above.'} +
+ )} + + {!loading && !error && viewMode === 'fixtures' && filteredFixtures.length > 0 && ( +
+ {filteredFixtures.map((fixture, index) => ( + + ))} +
+ )} + + {!loading && !error && viewMode === 'events' && messages.length > 0 && ( +
+ {messages.map((message, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/demo/messages/page.tsx b/apps/web/app/demo/messages/page.tsx index 299cf7daef..9c3db752bd 100644 --- a/apps/web/app/demo/messages/page.tsx +++ b/apps/web/app/demo/messages/page.tsx @@ -540,6 +540,199 @@ index 9876543..fedcba9 100644 }, }, }, + + // === Normalized Tool Messages (tool_edit, tool_read, tool_execute) === + + // Tool Edit - complete with diff + { + id: 'tool-edit-1', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_edit', + content: 'Edit src/auth/config.ts', + author_type: 'agent', + created_at: '2024-01-20T10:20:00Z', + metadata: { + tool_call_id: 'tool-edit-1', + status: 'complete', + file_path: 'src/auth/config.ts', + old_content: " clientSecret: process.env.OAUTH_CLIENT_SECRET,", + new_content: " clientSecret: process.env.OAUTH_CLIENT_SECRET || '',\n tokenExpiry: '1h',", + diff: { + hunks: [ + `diff --git a/src/auth/config.ts b/src/auth/config.ts +index 1234567..abcdefg 100644 +--- a/src/auth/config.ts ++++ b/src/auth/config.ts +@@ -2,3 +2,4 @@ + clientId: process.env.OAUTH_CLIENT_ID, +- clientSecret: process.env.OAUTH_CLIENT_SECRET, ++ clientSecret: process.env.OAUTH_CLIENT_SECRET || '', ++ tokenExpiry: '1h', + redirectUri: process.env.OAUTH_REDIRECT_URI,`, + ], + oldFile: { fileName: 'src/auth/config.ts', fileLang: 'typescript' }, + newFile: { fileName: 'src/auth/config.ts', fileLang: 'typescript' }, + }, + result: 'File edited successfully.', + }, + }, + + // Tool Edit - running (expanded) + { + id: 'tool-edit-2', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_edit', + content: 'Edit src/middleware/auth.ts', + author_type: 'agent', + created_at: '2024-01-20T10:20:30Z', + metadata: { + tool_call_id: 'tool-edit-2', + status: 'running', + file_path: 'src/middleware/auth.ts', + old_content: "return res.status(401).json({ error: 'No token' });", + new_content: "return res.status(401).json({\n error: 'No token',\n code: 'AUTH_MISSING'\n});", + diff: { + hunks: [ + `diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts +--- a/src/middleware/auth.ts ++++ b/src/middleware/auth.ts +@@ -12,1 +12,4 @@ +-return res.status(401).json({ error: 'No token' }); ++return res.status(401).json({ ++ error: 'No token', ++ code: 'AUTH_MISSING' ++});`, + ], + oldFile: { fileName: 'src/middleware/auth.ts', fileLang: 'typescript' }, + newFile: { fileName: 'src/middleware/auth.ts', fileLang: 'typescript' }, + }, + }, + }, + + // Tool Edit - error + { + id: 'tool-edit-3', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_edit', + content: 'Edit src/utils/format.ts', + author_type: 'agent', + created_at: '2024-01-20T10:21:00Z', + metadata: { + tool_call_id: 'tool-edit-3', + status: 'error', + file_path: 'src/utils/format.ts', + result: 'Error: old_string not found in file.', + }, + }, + + // Tool Read - complete + { + id: 'tool-read-1', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_read', + content: 'Read src/auth/config.ts', + author_type: 'agent', + created_at: '2024-01-20T10:22:00Z', + metadata: { + tool_call_id: 'tool-read-1', + status: 'complete', + file_path: 'src/auth/config.ts', + line_count: 50, + }, + }, + + // Tool Read - running + { + id: 'tool-read-2', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_read', + content: 'Read package.json', + author_type: 'agent', + created_at: '2024-01-20T10:22:30Z', + metadata: { + tool_call_id: 'tool-read-2', + status: 'running', + file_path: 'package.json', + }, + }, + + // Tool Read - error + { + id: 'tool-read-3', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_read', + content: 'Read /nonexistent/file.ts', + author_type: 'agent', + created_at: '2024-01-20T10:22:45Z', + metadata: { + tool_call_id: 'tool-read-3', + status: 'error', + file_path: '/nonexistent/file.ts', + }, + }, + + // Tool Execute - complete (success) + { + id: 'tool-exec-1', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_execute', + content: 'Run `npm test -- --coverage`', + author_type: 'agent', + created_at: '2024-01-20T10:23:00Z', + metadata: { + tool_call_id: 'tool-exec-1', + status: 'complete', + command: 'npm test -- --coverage', + cwd: '/workspace', + exit_code: 0, + stdout: 'PASS src/auth/config.test.ts\nPASS src/middleware/auth.test.ts\n\nTest Suites: 2 passed, 2 total\nTests: 12 passed, 12 total\nCoverage: 87.5%', + result: 'Tests passed successfully.', + }, + }, + + // Tool Execute - running + { + id: 'tool-exec-2', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_execute', + content: 'Run `go build ./...`', + author_type: 'agent', + created_at: '2024-01-20T10:23:30Z', + metadata: { + tool_call_id: 'tool-exec-2', + status: 'running', + command: 'go build ./...', + cwd: '/workspace', + }, + }, + + // Tool Execute - error with stderr + { + id: 'tool-exec-3', + session_id: 'demo', + task_id: 'demo-task', + type: 'tool_execute', + content: 'Run `npm run lint`', + author_type: 'agent', + created_at: '2024-01-20T10:24:00Z', + metadata: { + tool_call_id: 'tool-exec-3', + status: 'complete', + command: 'npm run lint', + exit_code: 1, + stdout: '', + stderr: 'Error: src/auth/config.ts(5,1): Missing semicolon.\nError: src/middleware/auth.ts(12,3): Unexpected token.', + result: 'Lint failed with 2 errors.', + }, + }, ]; export default function MessagesDemoPage() { diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 0973e5f0dc..2b65055e19 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -342,27 +342,27 @@ export function TaskCreateDialog({ value: repo.id, label: repo.name, renderLabel: () => ( - + {repo.name} - {truncateRepoPath(repo.local_path)} + {truncateRepoPath(repo.local_path, 24)} ), })), ...localRepoOptions.map((repo: LocalRepository) => ({ value: repo.path, - label: formatUserHomePath(repo.path), + label: truncateRepoPath(repo.path, 24), renderLabel: () => ( - {truncateRepoPath(repo.path)} + {truncateRepoPath(repo.path, 28)} ), })), diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 2842970733..21f14e59db 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -6,6 +6,9 @@ import { ChatMessage } from '@/components/task/chat/messages/chat-message'; import { PermissionRequestMessage } from '@/components/task/chat/messages/permission-request-message'; import { StatusMessage } from '@/components/task/chat/messages/status-message'; import { ToolCallMessage } from '@/components/task/chat/messages/tool-call-message'; +import { ToolEditMessage } from '@/components/task/chat/messages/tool-edit-message'; +import { ToolReadMessage } from '@/components/task/chat/messages/tool-read-message'; +import { ToolExecuteMessage } from '@/components/task/chat/messages/tool-execute-message'; import { ThinkingMessage } from '@/components/task/chat/messages/thinking-message'; import { TodoMessage } from '@/components/task/chat/messages/todo-message'; import { ScriptExecutionMessage } from '@/components/task/chat/messages/script-execution-message'; @@ -31,6 +34,18 @@ const adapters: MessageAdapter[] = [ matches: (comment) => comment.type === 'todo', render: (comment) => , }, + { + matches: (comment) => comment.type === 'tool_edit', + render: (comment) => , + }, + { + matches: (comment) => comment.type === 'tool_read', + render: (comment) => , + }, + { + matches: (comment) => comment.type === 'tool_execute', + render: (comment) => , + }, { matches: (comment) => comment.type === 'tool_call', render: (comment, ctx) => { diff --git a/apps/web/components/task/chat/messages/diff-view-block.tsx b/apps/web/components/task/chat/messages/diff-view-block.tsx new file mode 100644 index 0000000000..5ae342acd5 --- /dev/null +++ b/apps/web/components/task/chat/messages/diff-view-block.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { DiffModeEnum, DiffView } from '@git-diff-view/react'; +import { useTheme } from 'next-themes'; +import { IconCode } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import type { DiffPayload } from '@/components/task/chat/types'; + +type DiffViewBlockProps = { + diff: DiffPayload; + title?: string; + showTitle?: boolean; + className?: string; +}; + +export function DiffViewBlock({ + diff, + title = 'Diff', + showTitle = true, + className +}: DiffViewBlockProps) { + const { resolvedTheme } = useTheme(); + const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; + + return ( +
+ {showTitle && ( +
+ + {title} +
+ )} + +
+ ); +} diff --git a/apps/web/components/task/chat/messages/rich-blocks.tsx b/apps/web/components/task/chat/messages/rich-blocks.tsx index 50b396a4b9..00d2c70dd4 100644 --- a/apps/web/components/task/chat/messages/rich-blocks.tsx +++ b/apps/web/components/task/chat/messages/rich-blocks.tsx @@ -1,11 +1,10 @@ 'use client'; import { IconBrain, IconCode, IconListCheck } from '@tabler/icons-react'; -import { DiffModeEnum, DiffView } from '@git-diff-view/react'; -import { useTheme } from 'next-themes'; import { cn } from '@/lib/utils'; import type { Message } from '@/lib/types/http'; import type { DiffPayload, RichMetadata } from '@/components/task/chat/types'; +import { DiffViewBlock } from '@/components/task/chat/messages/diff-view-block'; function resolveDiffPayload(diff: unknown): DiffPayload | null { if (!diff) return null; @@ -30,7 +29,6 @@ function resolveDiffPayload(diff: unknown): DiffPayload | null { export function RichBlocks({ comment }: { comment: Message }) { const metadata = comment.metadata as RichMetadata | undefined; - const { resolvedTheme } = useTheme(); if (!metadata) return null; const todos = metadata.todos ?? []; @@ -39,7 +37,6 @@ export function RichBlocks({ comment }: { comment: Message }) { .filter((item) => item.text); const diffPayload = resolveDiffPayload(metadata.diff); const diffText = typeof metadata.diff === 'string' ? metadata.diff : null; - const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; return ( <> @@ -76,21 +73,7 @@ export function RichBlocks({ comment }: { comment: Message }) { )} {diffPayload && ( -
-
- - Diff -
- -
+ )} {!diffPayload && diffText && (
diff --git a/apps/web/components/task/chat/messages/tool-edit-message.tsx b/apps/web/components/task/chat/messages/tool-edit-message.tsx new file mode 100644 index 0000000000..8c9fe00049 --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-edit-message.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { IconCheck, IconX, IconLoader2, IconEdit, IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +import type { Message } from '@/lib/types/http'; +import { DiffViewBlock } from './diff-view-block'; + +type FileMutation = { + type?: string; + old_content?: string; + new_content?: string; + diff?: string; +}; + +type ModifyFilePayload = { + file_path?: string; + mutations?: FileMutation[]; +}; + +type NormalizedPayload = { + kind?: string; + modify_file?: ModifyFilePayload; +}; + +type ToolEditMetadata = { + tool_call_id?: string; + status?: 'pending' | 'running' | 'complete' | 'error'; + normalized?: NormalizedPayload; +}; + +// Parse unified diff string into hunks array for DiffViewBlock +function parseDiffToHunks(diffString: string): string[] { + if (!diffString) return []; + // Split on hunk headers (@@ ... @@) but keep them + const hunks = diffString.split(/(?=^@@)/m).filter(h => h.trim()); + return hunks; +} + +export function ToolEditMessage({ comment }: { comment: Message }) { + const metadata = comment.metadata as ToolEditMetadata | undefined; + const [manualExpandState, setManualExpandState] = useState(null); + const prevStatusRef = useRef(metadata?.status); + + const status = metadata?.status; + const normalized = metadata?.normalized; + const filePath = normalized?.modify_file?.file_path; + const diffString = normalized?.modify_file?.mutations?.[0]?.diff; + const diff = diffString ? { + hunks: parseDiffToHunks(diffString), + oldFile: { fileName: filePath }, + newFile: { fileName: filePath }, + } : undefined; + const isSuccess = status === 'complete'; + + // Reset manual state when status changes (allows auto-expand behavior to resume) + if (prevStatusRef.current !== status) { + prevStatusRef.current = status; + if (manualExpandState !== null) { + setManualExpandState(null); + } + } + + // Derive expanded state: manual override takes precedence, otherwise auto-expand when running + const isExpanded = manualExpandState ?? (status === 'running'); + + const getStatusIcon = () => { + switch (status) { + case 'complete': + return ; + case 'error': + return ; + case 'running': + return ; + default: + return null; + } + }; + + return ( +
+
+
+ +
+ +
+
+ + {filePath && ( + {filePath} + )} +
+ + {isExpanded && diff && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/apps/web/components/task/chat/messages/tool-execute-message.tsx b/apps/web/components/task/chat/messages/tool-execute-message.tsx new file mode 100644 index 0000000000..3d1391b714 --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-execute-message.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { IconCheck, IconX, IconLoader2, IconTerminal, IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import type { Message } from '@/lib/types/http'; + +type ShellExecOutput = { + exit_code?: number; + stdout?: string; + stderr?: string; +}; + +type ShellExecPayload = { + command?: string; + work_dir?: string; + description?: string; + timeout?: number; + background?: boolean; + output?: ShellExecOutput; +}; + +type NormalizedPayload = { + kind?: string; + shell_exec?: ShellExecPayload; +}; + +type ToolExecuteMetadata = { + tool_call_id?: string; + status?: 'pending' | 'running' | 'complete' | 'error'; + normalized?: NormalizedPayload; +}; + +export function ToolExecuteMessage({ comment }: { comment: Message }) { + const metadata = comment.metadata as ToolExecuteMetadata | undefined; + const [manualExpandState, setManualExpandState] = useState(null); + const prevStatusRef = useRef(metadata?.status); + + const status = metadata?.status; + const output = metadata?.normalized?.shell_exec?.output; + const hasOutput = output?.stdout || output?.stderr; + const isSuccess = status === 'complete' && (output?.exit_code === 0 || output?.exit_code === undefined); + + // Reset manual state when status changes (allows auto-expand behavior to resume) + if (prevStatusRef.current !== status) { + prevStatusRef.current = status; + if (manualExpandState !== null) { + setManualExpandState(null); + } + } + + // Derive expanded state: manual override takes precedence, otherwise auto-expand when running + const isExpanded = manualExpandState ?? (status === 'running'); + + const getStatusIcon = () => { + switch (status) { + case 'complete': + return output?.exit_code === 0 ? : ; + case 'error': + return ; + case 'running': + return ; + default: + return null; + } + }; + + return ( +
+
+
+ +
+ +
+
+ +
+ + {isExpanded && hasOutput && ( +
+ {output?.stdout && ( +
+                  {output.stdout}
+                
+ )} + {output?.stderr && ( +
+                  {output.stderr}
+                
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/components/task/chat/messages/tool-read-message.tsx b/apps/web/components/task/chat/messages/tool-read-message.tsx new file mode 100644 index 0000000000..0647421725 --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-read-message.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { IconCheck, IconX, IconLoader2, IconEye, IconSearch } from '@tabler/icons-react'; +import type { Message } from '@/lib/types/http'; + +type ReadFilePayload = { + file_path?: string; + offset?: number; + limit?: number; +}; + +type CodeSearchPayload = { + query?: string; + pattern?: string; + path?: string; + glob?: string; +}; + +type NormalizedPayload = { + kind?: string; + read_file?: ReadFilePayload; + code_search?: CodeSearchPayload; +}; + +type ToolReadMetadata = { + tool_call_id?: string; + status?: 'pending' | 'running' | 'complete' | 'error'; + normalized?: NormalizedPayload; +}; + +export function ToolReadMessage({ comment }: { comment: Message }) { + const metadata = comment.metadata as ToolReadMetadata | undefined; + const status = metadata?.status; + const normalized = metadata?.normalized; + const kind = normalized?.kind; + + // Determine display based on kind + const isCodeSearch = kind === 'code_search'; + const filePath = normalized?.read_file?.file_path; + const searchPath = normalized?.code_search?.path; + const searchPattern = normalized?.code_search?.glob || normalized?.code_search?.pattern; + + const isSuccess = status === 'complete'; + const Icon = isCodeSearch ? IconSearch : IconEye; + + const getStatusIcon = () => { + switch (status) { + case 'complete': + return ; + case 'error': + return ; + case 'running': + return ; + default: + return null; + } + }; + + // Display path - either file path for read or search path for code search + const displayPath = isCodeSearch ? searchPath : filePath; + const displayPattern = isCodeSearch ? searchPattern : null; + + return ( +
+
+
+ +
+ +
+
+ {comment.content} + {!isSuccess && getStatusIcon()} + {displayPattern && ( + {displayPattern} + )} + {displayPath && ( + {displayPath} + )} +
+
+
+
+ ); +} diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts index b8c61ee81c..b221da32ec 100644 --- a/apps/web/components/task/chat/types.ts +++ b/apps/web/components/task/chat/types.ts @@ -1,5 +1,23 @@ 'use client'; +export type SubagentTaskPayload = { + description?: string; + prompt?: string; + subagent_type?: string; +}; + +export type GenericPayload = { + name?: string; + input?: unknown; + output?: unknown; +}; + +export type NormalizedPayload = { + kind?: string; + subagent_task?: SubagentTaskPayload; + generic?: GenericPayload; +}; + export type ToolCallMetadata = { tool_call_id?: string; tool_name?: string; @@ -7,6 +25,7 @@ export type ToolCallMetadata = { status?: 'pending' | 'running' | 'complete' | 'error'; args?: Record; result?: string; + normalized?: NormalizedPayload; }; export type StatusMetadata = { diff --git a/apps/web/lib/api/domains/debug-api.ts b/apps/web/lib/api/domains/debug-api.ts new file mode 100644 index 0000000000..3aaaf5da1f --- /dev/null +++ b/apps/web/lib/api/domains/debug-api.ts @@ -0,0 +1,51 @@ +import { fetchJson } from '@/lib/api/client'; +import type { Message } from '@/lib/types/http'; + +export type DiscoveredFile = { + path: string; + protocol: string; + agent?: string; + message_count: number; + file_type?: string; +}; + +export type NormalizedFixture = { + protocol: string; + tool_name: string; + tool_type: string; + input: Record; + payload: Record; +}; + +/** + * Fetches list of discovered fixture files. + */ +export async function fetchFixtureFiles(): Promise { + const response = await fetchJson<{ files: DiscoveredFile[] }>('/api/v1/debug/fixture-files'); + return response.files ?? []; +} + +/** + * Fetches normalized message fixtures for a specific file. + * @param filePath - Relative path to the fixture file + */ +export async function fetchNormalizedMessages(filePath: string): Promise { + return fetchJson(`/api/v1/debug/normalize-messages?file=${encodeURIComponent(filePath)}`); +} + +/** + * Fetches list of discovered normalized event files. + */ +export async function fetchNormalizedFiles(): Promise { + const response = await fetchJson<{ files: DiscoveredFile[] }>('/api/v1/debug/normalized-files'); + return response.files ?? []; +} + +/** + * Fetches normalized events as Messages from a specific file. + * @param filePath - Relative path to the normalized file + */ +export async function fetchNormalizedEventsAsMessages(filePath: string): Promise { + const response = await fetchJson<{ messages: Message[] }>(`/api/v1/debug/normalized-events?file=${encodeURIComponent(filePath)}`); + return response.messages ?? []; +} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index b858ce9b85..0b3ea7dd1a 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -460,6 +460,9 @@ export type MessageType = | 'message' | 'content' | 'tool_call' + | 'tool_edit' + | 'tool_read' + | 'tool_execute' | 'progress' | 'log' | 'error'