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
+ {formattedOutput.content}
+
+ )}
- {/* Content */}
-
- {strValue}
-
- ) : (
- {strValue}
- )}
-
- {result}
-
-