From 122deb3b133d10d3fb3dab40fe3bbf86570ec662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 1 Feb 2026 00:40:56 +0000 Subject: [PATCH] feat: add GitHub Copilot CLI and Sourcegraph Amp agent support (#130) --- apps/backend/cmd/kandev/adapters.go | 42 +- apps/backend/docs/add_agent.md | 6 +- apps/backend/docs/opencode_gemini_plan.md | 4 +- apps/backend/go.mod | 2 + apps/backend/go.sum | 4 + .../internal/agent/lifecycle/command.go | 6 +- .../internal/agent/lifecycle/manager.go | 84 +- .../agent/lifecycle/runtime_standalone.go | 5 + .../internal/agent/lifecycle/session.go | 127 +-- .../backend/internal/agent/lifecycle/types.go | 10 + .../internal/agent/registry/agents.json | 353 +++++++- .../internal/agent/registry/registry.go | 16 +- .../agentctl/server/adapter/amp_adapter.go | 798 +++++++++++++++++ .../agentctl/server/adapter/amp_normalizer.go | 391 +++++++++ .../server/adapter/copilot_adapter.go | 805 ++++++++++++++++++ .../server/adapter/copilot_normalizer.go | 381 +++++++++ .../agentctl/server/adapter/factory.go | 4 + .../server/adapter/transport/shared/debug.go | 2 + .../agentctl/server/instance/manager.go | 28 +- .../agentctl/server/process/manager.go | 31 +- .../agentctl/types/streams/tool_payload.go | 19 + .../internal/orchestrator/event_handlers.go | 20 +- apps/backend/internal/task/service/service.go | 20 +- apps/backend/pkg/agent/protocol.go | 9 +- apps/backend/pkg/amp/client.go | 288 +++++++ apps/backend/pkg/copilot/client.go | 350 ++++++++ .../task/chat/messages/chat-message.tsx | 32 + .../task/chat/messages/tool-call-message.tsx | 256 +++--- apps/web/components/task/task-layout.tsx | 2 +- apps/web/components/task/task-right-panel.tsx | 2 +- 30 files changed, 3793 insertions(+), 304 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/adapter/amp_adapter.go create mode 100644 apps/backend/internal/agentctl/server/adapter/amp_normalizer.go create mode 100644 apps/backend/internal/agentctl/server/adapter/copilot_adapter.go create mode 100644 apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go create mode 100644 apps/backend/pkg/amp/client.go create mode 100644 apps/backend/pkg/copilot/client.go diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 6ba316e53c..03f4171df0 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -265,39 +265,6 @@ func (a *messageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, c return err } -// toolKindToMessageType maps the normalized tool kind to a frontend message type. -// This allows the frontend to use specialized renderers for different tool categories. -// Returns the message type and whether a warning should be logged (for unknown kinds). -func toolKindToMessageType(kind streams.ToolKind) (string, bool) { - switch kind { - case streams.ToolKindReadFile: - return "tool_read", false - case streams.ToolKindCodeSearch: - return "tool_search", false - case streams.ToolKindModifyFile: - return "tool_edit", false - case streams.ToolKindShellExec: - return "tool_execute", false - case streams.ToolKindHttpRequest: - return "tool_call", false - case streams.ToolKindGeneric: - return "tool_call", false - case streams.ToolKindCreateTask: - return "tool_call", false - case streams.ToolKindSubagentTask: - return "tool_call", false - case streams.ToolKindShowPlan: - return "tool_call", false - case streams.ToolKindManageTodos: - return "todo", false - case streams.ToolKindMisc: - return "tool_call", false - default: - // Unknown kind - this shouldn't happen, log a warning - return "tool_call", true - } -} - // CreateToolCallMessage creates a message for a tool call func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { metadata := map[string]interface{}{ @@ -317,14 +284,7 @@ func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskI // Determine message type from the normalized tool kind msgType := "tool_call" if normalized != nil { - var warn bool - msgType, warn = toolKindToMessageType(normalized.Kind()) - if warn { - a.logger.Warn("unknown tool kind falling back to tool_call", - zap.String("kind", string(normalized.Kind())), - zap.String("tool_call_id", toolCallID), - zap.String("title", title)) - } + msgType = normalized.Kind().ToMessageType() } _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ diff --git a/apps/backend/docs/add_agent.md b/apps/backend/docs/add_agent.md index 81724e842f..7bd89b3bb8 100644 --- a/apps/backend/docs/add_agent.md +++ b/apps/backend/docs/add_agent.md @@ -215,7 +215,7 @@ Add the agent definition: "model_flag": "--model {model}", "workspace_flag": "--workspace", "session_config": { - "resume_via_acp": false, + "native_session_resume": false, "resume_flag": "--resume", "can_recover": true, "session_dir_template": "{home}/.gemini/sessions", @@ -352,7 +352,7 @@ a.sendUpdate(AgentEvent{ Configure session handling in `session_config`: -- `resume_via_acp`: If true, sessions resume via ACP protocol. If false, use CLI flag. +- `native_session_resume`: If true, sessions resume via the adapter's native session loading (works across protocols). If false, use CLI flag or context injection. - `resume_flag`: CLI flag for resuming (e.g., `--resume`) - `can_recover`: Whether sessions can survive backend restarts - `session_dir_template`: Where session data is stored on host @@ -493,6 +493,6 @@ Before releasing a new agent integration: ### Session resume not working - For stream-based protocols: ensure adapter emits `session_status` event with agent's native session ID - Check that `resume_flag` is set in agent config (e.g., `"--resume"`) -- Verify `resume_via_acp` is `false` for CLI-based resume +- Verify `native_session_resume` is `false` for CLI-based resume - Check `executor_running` table has the correct `resume_token` value - For Claude Code: the session ID from `system` message should be stored as resume token diff --git a/apps/backend/docs/opencode_gemini_plan.md b/apps/backend/docs/opencode_gemini_plan.md index f0c8ba2455..ee4c2fa27d 100644 --- a/apps/backend/docs/opencode_gemini_plan.md +++ b/apps/backend/docs/opencode_gemini_plan.md @@ -514,7 +514,7 @@ Add these entries to the `agents` array: "model_flag": "", "workspace_flag": "", "session_config": { - "resume_via_acp": false, + "native_session_resume": false, "resume_flag": "", "can_recover": true, "session_dir_template": "{home}/.opencode", @@ -626,7 +626,7 @@ Add these entries to the `agents` array: "model_flag": "--model {model}", "workspace_flag": "", "session_config": { - "resume_via_acp": true, + "native_session_resume": true, "resume_flag": "", "can_recover": true, "session_dir_template": "{home}/.gemini", diff --git a/apps/backend/go.mod b/apps/backend/go.mod index d76a5e5e2e..19a933e3bb 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -39,12 +39,14 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/github/copilot-sdk/go v0.1.18 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index a58b499610..d79ef832fe 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -103,6 +103,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/github/copilot-sdk/go v0.1.18 h1:S1ocOfTKxiNGtj+/qp4z+RZeOr9hniqy3UqIIYZxsuQ= +github.com/github/copilot-sdk/go v0.1.18/go.mod h1:0SYT+64k347IDT0Trn4JHVFlUhPtGSE6ab479tU/+tY= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -160,6 +162,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= diff --git a/apps/backend/internal/agent/lifecycle/command.go b/apps/backend/internal/agent/lifecycle/command.go index a41d6c8b45..c01a82f6d2 100644 --- a/apps/backend/internal/agent/lifecycle/command.go +++ b/apps/backend/internal/agent/lifecycle/command.go @@ -20,7 +20,7 @@ func NewCommandBuilder() *CommandBuilder { // CommandOptions contains options for building a command type CommandOptions struct { Model string // Model to use (appended via ModelFlag if set) - SessionID string // Session ID to resume (appended via SessionConfig.ResumeFlag if not ResumeViaACP) + SessionID string // Session ID to resume (appended via SessionConfig.ResumeFlag if not NativeSessionResume) AutoApprove bool // If true, skip permission flags (auto-approve all tool calls) // PermissionValues contains the values for each permission setting @@ -47,10 +47,10 @@ func (cb *CommandBuilder) BuildCommand(agentConfig *registry.AgentTypeConfig, op // Append session resume flag if: // 1. Session ID is provided - // 2. Agent does NOT use ACP for session resumption (uses CLI flag instead) + // 2. Agent does NOT use native session loading (uses CLI flag instead) // 3. Agent has a ResumeFlag configured // 4. Agent supports session recovery (CanRecover is not false) - if opts.SessionID != "" && !agentConfig.SessionConfig.ResumeViaACP && agentConfig.SessionConfig.ResumeFlag != "" && agentConfig.SessionConfig.SupportsRecovery() { + if opts.SessionID != "" && !agentConfig.SessionConfig.NativeSessionResume && agentConfig.SessionConfig.ResumeFlag != "" && agentConfig.SessionConfig.SupportsRecovery() { cmd = append(cmd, agentConfig.SessionConfig.ResumeFlag, opts.SessionID) } diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index e3b08a5bbb..40a62f4cff 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -817,6 +817,10 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti execution.ACPSessionID = req.ACPSessionID } + // Set flag indicating if agent adapter reports completion via stream + // When true, session manager should NOT emit a complete event after prompt returns + execution.ReportsStatusViaStream = agentConfig.SessionConfig.ReportsStatusViaStream + // Build agent command string for later use with StartAgentProcess model := "" autoApprove := false @@ -1212,7 +1216,7 @@ func (m *Manager) emitSessionStatusEvent(execution *AgentExecution, agentConfig wasResumed := false if providedACPSessionID != "" { - if agentConfig.SessionConfig.ResumeViaACP { + if agentConfig.SessionConfig.NativeSessionResume { wasResumed = true } else if agentConfig.SessionConfig.ResumeFlag != "" && agentConfig.SessionConfig.SupportsRecovery() { wasResumed = true @@ -1238,6 +1242,13 @@ func (m *Manager) handlePermissionRequestEvent(execution *AgentExecution, event // handleAgentEvent processes incoming agent events from the agent func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.AgentEvent) { + // Log all incoming events for debugging + m.logger.Debug("handleAgentEvent entry", + zap.String("execution_id", execution.ID), + zap.String("event_type", event.Type), + zap.String("operation_id", event.OperationID), + zap.Int("text_length", len(event.Text))) + // Handle different event types based on the Type field switch event.Type { case "message_chunk": @@ -1245,6 +1256,12 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age if event.Text != "" { execution.messageMu.Lock() execution.messageBuffer.WriteString(event.Text) + bufferLenAfterWrite := execution.messageBuffer.Len() + m.logger.Debug("message_chunk written to buffer", + zap.String("execution_id", execution.ID), + zap.String("operation_id", event.OperationID), + zap.Int("text_length", len(event.Text)), + zap.Int("buffer_length_after", bufferLenAfterWrite)) // Check if buffer contains newlines - if so, flush up to the last newline bufContent := execution.messageBuffer.String() @@ -1349,17 +1366,65 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age zap.Any("data", event.Data)) case "complete": + // Check buffer content BEFORE any processing + execution.messageMu.Lock() + bufferContentBeforeFlush := execution.messageBuffer.String() + streamingWasUsed := execution.streamingUsedThisTurn + currentMsgID := execution.currentMessageID + execution.messageMu.Unlock() + + // Log preview of buffer content for debugging (first 100 chars) + bufferPreview := bufferContentBeforeFlush + if len(bufferPreview) > 100 { + bufferPreview = bufferPreview[:100] + "..." + } + m.logger.Info("agent turn complete", zap.String("execution_id", execution.ID), - zap.String("session_id", event.SessionID)) + zap.String("operation_id", event.OperationID), + zap.String("session_id", event.SessionID), + zap.Bool("streaming_was_used", streamingWasUsed), + zap.String("current_msg_id", currentMsgID), + zap.String("event_text", event.Text), + zap.Int("buffer_length", len(bufferContentBeforeFlush)), + zap.String("buffer_preview", bufferPreview)) - // Flush accumulated message buffer and include in the event - // The orchestrator will save this as an agent message - if flushedText := m.flushMessageBuffer(execution); flushedText != "" { - event.Text = flushedText + // Get the agent message text: + // 1. If streaming was used, flush the buffer (which publishes final chunk) and ignore event.Text + // 2. Otherwise, prefer text from event (adapter-provided, e.g., Amp) or flush buffer + var agentText string + if streamingWasUsed { + // Streaming messages were created - flush buffer to publish final chunk + // and return empty so we don't create duplicate messages + m.flushMessageBuffer(execution) + agentText = "" + } else if event.Text != "" { + // No streaming used, use adapter-provided text + agentText = event.Text + // Still clear the buffer to avoid stale data + m.flushMessageBuffer(execution) + } else { + // No streaming used, no adapter text - flush buffer for content + agentText = m.flushMessageBuffer(execution) + } + + m.logger.Info("complete event text resolution", + zap.String("execution_id", execution.ID), + zap.String("operation_id", event.OperationID), + zap.Int("agent_text_length", len(agentText)), + zap.Bool("will_save_message", agentText != "")) + + // Reset the streaming flag for next turn + execution.messageMu.Lock() + execution.streamingUsedThisTurn = false + execution.messageMu.Unlock() + + // Include agent text in the event for the orchestrator to save as an agent message + if agentText != "" { + event.Text = agentText // Store agent message to session history for context injection if m.historyManager != nil && execution.SessionID != "" { - if err := m.historyManager.AppendAgentMessage(execution.SessionID, flushedText); err != nil { + if err := m.historyManager.AppendAgentMessage(execution.SessionID, agentText); err != nil { m.logger.Warn("failed to store agent message to history", zap.Error(err)) } } @@ -1478,6 +1543,9 @@ func (m *Manager) publishStreamingMessage(execution *AgentExecution, content str messageID = uuid.New().String() execution.currentMessageID = messageID } + + // Mark that streaming was used this turn (for duplicate prevention on complete) + execution.streamingUsedThisTurn = true execution.messageMu.Unlock() event := AgentStreamEventData{ @@ -2471,7 +2539,7 @@ func (m *Manager) buildPassthroughCommand(agentConfig *registry.AgentTypeConfig, // 2. Agent uses CLI-based resume (not ACP) // 3. Agent has a ResumeFlag configured if acpSessionID != "" && - !agentConfig.SessionConfig.ResumeViaACP && + !agentConfig.SessionConfig.NativeSessionResume && agentConfig.SessionConfig.ResumeFlag != "" { cmd = append(cmd, agentConfig.SessionConfig.ResumeFlag, acpSessionID) } else if initialPrompt != "" { diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index f3148d493e..02ee6f57ec 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -111,6 +111,11 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea SessionID: req.SessionID, } + r.logger.Info("CreateInstance: sending request to agentctl", + zap.String("instance_id", req.InstanceID), + zap.String("req_protocol", req.Protocol), + zap.String("createReq_protocol", createReq.Protocol)) + resp, err := r.ctl.CreateInstance(ctx, createReq) if err != nil { return nil, fmt.Errorf("failed to create standalone instance: %w", err) diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index c4b1ed9759..8209ce2d8c 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -57,8 +57,8 @@ type InitializeResult struct { // It handles the initialize handshake and session creation/loading based on config. // // Session behavior: -// - If agentConfig.SessionConfig.ResumeViaACP is true AND existingSessionID is provided: use session/load -// - If agentConfig.SessionConfig.ResumeViaACP is false (CLI handles resume): always use session/new +// - If agentConfig.SessionConfig.NativeSessionResume is true AND existingSessionID is provided: use session/load +// - If agentConfig.SessionConfig.NativeSessionResume is false (CLI handles resume): always use session/new // - Otherwise: use session/new func (sm *SessionManager) InitializeSession( ctx context.Context, @@ -71,7 +71,7 @@ func (sm *SessionManager) InitializeSession( sm.logger.Info("initializing ACP session", zap.String("agent_type", agentConfig.ID), zap.String("workspace_path", workspacePath), - zap.Bool("resume_via_acp", agentConfig.SessionConfig.ResumeViaACP), + zap.Bool("native_session_resume", agentConfig.SessionConfig.NativeSessionResume), zap.String("existing_session_id", existingSessionID)) // Step 1: Send initialize request @@ -119,7 +119,7 @@ func (sm *SessionManager) createOrLoadSession( workspacePath string, mcpServers []agentctltypes.McpServer, ) (string, error) { - if agentConfig.SessionConfig.ResumeViaACP && existingSessionID != "" { + if agentConfig.SessionConfig.NativeSessionResume && existingSessionID != "" { sessionID, err := sm.loadSession(ctx, client, agentConfig, existingSessionID) if err != nil { // If session/load fails with "Method not found" or "LoadSession capability is false", @@ -142,7 +142,7 @@ func (sm *SessionManager) createOrLoadSession( // shouldInjectResumeContext determines if we should inject resume context for this session. // Returns true if: // 1. The agent uses ACP protocol -// 2. The agent doesn't support session/load (ResumeViaACP is false) +// 2. The agent doesn't support native session loading (NativeSessionResume is false) // 3. We have a task session ID (for history lookup) // 4. There's existing history for this session func (sm *SessionManager) shouldInjectResumeContext(agentConfig *registry.AgentTypeConfig, taskSessionID string) bool { @@ -155,8 +155,8 @@ func (sm *SessionManager) shouldInjectResumeContext(agentConfig *registry.AgentT return false } - // If agent supports session/load via ACP, don't inject (it will restore context natively) - if agentConfig.SessionConfig.ResumeViaACP { + // If agent supports native session loading, don't inject (it will restore context natively) + if agentConfig.SessionConfig.NativeSessionResume { return false } @@ -273,7 +273,7 @@ func (sm *SessionManager) InitializeAndPrompt( zap.String("agentctl_url", execution.agentctl.BaseURL()), zap.String("agent_type", agentConfig.ID), zap.String("existing_acp_session_id", execution.ACPSessionID), - zap.Bool("resume_via_acp", agentConfig.SessionConfig.ResumeViaACP)) + zap.Bool("native_session_resume", agentConfig.SessionConfig.NativeSessionResume)) // Use InitializeSession for configuration-driven session initialization result, err := sm.InitializeSession( @@ -464,58 +464,81 @@ func (sm *SessionManager) SendPrompt( } // Extract accumulated content from buffers and handle streaming message completion - execution.messageMu.Lock() - agentMessage := execution.messageBuffer.String() - execution.messageBuffer.Reset() - execution.thinkingBuffer.Reset() - currentMsgID := execution.currentMessageID - execution.currentMessageID = "" // Clear for next turn - execution.currentThinkingID = "" // Clear for next turn - execution.messageMu.Unlock() - + // NOTE: When ReportsStatusViaStream is true, the adapter's complete event (via handleAgentEvent) + // will handle the buffer content. We should NOT clear the buffer here to avoid a race condition + // where the buffer is cleared before handleAgentEvent processes the complete event. stopReason := "" if resp != nil { stopReason = string(resp.StopReason) } - trimmedMessage := strings.TrimSpace(agentMessage) + var agentMessage string - sm.logger.Info("ACP prompt completed", - zap.String("execution_id", execution.ID), - zap.String("stop_reason", stopReason), - zap.Int("message_length", len(trimmedMessage)), - zap.Bool("has_streaming_msg", currentMsgID != "")) + if execution.ReportsStatusViaStream { + // Agent adapter handles completion via stream - let handleAgentEvent deal with the buffer + // Just log for debugging. The buffer content will be handled by handleAgentEvent when + // the complete event arrives from the adapter. + execution.messageMu.Lock() + bufLen := execution.messageBuffer.Len() + // Peek at the message for the return value, but don't clear the buffer + agentMessage = execution.messageBuffer.String() + execution.messageMu.Unlock() - // Handle remaining content from the buffer - if sm.eventPublisher != nil { - // If there's remaining content and an active streaming message, - // append it to the streaming message instead of creating a new one - if trimmedMessage != "" && currentMsgID != "" { - // Publish streaming append with the remaining content - sm.eventPublisher.PublishAgentStreamEventPayload(&AgentStreamEventPayload{ - Type: "agent/event", - Timestamp: time.Now().UTC().Format(time.RFC3339), - AgentID: execution.AgentProfileID, - TaskID: execution.TaskID, - SessionID: execution.SessionID, - Data: &AgentStreamEventData{ - Type: "message_streaming", - MessageID: currentMsgID, - IsAppend: true, - Text: trimmedMessage, - }, - }) - // Content was handled via streaming, publish complete with empty text - trimmedMessage = "" + sm.logger.Info("ACP prompt completed (stream-based completion)", + zap.String("execution_id", execution.ID), + zap.String("stop_reason", stopReason), + zap.Int("buffer_length", bufLen), + zap.Bool("reports_status_via_stream", true)) + } else { + // Agent adapter does NOT handle completion via stream - we need to handle the buffer here + execution.messageMu.Lock() + agentMessage = execution.messageBuffer.String() + execution.messageBuffer.Reset() + execution.thinkingBuffer.Reset() + currentMsgID := execution.currentMessageID + execution.currentMessageID = "" // Clear for next turn + execution.currentThinkingID = "" // Clear for next turn + execution.messageMu.Unlock() + + trimmedMessage := strings.TrimSpace(agentMessage) + + sm.logger.Info("ACP prompt completed", + zap.String("execution_id", execution.ID), + zap.String("stop_reason", stopReason), + zap.Int("message_length", len(trimmedMessage)), + zap.Bool("has_streaming_msg", currentMsgID != "")) + + // Handle remaining content from the buffer + if sm.eventPublisher != nil { + // If there's remaining content and an active streaming message, + // append it to the streaming message instead of creating a new one + if trimmedMessage != "" && currentMsgID != "" { + // Publish streaming append with the remaining content + sm.eventPublisher.PublishAgentStreamEventPayload(&AgentStreamEventPayload{ + Type: "agent/event", + Timestamp: time.Now().UTC().Format(time.RFC3339), + AgentID: execution.AgentProfileID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + Data: &AgentStreamEventData{ + Type: "message_streaming", + MessageID: currentMsgID, + IsAppend: true, + Text: trimmedMessage, + }, + }) + // Content was handled via streaming, publish complete with empty text + trimmedMessage = "" + } + + // Publish complete event (with remaining text only if no streaming message was active) + completeEvent := streams.AgentEvent{ + Type: streams.EventTypeComplete, + SessionID: execution.ACPSessionID, + Text: trimmedMessage, + } + sm.eventPublisher.PublishAgentStreamEvent(execution, completeEvent) } - - // Publish complete event (with remaining text only if no streaming message was active) - completeEvent := streams.AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: execution.ACPSessionID, - Text: trimmedMessage, - } - sm.eventPublisher.PublishAgentStreamEvent(execution, completeEvent) } // Mark as READY for next prompt diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 704940c04b..9320cafbe0 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -59,11 +59,21 @@ type AgentExecution struct { currentMessageID string currentThinkingID string + // Track whether streaming messages were created during this turn + // This is set true when publishStreamingMessage is called and cleared on complete + // Used to avoid duplicate messages when both streaming and complete event have text + streamingUsedThisTurn bool + // Resume context tracking for fork_session pattern (ACP agents that don't support session/load) // needsResumeContext is set to true when the session has history that should be injected // resumeContextInjected is set to true after context has been injected into a prompt needsResumeContext bool resumeContextInjected bool + + // ReportsStatusViaStream indicates whether the agent adapter handles completion events + // via the stream protocol. When true, the session manager should NOT emit a complete + // event after prompt returns, as the adapter already sent one via the stream. + ReportsStatusViaStream bool } // GetAgentCtlClient returns the agentctl client for this execution diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index 1fa9be1672..7d4ab06d13 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -59,7 +59,7 @@ "model_flag": "--model {model}", "workspace_flag": "--workspace-root", "session_config": { - "resume_via_acp": false, + "native_session_resume": false, "resume_flag": "--resume", "can_recover": false, "session_dir_template": "{home}/.augment/sessions", @@ -225,7 +225,7 @@ "model_flag": "-c model=\"{model}\"", "workspace_flag": "", "session_config": { - "resume_via_acp": true, + "native_session_resume": true, "resume_flag": "", "can_recover": true, "session_dir_template": "", @@ -364,7 +364,7 @@ "model_flag": "--model {model}", "workspace_flag": "", "session_config": { - "resume_via_acp": false, + "native_session_resume": false, "resume_flag": "--resume", "can_recover": true, "session_dir_template": "{home}/.claude", @@ -489,7 +489,7 @@ "model_flag": "--model {model}", "workspace_flag": "", "session_config": { - "resume_via_acp": false, + "native_session_resume": false, "resume_flag": "", "can_recover": false, "session_dir_template": "{home}/.gemini", @@ -629,7 +629,7 @@ "model_flag": "", "workspace_flag": "", "session_config": { - "resume_via_acp": true, + "native_session_resume": true, "resume_flag": "", "can_recover": true, "session_dir_template": "{home}/.opencode", @@ -728,6 +728,349 @@ } ] } + }, + { + "id": "copilot", + "name": "GitHub Copilot Agent", + "display_name": "Copilot", + "description": "GitHub Copilot CLI-powered autonomous coding agent using the Copilot SDK protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "@github/copilot@0.0.375", + "--server", + "--log-level", + "error" + ], + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "npx", + "-y", + "@github/copilot" + ], + "model_flag": "--model {model}", + "idle_timeout_ms": 3000, + "buffer_max_bytes": 2097152, + "resume_flag": "--resume" + }, + "working_dir": "{workspace}", + "required_env": [], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "copilot", + "protocol_config": {}, + "model_flag": "--model {model}", + "workspace_flag": "", + "session_config": { + "native_session_resume": true, + "resume_flag": "--resume", + "can_recover": true, + "session_dir_template": "{home}/.copilot", + "session_dir_target": "", + "reports_status_via_stream": true + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": [ + "bash", + "write_file", + "edit_file" + ] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": true, + "label": "Auto-approve", + "description": "Automatically approve tool calls", + "apply_method": "cli_flag", + "cli_flag": "--allow-all-tools" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": [ + "~/.copilot/mcp-config.json" + ], + "windows": [], + "macos": [ + "~/.copilot/mcp-config.json" + ] + }, + "installation_path": { + "linux": [], + "windows": [], + "macos": [ + "~/.copilot/pkg" + ] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "gpt-4.1", + "available_models": [ + { + "id": "gpt-4.1", + "name": "GPT-4.1", + "description": "OpenAI GPT-4.1", + "provider": "openai", + "is_default": true + }, + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "Anthropic Claude Sonnet 4", + "provider": "anthropic", + "is_default": false + }, + { + "id": "claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "Anthropic Claude Sonnet 4.5", + "provider": "anthropic", + "is_default": false + }, + { + "id": "claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "Anthropic Claude Opus 4.5", + "provider": "anthropic", + "is_default": false + }, + { + "id": "claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "Anthropic Claude Haiku 4.5", + "provider": "anthropic", + "is_default": false + }, + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "OpenAI GPT-5.2 Codex", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5.2", + "name": "GPT-5.2", + "description": "OpenAI GPT-5.2", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "OpenAI GPT-5.1 Codex Max", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "OpenAI GPT-5.1 Codex", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5.1", + "name": "GPT-5.1", + "description": "OpenAI GPT-5.1", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5", + "name": "GPT-5", + "description": "OpenAI GPT-5", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex Mini", + "description": "OpenAI GPT-5.1 Codex Mini", + "provider": "openai", + "is_default": false + }, + { + "id": "gpt-5-mini", + "name": "GPT-5 Mini", + "description": "OpenAI GPT-5 Mini", + "provider": "openai", + "is_default": false + }, + { + "id": "gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "Google Gemini 3 Pro Preview", + "provider": "google", + "is_default": false + } + ] + } + }, + { + "id": "amp", + "name": "Sourcegraph Amp Agent", + "display_name": "Amp", + "description": "Sourcegraph Amp CLI-powered autonomous coding agent using stream-json protocol.", + "enabled": true, + "image": "", + "tag": "", + "cmd": [ + "npx", + "-y", + "@sourcegraph/amp@latest", + "--execute", + "--stream-json", + "--stream-json-input" + ], + "passthrough_config": { + "supported": true, + "label": "CLI Passthrough", + "description": "Show terminal directly instead of chat interface", + "passthrough_cmd": [ + "npx", + "-y", + "@sourcegraph/amp@latest" + ], + "model_flag": "-m {model}", + "idle_timeout_ms": 3000, + "buffer_max_bytes": 2097152, + "resume_flag": "" + }, + "working_dir": "{workspace}", + "required_env": [], + "env": {}, + "mounts": [], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": [ + "code_generation", + "code_review", + "refactoring", + "testing", + "shell_execution" + ], + "protocol": "amp", + "protocol_config": {}, + "model_flag": "-m {model}", + "workspace_flag": "", + "session_config": { + "native_session_resume": false, + "resume_flag": "", + "can_recover": true, + "session_dir_template": "{home}/.config/amp", + "session_dir_target": "", + "reports_status_via_stream": true, + "fork_session_cmd": [ + "npx", + "-y", + "@sourcegraph/amp@latest", + "threads", + "fork" + ], + "continue_session_cmd": [ + "npx", + "-y", + "@sourcegraph/amp@latest", + "threads", + "continue", + "--execute", + "--stream-json", + "--stream-json-input" + ] + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": [ + "bash", + "write_file", + "edit_file" + ] + }, + "permission_settings": { + "auto_approve": { + "supported": true, + "default": true, + "label": "Auto-approve (Dangerously Allow All)", + "description": "Automatically approve all tool calls including shell commands", + "apply_method": "cli_flag", + "cli_flag": "--dangerously-allow-all" + } + }, + "discovery": { + "supports_mcp": true, + "mcp_config_path": { + "linux": [ + "~/.config/amp/settings.json" + ], + "windows": [], + "macos": [ + "~/.amp/bin" + ] + }, + "installation_path": { + "linux": [ + "~/.amp/bin" + ], + "windows": [], + "macos": [ + "~/.amp/bin" + ] + }, + "discovery_capabilities": { + "supports_session_resume": true, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "smart", + "available_models": [ + { + "id": "smart", + "name": "Smart Mode", + "description": "State-of-the-art models for maximum capability and autonomy", + "provider": "amp", + "is_default": true + }, + { + "id": "deep", + "name": "Deep Mode", + "description": "Deep reasoning with extended thinking on complex problems", + "provider": "amp", + "is_default": false + } + ] + } } ] } diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 14e9048c89..bfea6eb829 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -72,12 +72,14 @@ type AgentTypeConfig struct { // SessionConfig defines how session resumption is handled for an agent type type SessionConfig struct { - // ResumeViaACP indicates whether session resumption should use the ACP session/load method. - // If false, session resumption is handled via CLI flags (ResumeFlag). - ResumeViaACP bool `json:"resume_via_acp"` + // NativeSessionResume indicates whether session resumption should use the adapter's + // native session loading mechanism (e.g., LoadSession method). This works across + // different protocols (ACP, Copilot SDK, Codex, OpenCode, etc.). + // If false, session resumption is handled via CLI flags (ResumeFlag) or context injection. + NativeSessionResume bool `json:"native_session_resume"` - // ResumeFlag is the CLI flag used for session resumption when ResumeViaACP is false. - // Example: "--resume" for auggie, empty for agents that use ACP session/load. + // ResumeFlag is the CLI flag used for session resumption when NativeSessionResume is false. + // Example: "--resume" for auggie, empty for agents that use native session loading. ResumeFlag string `json:"resume_flag,omitempty"` // CanRecover indicates whether this agent supports session recovery after a backend restart. @@ -510,6 +512,10 @@ func parseProtocol(p string) agent.Protocol { return agent.ProtocolClaudeCode case "opencode": return agent.ProtocolOpenCode + case "copilot": + return agent.ProtocolCopilot + case "amp": + return agent.ProtocolAmp default: return agent.ProtocolACP // Default to ACP } diff --git a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go new file mode 100644 index 0000000000..e776bf709c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go @@ -0,0 +1,798 @@ +package adapter + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "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/amp" + "go.uber.org/zap" +) + +// AmpAdapter implements AgentAdapter for Sourcegraph Amp CLI. +// It uses the stream-json protocol over stdin/stdout, similar to Claude Code. +type AmpAdapter struct { + cfg *Config + logger *logger.Logger + + // Normalizer for converting tool data to NormalizedPayload + normalizer *AmpNormalizer + + // Subprocess stdin/stdout (set via Connect) + stdin io.Writer + stdout io.Reader + + // Amp client for protocol communication + client *amp.Client + + // Context for managing goroutine lifecycle + ctx context.Context + cancel context.CancelFunc + + // Session state + sessionID string // Thread ID in Amp terminology + operationID string + sessionStatusSent bool + + // Track pending tool calls and their normalized payloads + pendingToolCalls map[string]bool + pendingToolPayloads map[string]*streams.NormalizedPayload + + // Accumulate text for the complete event + // This is needed because the manager's buffer may be cleared by session code + // before events are processed, causing a race condition + textAccumulator strings.Builder + + // Agent info + agentInfo *AgentInfo + + // Update channel + updatesCh chan AgentEvent + + // Permission handler + permissionHandler PermissionHandler + + // Result completion signaling + resultCh chan resultComplete + + // Context window tracking + mainModelName string + mainModelContextWindow int64 + contextTokensUsed int64 + + // Track if complete event was already sent for current operation + // (to avoid duplicates from both stop_reason=end_turn and result message) + completeSent bool + + // Synchronization + mu sync.RWMutex + closed bool +} + +// defaultAmpContextWindow is the fallback context window size. +const defaultAmpContextWindow = 200000 + +// NewAmpAdapter creates a new Amp protocol adapter. +func NewAmpAdapter(cfg *Config, log *logger.Logger) *AmpAdapter { + ctx, cancel := context.WithCancel(context.Background()) + + return &AmpAdapter{ + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "amp")), + normalizer: NewAmpNormalizer(), + ctx: ctx, + cancel: cancel, + updatesCh: make(chan AgentEvent, 100), + mainModelContextWindow: defaultAmpContextWindow, + pendingToolCalls: make(map[string]bool), + pendingToolPayloads: make(map[string]*streams.NormalizedPayload), + } +} + +// PrepareEnvironment performs protocol-specific setup before the agent process starts. +// Amp reads configuration from ~/.config/amp/settings.json. +func (a *AmpAdapter) PrepareEnvironment() (map[string]string, error) { + // Amp MCP configuration is handled via config file + return nil, nil +} + +// PrepareCommandArgs returns extra command-line arguments for the agent process. +// For Amp, no extra args are needed - configuration is via config files. +func (a *AmpAdapter) PrepareCommandArgs() []string { + return nil +} + +// Connect wires up the stdin/stdout pipes from the running agent subprocess. +func (a *AmpAdapter) Connect(stdin io.Writer, stdout io.Reader) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.stdin != nil || a.stdout != nil { + return fmt.Errorf("adapter already connected") + } + + a.stdin = stdin + a.stdout = stdout + return nil +} + +// Initialize establishes the Amp connection with the agent subprocess. +func (a *AmpAdapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing Amp adapter", + zap.String("workdir", a.cfg.WorkDir)) + + // Create Amp client + a.client = amp.NewClient(a.stdin, a.stdout, a.logger) + a.client.SetMessageHandler(a.handleMessage) + + // Start reading from stdout + a.client.Start(a.ctx) + + // Store agent info + a.agentInfo = &AgentInfo{ + Name: "amp", + Version: "unknown", + } + + a.logger.Info("Amp adapter initialized") + return nil +} + +// GetAgentInfo returns information about the connected agent. +func (a *AmpAdapter) GetAgentInfo() *AgentInfo { + return a.agentInfo +} + +// NewSession creates a new Amp session (thread). +// Note: Amp sessions are created implicitly with the first prompt. +func (a *AmpAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + a.mu.Lock() + defer a.mu.Unlock() + + // Generate a placeholder session ID - Amp will return its thread ID + sessionID := uuid.New().String() + a.sessionID = sessionID + + a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) + + return sessionID, nil +} + +// LoadSession resumes an existing Amp session (thread). +// The thread ID will be passed to Amp via the process manager which handles +// the "threads fork" and "threads continue" commands. +func (a *AmpAdapter) LoadSession(ctx context.Context, sessionID string) error { + a.mu.Lock() + a.sessionID = sessionID + a.mu.Unlock() + + // Set thread ID in client for tracking + a.client.SetThreadID(sessionID) + + a.logger.Info("loaded session", zap.String("session_id", sessionID)) + + return nil +} + +// Prompt sends a prompt to Amp and waits for completion. +func (a *AmpAdapter) Prompt(ctx context.Context, message string) error { + a.mu.Lock() + sessionID := a.sessionID + operationID := uuid.New().String() + a.operationID = operationID + a.resultCh = make(chan resultComplete, 1) + a.completeSent = false // Reset completion flag for new operation + a.mu.Unlock() + + if a.client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.logger.Info("sending prompt", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + + // Send user message + if err := a.client.SendUserMessage(message); err != nil { + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return fmt.Errorf("failed to send user message: %w", err) + } + + // Wait for result or context cancellation + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + select { + case <-ctx.Done(): + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return ctx.Err() + case result := <-resultCh: + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + if !result.success && result.err != "" { + return fmt.Errorf("prompt failed: %s", result.err) + } + a.logger.Info("prompt completed", + zap.String("operation_id", operationID), + zap.Bool("success", result.success)) + return nil + } +} + +// Cancel interrupts the current operation. +func (a *AmpAdapter) Cancel(ctx context.Context) error { + // Amp doesn't have a cancel command in stream-json mode + // Cancellation is handled by killing the process + a.logger.Info("cancel requested (not supported in stream-json mode)") + return nil +} + +// Updates returns the channel for agent events. +func (a *AmpAdapter) Updates() <-chan AgentEvent { + return a.updatesCh +} + +// GetSessionID returns the current session ID (thread ID). +func (a *AmpAdapter) GetSessionID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.sessionID +} + +// GetOperationID returns the current operation ID. +func (a *AmpAdapter) GetOperationID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.operationID +} + +// SetPermissionHandler sets the handler for permission requests. +func (a *AmpAdapter) SetPermissionHandler(handler PermissionHandler) { + a.mu.Lock() + defer a.mu.Unlock() + a.permissionHandler = handler +} + +// Close releases resources held by the adapter. +func (a *AmpAdapter) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.closed { + return nil + } + a.closed = true + + a.logger.Info("closing Amp adapter") + + // Cancel the context + if a.cancel != nil { + a.cancel() + } + + // Stop the client + if a.client != nil { + a.client.Stop() + } + + // Close update channel + close(a.updatesCh) + + return nil +} + +// sendUpdate safely sends an event to the updates channel. +func (a *AmpAdapter) sendUpdate(update AgentEvent) { + shared.LogNormalizedEvent(shared.ProtocolAmp, a.cfg.AgentID, &update) + select { + case a.updatesCh <- update: + default: + a.logger.Warn("updates channel full, dropping event") + } +} + +// handleMessage processes streaming messages from Amp. +func (a *AmpAdapter) handleMessage(msg *amp.Message) { + // Log raw event for debugging + if rawData, err := json.Marshal(msg); err == nil { + shared.LogRawEvent(shared.ProtocolAmp, a.cfg.AgentID, msg.Type, rawData) + } + + a.mu.RLock() + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + + // Update session ID from thread ID if provided + if msg.ThreadID != "" { + a.mu.Lock() + a.sessionID = msg.ThreadID + sessionID = msg.ThreadID + a.mu.Unlock() + } + + switch msg.Type { + case amp.MessageTypeSystem: + a.handleSystemMessage(msg, sessionID) + + case amp.MessageTypeAssistant: + a.handleAssistantMessage(msg, sessionID, operationID) + + case amp.MessageTypeUser: + // Amp sends tool_result blocks in user messages (unlike Claude Code which sends them in assistant messages) + a.handleUserMessage(msg, sessionID, operationID) + + case amp.MessageTypeResult: + a.handleResultMessage(msg, sessionID, operationID) + + default: + a.logger.Debug("unhandled message type", zap.String("type", msg.Type)) + } +} + +// handleSystemMessage processes system init messages. +func (a *AmpAdapter) handleSystemMessage(msg *amp.Message, sessionID string) { + a.logger.Info("received system message", + zap.String("thread_id", msg.ThreadID)) + + a.mu.Lock() + alreadySent := a.sessionStatusSent + a.sessionStatusSent = true + a.mu.Unlock() + + // Only send session status once + if alreadySent { + return + } + + status := "new" + if msg.ThreadID != "" && msg.ThreadID == sessionID { + status = "resumed" + } + + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: sessionID, + SessionStatus: status, + Data: map[string]any{ + "session_status": status, + "init": true, + }, + }) +} + +// handleUserMessage processes user messages. +// In Amp's protocol, tool_result blocks come in user messages (not assistant messages like Claude Code). +func (a *AmpAdapter) handleUserMessage(msg *amp.Message, sessionID, operationID string) { + if msg.Message == nil { + return + } + + // Process content blocks - only interested in tool_result + for _, block := range msg.Message.Content { + if block.Type == amp.ContentTypeToolResult { + a.handleToolResult(&block, sessionID, operationID) + } + } +} + +// handleAssistantMessage processes assistant messages. +func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operationID string) { + if msg.Message == nil { + return + } + + // Log content block types for debugging + blockTypes := make([]string, 0, len(msg.Message.Content)) + for _, block := range msg.Message.Content { + blockTypes = append(blockTypes, block.Type) + } + a.logger.Debug("processing assistant message", + zap.Int("num_blocks", len(msg.Message.Content)), + zap.Strings("block_types", blockTypes)) + + // Update agent version and track main model + if msg.Message.Model != "" && a.agentInfo != nil { + a.agentInfo.Version = msg.Message.Model + + a.mu.Lock() + if a.mainModelName == "" { + a.mainModelName = msg.Message.Model + a.logger.Debug("tracking main model", zap.String("model", msg.Message.Model)) + } + a.mu.Unlock() + } + + // Process content blocks + for _, block := range msg.Message.Content { + switch block.Type { + case amp.ContentTypeText: + if block.Text != "" { + // Accumulate text locally to include in complete event + // This bypasses the race condition with the manager's buffer + a.mu.Lock() + a.textAccumulator.WriteString(block.Text) + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: block.Text, + }) + } + + case amp.ContentTypeThinking: + if block.Thinking != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: block.Thinking, + }) + } + + case amp.ContentTypeToolUse: + a.handleToolUse(&block, sessionID, operationID) + + case amp.ContentTypeToolResult: + a.handleToolResult(&block, sessionID, operationID) + } + } + + // Calculate and emit token usage + if msg.Message.Usage != nil { + usage := msg.Message.Usage + contextUsed := usage.InputTokens + usage.OutputTokens + + usage.CacheCreationInputTokens + usage.CacheReadInputTokens + + a.mu.Lock() + a.contextTokensUsed = contextUsed + contextSize := a.mainModelContextWindow + a.mu.Unlock() + + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) + } + + // Check for turn completion - Amp signals this via stop_reason in assistant messages + // (unlike Claude Code which sends a separate "result" message) + if msg.Message.StopReason == "end_turn" { + a.handleTurnComplete(sessionID, operationID) + } +} + +// handleToolUse processes a tool_use content block. +func (a *AmpAdapter) handleToolUse(block *amp.ContentBlock, sessionID, operationID string) { + // Generate normalized payload using the normalizer + normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) + + // Build human-readable title + toolTitle := block.Name + // Amp uses "cmd" for Bash commands, Claude Code uses "command" + if cmd, ok := block.Input["cmd"].(string); ok && block.Name == AmpToolBash { + toolTitle = cmd + } else if cmd, ok := block.Input["command"].(string); ok && block.Name == AmpToolBash { + toolTitle = cmd + } else if path, ok := block.Input["path"].(string); ok { + // Amp uses "path" for Read tool + toolTitle = path + } else if path, ok := block.Input["file_path"].(string); ok { + // Claude Code uses "file_path" + toolTitle = path + } + + a.logger.Info("tool_use block received", + zap.String("tool_call_id", block.ID), + zap.String("tool_name", block.Name), + zap.String("title", toolTitle)) + + // Track pending tool call and cache the normalized payload for result handling + a.mu.Lock() + a.pendingToolCalls[block.ID] = true + a.pendingToolPayloads[block.ID] = normalizedPayload + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ID, + ToolName: block.Name, + ToolTitle: toolTitle, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, + }) +} + +// handleToolResult processes a tool_result content block. +func (a *AmpAdapter) handleToolResult(block *amp.ContentBlock, sessionID, operationID string) { + status := "complete" + if block.IsError { + status = "error" + } + + a.logger.Info("tool_result block received", + zap.String("tool_call_id", block.ToolUseID), + zap.String("status", status)) + + // Get cached payload and remove from pending + a.mu.Lock() + delete(a.pendingToolCalls, block.ToolUseID) + cachedPayload := a.pendingToolPayloads[block.ToolUseID] + delete(a.pendingToolPayloads, block.ToolUseID) + a.mu.Unlock() + + // Normalize the tool result content + normalizedPayload := a.normalizer.NormalizeToolResult(cachedPayload, block.Content, block.IsError) + + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ToolUseID, + ToolStatus: status, + NormalizedPayload: normalizedPayload, + }) +} + +// handleTurnComplete processes turn completion when stop_reason is "end_turn". +// This is Amp's way of signaling turn completion (instead of a separate "result" message). +func (a *AmpAdapter) handleTurnComplete(sessionID, operationID string) { + // Check if we already sent completion for this operation + // (Amp may send both stop_reason=end_turn AND a result message) + a.mu.Lock() + if a.completeSent { + a.mu.Unlock() + a.logger.Debug("ignoring duplicate turn complete event", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + return + } + a.completeSent = true + + a.logger.Info("turn complete (stop_reason=end_turn)") + + // Auto-complete any pending tool calls and get accumulated text + pendingTools := make(map[string]*streams.NormalizedPayload, len(a.pendingToolCalls)) + for toolID := range a.pendingToolCalls { + pendingTools[toolID] = a.pendingToolPayloads[toolID] + } + a.pendingToolCalls = make(map[string]bool) + a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) + contextSize := a.mainModelContextWindow + contextUsed := a.contextTokensUsed + // Clear the text accumulator (text was already sent via message_chunk events) + a.textAccumulator.Reset() + a.mu.Unlock() + + for toolID, cachedPayload := range pendingTools { + a.logger.Info("auto-completing pending tool call on turn complete", + zap.String("tool_call_id", toolID)) + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolID, + ToolStatus: "complete", + NormalizedPayload: cachedPayload, + }) + } + + // Emit final context window event + if contextUsed > 0 { + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) + } + + // Send completion event WITHOUT text - text was already sent via message_chunk events + // Including text here would cause duplicate messages + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + OperationID: operationID, + Data: map[string]any{ + "stop_reason": "end_turn", + }, + }) + + // Signal completion + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + if resultCh != nil { + select { + case resultCh <- resultComplete{success: true}: + a.logger.Debug("signaled prompt completion") + default: + a.logger.Warn("result channel full, dropping signal") + } + } +} + +// handleResultMessage processes result (completion) messages. +func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationID string) { + a.logger.Info("received result message", + zap.Bool("is_error", msg.IsError), + zap.Int("num_turns", msg.NumTurns), + zap.String("subtype", msg.Subtype), + zap.String("error", msg.Error)) + + // Update session ID from result if provided + if resultData := msg.GetResultData(); resultData != nil { + if resultData.ThreadID != "" { + a.mu.Lock() + a.sessionID = resultData.ThreadID + sessionID = resultData.ThreadID + a.mu.Unlock() + } + } + + // Check if complete was already sent (by handleTurnComplete on stop_reason=end_turn) + a.mu.Lock() + alreadyCompleted := a.completeSent + if !alreadyCompleted { + a.completeSent = true + } + + // Auto-complete any pending tool calls + pendingTools := make(map[string]*streams.NormalizedPayload, len(a.pendingToolCalls)) + for toolID := range a.pendingToolCalls { + pendingTools[toolID] = a.pendingToolPayloads[toolID] + } + a.pendingToolCalls = make(map[string]bool) + a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) + a.mu.Unlock() + + for toolID, cachedPayload := range pendingTools { + a.logger.Info("auto-completing pending tool call on result", + zap.String("tool_call_id", toolID)) + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolID, + ToolStatus: "complete", + NormalizedPayload: cachedPayload, + }) + } + + // Extract context window from model_usage if available + a.mu.Lock() + modelName := a.mainModelName + if msg.ModelUsage != nil && modelName != "" { + if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { + a.mainModelContextWindow = *modelStats.ContextWindow + a.logger.Debug("updated context window from model_usage", + zap.String("model", modelName), + zap.Int64("context_window", a.mainModelContextWindow)) + } + } + contextSize := a.mainModelContextWindow + contextUsed := a.contextTokensUsed + a.mu.Unlock() + + // Emit final context window event + if contextUsed > 0 { + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) + } + + // Only send completion event if not already sent by handleTurnComplete + if !alreadyCompleted { + // Clear the text accumulator (text was already sent via message_chunk events) + a.mu.Lock() + a.textAccumulator.Reset() + a.mu.Unlock() + + // Send complete event WITHOUT text to avoid duplicates + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + OperationID: operationID, + Data: map[string]any{ + "cost_usd": msg.CostUSD, + "duration_ms": msg.DurationMS, + "num_turns": msg.NumTurns, + "input_tokens": msg.TotalInputTokens, + "output_tokens": msg.TotalOutputTokens, + "is_error": msg.IsError, + }, + }) + } + + // Extract error message if failed + var errorMsg string + if msg.IsError { + errorMsg = "prompt failed" + // Try the error field first (Amp sends errors in this field) + if msg.Error != "" { + errorMsg = msg.Error + } else if errStr := msg.GetResultString(); errStr != "" { + errorMsg = errStr + } else if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + errorMsg = resultData.Text + } + } + + // Signal completion + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + if resultCh != nil { + select { + case resultCh <- resultComplete{success: !msg.IsError, err: errorMsg}: + a.logger.Debug("signaled prompt completion") + default: + a.logger.Warn("result channel full, dropping signal") + } + } + + // Send error event if failed + if msg.IsError { + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + SessionID: sessionID, + OperationID: operationID, + Error: errorMsg, + }) + } +} + +// RequiresProcessKill returns false because Amp agents exit when stdin is closed. +func (a *AmpAdapter) RequiresProcessKill() bool { + return false +} + +// Verify interface implementation +var _ AgentAdapter = (*AmpAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go b/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go new file mode 100644 index 0000000000..78df570b67 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go @@ -0,0 +1,391 @@ +package adapter + +import ( + "encoding/json" + "strings" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// Amp tool name constants (matching Claude Code tool names). +const ( + AmpToolBash = "Bash" + AmpToolRead = "Read" + AmpToolWrite = "Write" + AmpToolEdit = "Edit" + AmpToolGlob = "Glob" + AmpToolGrep = "Grep" + AmpToolWebFetch = "WebFetch" + AmpToolWebSearch = "WebSearch" + AmpToolTask = "Task" + AmpToolTaskCreate = "TaskCreate" + AmpToolTaskUpdate = "TaskUpdate" + AmpToolTaskList = "TaskList" + AmpToolNotebookEdit = "NotebookEdit" + AmpToolTodoWrite = "TodoWrite" +) + +// AmpNormalizer converts Amp protocol tool data to NormalizedPayload. +type AmpNormalizer struct{} + +// NewAmpNormalizer creates a new Amp normalizer. +func NewAmpNormalizer() *AmpNormalizer { + return &AmpNormalizer{} +} + +// NormalizeToolCall converts Amp tool call data to NormalizedPayload. +func (n *AmpNormalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { + switch toolName { + case AmpToolEdit, AmpToolWrite, AmpToolNotebookEdit: + return n.normalizeEdit(toolName, args) + case AmpToolRead: + return n.normalizeRead(args) + case AmpToolGlob, AmpToolGrep: + return n.normalizeCodeSearch(toolName, args) + case AmpToolBash: + return n.normalizeExecute(args) + case AmpToolWebFetch, AmpToolWebSearch: + return n.normalizeHttpRequest(toolName, args) + case AmpToolTask: + return n.normalizeSubagentTask(args) + case AmpToolTaskCreate: + return n.normalizeCreateTask(args) + case AmpToolTaskUpdate, AmpToolTaskList: + return n.normalizeManageTodos(toolName, args) + case AmpToolTodoWrite: + return n.normalizeManageTodos(toolName, args) + default: + return n.normalizeGeneric(toolName, args) + } +} + +// NormalizeToolResult updates the payload with tool result data from Amp. +// Amp's tool results are JSON-wrapped strings like "{\"output\":\"...\",\"exitCode\":0}". +func (n *AmpNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, content any, isError bool) *streams.NormalizedPayload { + if cachedPayload == nil { + // No cached payload, create a generic one with the result + output, _ := n.parseToolResultContent(content) + payload := streams.NewGeneric("unknown", nil) + if payload.Generic() != nil { + payload.Generic().Output = output + } + return payload + } + + // Update the cached payload with the result + switch cachedPayload.Kind() { + case streams.ToolKindShellExec: + if cachedPayload.ShellExec() != nil { + output, exitCode := n.parseToolResultContent(content) + cachedPayload.ShellExec().Output = &streams.ShellExecOutput{ + Stdout: output, + ExitCode: exitCode, + } + if isError { + cachedPayload.ShellExec().Output.Stderr = output + cachedPayload.ShellExec().Output.Stdout = "" + } + } + case streams.ToolKindHttpRequest: + if cachedPayload.HttpRequest() != nil { + output, _ := n.parseToolResultContent(content) + cachedPayload.HttpRequest().Response = output + } + case streams.ToolKindGeneric: + if cachedPayload.Generic() != nil { + output, _ := n.parseToolResultContent(content) + cachedPayload.Generic().Output = output + } + case streams.ToolKindReadFile: + // Parse Amp's read result format: {absolutePath, content, isDirectory, directoryEntries} + if cachedPayload.ReadFile() != nil { + result := n.parseAmpReadResult(content) + if result != nil { + // If it's a directory, convert to code_search + if result.IsDirectory { + codeSearch := streams.NewCodeSearch( + "", // query + "", // pattern + result.AbsolutePath, // path + "*", // glob - all entries + ) + if codeSearch.CodeSearch() != nil { + codeSearch.CodeSearch().Output = &streams.CodeSearchOutput{ + Files: result.DirectoryEntries, + FileCount: len(result.DirectoryEntries), + } + } + return codeSearch + } + // Regular file read + output := &streams.ReadFileOutput{ + Content: result.Content, + } + if result.Content != "" { + output.LineCount = strings.Count(result.Content, "\n") + 1 + } + cachedPayload.ReadFile().Output = output + } + } + case streams.ToolKindCodeSearch: + if cachedPayload.CodeSearch() != nil { + output, _ := n.parseToolResultContent(content) + shared.NormalizeCodeSearchResult(cachedPayload.CodeSearch(), output) + } + } + + return cachedPayload +} + +// ampReadResult holds parsed read result data from Amp. +type ampReadResult struct { + AbsolutePath string + Content string + IsDirectory bool + DirectoryEntries []string +} + +// parseAmpReadResult parses Amp's read result format. +func (n *AmpNormalizer) parseAmpReadResult(content any) *ampReadResult { + switch c := content.(type) { + case string: + var result ampReadResult + if err := json.Unmarshal([]byte(c), &result); err == nil { + return &result + } + // Not JSON, treat as raw file content + return &ReadResult{Content: c} + + case map[string]any: + result := &ReadResult{} + if v, ok := c["absolutePath"].(string); ok { + result.AbsolutePath = v + } + if v, ok := c["content"].(string); ok { + result.Content = v + } + if v, ok := c["isDirectory"].(bool); ok { + result.IsDirectory = v + } + if v, ok := c["directoryEntries"].([]any); ok { + for _, entry := range v { + if s, ok := entry.(string); ok { + result.DirectoryEntries = append(result.DirectoryEntries, s) + } + } + } + return result + + default: + return &ReadResult{} + } +} + + +// parseToolResultContent extracts the output string and exit code from Amp's JSON-wrapped content. +// Amp sends tool results in formats like: +// - String: "{\"output\":\"actual content\",\"exitCode\":0}" +// - Direct string (non-JSON) +// - Map with output/exitCode keys +// - Any other JSON structure (returned as-is) +func (n *AmpNormalizer) parseToolResultContent(content any) (string, int) { + switch c := content.(type) { + case string: + // Try to parse as JSON-wrapped content with {output, exitCode} structure + var wrapped struct { + Output string `json:"output"` + ExitCode int `json:"exitCode"` + } + if err := json.Unmarshal([]byte(c), &wrapped); err == nil && wrapped.Output != "" { + // Successfully parsed AND has output field + return wrapped.Output, wrapped.ExitCode + } + // Either not JSON, or JSON without "output" field - use as-is + return c, 0 + + case map[string]any: + // Handle map format (already parsed JSON) + output := shared.GetString(c, "output") + exitCode := shared.GetInt(c, "exitCode") + if output == "" { + // Try alternative field names + output = shared.GetString(c, "stdout") + if stderr := shared.GetString(c, "stderr"); stderr != "" && output == "" { + output = stderr + } + if ec := shared.GetInt(c, "exit_code"); ec != 0 { + exitCode = ec + } + } + return output, exitCode + + case []any: + // Handle array of content blocks (some tools return multiple blocks) + var sb strings.Builder + for _, item := range c { + if str, ok := item.(string); ok { + sb.WriteString(str) + } else if m, ok := item.(map[string]any); ok { + if text := shared.GetString(m, "text"); text != "" { + sb.WriteString(text) + } + } + } + return sb.String(), 0 + + default: + // Unknown format, try JSON marshaling for debugging + if data, err := json.Marshal(content); err == nil { + return string(data), 0 + } + return "", 0 + } +} + +// normalizeEdit converts Amp Edit/Write tool data. +func (n *AmpNormalizer) 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") + + var mutations []streams.FileMutation + + if toolName == AmpToolWrite { + // Write is a full file creation/replacement + mutations = append(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) + } + + mutations = append(mutations, mutation) + } + + return streams.NewModifyFile(filePath, mutations) +} + +// normalizeRead converts Amp Read tool data. +// Note: Amp uses "path" for the file path, while Claude Code uses "file_path". +func (n *AmpNormalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { + // Amp uses "path", Claude Code uses "file_path" - check both + filePath := shared.GetString(args, "path") + if filePath == "" { + filePath = shared.GetString(args, "file_path") + } + offset := shared.GetInt(args, "offset") + limit := shared.GetInt(args, "limit") + + return streams.NewReadFile(filePath, offset, limit) +} + +// normalizeCodeSearch converts Amp Glob/Grep tool data. +func (n *AmpNormalizer) 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 AmpToolGlob: + glob = pattern + case AmpToolGrep: + query = shared.GetString(args, "query") + if query == "" { + query = pattern + } + } + + return streams.NewCodeSearch(query, pattern, path, glob) +} + +// normalizeExecute converts Amp Bash tool data. +// Note: Amp uses "cmd" for the command field, while Claude Code uses "command". +func (n *AmpNormalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { + // Amp uses "cmd", Claude Code uses "command" - check both + command := shared.GetString(args, "cmd") + if command == "" { + 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 Amp WebFetch/WebSearch tool data. +func (n *AmpNormalizer) 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 == AmpToolWebSearch { + method = "SEARCH" + } + + return streams.NewHttpRequest(url, method) +} + +// normalizeSubagentTask converts Amp Task tool data (subagent invocation). +func (n *AmpNormalizer) 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 Amp TaskCreate tool data. +func (n *AmpNormalizer) normalizeCreateTask(args map[string]any) *streams.NormalizedPayload { + title := shared.GetString(args, "subject") + description := shared.GetString(args, "description") + + return streams.NewCreateTask(title, description) +} + +// normalizeManageTodos converts Amp TaskUpdate/TaskList/TodoWrite tool data. +func (n *AmpNormalizer) normalizeManageTodos(toolName string, args map[string]any) *streams.NormalizedPayload { + operation := "update" + switch toolName { + case AmpToolTaskList: + operation = "list" + case AmpToolTodoWrite: + operation = "write" + } + + var items []streams.TodoItem + + // Extract items if present + if rawItems, ok := args["items"].([]any); ok { + for _, item := range rawItems { + if itemMap, ok := item.(map[string]any); ok { + items = append(items, streams.TodoItem{ + ID: shared.GetString(itemMap, "id"), + Description: shared.GetString(itemMap, "description"), + Status: shared.GetString(itemMap, "status"), + }) + } + } + } + + return streams.NewManageTodos(operation, items) +} + +// normalizeGeneric wraps unknown tools as generic. +func (n *AmpNormalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { + return streams.NewGeneric(toolName, args) +} diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go new file mode 100644 index 0000000000..a2f115f4e9 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go @@ -0,0 +1,805 @@ +package adapter + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "sync" + "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/copilot" + "go.uber.org/zap" +) + +// CopilotAdapter implements AgentAdapter for GitHub Copilot using the official SDK. +// +// The process manager spawns the Copilot CLI in server mode (--server), which +// listens on a random TCP port and prints "listening on port " to stdout. +// Connect() captures the stdout pipe, and Initialize() scans it for the port, +// then creates an SDK client that connects to the external server via CLIUrl. +type CopilotAdapter struct { + cfg *Config + logger *logger.Logger + + // Normalizer for converting tool data to NormalizedPayload + normalizer *CopilotNormalizer + + // stdout from the process manager (used to detect the listening port) + stdout io.Reader + + // Copilot SDK client (connects to externally managed CLI server via TCP) + client *copilot.Client + + // Context for managing goroutine lifecycle + ctx context.Context + cancel context.CancelFunc + + // Session state + sessionID string + operationID string + + // Track pending tool calls and their normalized payloads + pendingToolPayloads map[string]*streams.NormalizedPayload + + // Accumulate text for the complete event + textAccumulator strings.Builder + + // Agent info + agentInfo *AgentInfo + + // Update channel + updatesCh chan AgentEvent + + // Permission handler + permissionHandler PermissionHandler + + // Result completion signaling + resultCh chan resultComplete + + // Context window tracking (from usage events) + contextWindowSize int64 + contextTokensUsed int64 + + // Track if completion was already sent for current operation + completeSent bool + + // Track if we received streaming deltas for this operation + // (to avoid double-accumulating from both delta and full message events) + receivedDeltas bool + + // Synchronization + mu sync.RWMutex + closed bool +} + +// resultComplete holds the result of a completed prompt +type resultComplete struct { + success bool + err string +} + +// defaultCopilotContextWindow is the fallback context window size. +const defaultCopilotContextWindow = 128000 + +// NewCopilotAdapter creates a new Copilot protocol adapter. +func NewCopilotAdapter(cfg *Config, log *logger.Logger) *CopilotAdapter { + ctx, cancel := context.WithCancel(context.Background()) + + return &CopilotAdapter{ + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "copilot")), + normalizer: NewCopilotNormalizer(), + ctx: ctx, + cancel: cancel, + updatesCh: make(chan AgentEvent, 100), + contextWindowSize: defaultCopilotContextWindow, + pendingToolPayloads: make(map[string]*streams.NormalizedPayload), + } +} + +// PrepareEnvironment performs protocol-specific setup before the agent process starts. +// No special environment is needed; the process manager spawns the CLI in server mode. +func (a *CopilotAdapter) PrepareEnvironment() (map[string]string, error) { + return nil, nil +} + +// PrepareCommandArgs returns extra command-line arguments for the agent process. +// For Copilot, no extra args are needed - the CLI is spawned in server mode. +func (a *CopilotAdapter) PrepareCommandArgs() []string { + return nil +} + +// Connect stores the stdout pipe from the process manager. +// The adapter reads stdout during Initialize to detect the TCP port +// the Copilot CLI server is listening on. +func (a *CopilotAdapter) Connect(stdin io.Writer, stdout io.Reader) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.stdout != nil { + return fmt.Errorf("adapter already connected") + } + + a.stdout = stdout + a.logger.Info("Connect: stored stdout pipe for port detection") + return nil +} + +// Initialize waits for the Copilot CLI server to print its listening port, +// then creates an SDK client that connects to it via TCP. +func (a *CopilotAdapter) Initialize(ctx context.Context) error { + a.logger.Info("initializing Copilot adapter (server mode)", + zap.String("workdir", a.cfg.WorkDir)) + + if a.stdout == nil { + return fmt.Errorf("stdout not connected; call Connect() before Initialize()") + } + + // Scan stdout for "listening on port " from the CLI server + port, scanner, err := a.waitForPort(ctx) + if err != nil { + return fmt.Errorf("failed to detect Copilot CLI server port: %w", err) + } + + cliURL := fmt.Sprintf("localhost:%d", port) + a.logger.Info("detected Copilot CLI server", + zap.Int("port", port), + zap.String("cli_url", cliURL)) + + // Create SDK client pointing at the external server + a.client = copilot.NewClient(copilot.ClientConfig{ + CLIUrl: cliURL, + }, a.logger) + + // Set event handler before starting + a.client.SetEventHandler(a.handleEvent) + + // Start the SDK client (connects to external server via TCP) + if err := a.client.Start(ctx); err != nil { + return fmt.Errorf("failed to start Copilot SDK client: %w", err) + } + + // Drain remaining stdout in background so the process doesn't block + go func() { + for scanner.Scan() { + // discard + } + }() + + // Store agent info + a.agentInfo = &AgentInfo{ + Name: "copilot", + Version: "sdk", + } + + a.logger.Info("Copilot adapter initialized (server mode)") + return nil +} + +// portPattern matches "listening on port " printed by the Copilot CLI in server mode. +var portPattern = regexp.MustCompile(`listening on port (\d+)`) + +// waitForPort scans stdout line-by-line until it finds the listening port. +// Returns the detected port and the scanner (for background draining). +func (a *CopilotAdapter) waitForPort(ctx context.Context) (int, *bufio.Scanner, error) { + const timeout = 180 * time.Second + + scanner := bufio.NewScanner(a.stdout) + portCh := make(chan int, 1) + errCh := make(chan error, 1) + + go func() { + var capturedLines []string + for scanner.Scan() { + line := scanner.Text() + a.logger.Debug("CLI stdout", zap.String("line", line)) + + if len(capturedLines) < 64 { + capturedLines = append(capturedLines, line) + } + + if m := portPattern.FindStringSubmatch(line); m != nil { + port, err := strconv.Atoi(m[1]) + if err != nil { + errCh <- fmt.Errorf("invalid port number %q: %w", m[1], err) + return + } + portCh <- port + return + } + } + // EOF before finding port + if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("error reading stdout: %w", err) + } else { + var detail strings.Builder + if len(capturedLines) > 0 { + start := 0 + if len(capturedLines) > 12 { + start = len(capturedLines) - 12 + } + for _, l := range capturedLines[start:] { + detail.WriteString("\n ") + detail.WriteString(l) + } + } + errCh <- fmt.Errorf("CLI exited before printing listening port%s", detail.String()) + } + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case port := <-portCh: + return port, scanner, nil + case err := <-errCh: + return 0, nil, err + case <-timer.C: + return 0, nil, fmt.Errorf("timeout (%s) waiting for Copilot CLI to print listening port", timeout) + case <-ctx.Done(): + return 0, nil, ctx.Err() + } +} + +// GetAgentInfo returns information about the connected agent. +func (a *CopilotAdapter) GetAgentInfo() *AgentInfo { + return a.agentInfo +} + +// NewSession creates a new Copilot session via the SDK. +func (a *CopilotAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { + if a.client == nil { + return "", fmt.Errorf("adapter not initialized") + } + + sessionID, err := a.client.CreateSession(ctx) + if err != nil { + // If session creation fails, generate a placeholder ID + sessionID = uuid.New().String() + a.logger.Warn("failed to create SDK session, using placeholder", + zap.Error(err), + zap.String("session_id", sessionID)) + } + + a.mu.Lock() + a.sessionID = sessionID + a.mu.Unlock() + + a.logger.Info("created new session", zap.String("session_id", sessionID)) + + // Send session status event + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: sessionID, + SessionStatus: "new", + Data: map[string]any{ + "session_status": "new", + "init": true, + }, + }) + + return sessionID, nil +} + +// LoadSession resumes an existing Copilot session via the SDK. +func (a *CopilotAdapter) LoadSession(ctx context.Context, sessionID string) error { + if a.client == nil { + return fmt.Errorf("adapter not initialized") + } + + if err := a.client.ResumeSession(ctx, sessionID); err != nil { + a.logger.Warn("failed to resume SDK session, continuing anyway", + zap.Error(err), + zap.String("session_id", sessionID)) + } + + a.mu.Lock() + a.sessionID = sessionID + a.mu.Unlock() + + a.logger.Info("loaded session", zap.String("session_id", sessionID)) + + // Send session status event + a.sendUpdate(AgentEvent{ + Type: EventTypeSessionStatus, + SessionID: sessionID, + SessionStatus: "resumed", + Data: map[string]any{ + "session_status": "resumed", + "init": true, + }, + }) + + return nil +} + +// Prompt sends a prompt to Copilot and waits for completion. +func (a *CopilotAdapter) Prompt(ctx context.Context, message string) error { + if a.client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.mu.Lock() + sessionID := a.sessionID + operationID := uuid.New().String() + a.operationID = operationID + a.resultCh = make(chan resultComplete, 1) + a.completeSent = false // Reset completion flag for new operation + a.receivedDeltas = false // Reset delta tracking for new operation + a.mu.Unlock() + + a.logger.Info("sending prompt via SDK", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + + // Send message (non-blocking, events come via handler) + if _, err := a.client.Send(ctx, message); err != nil { + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return fmt.Errorf("failed to send prompt: %w", err) + } + + // Wait for idle (completion) or context cancellation + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + select { + case <-ctx.Done(): + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + return ctx.Err() + case result := <-resultCh: + a.mu.Lock() + a.resultCh = nil + a.mu.Unlock() + if !result.success && result.err != "" { + return fmt.Errorf("prompt failed: %s", result.err) + } + a.logger.Info("prompt completed", + zap.String("operation_id", operationID), + zap.Bool("success", result.success)) + return nil + } +} + +// Cancel interrupts the current operation. +func (a *CopilotAdapter) Cancel(ctx context.Context) error { + if a.client == nil { + return fmt.Errorf("adapter not initialized") + } + + a.logger.Info("cancelling operation via SDK") + return a.client.Abort(ctx) +} + +// Updates returns the channel for agent events. +func (a *CopilotAdapter) Updates() <-chan AgentEvent { + return a.updatesCh +} + +// GetSessionID returns the current session ID. +func (a *CopilotAdapter) GetSessionID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.sessionID +} + +// GetOperationID returns the current operation ID. +func (a *CopilotAdapter) GetOperationID() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.operationID +} + +// SetPermissionHandler sets the handler for permission requests. +func (a *CopilotAdapter) SetPermissionHandler(handler PermissionHandler) { + a.mu.Lock() + defer a.mu.Unlock() + a.permissionHandler = handler +} + +// Close releases resources held by the adapter. +func (a *CopilotAdapter) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.closed { + return nil + } + a.closed = true + + a.logger.Info("closing Copilot SDK adapter") + + // Cancel the context + if a.cancel != nil { + a.cancel() + } + + // Stop the SDK client (disconnects from the CLI server) + if a.client != nil { + if err := a.client.Stop(); err != nil { + a.logger.Warn("error stopping SDK client", zap.Error(err)) + } + a.client = nil + } + + // Close update channel + close(a.updatesCh) + + return nil +} + +// sendUpdate safely sends an event to the updates channel. +func (a *CopilotAdapter) sendUpdate(update AgentEvent) { + shared.LogNormalizedEvent(shared.ProtocolCopilot, a.cfg.AgentID, &update) + select { + case a.updatesCh <- update: + default: + a.logger.Warn("updates channel full, dropping event") + } +} + +// handleEvent processes events from the Copilot SDK client. +func (a *CopilotAdapter) handleEvent(evt copilot.SessionEvent) { + // Log raw event for debugging + if rawData, err := json.Marshal(evt); err == nil { + shared.LogRawEvent(shared.ProtocolCopilot, a.cfg.AgentID, string(evt.Type), rawData) + } + + a.mu.RLock() + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + + // Use session ID from event data if provided + if evt.Data.SessionID != nil && *evt.Data.SessionID != "" { + sessionID = *evt.Data.SessionID + } + + switch evt.Type { + case copilot.EventTypeSessionStart: + a.logger.Info("session started", zap.String("session_id", sessionID)) + + case copilot.EventTypeSessionResume: + a.logger.Info("session resumed", zap.String("session_id", sessionID)) + + case copilot.EventTypeAssistantMessage: + // Full message (non-streaming) - only accumulate if we haven't received deltas + // When streaming is used, deltas build up the text incrementally, and the final + // assistant.message contains the full text again - skip to avoid duplication + if evt.Data.Content != nil && *evt.Data.Content != "" { + a.mu.Lock() + alreadyReceivedDeltas := a.receivedDeltas + if !alreadyReceivedDeltas { + // Non-streaming mode: accumulate the full message + a.textAccumulator.WriteString(*evt.Data.Content) + } + a.mu.Unlock() + + // Only send message chunk if we haven't been streaming + if !alreadyReceivedDeltas { + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: *evt.Data.Content, + }) + } + } + + case copilot.EventTypeAssistantMessageDelta: + // Streaming message delta + if evt.Data.DeltaContent != nil && *evt.Data.DeltaContent != "" { + text := *evt.Data.DeltaContent + // Accumulate text locally to include in complete event + a.mu.Lock() + a.textAccumulator.WriteString(text) + a.receivedDeltas = true // Mark that we've received streaming deltas + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: text, + }) + } + + case copilot.EventTypeAssistantReasoning, copilot.EventTypeAssistantReasoningDelta: + // Reasoning/thinking content + var content string + if evt.Data.Content != nil { + content = *evt.Data.Content + } + if content == "" && evt.Data.DeltaContent != nil { + content = *evt.Data.DeltaContent + } + if content != "" { + a.sendUpdate(AgentEvent{ + Type: EventTypeReasoning, + SessionID: sessionID, + OperationID: operationID, + ReasoningText: content, + }) + } + + case copilot.EventTypeToolStart: + a.handleToolStart(evt, sessionID, operationID) + + case copilot.EventTypeToolComplete: + a.handleToolComplete(evt, sessionID, operationID) + + case copilot.EventTypeToolProgress: + // Tool progress update + toolCallID := "" + if evt.Data.ToolCallID != nil { + toolCallID = *evt.Data.ToolCallID + } + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolStatus: "running", + }) + + case copilot.EventTypeSessionIdle: + a.handleSessionIdle(sessionID, operationID) + + case copilot.EventTypeSessionError: + a.handleSessionError(evt, sessionID, operationID) + + case copilot.EventTypeSessionUsageInfo, copilot.EventTypeAssistantUsage: + a.handleUsageInfo(evt, sessionID, operationID) + + case copilot.EventTypeAssistantTurnStart: + a.logger.Debug("turn started", zap.String("operation_id", operationID)) + + case copilot.EventTypeAssistantTurnEnd: + a.logger.Debug("turn ended", zap.String("operation_id", operationID)) + + case copilot.EventTypeAbort: + a.logger.Info("operation aborted") + a.signalCompletion(false, "operation aborted") + + default: + a.logger.Debug("unhandled SDK event", zap.String("type", string(evt.Type))) + } +} + +// handleToolStart processes tool execution start events. +func (a *CopilotAdapter) handleToolStart(evt copilot.SessionEvent, sessionID, operationID string) { + toolCallID := "" + toolName := "" + var toolArgs any + + if evt.Data.ToolCallID != nil { + toolCallID = *evt.Data.ToolCallID + } + if evt.Data.ToolName != nil { + toolName = *evt.Data.ToolName + } + toolArgs = evt.Data.Arguments + + // Generate normalized payload using the normalizer + normalizedPayload := a.normalizer.NormalizeToolCall(toolName, toolArgs) + + // Build human-readable title + toolTitle := toolName + if argsMap, ok := toolArgs.(map[string]any); ok && argsMap != nil { + if cmd, ok := argsMap["command"].(string); ok && strings.ToLower(toolName) == "bash" { + toolTitle = cmd + } else if path, ok := argsMap["file_path"].(string); ok { + toolTitle = fmt.Sprintf("%s: %s", toolName, path) + } + } + + a.logger.Info("tool execution started", + zap.String("tool_call_id", toolCallID), + zap.String("tool_name", toolName)) + + // Track pending tool call and cache the normalized payload for result handling + a.mu.Lock() + a.pendingToolPayloads[toolCallID] = normalizedPayload + a.mu.Unlock() + + a.sendUpdate(AgentEvent{ + Type: EventTypeToolCall, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolName: toolName, + ToolTitle: toolTitle, + ToolStatus: "running", + NormalizedPayload: normalizedPayload, + }) +} + +// handleToolComplete processes tool execution complete events. +func (a *CopilotAdapter) handleToolComplete(evt copilot.SessionEvent, sessionID, operationID string) { + toolCallID := "" + if evt.Data.ToolCallID != nil { + toolCallID = *evt.Data.ToolCallID + } + + status := "complete" + + a.logger.Info("tool execution completed", + zap.String("tool_call_id", toolCallID), + zap.String("status", status)) + + // Get cached payload and remove from pending + a.mu.Lock() + cachedPayload := a.pendingToolPayloads[toolCallID] + delete(a.pendingToolPayloads, toolCallID) + a.mu.Unlock() + + // Normalize the tool result if we have result data + var normalizedPayload *streams.NormalizedPayload + if cachedPayload != nil { + // Use the result from the event if available + normalizedPayload = a.normalizer.NormalizeToolResult(cachedPayload, evt.Data.Result, false) + } + + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolCallID, + ToolStatus: status, + NormalizedPayload: normalizedPayload, + }) +} + +// handleSessionIdle processes session idle (completion) events. +func (a *CopilotAdapter) handleSessionIdle(sessionID, operationID string) { + // Check if we already sent completion for this operation (SDK may send multiple idle events) + a.mu.Lock() + if a.completeSent { + a.mu.Unlock() + a.logger.Debug("ignoring duplicate session idle event", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + return + } + a.completeSent = true + + // Clear the text accumulator - text was already sent via message_chunk events + // so we should NOT include it in the complete event to avoid duplicates + a.textAccumulator.Reset() + + // Auto-complete any pending tool calls + pendingTools := make(map[string]*streams.NormalizedPayload, len(a.pendingToolPayloads)) + for toolID, payload := range a.pendingToolPayloads { + pendingTools[toolID] = payload + } + a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) + a.mu.Unlock() + + a.logger.Info("session idle (operation completed)", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + + // Auto-complete pending tool calls + for toolID, cachedPayload := range pendingTools { + a.logger.Info("auto-completing pending tool call on idle", + zap.String("tool_call_id", toolID)) + a.sendUpdate(AgentEvent{ + Type: EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: toolID, + ToolStatus: "complete", + NormalizedPayload: cachedPayload, + }) + } + + // Send completion event WITHOUT text - text was already sent via message_chunk events. + // Including text here would cause duplicate messages. + a.sendUpdate(AgentEvent{ + Type: EventTypeComplete, + SessionID: sessionID, + OperationID: operationID, + }) + + // Signal completion + a.signalCompletion(true, "") +} + +// handleSessionError processes session error events. +func (a *CopilotAdapter) handleSessionError(evt copilot.SessionEvent, sessionID, operationID string) { + errorMsg := "unknown error" + if evt.Data.Message != nil { + errorMsg = *evt.Data.Message + } + + errorType := "" + if evt.Data.ErrorType != nil { + errorType = *evt.Data.ErrorType + } + + a.logger.Error("session error", + zap.String("error", errorMsg), + zap.String("error_type", errorType)) + + a.sendUpdate(AgentEvent{ + Type: EventTypeError, + SessionID: sessionID, + OperationID: operationID, + Error: errorMsg, + }) + + // Signal failure + a.signalCompletion(false, errorMsg) +} + +// handleUsageInfo processes usage info events. +func (a *CopilotAdapter) handleUsageInfo(evt copilot.SessionEvent, sessionID, operationID string) { + var inputTokens, outputTokens int64 + if evt.Data.InputTokens != nil { + inputTokens = int64(*evt.Data.InputTokens) + } + if evt.Data.OutputTokens != nil { + outputTokens = int64(*evt.Data.OutputTokens) + } + + // Update context tracking + contextUsed := inputTokens + outputTokens + + a.mu.Lock() + a.contextTokensUsed = contextUsed + contextSize := a.contextWindowSize + a.mu.Unlock() + + remaining := contextSize - contextUsed + if remaining < 0 { + remaining = 0 + } + + a.sendUpdate(AgentEvent{ + Type: EventTypeContextWindow, + SessionID: sessionID, + OperationID: operationID, + ContextWindowSize: contextSize, + ContextWindowUsed: contextUsed, + ContextWindowRemaining: remaining, + ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, + }) +} + +// signalCompletion signals the result channel. +func (a *CopilotAdapter) signalCompletion(success bool, errMsg string) { + a.mu.RLock() + resultCh := a.resultCh + a.mu.RUnlock() + + if resultCh != nil { + select { + case resultCh <- resultComplete{success: success, err: errMsg}: + default: + } + } +} + +// RequiresProcessKill returns true because the Copilot CLI server doesn't exit on stdin close. +// The CLI runs as an HTTP server that must be explicitly killed. +func (a *CopilotAdapter) RequiresProcessKill() bool { + return true +} + +// Verify interface implementation +var _ AgentAdapter = (*CopilotAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go b/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go new file mode 100644 index 0000000000..cd07d5f1df --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go @@ -0,0 +1,381 @@ +package adapter + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// exitedPattern matches the "" suffix that Copilot appends to shell output. +var exitedPattern = regexp.MustCompile(`\n?$`) + +// Copilot tool name constants. +// Copilot typically uses lowercase snake_case tool names. +const ( + CopilotToolBash = "bash" + CopilotToolReadFile = "read_file" + CopilotToolWriteFile = "write_file" + CopilotToolEditFile = "edit_file" + CopilotToolGlob = "glob" + CopilotToolGrep = "grep" + CopilotToolWebFetch = "web_fetch" + CopilotToolWebSearch = "web_search" +) + +// CopilotNormalizer converts Copilot SDK tool data to NormalizedPayload. +type CopilotNormalizer struct{} + +// NewCopilotNormalizer creates a new Copilot normalizer. +func NewCopilotNormalizer() *CopilotNormalizer { + return &CopilotNormalizer{} +} + +// NormalizeToolCall converts Copilot tool call data to NormalizedPayload. +func (n *CopilotNormalizer) NormalizeToolCall(toolName string, args any) *streams.NormalizedPayload { + // Convert args to map if possible + argsMap := n.toMap(args) + + // Normalize tool name to lowercase for comparison + toolNameLower := strings.ToLower(toolName) + + switch toolNameLower { + case CopilotToolBash, "shell", "execute", "run": + return n.normalizeExecute(argsMap) + case CopilotToolReadFile, "read": + return n.normalizeRead(argsMap) + case CopilotToolWriteFile, "write": + return n.normalizeWrite(argsMap) + case CopilotToolEditFile, "edit": + return n.normalizeEdit(argsMap) + case CopilotToolGlob, CopilotToolGrep, "search", "find": + return n.normalizeCodeSearch(toolNameLower, argsMap) + case CopilotToolWebFetch, CopilotToolWebSearch, "fetch", "http": + return n.normalizeHttpRequest(toolNameLower, argsMap) + default: + return n.normalizeGeneric(toolName, argsMap) + } +} + +// NormalizeToolResult updates the payload with tool result data. +func (n *CopilotNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, result any, isError bool) *streams.NormalizedPayload { + if cachedPayload == nil { + // No cached payload, create a generic one with the result + output := n.extractOutput(result) + payload := streams.NewGeneric("unknown", nil) + if payload.Generic() != nil { + payload.Generic().Output = output + } + return payload + } + + // Update the cached payload with the result + switch cachedPayload.Kind() { + case streams.ToolKindShellExec: + if cachedPayload.ShellExec() != nil { + output, exitCode := n.parseShellResult(result) + cachedPayload.ShellExec().Output = &streams.ShellExecOutput{ + Stdout: output, + ExitCode: exitCode, + } + if isError { + cachedPayload.ShellExec().Output.Stderr = output + cachedPayload.ShellExec().Output.Stdout = "" + } + } + case streams.ToolKindHttpRequest: + if cachedPayload.HttpRequest() != nil { + if output, ok := n.extractOutput(result).(string); ok { + cachedPayload.HttpRequest().Response = output + } + } + case streams.ToolKindGeneric: + if cachedPayload.Generic() != nil { + cachedPayload.Generic().Output = n.extractOutput(result) + } + case streams.ToolKindReadFile: + if cachedPayload.ReadFile() != nil { + if output, ok := n.extractOutput(result).(string); ok { + cachedPayload.ReadFile().Output = &streams.ReadFileOutput{ + Content: output, + } + } + } + case streams.ToolKindCodeSearch: + if cachedPayload.CodeSearch() != nil { + if output, ok := n.extractOutput(result).(string); ok { + // Parse file list from newline-separated output + files := n.parseFileList(output) + cachedPayload.CodeSearch().Output = &streams.CodeSearchOutput{ + Files: files, + FileCount: len(files), + } + } + } + } + + return cachedPayload +} + +// toMap converts an argument to a map[string]any. +func (n *CopilotNormalizer) toMap(args any) map[string]any { + if args == nil { + return nil + } + if m, ok := args.(map[string]any); ok { + return m + } + // Try JSON round-trip for struct types + if data, err := json.Marshal(args); err == nil { + var m map[string]any + if err := json.Unmarshal(data, &m); err == nil { + return m + } + } + return nil +} + +// extractOutput extracts a string output from various result formats. +func (n *CopilotNormalizer) extractOutput(result any) any { + if result == nil { + return nil + } + + switch r := result.(type) { + case string: + return r + case map[string]any: + // Try common output field names + if output, ok := r["output"].(string); ok { + return output + } + if stdout, ok := r["stdout"].(string); ok { + return stdout + } + if content, ok := r["content"].(string); ok { + return content + } + if text, ok := r["text"].(string); ok { + return text + } + return r + default: + // Handle struct types (like Copilot SDK's *Result) via JSON round-trip + if data, err := json.Marshal(result); err == nil { + var m map[string]any + if err := json.Unmarshal(data, &m); err == nil { + // Try to extract content from the unmarshaled map + if content, ok := m["content"].(string); ok { + return content + } + if output, ok := m["output"].(string); ok { + return output + } + if stdout, ok := m["stdout"].(string); ok { + return stdout + } + if text, ok := m["text"].(string); ok { + return text + } + } + } + return result + } +} + +// parseShellResult extracts output and exit code from shell execution result. +func (n *CopilotNormalizer) parseShellResult(result any) (string, int) { + if result == nil { + return "", 0 + } + + var output string + var exitCode int + + switch r := result.(type) { + case string: + output = r + case map[string]any: + // Try various output field names used by different tools + output = shared.GetString(r, "output") + if output == "" { + output = shared.GetString(r, "stdout") + } + if output == "" { + output = shared.GetString(r, "content") + } + if output == "" { + output = shared.GetString(r, "text") + } + exitCode = shared.GetInt(r, "exitCode") + if exitCode == 0 { + exitCode = shared.GetInt(r, "exit_code") + } + default: + // Handle struct types (like Copilot SDK's *Result) via JSON round-trip + if data, err := json.Marshal(result); err == nil { + var m map[string]any + if err := json.Unmarshal(data, &m); err == nil { + // Try to extract content from the unmarshaled map + if content, ok := m["content"].(string); ok { + output = content + } else if out, ok := m["output"].(string); ok { + output = out + } else if stdout, ok := m["stdout"].(string); ok { + output = stdout + } else { + // Fallback: return the JSON string + output = string(data) + } + } else { + output = string(data) + } + } + } + + // Strip Copilot's "" suffix and extract exit code + output, exitCode = n.cleanShellOutput(output, exitCode) + + return output, exitCode +} + +// cleanShellOutput strips Copilot's "" suffix from shell output +// and extracts the exit code if not already set. +func (n *CopilotNormalizer) cleanShellOutput(output string, exitCode int) (string, int) { + if matches := exitedPattern.FindStringSubmatch(output); matches != nil { + // Strip the suffix + output = exitedPattern.ReplaceAllString(output, "") + // Extract exit code from the suffix if we don't already have one + if exitCode == 0 && len(matches) > 1 { + if code, err := strconv.Atoi(matches[1]); err == nil { + exitCode = code + } + } + } + return strings.TrimSuffix(output, "\n"), exitCode +} + +// normalizeExecute converts shell execution tool data. +func (n *CopilotNormalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { + command := shared.GetString(args, "command") + if command == "" { + command = shared.GetString(args, "cmd") + } + description := shared.GetString(args, "description") + timeout := shared.GetInt(args, "timeout") + background := shared.GetBool(args, "background") + + return streams.NewShellExec(command, "", description, timeout, background) +} + +// normalizeRead converts file read tool data. +func (n *CopilotNormalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { + filePath := shared.GetString(args, "file_path") + if filePath == "" { + filePath = shared.GetString(args, "path") + } + offset := shared.GetInt(args, "offset") + limit := shared.GetInt(args, "limit") + + return streams.NewReadFile(filePath, offset, limit) +} + +// normalizeWrite converts file write tool data. +func (n *CopilotNormalizer) normalizeWrite(args map[string]any) *streams.NormalizedPayload { + filePath := shared.GetString(args, "file_path") + if filePath == "" { + filePath = shared.GetString(args, "path") + } + content := shared.GetString(args, "content") + + return streams.NewModifyFile(filePath, []streams.FileMutation{ + { + Type: streams.MutationCreate, + Content: content, + }, + }) +} + +// normalizeEdit converts file edit tool data. +func (n *CopilotNormalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { + filePath := shared.GetString(args, "file_path") + if filePath == "" { + filePath = shared.GetString(args, "path") + } + oldContent := shared.GetString(args, "old_string") + if oldContent == "" { + oldContent = shared.GetString(args, "old_content") + } + newContent := shared.GetString(args, "new_string") + if newContent == "" { + newContent = shared.GetString(args, "new_content") + } + + mutation := streams.FileMutation{ + Type: streams.MutationPatch, + OldContent: oldContent, + NewContent: newContent, + } + + // Generate unified diff + if oldContent != "" && newContent != "" { + mutation.Diff = shared.GenerateUnifiedDiff(oldContent, newContent, filePath, 0) + } + + return streams.NewModifyFile(filePath, []streams.FileMutation{mutation}) +} + +// normalizeCodeSearch converts code search tool data. +func (n *CopilotNormalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload { + path := shared.GetString(args, "path") + if path == "" { + path = shared.GetString(args, "directory") + } + pattern := shared.GetString(args, "pattern") + query := shared.GetString(args, "query") + glob := "" + + if toolName == CopilotToolGlob { + glob = pattern + } + + return streams.NewCodeSearch(query, pattern, path, glob) +} + +// normalizeHttpRequest converts HTTP request tool data. +func (n *CopilotNormalizer) normalizeHttpRequest(toolName string, args map[string]any) *streams.NormalizedPayload { + url := shared.GetString(args, "url") + method := shared.GetString(args, "method") + if method == "" { + method = "GET" + } + if toolName == CopilotToolWebSearch { + method = "SEARCH" + } + + return streams.NewHttpRequest(url, method) +} + +// normalizeGeneric wraps unknown tools as generic. +func (n *CopilotNormalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { + return streams.NewGeneric(toolName, args) +} + +// parseFileList parses newline-separated file paths from grep/glob output. +func (n *CopilotNormalizer) parseFileList(output string) []string { + if output == "" { + return nil + } + lines := strings.Split(output, "\n") + var files []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + return files +} diff --git a/apps/backend/internal/agentctl/server/adapter/factory.go b/apps/backend/internal/agentctl/server/adapter/factory.go index 8d54c8cb94..1723dba7b3 100644 --- a/apps/backend/internal/agentctl/server/adapter/factory.go +++ b/apps/backend/internal/agentctl/server/adapter/factory.go @@ -32,6 +32,10 @@ func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (Agent return newCodexAdapterWrapper(codex.NewAdapter(sharedCfg, log)), nil case agent.ProtocolOpenCode: return newOpenCodeAdapterWrapper(opencode.NewAdapter(sharedCfg, log)), nil + case agent.ProtocolCopilot: + return NewCopilotAdapter(cfg, log), nil + case agent.ProtocolAmp: + return NewAmpAdapter(cfg, log), nil default: return nil, fmt.Errorf("unsupported protocol: %s", protocol) } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go index 65a97abb22..a641c8b644 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go @@ -34,6 +34,8 @@ const ( ProtocolStreamJSON = "streamjson" ProtocolCodex = "codex" ProtocolOpenCode = "opencode" + ProtocolAmp = "amp" + ProtocolCopilot = "copilot" ) func resolveDebugLogDir() string { diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index a074cd20ad..59495bc5bb 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -125,6 +125,10 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea }) } + m.logger.Info("CreateInstance: received request", + zap.String("req_protocol", req.Protocol), + zap.String("workspace_path", req.WorkspacePath)) + overrides := &config.InstanceOverrides{ Protocol: agent.Protocol(req.Protocol), AgentCommand: agentCmd, @@ -136,9 +140,15 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea SessionID: req.SessionID, } + m.logger.Info("CreateInstance: applying overrides", + zap.String("override_protocol", string(overrides.Protocol))) + // Create instance config using the unified method instanceCfg := m.config.NewInstanceConfig(port, overrides) + m.logger.Info("CreateInstance: instance config created", + zap.String("config_protocol", string(instanceCfg.Protocol))) + // Create process manager procMgr := process.NewManager(instanceCfg, m.logger) @@ -220,17 +230,20 @@ func (m *Manager) ListInstances() []*InstanceInfo { // StopInstance stops and removes an instance by ID. func (m *Manager) StopInstance(ctx context.Context, id string) error { + // Get instance and remove from map under lock (quick operation) m.mu.Lock() - defer m.mu.Unlock() - inst, ok := m.instances[id] if !ok { + m.mu.Unlock() return fmt.Errorf("instance %s not found", id) } + // Remove from map immediately so new instances aren't blocked + delete(m.instances, id) + m.mu.Unlock() m.logger.Debug("stopping instance", zap.String("instance_id", id)) - // Stop the process manager + // Stop the process manager (potentially slow, done without lock) if inst.manager != nil { if err := inst.manager.Stop(ctx); err != nil { m.logger.Warn("error stopping process manager", @@ -243,7 +256,7 @@ func (m *Manager) StopInstance(ctx context.Context, id string) error { zap.String("instance_id", id), zap.Int("port", inst.Port)) - // Shutdown HTTP server + // Shutdown HTTP server (potentially slow, done without lock) if inst.server != nil { if err := inst.server.Shutdown(ctx); err != nil { m.logger.Warn("error shutting down HTTP server", @@ -256,11 +269,10 @@ func (m *Manager) StopInstance(ctx context.Context, id string) error { zap.String("instance_id", id), zap.Int("port", inst.Port)) - // Release port + // Release port (quick operation, re-acquire lock) + m.mu.Lock() m.portAlloc.Release(inst.Port) - - // Remove from instances map - delete(m.instances, id) + m.mu.Unlock() m.logger.Info("StopInstance completed", zap.String("instance_id", id), diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 1617883cb8..4f5e1df74e 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -411,7 +411,7 @@ func (m *Manager) createAdapter() error { return fmt.Errorf("protocol not specified in configuration") } - // Use the adapter factory to create the appropriate adapter + m.logger.Debug("creating adapter", zap.String("protocol", string(protocol))) adpt, err := adapter.NewAdapter(protocol, m.adapterCfg, m.logger) if err != nil { return fmt.Errorf("failed to create adapter: %w", err) @@ -653,10 +653,35 @@ func (m *Manager) waitForExit() { if err != nil { m.exitErr.Store(errorWrapper{err: err}) + exitCode := -1 if exitErr, ok := err.(*exec.ExitError); ok { - m.exitCode.Store(int32(exitErr.ExitCode())) + exitCode = exitErr.ExitCode() + m.exitCode.Store(int32(exitCode)) + } + // Include recent stderr for better error diagnostics + recentStderr := m.GetRecentStderr() + m.logger.Error("agent process exited with error", + zap.Error(err), + zap.Int("exit_code", exitCode), + zap.Strings("recent_stderr", recentStderr)) + + // Send error event to the updates channel so UI can display it + errorMsg := fmt.Sprintf("Agent process exited with code %d", exitCode) + if len(recentStderr) > 0 { + errorMsg = fmt.Sprintf("%s: %s", errorMsg, strings.Join(recentStderr, "; ")) + } + select { + case m.updatesCh <- adapter.AgentEvent{ + Type: adapter.EventTypeError, + Error: errorMsg, + Data: map[string]any{ + "exit_code": exitCode, + "recent_stderr": recentStderr, + }, + }: + default: + m.logger.Warn("updates channel full, could not send exit error event") } - m.logger.Info("agent process exited with error", zap.Error(err)) } else { m.exitCode.Store(0) m.logger.Info("agent process exited successfully") diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go index 7c6c040ab7..1e417312ec 100644 --- a/apps/backend/internal/agentctl/types/streams/tool_payload.go +++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go @@ -19,6 +19,25 @@ const ( ToolKindMisc ToolKind = "misc" ) +// ToMessageType maps the ToolKind to a frontend message type. +// This allows the frontend to use specialized renderers for different tool categories. +func (k ToolKind) ToMessageType() string { + switch k { + case ToolKindReadFile: + return "tool_read" + case ToolKindCodeSearch: + return "tool_search" + case ToolKindModifyFile: + return "tool_edit" + case ToolKindShellExec: + return "tool_execute" + case ToolKindManageTodos: + return "todo" + default: + return "tool_call" + } +} + // NormalizedPayload is the normalized tool data (discriminated union). // Exactly one of the kind-specific fields will be set based on Kind. // Fields are unexported to enforce use of factory functions (NewReadFile, NewShellExec, etc.) diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 22b59127a2..6fc558a3de 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -21,20 +21,7 @@ func toolKindToMessageType(normalized *streams.NormalizedPayload) string { if normalized == nil { return "tool_call" } - switch normalized.Kind() { - case streams.ToolKindReadFile: - return "tool_read" - case streams.ToolKindCodeSearch: - return "tool_search" - case streams.ToolKindModifyFile: - return "tool_edit" - case streams.ToolKindShellExec: - return "tool_execute" - case streams.ToolKindManageTodos: - return "todo" - default: - return "tool_call" - } + return normalized.Kind().ToMessageType() } // Event handlers @@ -548,6 +535,11 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle s.handleToolUpdateEvent(ctx, payload) case "complete": + s.logger.Debug("orchestrator received complete event", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Int("text_length", len(payload.Data.Text)), + zap.Bool("has_text", payload.Data.Text != "")) s.saveAgentTextIfPresent(ctx, payload) s.completeTurnForSession(ctx, sessionID) s.setSessionWaitingForInput(ctx, taskID, sessionID) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 7c24799b65..da9f03f2db 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2105,8 +2105,21 @@ func (s *Service) UpdateToolCallMessageWithCreate(ctx context.Context, sessionID } // Add normalized tool data to metadata for frontend consumption + // and update message type if the normalized kind changed (e.g., read_file -> code_search for directories) if normalized != nil { message.Metadata["normalized"] = normalized + + // Update message type if the normalized kind changed + // This handles cases like Read on a directory converting to code_search + newMsgType := models.MessageType(normalized.Kind().ToMessageType()) + if newMsgType != message.Type { + s.logger.Debug("updating message type based on normalized kind", + zap.String("message_id", message.ID), + zap.String("old_type", string(message.Type)), + zap.String("new_type", string(newMsgType)), + zap.String("normalized_kind", string(normalized.Kind()))) + message.Type = newMsgType + } } // Update title/content if provided and different from current @@ -2315,7 +2328,8 @@ func (s *Service) CompleteTurn(ctx context.Context, turnID string) error { turn, err := s.repo.GetTurn(ctx, turnID) if err != nil { s.logger.Warn("failed to refetch completed turn", zap.String("turn_id", turnID), zap.Error(err)) - // Still publish with the old turn data + // Turn was likely deleted (task deletion race), skip publishing + return nil } s.publishTurnEvent(events.TurnCompleted, turn) @@ -2351,6 +2365,10 @@ func (s *Service) publishTurnEvent(eventType string, turn *models.Turn) { if s.eventBus == nil { return } + if turn == nil { + s.logger.Warn("publishTurnEvent: turn is nil, skipping", zap.String("event_type", eventType)) + return + } _ = s.eventBus.Publish(context.Background(), eventType, bus.NewEvent(eventType, "task-service", map[string]interface{}{ "id": turn.ID, "session_id": turn.TaskSessionID, diff --git a/apps/backend/pkg/agent/protocol.go b/apps/backend/pkg/agent/protocol.go index ba9cd73639..00256e176d 100644 --- a/apps/backend/pkg/agent/protocol.go +++ b/apps/backend/pkg/agent/protocol.go @@ -23,6 +23,13 @@ const ( // OpenCode spawns an HTTP server and communicates via REST API calls // with Server-Sent Events (SSE) for streaming responses. ProtocolOpenCode Protocol = "opencode" + // ProtocolCopilot is the GitHub Copilot SDK protocol. + // Uses the official Go SDK which internally communicates via JSON-RPC with the Copilot CLI. + ProtocolCopilot Protocol = "copilot" + // ProtocolAmp is the Sourcegraph Amp protocol (stream-json over stdin/stdout). + // Amp uses a streaming JSON format similar to Claude Code, with thread-based + // session management for multi-turn conversations. + ProtocolAmp Protocol = "amp" ) // String returns the string representation of the protocol. @@ -33,7 +40,7 @@ func (p Protocol) String() string { // IsValid returns true if the protocol is a known valid protocol. func (p Protocol) IsValid() bool { switch p { - case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, ProtocolClaudeCode, ProtocolOpenCode: + case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, ProtocolClaudeCode, ProtocolOpenCode, ProtocolCopilot, ProtocolAmp: return true default: return false diff --git a/apps/backend/pkg/amp/client.go b/apps/backend/pkg/amp/client.go new file mode 100644 index 0000000000..43930a8eea --- /dev/null +++ b/apps/backend/pkg/amp/client.go @@ -0,0 +1,288 @@ +// Package amp provides a client wrapper for the Sourcegraph Amp CLI. +// This package handles communication with the Amp CLI via stream-json protocol. +package amp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// Message types for Amp stream-json protocol. +const ( + MessageTypeSystem = "system" + MessageTypeAssistant = "assistant" + MessageTypeResult = "result" + MessageTypeUser = "user" +) + +// Content block types. +const ( + ContentTypeText = "text" + ContentTypeThinking = "thinking" + ContentTypeToolUse = "tool_use" + ContentTypeToolResult = "tool_result" +) + +// Client wraps communication with Amp CLI via stdin/stdout. +type Client struct { + stdin io.Writer + stdout io.Reader + logger *logger.Logger + + scanner *bufio.Scanner + + // Message handler + messageHandler func(*Message) + handlerMu sync.RWMutex + + // Context for lifecycle + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + + // State + threadID string + mu sync.RWMutex +} + +// Message represents a message from Amp. +type Message struct { + Type string `json:"type"` + ThreadID string `json:"thread_id,omitempty"` + Message *Content `json:"message,omitempty"` + + // Result fields (when Type == "result") + IsError bool `json:"is_error,omitempty"` + Error string `json:"error,omitempty"` // Error message when is_error is true + Subtype string `json:"subtype,omitempty"` // e.g., "error_during_execution" + CostUSD float64 `json:"cost_usd,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + NumTurns int `json:"num_turns,omitempty"` + TotalInputTokens int64 `json:"total_input_tokens,omitempty"` + TotalOutputTokens int64 `json:"total_output_tokens,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + ModelUsage map[string]*Usage `json:"model_usage,omitempty"` +} + +// Content represents the message content. +type Content struct { + Model string `json:"model,omitempty"` + Content []ContentBlock `json:"content,omitempty"` + Usage *TokenUsage `json:"usage,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence string `json:"stop_sequence,omitempty"` +} + +// ContentBlock represents a content block (text, tool_use, etc.). +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input map[string]any `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content any `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// TokenUsage represents token usage information. +type TokenUsage struct { + InputTokens int64 `json:"input_tokens,omitempty"` + OutputTokens int64 `json:"output_tokens,omitempty"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` +} + +// Usage represents model-specific usage stats. +type Usage struct { + InputTokens int64 `json:"input_tokens,omitempty"` + OutputTokens int64 `json:"output_tokens,omitempty"` + ContextWindow *int64 `json:"context_window,omitempty"` +} + +// ResultData represents the result data structure. +type ResultData struct { + SessionID string `json:"session_id,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + Text string `json:"text,omitempty"` +} + +// UserMessage represents a user message to send (Claude Code-compatible format). +type UserMessage struct { + Type string `json:"type"` // "user" + Message UserMessageBody `json:"message"` +} + +// UserMessageBody is the nested message body for user messages. +type UserMessageBody struct { + Role string `json:"role"` // "user" + Content []UserContentBlock `json:"content"` +} + +// UserContentBlock represents a content block in user messages. +type UserContentBlock struct { + Type string `json:"type"` // "text" + Text string `json:"text"` +} + +// NewClient creates a new Amp client. +func NewClient(stdin io.Writer, stdout io.Reader, log *logger.Logger) *Client { + ctx, cancel := context.WithCancel(context.Background()) + return &Client{ + stdin: stdin, + stdout: stdout, + logger: log.WithFields(zap.String("component", "amp-client")), + ctx: ctx, + cancel: cancel, + } +} + +// SetMessageHandler sets the handler for incoming messages. +func (c *Client) SetMessageHandler(handler func(*Message)) { + c.handlerMu.Lock() + defer c.handlerMu.Unlock() + c.messageHandler = handler +} + +// Start begins reading from stdout and dispatching messages. +func (c *Client) Start(ctx context.Context) { + c.scanner = bufio.NewScanner(c.stdout) + // Allow larger buffer for JSON messages + c.scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + c.wg.Add(1) + go c.readLoop(ctx) +} + +// Stop stops the client and waits for goroutines to finish. +func (c *Client) Stop() { + c.cancel() + c.wg.Wait() +} + +// readLoop reads JSON lines from stdout and dispatches them. +func (c *Client) readLoop(ctx context.Context) { + defer c.wg.Done() + + for { + select { + case <-ctx.Done(): + return + case <-c.ctx.Done(): + return + default: + } + + if !c.scanner.Scan() { + if err := c.scanner.Err(); err != nil { + c.logger.Error("scanner error", zap.Error(err)) + } + return + } + + line := c.scanner.Text() + if line == "" { + continue + } + + c.handleLine(line) + } +} + +// handleLine parses and dispatches a JSON line. +func (c *Client) handleLine(line string) { + var msg Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + c.logger.Debug("failed to parse message", zap.Error(err), zap.String("line", line)) + return + } + + // Update thread ID if provided + if msg.ThreadID != "" { + c.mu.Lock() + c.threadID = msg.ThreadID + c.mu.Unlock() + } + + c.handlerMu.RLock() + handler := c.messageHandler + c.handlerMu.RUnlock() + + if handler != nil { + handler(&msg) + } +} + +// SendUserMessage sends a user message to Amp. +func (c *Client) SendUserMessage(message string) error { + msg := &UserMessage{ + Type: "user", + Message: UserMessageBody{ + Role: "user", + Content: []UserContentBlock{ + {Type: "text", Text: message}, + }, + }, + } + + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + if _, err := c.stdin.Write(append(data, '\n')); err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + + return nil +} + +// GetThreadID returns the current thread ID. +func (c *Client) GetThreadID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.threadID +} + +// SetThreadID sets the current thread ID. +func (c *Client) SetThreadID(threadID string) { + c.mu.Lock() + defer c.mu.Unlock() + c.threadID = threadID +} + +// GetResultData extracts ResultData from a result message. +func (m *Message) GetResultData() *ResultData { + if len(m.Result) == 0 { + return nil + } + + var data ResultData + if err := json.Unmarshal(m.Result, &data); err != nil { + return nil + } + + return &data +} + +// GetResultString extracts a string result (for error messages). +func (m *Message) GetResultString() string { + if len(m.Result) == 0 { + return "" + } + + var s string + if err := json.Unmarshal(m.Result, &s); err != nil { + return "" + } + + return s +} diff --git a/apps/backend/pkg/copilot/client.go b/apps/backend/pkg/copilot/client.go new file mode 100644 index 0000000000..79e8994306 --- /dev/null +++ b/apps/backend/pkg/copilot/client.go @@ -0,0 +1,350 @@ +// Package copilot provides integration with the GitHub Copilot SDK. +// This is a thin wrapper around github.com/github/copilot-sdk/go that +// provides a consistent interface for the kandev agent system. +// +// When CLIUrl is configured, the SDK connects to an externally managed +// Copilot CLI server via TCP (JSON-RPC). Otherwise, the SDK spawns and +// manages the CLI process internally via stdio. +package copilot + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/github/copilot-sdk/go" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// Re-export SDK types for convenience +type ( + SessionEvent = copilot.SessionEvent + SessionEventType = copilot.SessionEventType + SessionConfig = copilot.SessionConfig + MessageOptions = copilot.MessageOptions + Data = copilot.Data +) + +// Re-export event type constants +const ( + EventTypeSessionStart = copilot.SessionStart + EventTypeSessionResume = copilot.SessionResume + EventTypeSessionIdle = copilot.SessionIdle + EventTypeSessionError = copilot.SessionError + EventTypeSessionUsageInfo = copilot.SessionUsageInfo + EventTypeAssistantMessage = copilot.AssistantMessage + EventTypeAssistantMessageDelta = copilot.AssistantMessageDelta + EventTypeAssistantReasoning = copilot.AssistantReasoning + EventTypeAssistantReasoningDelta = copilot.AssistantReasoningDelta + EventTypeAssistantTurnStart = copilot.AssistantTurnStart + EventTypeAssistantTurnEnd = copilot.AssistantTurnEnd + EventTypeAssistantUsage = copilot.AssistantUsage + EventTypeToolStart = copilot.ToolExecutionStart + EventTypeToolComplete = copilot.ToolExecutionComplete + EventTypeToolProgress = copilot.ToolExecutionProgress + EventTypeAbort = copilot.Abort +) + +// Client wraps the Copilot SDK client with additional functionality. +type Client struct { + sdkClient *copilot.Client + session *copilot.Session + logger *logger.Logger + + // Configuration + cliURL string + model string + + // Event handler + eventHandler func(SessionEvent) + unsubscribe func() + handlerMu sync.RWMutex + + // State + sessionID string + mu sync.RWMutex + started bool +} + +// ClientConfig holds configuration for creating a Client. +type ClientConfig struct { + // CLIUrl is the address of an externally managed Copilot CLI server (e.g. "localhost:12345"). + // When set, the SDK connects via TCP instead of spawning its own process. + CLIUrl string + Model string +} + +// NewClient creates a new Copilot client wrapper. +func NewClient(cfg ClientConfig, log *logger.Logger) *Client { + if cfg.Model == "" { + cfg.Model = "gpt-4.1" + } + + return &Client{ + cliURL: cfg.CLIUrl, + model: cfg.Model, + logger: log.WithFields(zap.String("component", "copilot-sdk-client")), + } +} + +// SetEventHandler sets the handler for session events. +func (c *Client) SetEventHandler(handler func(SessionEvent)) { + c.handlerMu.Lock() + defer c.handlerMu.Unlock() + c.eventHandler = handler +} + +// Start initializes the Copilot SDK client. +// When CLIUrl is configured, the SDK connects to an external CLI server via TCP. +// Otherwise, the SDK spawns and manages the CLI process internally via stdio. +// The actual connection is deferred to the first CreateSession call via AutoStart. +func (c *Client) Start(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.started { + return fmt.Errorf("client already started") + } + + c.logger.Info("starting Copilot SDK client", + zap.String("model", c.model), + zap.String("cli_url", c.cliURL)) + + if c.cliURL != "" { + // Connect to externally managed CLI server via TCP + c.sdkClient = copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: c.cliURL, + LogLevel: "error", + }) + } else { + // SDK spawns and manages the CLI process internally (stdio) + c.sdkClient = copilot.NewClient(nil) + } + + // SDK AutoStart (default: true) defers the actual connection + // to the first CreateSession call, so no explicit Start() needed. + + c.started = true + c.logger.Info("Copilot SDK client initialized") + + return nil +} + +// Stop shuts down the Copilot SDK client. +func (c *Client) Stop() error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.started { + return nil + } + + c.logger.Info("stopping Copilot SDK client") + + // Unsubscribe from events + c.handlerMu.Lock() + if c.unsubscribe != nil { + c.unsubscribe() + c.unsubscribe = nil + } + c.handlerMu.Unlock() + + // Destroy session if active + if c.session != nil { + if err := c.session.Destroy(); err != nil { + c.logger.Warn("error destroying session", zap.Error(err)) + } + c.session = nil + } + + // Stop the SDK client + if c.sdkClient != nil { + errs := c.sdkClient.Stop() + for _, err := range errs { + c.logger.Warn("error stopping SDK client", zap.Error(err)) + } + c.sdkClient = nil + } + + c.started = false + return nil +} + +// CreateSession creates a new Copilot session. +func (c *Client) CreateSession(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.started { + return "", fmt.Errorf("client not started") + } + + // Unsubscribe from previous session events + c.handlerMu.Lock() + if c.unsubscribe != nil { + c.unsubscribe() + c.unsubscribe = nil + } + c.handlerMu.Unlock() + + // Destroy existing session if any + if c.session != nil { + if err := c.session.Destroy(); err != nil { + c.logger.Warn("error destroying previous session", zap.Error(err)) + } + c.session = nil + } + + c.logger.Info("creating new session", zap.String("model", c.model)) + + // Create session with configuration + session, err := c.sdkClient.CreateSession(&copilot.SessionConfig{ + Model: c.model, + Streaming: true, + }) + if err != nil { + return "", fmt.Errorf("failed to create session: %w", err) + } + + c.session = session + c.sessionID = session.SessionID + + // Register event handler + c.handlerMu.Lock() + if c.eventHandler != nil { + c.unsubscribe = session.On(c.eventHandler) + } + c.handlerMu.Unlock() + + c.logger.Info("session created", zap.String("session_id", c.sessionID)) + + return c.sessionID, nil +} + +// ResumeSession resumes an existing session with streaming enabled. +func (c *Client) ResumeSession(ctx context.Context, sessionID string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.started { + return fmt.Errorf("client not started") + } + + // Unsubscribe from previous session events + c.handlerMu.Lock() + if c.unsubscribe != nil { + c.unsubscribe() + c.unsubscribe = nil + } + c.handlerMu.Unlock() + + // Destroy existing session if any + if c.session != nil { + if err := c.session.Destroy(); err != nil { + c.logger.Warn("error destroying previous session", zap.Error(err)) + } + c.session = nil + } + + c.logger.Info("resuming session", zap.String("session_id", sessionID)) + + // Use ResumeSessionWithOptions to enable streaming on resumed sessions + session, err := c.sdkClient.ResumeSessionWithOptions(sessionID, &copilot.ResumeSessionConfig{ + Streaming: true, + }) + if err != nil { + return fmt.Errorf("failed to resume session: %w", err) + } + + c.session = session + c.sessionID = sessionID + + // Register event handler + c.handlerMu.Lock() + if c.eventHandler != nil { + c.unsubscribe = session.On(c.eventHandler) + } + c.handlerMu.Unlock() + + c.logger.Info("session resumed", zap.String("session_id", sessionID)) + + return nil +} + +// Send sends a message to the current session (non-blocking). +func (c *Client) Send(ctx context.Context, message string) (string, error) { + c.mu.RLock() + session := c.session + c.mu.RUnlock() + + if session == nil { + return "", fmt.Errorf("no active session") + } + + c.logger.Info("sending message to session") + + messageID, err := session.Send(copilot.MessageOptions{ + Prompt: message, + }) + if err != nil { + return "", fmt.Errorf("failed to send message: %w", err) + } + + return messageID, nil +} + +// SendAndWait sends a message and waits for completion. +func (c *Client) SendAndWait(ctx context.Context, message string, timeout time.Duration) (*SessionEvent, error) { + c.mu.RLock() + session := c.session + c.mu.RUnlock() + + if session == nil { + return nil, fmt.Errorf("no active session") + } + + if timeout == 0 { + timeout = 5 * time.Minute + } + + c.logger.Info("sending message and waiting for completion") + + result, err := session.SendAndWait(copilot.MessageOptions{ + Prompt: message, + }, timeout) + if err != nil { + return nil, fmt.Errorf("failed to send message: %w", err) + } + + return result, nil +} + +// Abort cancels the current operation. +func (c *Client) Abort(ctx context.Context) error { + c.mu.RLock() + session := c.session + c.mu.RUnlock() + + if session == nil { + return nil + } + + c.logger.Info("aborting current operation") + return session.Abort() +} + +// GetSessionID returns the current session ID. +func (c *Client) GetSessionID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.sessionID +} + +// IsStarted returns whether the client has been started. +func (c *Client) IsStarted() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.started +} diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index 4d8ea7df3e..2f7bba90b8 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -194,6 +194,38 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className {children}

), + table: ({ children }) => ( +
+ + {children} +
+
+ ), + thead: ({ children }) => ( + + {children} + + ), + tbody: ({ children }) => ( + + {children} + + ), + tr: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), }} > {comment.content || '(empty)'} diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx index d10efe9e99..cee90087f2 100644 --- a/apps/web/components/task/chat/messages/tool-call-message.tsx +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useCallback, useEffect, useState, memo } from 'react'; +import { useCallback, useState, useRef, memo } from 'react'; import { IconCheck, - IconChevronDown, - IconChevronRight, IconCode, IconEdit, IconEye, @@ -14,11 +12,12 @@ import { IconX, } from '@tabler/icons-react'; import { GridSpinner } from '@/components/grid-spinner'; -import { cn, toRelativePath, transformPathsInText } from '@/lib/utils'; +import { cn, transformPathsInText } from '@/lib/utils'; import { getWebSocketClient } from '@/lib/ws/connection'; import type { Message } from '@/lib/types/http'; import type { ToolCallMetadata } from '@/components/task/chat/types'; import { PermissionActionRow } from './permission-action-row'; +import { ExpandableRow } from './expandable-row'; type PermissionOption = { option_id: string; @@ -59,28 +58,6 @@ function getToolIcon(toolName: string | undefined, className: string) { return ; } -function getStatusIcon(status?: string, permissionStatus?: string) { - // If there's a permission status, show that instead - if (permissionStatus === 'approved') { - return ; - } - if (permissionStatus === 'rejected') { - return ; - } - - switch (status) { - case 'complete': - return ; - case 'error': - return ; - case 'running': - case 'in_progress': - return ; - default: - return null; - } -} - type ToolCallMessageProps = { comment: Message; permissionMessage?: Message; @@ -95,38 +72,40 @@ export const ToolCallMessage = memo(function ToolCallMessage({ comment, permissi const rawTitle = metadata?.title ?? comment.content ?? 'Tool call'; const title = transformPathsInText(rawTitle, worktreePath); const status = metadata?.status; - const args = metadata?.args; - const result = metadata?.result; + + // Get output from normalized payload (generic tools) + const normalizedGeneric = metadata?.normalized?.generic; + const output = normalizedGeneric?.output ?? metadata?.result; // Permission state const hasPermission = !!permissionMessage; const permissionStatus = permissionMetadata?.status; const isPermissionPending = hasPermission && permissionStatus !== 'approved' && permissionStatus !== 'rejected'; - // Initialize isExpanded based on status - auto-expand if running - const [isExpanded, setIsExpanded] = useState(() => { - return status === 'running'; - }); - const [isManuallyToggled, setIsManuallyToggled] = useState(false); + // Expand state management + const [manualExpandState, setManualExpandState] = useState(null); + const prevStatusRef = useRef(status); const [isResponding, setIsResponding] = useState(false); - // Auto-collapse when complete (unless manually overridden) - useEffect(() => { - // Permission requests always stay expanded - if (isPermissionPending) { - setIsExpanded(true); - return; + // Reset manual state when status changes + if (prevStatusRef.current !== status) { + prevStatusRef.current = status; + if (manualExpandState !== null) { + setManualExpandState(null); } + } - // Only auto-collapse if user hasn't manually toggled - if (!isManuallyToggled) { - if (status === 'running') { - setIsExpanded(true); - } else if (status === 'complete' || status === 'error') { - setIsExpanded(false); - } - } - }, [status, isManuallyToggled, isPermissionPending]); + // Auto-expand when running or permission pending + const autoExpanded = status === 'running' || isPermissionPending; + const isExpanded = manualExpandState ?? autoExpanded; + + // Determine if we have expandable content + const hasOutput = output && (typeof output === 'string' ? output.length > 0 : Object.keys(output).length > 0); + const hasExpandableContent = hasOutput || isPermissionPending; + + const handleToggle = useCallback(() => { + setManualExpandState((prev) => !(prev ?? autoExpanded)); + }, [autoExpanded]); const handleRespond = useCallback( async (optionId: string, cancelled: boolean = false) => { @@ -175,123 +154,88 @@ export const ToolCallMessage = memo(function ToolCallMessage({ comment, permissi } }, [permissionMetadata, handleRespond]); - const hasDetails = args && Object.keys(args).length > 0; - - let filePath: string | undefined; - const rawPath = args?.path ?? args?.file ?? args?.file_path; - if (typeof rawPath === 'string') { - filePath = rawPath; - } - if (!filePath && Array.isArray(args?.locations) && args.locations.length > 0) { - const firstLoc = args.locations[0] as { path?: string } | undefined; - if (firstLoc?.path) { - filePath = firstLoc.path; - } - } - const isSuccess = status === 'complete' && !permissionStatus; - const handleToggleExpand = () => { - if (hasDetails) { - setIsExpanded(!isExpanded); - setIsManuallyToggled(true); + const getStatusIcon = () => { + // If there's a permission status, show that instead + if (permissionStatus === 'approved') { + return ; + } + if (permissionStatus === 'rejected') { + return ; + } + + switch (status) { + case 'complete': + return ; + case 'error': + return ; + case 'running': + return ; + default: + return null; } }; + // Format output for display - beautify JSON if detected + const formatOutput = (value: unknown): { content: string; isJson: boolean } => { + if (typeof value === 'string') { + // Try to parse as JSON and beautify + try { + const parsed = JSON.parse(value); + return { content: JSON.stringify(parsed, null, 2), isJson: true }; + } catch { + // Not valid JSON, return as-is + return { content: value, isJson: false }; + } + } + // Already an object, stringify with formatting + return { content: JSON.stringify(value, null, 2), isJson: true }; + }; + + const formattedOutput = hasOutput ? formatOutput(output) : null; + return ( -
-
- {/* Icon with hover-to-show chevron */} -
- {/* Show tool icon by default, chevron on hover if expandable */} -
+ - {getToolIcon(toolName, cn( - 'h-4 w-4', - isPermissionPending ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground' - ))} -
- {hasDetails && ( - isExpanded - ? - : - )} + {title} + {!isSuccess && getStatusIcon()} +
+ } + hasExpandableContent={!!hasExpandableContent} + isExpanded={isExpanded} + onToggle={handleToggle} + > +
+ {/* Output display */} + {formattedOutput && ( +
+            {formattedOutput.content}
+          
+ )} - {/* Content */} -
-
- - - {title} - - {/* Status indicator - only show if not success */} - {!isSuccess && getStatusIcon(status, permissionStatus)} - - {filePath && ( - - {toRelativePath(filePath, worktreePath)} - - )} -
- - {/* Expanded Details */} - {isExpanded && hasDetails && ( -
- {args && Object.entries(args).map(([key, value]) => { - const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2); - const isLongValue = strValue.length > 100 || strValue.includes('\n'); - - return ( -
- {key}: - {isLongValue ? ( -
-                        {strValue}
-                      
- ) : ( - {strValue} - )} -
- ); - })} - {result && ( -
- Result: -
-                    {result}
-                  
-
- )} -
- )} - - {/* Inline permission approval UI */} - {isPermissionPending && ( -
- -
- )} -
+ {/* Inline permission approval UI */} + {isPermissionPending && ( + + )}
-
+ ); }); diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index e23b2c8f64..0bc7fdc527 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -100,7 +100,7 @@ export const TaskLayout = memo(function TaskLayout({ }); const previewPanelIds = ['chat', 'preview']; - const previewLayoutKey = `task-layout-preview-v2:${sessionKey}`; + const previewLayoutKey = 'task-layout-preview-v2'; const { defaultLayout: defaultPreviewLayout, onLayoutChanged: onPreviewLayoutChange } = useDefaultLayout({ id: previewLayoutKey, diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx index 6c9eb6e0ba..246c7dd116 100644 --- a/apps/web/components/task/task-right-panel.tsx +++ b/apps/web/components/task/task-right-panel.tsx @@ -32,7 +32,7 @@ const DEFAULT_RIGHT_LAYOUT: Record = { top: 55, bottom: 45 }; const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null }: TaskRightPanelProps) { const rightPanelIds = ['top', 'bottom']; - const rightLayoutKey = `task-layout-right-v2:${sessionId ?? 'default'}`; + const rightLayoutKey = 'task-layout-right-v2'; const { defaultLayout: rightLayout, onLayoutChanged: onRightLayoutChange } = useDefaultLayout({ id: rightLayoutKey, panelIds: rightPanelIds,