diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index f0c4630494..6302a4562c 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -18,6 +18,7 @@ import ( "syscall" "time" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/server/api" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/instance" @@ -126,6 +127,10 @@ func run(cfg *config.Config, log *logger.Logger) { // Wait for shutdown signal waitForShutdown(log, func(ctx context.Context) { + // Flush pending traces before stopping instances + if err := shared.ShutdownTracing(ctx); err != nil { + log.Error("error shutting down tracing", zap.Error(err)) + } // Shutdown all instances if err := instMgr.Shutdown(ctx); err != nil { log.Error("error shutting down instance manager", zap.Error(err)) diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 67238a0312..4d072db3d6 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -20,6 +20,10 @@ require ( github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.11.1 github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 go.uber.org/zap v1.26.0 ) @@ -28,6 +32,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -47,6 +52,7 @@ require ( 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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -84,11 +90,9 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.uber.org/goleak v1.3.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.44.0 // indirect @@ -98,6 +102,7 @@ require ( golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index b1fc27aa8b..21352dcf8c 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -152,6 +152,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -570,6 +572,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index fcbb4193bb..4ce166eca5 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -124,7 +124,9 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe } // Create agentctl client pointing to the instance port - agentctlClient := agentctl.NewClient(containerIP, resp.Port, cm.logger) + agentctlClient := agentctl.NewClient(containerIP, resp.Port, cm.logger, + agentctl.WithExecutionID(config.InstanceID), + agentctl.WithSessionID(config.SessionID)) cm.logger.Info("docker container launched", zap.String("container_id", containerID), diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 0df30c6458..a276f1e666 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -75,6 +75,22 @@ type AgentStreamEventData struct { // AvailableCommands contains the slash commands available from the agent. // Populated when Type is "available_commands". AvailableCommands []streams.AvailableCommand `json:"available_commands,omitempty"` + + // ToolCallContents contains rich content produced by a tool call (diffs, text, terminals). + // Populated when Type is "tool_call" or "tool_update". + ToolCallContents []streams.ToolCallContentItem `json:"tool_call_contents,omitempty"` + + // ContentBlocks contains multimodal content blocks (images, audio, resource links). + // Populated when Type is "message_chunk" with non-text content. + ContentBlocks []streams.ContentBlock `json:"content_blocks,omitempty"` + + // Role distinguishes user vs assistant messages (e.g., during session/load replay). + // Populated when Type is "message_chunk" with role "user". + Role string `json:"role,omitempty"` + + // CurrentModeID is the active session mode identifier. + // Populated when Type is "session_mode". + CurrentModeID string `json:"current_mode_id,omitempty"` } // AgentStreamEventPayload is the payload for agent stream events (WebSocket streaming). @@ -305,3 +321,17 @@ type AvailableCommandsEventPayload struct { func (p AvailableCommandsEventPayload) GetSessionID() string { return p.SessionID } + +// SessionModeEventPayload is the payload for session mode change events. +type SessionModeEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + CurrentModeID string `json:"current_mode_id"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p SessionModeEventPayload) GetSessionID() string { + return p.SessionID +} diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 3d2362b5c6..89979323c8 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -144,6 +144,10 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even Data: event.Data, Normalized: event.NormalizedPayload, AvailableCommands: event.AvailableCommands, + ToolCallContents: event.ToolCallContents, + ContentBlocks: event.ContentBlocks, + Role: event.Role, + CurrentModeID: event.CurrentModeID, } // Build agent event message payload diff --git a/apps/backend/internal/agent/lifecycle/manager_boot_test.go b/apps/backend/internal/agent/lifecycle/manager_boot_test.go index 2f94794e2b..c7529966db 100644 --- a/apps/backend/internal/agent/lifecycle/manager_boot_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_boot_test.go @@ -74,7 +74,7 @@ func TestFinalizeBootMessage_Success(t *testing.T) { } stopCh := make(chan struct{}) - mgr.finalizeBootMessage(msg, stopCh, nil, "exited") + mgr.finalizeBootMessage(nil, msg, stopCh, nil, "exited") // Verify stop channel was closed select { @@ -112,7 +112,7 @@ func TestFinalizeBootMessage_Failed(t *testing.T) { } stopCh := make(chan struct{}) - mgr.finalizeBootMessage(msg, stopCh, nil, "failed") + mgr.finalizeBootMessage(nil, msg, stopCh, nil, "failed") lastMsg := bootSvc.getLastUpdatedMessage() if lastMsg == nil { @@ -134,7 +134,7 @@ func TestFinalizeBootMessage_NilMessage(t *testing.T) { mgr.bootMessageService = bootSvc // Should not panic with nil message - mgr.finalizeBootMessage(nil, nil, nil, "exited") + mgr.finalizeBootMessage(nil, nil, nil, nil, "exited") bootSvc.mu.Lock() defer bootSvc.mu.Unlock() @@ -153,7 +153,7 @@ func TestFinalizeBootMessage_NilService(t *testing.T) { } // Should not panic with nil service - mgr.finalizeBootMessage(msg, nil, nil, "exited") + mgr.finalizeBootMessage(nil, msg, nil, nil, "exited") } func TestBootMessage_IsResumingFlag(t *testing.T) { diff --git a/apps/backend/internal/agent/lifecycle/manager_events.go b/apps/backend/internal/agent/lifecycle/manager_events.go index 1511e4fd0d..e2fbd180c6 100644 --- a/apps/backend/internal/agent/lifecycle/manager_events.go +++ b/apps/backend/internal/agent/lifecycle/manager_events.go @@ -4,9 +4,11 @@ import ( "strings" "time" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/tracing" ) // handleMessageChunkEvent handles a "message_chunk" agent event, accumulating and flushing on newlines. @@ -107,6 +109,11 @@ func handleCompleteEventSignal(execution *AgentExecution, event *agentctl.AgentE if event.Error != "" { errorMsg = event.Error } + } else if event.Data != nil { + // Read StopReason from the complete event (set by ACP adapter from PromptResponse) + if sr, ok := event.Data["stop_reason"].(string); ok && sr != "" { + stopReason = sr + } } select { case execution.promptDoneCh <- PromptCompletionSignal{ @@ -140,6 +147,24 @@ func (m *Manager) handleCompleteEvent(execution *AgentExecution, event *agentctl } } + // Determine stop reason for tracing + stopReason := "end_turn" + if isError { + stopReason = "error" + } else if event.Data != nil { + if sr, ok := event.Data["stop_reason"].(string); ok && sr != "" { + stopReason = sr + } + } + + // Create a turn_end span on the session trace + _, turnSpan := tracing.TraceTurnEnd(execution.SessionTraceContext(), execution.ID, execution.SessionID) + turnSpan.SetAttributes( + attribute.String("stop_reason", stopReason), + attribute.Bool("is_error", isError), + ) + turnSpan.End() + m.logger.Info("agent turn complete", zap.String("execution_id", execution.ID), zap.String("operation_id", event.OperationID), diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index 2e0de72a26..4edd8d56ba 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/common/appctx" ) @@ -233,6 +234,15 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work execution.ACPSessionID = info.ACPSessionID } + // Create trace span for workspace-only execution + _, sessionSpan := tracing.TraceSessionStart( + context.Background(), taskID, info.SessionID, executionID, + ) + execution.SetSessionSpan(sessionSpan) + if execution.agentctl != nil { + execution.agentctl.SetTraceContext(execution.SessionTraceContext()) + } + m.executionStore.Add(execution) go m.waitForAgentctlReady(execution) diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 7a5ef02f48..1744a4b39d 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -48,22 +48,12 @@ func (m *Manager) CancelAgent(ctx context.Context, executionID string) error { return fmt.Errorf("failed to cancel agent: %w", err) } - // Clear streaming state after cancel to ensure clean state for next prompt - execution.messageMu.Lock() - execution.messageBuffer.Reset() - execution.thinkingBuffer.Reset() - execution.currentMessageID = "" - execution.currentThinkingID = "" - execution.messageMu.Unlock() + // Don't clear buffers or mark ready here. + // The agent will respond to the original prompt with StopReason=cancelled, + // which triggers handleCompleteEvent() to properly flush buffers and mark state. + // Clearing here would race with in-flight notifications and lose content. - // Mark as ready for follow-up prompts after successful cancel - if err := m.MarkReady(executionID); err != nil { - m.logger.Warn("failed to mark execution as ready after cancel", - zap.String("execution_id", executionID), - zap.Error(err)) - } - - m.logger.Info("agent turn cancelled successfully", + m.logger.Info("agent cancel sent, waiting for turn completion", zap.String("execution_id", executionID)) return nil @@ -110,6 +100,10 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool) now := time.Now() exec.FinishedAt = &now }) + + // End session trace span + execution.EndSessionSpan() + m.executionStore.Remove(executionID) m.logger.Info("agent stopped and removed from tracking", @@ -319,6 +313,9 @@ func (m *Manager) MarkCompleted(executionID string, exitCode int, errorMessage s } }) + // End session trace span + execution.EndSessionSpan() + m.logger.Info("execution completed", zap.String("execution_id", executionID), zap.Int("exit_code", exitCode), diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index e018b1b147..2abe49ab0c 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -318,7 +318,7 @@ func (m *Manager) createBootMessage(ctx context.Context, execution *AgentExecuti return nil, nil } bootStopCh := make(chan struct{}) - go m.pollAgentStderr(execution.agentctl, bootMsg, bootStopCh) + go m.pollAgentStderr(execution, execution.agentctl, bootMsg, bootStopCh) return bootMsg, bootStopCh } @@ -368,23 +368,23 @@ func (m *Manager) initializeAgentSession(ctx context.Context, execution *AgentEx agentConfig, err := m.getAgentConfigForExecution(execution) if err != nil { - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") + m.finalizeBootMessage(execution, bootMsg, bootStopCh, execution.agentctl, "failed") return fmt.Errorf("failed to get agent config: %w", err) } mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig) if err != nil { - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") + m.finalizeBootMessage(execution, bootMsg, bootStopCh, execution.agentctl, "failed") m.updateExecutionError(execution.ID, "failed to resolve MCP config: "+err.Error()) return fmt.Errorf("failed to resolve MCP config: %w", err) } if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil { - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, "failed") + m.finalizeBootMessage(execution, bootMsg, bootStopCh, execution.agentctl, "failed") m.updateExecutionError(execution.ID, "failed to initialize ACP: "+err.Error()) return fmt.Errorf("failed to initialize ACP: %w", err) } - m.finalizeBootMessage(bootMsg, bootStopCh, execution.agentctl, containerStateExited) + m.finalizeBootMessage(execution, bootMsg, bootStopCh, execution.agentctl, containerStateExited) return nil } diff --git a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go index bc98bf6844..87eedb6e1f 100644 --- a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agentctl/tracing" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -61,11 +62,27 @@ func (m *Manager) Start(ctx context.Context) error { standalonePort: ri.StandalonePort, promptDoneCh: make(chan PromptCompletionSignal, 1), } + // Create trace span for the recovered session + _, recoverySpan := tracing.TraceSessionRecovered( + context.Background(), execution.TaskID, execution.SessionID, execution.ID, + ) + execution.SetSessionSpan(recoverySpan) + if execution.agentctl != nil { + execution.agentctl.SetTraceContext(execution.SessionTraceContext()) + } + + // Create short-lived init span so recovery-phase operations are visible + _, initSpan := tracing.TraceSessionInit( + execution.SessionTraceContext(), execution.TaskID, execution.SessionID, execution.ID, + ) + m.executionStore.Add(execution) // Reconnect to workspace streams (shell, git, file changes) in background // This is needed so shell.input, git status, etc. work after backend restart go m.streamManager.ReconnectAll(execution) + + initSpan.End() } m.logger.Info("recovered executions", zap.Int("count", len(recovered))) } @@ -275,6 +292,9 @@ func (m *Manager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionI zap.String("session_id", sessionID), zap.String("execution_id", execution.ID)) + // End session trace span + execution.EndSessionSpan() + // Close agentctl connection if it exists if execution.agentctl != nil { execution.agentctl.Close() diff --git a/apps/backend/internal/agent/lifecycle/manager_startup.go b/apps/backend/internal/agent/lifecycle/manager_startup.go index efa6243ce0..17b8c3b61d 100644 --- a/apps/backend/internal/agent/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/lifecycle/manager_startup.go @@ -71,7 +71,7 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err } // pollAgentStderr polls the agent's stderr buffer every 2 seconds and updates the boot message. -func (m *Manager) pollAgentStderr(client *agentctl.Client, msg *models.Message, stopCh chan struct{}) { +func (m *Manager) pollAgentStderr(execution *AgentExecution, client *agentctl.Client, msg *models.Message, stopCh chan struct{}) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -82,7 +82,8 @@ func (m *Manager) pollAgentStderr(client *agentctl.Client, msg *models.Message, case <-stopCh: return case <-ticker.C: - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + baseCtx := execution.SessionTraceContext() + ctx, cancel := context.WithTimeout(baseCtx, 5*time.Second) lines, err := client.GetAgentStderr(ctx) cancel() if err != nil { @@ -106,7 +107,7 @@ func (m *Manager) pollAgentStderr(client *agentctl.Client, msg *models.Message, } // finalizeBootMessage stops the polling goroutine and updates the boot message with final status. -func (m *Manager) finalizeBootMessage(msg *models.Message, stopCh chan struct{}, client *agentctl.Client, status string) { +func (m *Manager) finalizeBootMessage(execution *AgentExecution, msg *models.Message, stopCh chan struct{}, client *agentctl.Client, status string) { if msg == nil || m.bootMessageService == nil { return } @@ -117,8 +118,9 @@ func (m *Manager) finalizeBootMessage(msg *models.Message, stopCh chan struct{}, } // Final stderr fetch - if client != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if client != nil && execution != nil { + baseCtx := execution.SessionTraceContext() + ctx, cancel := context.WithTimeout(baseCtx, 5*time.Second) lines, err := client.GetAgentStderr(ctx) cancel() if err == nil && len(lines) > 0 { diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go index 2a6f5cc2b8..0dc7715018 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_docker.go +++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go @@ -185,7 +185,9 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc continue } - client := agentctl.NewClient(containerIP, instanceInfo.Port, r.logger) + client := agentctl.NewClient(containerIP, instanceInfo.Port, r.logger, + agentctl.WithExecutionID(instanceID), + agentctl.WithSessionID(sessionID)) recovered = append(recovered, &RuntimeInstance{ InstanceID: instanceID, diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index 6c28fd3efb..4d0b424f4d 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -122,7 +122,9 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea } // Create agentctl client pointing to the instance port - client := agentctl.NewClient(r.host, resp.Port, r.logger) + client := agentctl.NewClient(r.host, resp.Port, r.logger, + agentctl.WithExecutionID(req.InstanceID), + agentctl.WithSessionID(req.SessionID)) // Extract runtime-specific values from metadata worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID) diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 7bd51438e1..b6fd237b8e 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -2,14 +2,17 @@ package lifecycle import ( "context" + "errors" "fmt" "strings" "time" + "github.com/coder/acp-go-sdk" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/tracing" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/appctx" "github.com/kandev/kandev/internal/common/constants" @@ -17,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/pkg/agent" v1 "github.com/kandev/kandev/pkg/api/v1" + "go.opentelemetry.io/otel/trace" ) // SessionManager handles ACP session initialization and management @@ -124,9 +128,9 @@ func (sm *SessionManager) createOrLoadSession( if rt.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", - // fall back to creating a new session. This handles agents that don't support session/load. - if strings.Contains(err.Error(), "Method not found") || + // If session/load fails because the agent doesn't support it, fall back to session/new. + // Check for: JSON-RPC -32601 (Method not found) or our own capability check string. + if isMethodNotFoundErr(err) || strings.Contains(err.Error(), "LoadSession capability is false") { sm.logger.Warn("agent does not support session loading, falling back to session/new", zap.String("agent_type", agentConfig.ID()), @@ -263,6 +267,21 @@ func (sm *SessionManager) InitializeAndPrompt( mcpServers []agentctltypes.McpServer, markReady func(executionID string) error, ) error { + // Create session-level trace span to group all operations under one trace + _, sessionSpan := tracing.TraceSessionStart( + context.Background(), execution.TaskID, execution.SessionID, execution.ID, + ) + execution.SetSessionSpan(sessionSpan) + ctx = trace.ContextWithSpan(ctx, sessionSpan) + if execution.agentctl != nil { + execution.agentctl.SetTraceContext(execution.SessionTraceContext()) + } + + // Create short-lived init span so the init phase is visible in trace backends + // (the parent session span won't be exported until the session ends) + ctx, initSpan := tracing.TraceSessionInit(ctx, execution.TaskID, execution.SessionID, execution.ID) + defer initSpan.End() + rt := agentConfig.Runtime() sm.logger.Info("initializing ACP session", zap.String("execution_id", execution.ID), @@ -464,6 +483,11 @@ func (sm *SessionManager) SendPrompt( return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID) } + // Inject session trace context so prompt spans become children of the session span + if sessionSpan := trace.SpanFromContext(execution.SessionTraceContext()); sessionSpan.SpanContext().IsValid() { + ctx = trace.ContextWithSpan(ctx, sessionSpan) + } + // For follow-up prompts, validate status and update to RUNNING if validateStatus { if execution.Status != v1.AgentStatusRunning && execution.Status != v1.AgentStatusReady { @@ -520,3 +544,15 @@ func (sm *SessionManager) SendPrompt( // Wait for completion signal from handleAgentEvent(complete) or stream disconnect. return sm.waitForPromptDone(ctx, execution) } + +// jsonRPCMethodNotFound is the JSON-RPC 2.0 error code for "Method not found". +const jsonRPCMethodNotFound = -32601 + +// isMethodNotFoundErr checks if an error wraps a JSON-RPC "Method not found" error. +func isMethodNotFoundErr(err error) bool { + var reqErr *acp.RequestError + if errors.As(err, &reqErr) { + return reqErr.Code == jsonRPCMethodNotFound + } + return false +} diff --git a/apps/backend/internal/agent/lifecycle/session_test.go b/apps/backend/internal/agent/lifecycle/session_test.go index 940e1fbb0d..6d89482125 100644 --- a/apps/backend/internal/agent/lifecycle/session_test.go +++ b/apps/backend/internal/agent/lifecycle/session_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/coder/acp-go-sdk" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agent/agents" agentctl "github.com/kandev/kandev/internal/agentctl/client" @@ -478,6 +479,49 @@ func TestInitializeSession_CreatesNewSession(t *testing.T) { } } +func TestIsMethodNotFoundErr(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "matching JSON-RPC method not found code", + err: fmt.Errorf("wrapped: %w", &acp.RequestError{Code: -32601, Message: "Method not found"}), + expected: true, + }, + { + name: "non-matching JSON-RPC code", + err: fmt.Errorf("wrapped: %w", &acp.RequestError{Code: -32600, Message: "Invalid Request"}), + expected: false, + }, + { + name: "non-RequestError error", + err: fmt.Errorf("some other error"), + expected: false, + }, + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "direct RequestError with method not found code", + err: &acp.RequestError{Code: -32601, Message: "Method not found"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isMethodNotFoundErr(tt.err) + if got != tt.expected { + t.Errorf("isMethodNotFoundErr(%v) = %v, want %v", tt.err, got, tt.expected) + } + }) + } +} + func TestInitializeSession_LoadsExistingSession(t *testing.T) { mock := newMockAgentServer(t) defer mock.Close() diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index 27e2607120..13cc76982e 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -90,7 +90,7 @@ func (sm *StreamManager) ReconnectAll(execution *AgentExecution) { // connectUpdatesStream handles the updates WebSocket stream with ready signaling func (sm *StreamManager) connectUpdatesStream(execution *AgentExecution, ready chan<- struct{}) { - ctx := context.Background() + ctx := execution.SessionTraceContext() err := execution.agentctl.StreamUpdates(ctx, func(event agentctl.AgentEvent) { if sm.callbacks.OnAgentEvent != nil { @@ -182,7 +182,7 @@ func (sm *StreamManager) buildWorkspaceCallbacks(execution *AgentExecution) agen // connectWorkspaceStream handles the unified workspace stream with retry logic func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution, ready chan<- struct{}) { - ctx := context.Background() + ctx := execution.SessionTraceContext() // Retry connection with exponential backoff maxRetries := 5 diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 24adcedfce..bdae5f00d6 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" + "go.opentelemetry.io/otel/trace" ) // Default agentctl port @@ -78,6 +79,10 @@ type AgentExecution struct { // Last time an agent event was received (for stall detection) lastActivityAt time.Time lastActivityAtMu sync.Mutex + + // Session-level trace span for grouping all operations under one trace + sessionSpan trace.Span + sessionSpanMu sync.RWMutex } // PromptCompletionSignal carries the result from a complete event or disconnect. @@ -120,6 +125,35 @@ func (ae *AgentExecution) GetAvailableCommands() []streams.AvailableCommand { return ae.availableCommands } +// SetSessionSpan stores the session-level trace span on the execution. +func (ae *AgentExecution) SetSessionSpan(span trace.Span) { + ae.sessionSpanMu.Lock() + defer ae.sessionSpanMu.Unlock() + ae.sessionSpan = span +} + +// SessionTraceContext returns a context carrying the session span for creating child spans. +// Uses context.Background() so the span lifetime is independent of request cancellation. +// Returns plain context.Background() when no session span is set (no-op safe). +func (ae *AgentExecution) SessionTraceContext() context.Context { + ae.sessionSpanMu.RLock() + defer ae.sessionSpanMu.RUnlock() + if ae.sessionSpan == nil { + return context.Background() + } + return trace.ContextWithSpan(context.Background(), ae.sessionSpan) +} + +// EndSessionSpan ends the session-level trace span if one exists. Idempotent. +func (ae *AgentExecution) EndSessionSpan() { + ae.sessionSpanMu.Lock() + defer ae.sessionSpanMu.Unlock() + if ae.sessionSpan != nil { + ae.sessionSpan.End() + ae.sessionSpan = nil + } +} + // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string diff --git a/apps/backend/internal/agentctl/client/agent.go b/apps/backend/internal/agentctl/client/agent.go index a9eed389b5..ce8c01d1e8 100644 --- a/apps/backend/internal/agentctl/client/agent.go +++ b/apps/backend/internal/agentctl/client/agent.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -275,6 +276,7 @@ func (c *Client) readUpdatesStream( continue } + tracing.TraceAgentEvent(ctx, event.Type, event.SessionID, c.executionID, message) handler(event) } } @@ -282,10 +284,16 @@ func (c *Client) readUpdatesStream( // dispatchMCPRequest handles an incoming MCP request message, dispatches it to the handler, // and writes the response back over the stream. func (c *Client) dispatchMCPRequest(ctx context.Context, msg ws.Message, mcpHandler MCPHandler, writeMessage func([]byte) error) { + ctx, span := tracing.TraceMCPDispatch(ctx, msg.Action, msg.ID, c.executionID) + defer span.End() + resp, err := mcpHandler.Dispatch(ctx, &msg) if err != nil { c.logger.Error("MCP dispatch error", zap.Error(err)) + tracing.TraceMCPResponse(span, err) resp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } else { + tracing.TraceMCPResponse(span, nil) } if resp != nil { data, err := json.Marshal(resp) diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index b9487ab563..f0271ed370 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" @@ -19,9 +20,15 @@ import ( // Client communicates with agentctl via HTTP and WebSocket type Client struct { - baseURL string - httpClient *http.Client - logger *logger.Logger + baseURL string + httpClient *http.Client + logger *logger.Logger + executionID string + sessionID string + + // Optional trace context for session-scoped spans in background goroutines. + // When set, stream read loops use this as parent context for tracing instead of context.Background(). + traceCtx context.Context // WebSocket connections for streaming agentStreamConn *websocket.Conn @@ -36,6 +43,42 @@ type Client struct { pendingMu sync.Mutex } +// ClientOption configures optional Client settings. +type ClientOption func(*Client) + +// WithExecutionID sets the execution ID used for tracing spans. +func WithExecutionID(id string) ClientOption { + return func(c *Client) { + c.executionID = id + } +} + +// WithSessionID sets the session ID used for tracing spans. +func WithSessionID(id string) ClientOption { + return func(c *Client) { + c.sessionID = id + } +} + +// SetTraceContext sets the trace context used as parent for spans created in +// background goroutines (stream read loops). Thread-safe: can be called after construction. +func (c *Client) SetTraceContext(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + c.traceCtx = ctx +} + +// getTraceCtx returns the trace context for background operations. +// Returns context.Background() when no trace context is set. +func (c *Client) getTraceCtx() context.Context { + c.mu.RLock() + defer c.mu.RUnlock() + if c.traceCtx != nil { + return c.traceCtx + } + return context.Background() +} + // StatusResponse from agentctl type StatusResponse struct { AgentStatus string `json:"agent_status"` @@ -49,8 +92,8 @@ func (s *StatusResponse) IsAgentRunning() bool { } // NewClient creates a new agentctl client -func NewClient(host string, port int, log *logger.Logger) *Client { - return &Client{ +func NewClient(host string, port int, log *logger.Logger, opts ...ClientOption) *Client { + c := &Client{ baseURL: fmt.Sprintf("http://%s:%d", host, port), httpClient: &http.Client{ Timeout: 60 * time.Second, @@ -58,6 +101,10 @@ func NewClient(host string, port int, log *logger.Logger) *Client { logger: log.WithFields(zap.String("component", "agentctl-client")), pendingRequests: make(map[string]chan *ws.Message), } + for _, opt := range opts { + opt(c) + } + return c } // Health checks if agentctl is healthy @@ -81,35 +128,49 @@ func (c *Client) Health(ctx context.Context) error { // GetStatus returns the agent status func (c *Client) GetStatus(ctx context.Context) (*StatusResponse, error) { + ctx, span := tracing.TraceHTTPRequest(ctx, "GET", "/api/v1/status", c.executionID) + defer span.End() + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/status", nil) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return nil, err } resp, err := c.httpClient.Do(req) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return nil, err } defer func() { _ = resp.Body.Close() }() respBody, err := readResponseBody(resp) if err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return nil, fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("status request failed with status %d: %s", resp.StatusCode, string(respBody)) + httpErr := fmt.Errorf("status request failed with status %d: %s", resp.StatusCode, string(respBody)) + tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr) + return nil, httpErr } var status StatusResponse if err := json.Unmarshal(respBody, &status); err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return nil, fmt.Errorf("failed to parse status response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } + + tracing.TraceHTTPResponse(span, resp.StatusCode, nil) return &status, nil } // ConfigureAgent configures the agent command and optional approval policy. Must be called before Start(). func (c *Client) ConfigureAgent(ctx context.Context, command string, env map[string]string, approvalPolicy string) error { + ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/agent/configure", c.executionID) + defer span.End() + payload := struct { Command string `json:"command"` Env map[string]string `json:"env,omitempty"` @@ -122,30 +183,34 @@ func (c *Client) ConfigureAgent(ctx context.Context, command string, env map[str body, err := json.Marshal(payload) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return err } req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/configure", bytes.NewReader(body)) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return err } defer func() { _ = resp.Body.Close() }() - // Read the response body for better error handling respBody, err := readResponseBody(resp) if err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return fmt.Errorf("failed to read response body: %w", err) } - // Check HTTP status code first if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("configure request failed with status %d: %s", resp.StatusCode, string(respBody)) + httpErr := fmt.Errorf("configure request failed with status %d: %s", resp.StatusCode, string(respBody)) + tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr) + return httpErr } var result struct { @@ -153,34 +218,47 @@ func (c *Client) ConfigureAgent(ctx context.Context, command string, env map[str Error string `json:"error,omitempty"` } if err := json.Unmarshal(respBody, &result); err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return fmt.Errorf("failed to parse configure response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { - return fmt.Errorf("configure failed: %s", result.Error) + cfgErr := fmt.Errorf("configure failed: %s", result.Error) + tracing.TraceHTTPResponse(span, resp.StatusCode, cfgErr) + return cfgErr } + + tracing.TraceHTTPResponse(span, resp.StatusCode, nil) return nil } // Start starts the agent process and returns the full command that was executed. func (c *Client) Start(ctx context.Context) (string, error) { + ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/start", c.executionID) + defer span.End() + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/start", nil) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return "", err } resp, err := c.httpClient.Do(req) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return "", err } defer func() { _ = resp.Body.Close() }() respBody, err := readResponseBody(resp) if err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return "", fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("start request failed with status %d: %s", resp.StatusCode, string(respBody)) + httpErr := fmt.Errorf("start request failed with status %d: %s", resp.StatusCode, string(respBody)) + tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr) + return "", httpErr } var result struct { @@ -189,34 +267,47 @@ func (c *Client) Start(ctx context.Context) (string, error) { Error string `json:"error,omitempty"` } if err := json.Unmarshal(respBody, &result); err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return "", fmt.Errorf("failed to parse start response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { - return "", fmt.Errorf("start failed: %s", result.Error) + startErr := fmt.Errorf("start failed: %s", result.Error) + tracing.TraceHTTPResponse(span, resp.StatusCode, startErr) + return "", startErr } + + tracing.TraceHTTPResponse(span, resp.StatusCode, nil) return result.Command, nil } // Stop stops the agent process func (c *Client) Stop(ctx context.Context) error { + ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/stop", c.executionID) + defer span.End() + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/stop", nil) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return err } resp, err := c.httpClient.Do(req) if err != nil { + tracing.TraceHTTPResponse(span, 0, err) return err } defer func() { _ = resp.Body.Close() }() respBody, err := readResponseBody(resp) if err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("stop request failed with status %d: %s", resp.StatusCode, string(respBody)) + httpErr := fmt.Errorf("stop request failed with status %d: %s", resp.StatusCode, string(respBody)) + tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr) + return httpErr } var result struct { @@ -224,11 +315,16 @@ func (c *Client) Stop(ctx context.Context) error { Error string `json:"error,omitempty"` } if err := json.Unmarshal(respBody, &result); err != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, err) return fmt.Errorf("failed to parse stop response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } if !result.Success { - return fmt.Errorf("stop failed: %s", result.Error) + stopErr := fmt.Errorf("stop failed: %s", result.Error) + tracing.TraceHTTPResponse(span, resp.StatusCode, stopErr) + return stopErr } + + tracing.TraceHTTPResponse(span, resp.StatusCode, nil) return nil } diff --git a/apps/backend/internal/agentctl/client/client_stream.go b/apps/backend/internal/agentctl/client/client_stream.go index 531a3347a2..e10bc6a12c 100644 --- a/apps/backend/internal/agentctl/client/client_stream.go +++ b/apps/backend/internal/agentctl/client/client_stream.go @@ -7,7 +7,10 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/tracing" ws "github.com/kandev/kandev/pkg/websocket" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" ) // sendStreamRequest sends a request over the agent WebSocket stream and waits for a response. @@ -23,11 +26,20 @@ func (c *Client) sendStreamRequest(ctx context.Context, action string, payload i } reqID := uuid.New().String() + + // Start tracing span for the request/response round-trip + ctx, span := tracing.TraceWSRequest(ctx, action, reqID, c.executionID, c.sessionID) + defer span.End() + msg, err := ws.NewRequest(reqID, action, payload) if err != nil { + tracing.TraceWSResponse(span, "", err) return nil, fmt.Errorf("failed to create request message: %w", err) } + // Inject trace context (traceparent) into WS message metadata for cross-process propagation + otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier(msg.EnsureMetadata())) + // Register pending request respCh := make(chan *ws.Message, 1) c.pendingMu.Lock() @@ -44,6 +56,7 @@ func (c *Client) sendStreamRequest(ctx context.Context, action string, payload i // Serialize write to stream data, err := json.Marshal(msg) if err != nil { + tracing.TraceWSResponse(span, "", err) return nil, fmt.Errorf("failed to marshal request: %w", err) } @@ -51,6 +64,7 @@ func (c *Client) sendStreamRequest(ctx context.Context, action string, payload i writeErr := conn.WriteMessage(websocket.TextMessage, data) c.streamWriteMu.Unlock() if writeErr != nil { + tracing.TraceWSResponse(span, "", writeErr) return nil, fmt.Errorf("failed to write request to stream: %w", writeErr) } @@ -58,10 +72,14 @@ func (c *Client) sendStreamRequest(ctx context.Context, action string, payload i select { case resp := <-respCh: if resp == nil { - return nil, fmt.Errorf("agent stream disconnected while waiting for response") + disconnErr := fmt.Errorf("agent stream disconnected while waiting for response") + tracing.TraceWSResponse(span, "", disconnErr) + return nil, disconnErr } + tracing.TraceWSResponse(span, string(resp.Type), nil) return resp, nil case <-ctx.Done(): + tracing.TraceWSResponse(span, "", ctx.Err()) return nil, ctx.Err() } } diff --git a/apps/backend/internal/agentctl/client/workspace_stream.go b/apps/backend/internal/agentctl/client/workspace_stream.go index 960acbd647..68def5dc28 100644 --- a/apps/backend/internal/agentctl/client/workspace_stream.go +++ b/apps/backend/internal/agentctl/client/workspace_stream.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -68,6 +69,18 @@ func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamC return stream, nil } +// workspaceTracedTypes contains message types that are low-volume and worth tracing. +// High-volume types (shell_output, process_output, shell_input, ping, pong) are excluded. +var workspaceTracedTypes = map[types.WorkspaceMessageType]bool{ + types.WorkspaceMessageTypeGitStatus: true, + types.WorkspaceMessageTypeGitCommit: true, + types.WorkspaceMessageTypeGitReset: true, + types.WorkspaceMessageTypeFileChange: true, + types.WorkspaceMessageTypeProcessStatus: true, + types.WorkspaceMessageTypeConnected: true, + types.WorkspaceMessageTypeError: true, +} + // readWorkspaceStream is the read loop for the workspace WebSocket stream. func (c *Client) readWorkspaceStream(conn *websocket.Conn, stream *WorkspaceStream, callbacks WorkspaceStreamCallbacks) { defer func() { @@ -85,6 +98,9 @@ func (c *Client) readWorkspaceStream(conn *websocket.Conn, stream *WorkspaceStre } return } + if workspaceTracedTypes[msg.Type] { + tracing.TraceWorkspaceEvent(c.getTraceCtx(), string(msg.Type), c.executionID, c.sessionID) + } dispatchWorkspaceMessage(msg, callbacks) } } diff --git a/apps/backend/internal/agentctl/server/acp/client.go b/apps/backend/internal/agentctl/server/acp/client.go index ed5c635fab..272d120396 100644 --- a/apps/backend/internal/agentctl/server/acp/client.go +++ b/apps/backend/internal/agentctl/server/acp/client.go @@ -10,7 +10,9 @@ import ( "sync" "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -92,10 +94,18 @@ func (c *Client) SetPermissionHandler(h PermissionRequestHandler) { // If a permission handler is set, it forwards the request to the handler. // Otherwise, auto-approves by selecting the first "allow" option. func (c *Client) RequestPermission(ctx context.Context, p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.permission") + defer span.End() + title := "" if p.ToolCall.Title != nil { title = *p.ToolCall.Title } + span.SetAttributes( + attribute.String("tool_call_id", string(p.ToolCall.ToolCallId)), + attribute.Int("options_count", len(p.Options)), + ) + c.logger.Info("received permission request", zap.String("session_id", string(p.SessionId)), zap.String("tool_call_id", string(p.ToolCall.ToolCallId)), @@ -261,18 +271,40 @@ func (c *Client) SessionUpdate(ctx context.Context, n acp.SessionNotification) e return nil } -// ReadTextFile reads a text file from the workspace +// resolvePath resolves a file path, making relative paths relative to the workspace root. +// It validates that the resolved path stays within the workspace root to prevent path traversal. +func (c *Client) resolvePath(reqPath string) (string, error) { + var resolved string + if filepath.IsAbs(reqPath) { + resolved = filepath.Clean(reqPath) + } else { + resolved = filepath.Join(c.workspaceRoot, reqPath) + } + // Ensure the resolved path is within the workspace root to prevent path traversal + root := filepath.Clean(c.workspaceRoot) + string(filepath.Separator) + if resolved != filepath.Clean(c.workspaceRoot) && !strings.HasPrefix(resolved, root) { + return "", fmt.Errorf("path %q resolves outside workspace root %q", reqPath, c.workspaceRoot) + } + return resolved, nil +} + +// ReadTextFile reads a text file func (c *Client) ReadTextFile(ctx context.Context, p acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.read_file") + defer span.End() + span.SetAttributes(attribute.String("path", p.Path)) + c.logger.Debug("reading file", zap.String("path", p.Path)) - // Validate path is absolute - if !filepath.IsAbs(p.Path) { - return acp.ReadTextFileResponse{}, fmt.Errorf("path must be absolute: %s", p.Path) + filePath, err := c.resolvePath(p.Path) + if err != nil { + span.RecordError(err) + return acp.ReadTextFileResponse{}, err } - // Read the file - b, err := os.ReadFile(p.Path) + b, err := os.ReadFile(filePath) if err != nil { + span.RecordError(err) return acp.ReadTextFileResponse{}, err } @@ -295,55 +327,88 @@ func (c *Client) ReadTextFile(ctx context.Context, p acp.ReadTextFileRequest) (a content = strings.Join(lines[start:end], "\n") } + span.SetAttributes(attribute.Int("content_length", len(content))) return acp.ReadTextFileResponse{Content: content}, nil } -// WriteTextFile writes a text file to the workspace +// WriteTextFile writes a text file func (c *Client) WriteTextFile(ctx context.Context, p acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.write_file") + defer span.End() + span.SetAttributes( + attribute.String("path", p.Path), + attribute.Int("content_length", len(p.Content)), + ) + c.logger.Debug("writing file", zap.String("path", p.Path)) - // Validate path is absolute - if !filepath.IsAbs(p.Path) { - return acp.WriteTextFileResponse{}, fmt.Errorf("path must be absolute: %s", p.Path) + filePath, err := c.resolvePath(p.Path) + if err != nil { + span.RecordError(err) + return acp.WriteTextFileResponse{}, err } // Create directory if needed - if dir := filepath.Dir(p.Path); dir != "" { + if dir := filepath.Dir(filePath); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { + span.RecordError(err) return acp.WriteTextFileResponse{}, err } } - return acp.WriteTextFileResponse{}, os.WriteFile(p.Path, []byte(p.Content), 0o644) + err = os.WriteFile(filePath, []byte(p.Content), 0o644) + if err != nil { + span.RecordError(err) + } + return acp.WriteTextFileResponse{}, err } // CreateTerminal creates a new terminal func (c *Client) CreateTerminal(ctx context.Context, p acp.CreateTerminalRequest) (acp.CreateTerminalResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.create_terminal") + defer span.End() + span.SetAttributes(attribute.String("command", p.Command)) + c.logger.Debug("create terminal request", zap.String("command", p.Command)) - // For now, return a dummy terminal ID - we can implement real terminal support later return acp.CreateTerminalResponse{TerminalId: "t-1"}, nil } // KillTerminalCommand kills a terminal command func (c *Client) KillTerminalCommand(ctx context.Context, p acp.KillTerminalCommandRequest) (acp.KillTerminalCommandResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.kill_terminal_command") + defer span.End() + span.SetAttributes(attribute.String("terminal_id", p.TerminalId)) + c.logger.Debug("kill terminal request", zap.String("terminal_id", p.TerminalId)) return acp.KillTerminalCommandResponse{}, nil } // TerminalOutput gets terminal output func (c *Client) TerminalOutput(ctx context.Context, p acp.TerminalOutputRequest) (acp.TerminalOutputResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.terminal_output") + defer span.End() + span.SetAttributes(attribute.String("terminal_id", p.TerminalId)) + c.logger.Debug("terminal output request", zap.String("terminal_id", p.TerminalId)) return acp.TerminalOutputResponse{Output: "ok", Truncated: false}, nil } // ReleaseTerminal releases a terminal func (c *Client) ReleaseTerminal(ctx context.Context, p acp.ReleaseTerminalRequest) (acp.ReleaseTerminalResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.release_terminal") + defer span.End() + span.SetAttributes(attribute.String("terminal_id", p.TerminalId)) + c.logger.Debug("release terminal request", zap.String("terminal_id", p.TerminalId)) return acp.ReleaseTerminalResponse{}, nil } // WaitForTerminalExit waits for terminal to exit func (c *Client) WaitForTerminalExit(ctx context.Context, p acp.WaitForTerminalExitRequest) (acp.WaitForTerminalExitResponse, error) { + _, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, "", "request.wait_for_terminal_exit") + defer span.End() + span.SetAttributes(attribute.String("terminal_id", p.TerminalId)) + c.logger.Debug("wait for terminal exit request", zap.String("terminal_id", p.TerminalId)) exitCode := 0 return acp.WaitForTerminalExitResponse{ExitCode: &exitCode}, nil diff --git a/apps/backend/internal/agentctl/server/acp/client_test.go b/apps/backend/internal/agentctl/server/acp/client_test.go new file mode 100644 index 0000000000..92fc24bcc3 --- /dev/null +++ b/apps/backend/internal/agentctl/server/acp/client_test.go @@ -0,0 +1,82 @@ +package acp + +import ( + "path/filepath" + "testing" +) + +func TestResolvePath(t *testing.T) { + client := NewClient(WithWorkspaceRoot("/workspace/project")) + + tests := []struct { + name string + input string + expected string + expectErr bool + }{ + { + name: "absolute path within workspace", + input: "/workspace/project/src/main.go", + expected: "/workspace/project/src/main.go", + }, + { + name: "relative path resolves within workspace", + input: "src/main.go", + expected: filepath.Join("/workspace/project", "src/main.go"), + }, + { + name: "workspace root itself is allowed", + input: "/workspace/project", + expected: "/workspace/project", + }, + { + name: "dot path resolves to workspace root", + input: ".", + expected: "/workspace/project", + }, + { + name: "path traversal with relative path is rejected", + input: "../../etc/passwd", + expectErr: true, + }, + { + name: "path traversal with dot-dot in middle is rejected", + input: "src/../../etc/passwd", + expectErr: true, + }, + { + name: "absolute path outside workspace is rejected", + input: "/etc/passwd", + expectErr: true, + }, + { + name: "absolute path with parent traversal is rejected", + input: "/workspace/project/../../../etc/passwd", + expectErr: true, + }, + { + name: "nested relative path within workspace", + input: "src/pkg/handler.go", + expected: filepath.Join("/workspace/project", "src/pkg/handler.go"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := client.resolvePath(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("resolvePath(%q) expected error, got path %q", tt.input, got) + } + return + } + if err != nil { + t.Errorf("resolvePath(%q) unexpected error: %v", tt.input, err) + return + } + if got != tt.expected { + t.Errorf("resolvePath(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go index b5fd18fe05..cfb5b67208 100644 --- a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go @@ -73,6 +73,10 @@ type AmpAdapter struct { // (to avoid duplicates from both stop_reason=end_turn and result message) completeSent bool + // lastRawData holds the raw JSON of the current message being processed. + // Set in handleMessage before dispatch; used by sendUpdate for OTel tracing. + lastRawData json.RawMessage + // Synchronization mu sync.RWMutex closed bool @@ -300,6 +304,8 @@ func (a *AmpAdapter) Close() error { // sendUpdate safely sends an event to the updates channel. func (a *AmpAdapter) sendUpdate(update AgentEvent) { shared.LogNormalizedEvent(shared.ProtocolAmp, a.cfg.AgentID, &update) + shared.TraceProtocolEvent(context.Background(), shared.ProtocolAmp, a.cfg.AgentID, + update.Type, a.lastRawData, &update) select { case a.updatesCh <- update: default: @@ -309,9 +315,10 @@ func (a *AmpAdapter) sendUpdate(update AgentEvent) { // 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) + // Marshal once for debug logging and tracing + a.lastRawData, _ = json.Marshal(msg) + if len(a.lastRawData) > 0 { + shared.LogRawEvent(shared.ProtocolAmp, a.cfg.AgentID, msg.Type, a.lastRawData) } a.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go index a0fdf9b869..2066241122 100644 --- a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go @@ -78,6 +78,10 @@ type CopilotAdapter struct { // (to avoid double-accumulating from both delta and full message events) receivedDeltas bool + // lastRawData holds the raw JSON of the current event being processed. + // Set in handleEvent before dispatch; used by sendUpdate for OTel tracing. + lastRawData json.RawMessage + // Synchronization mu sync.RWMutex closed bool @@ -484,6 +488,8 @@ func (a *CopilotAdapter) Close() error { // sendUpdate safely sends an event to the updates channel. func (a *CopilotAdapter) sendUpdate(update AgentEvent) { shared.LogNormalizedEvent(shared.ProtocolCopilot, a.cfg.AgentID, &update) + shared.TraceProtocolEvent(context.Background(), shared.ProtocolCopilot, a.cfg.AgentID, + update.Type, a.lastRawData, &update) select { case a.updatesCh <- update: default: @@ -493,9 +499,10 @@ func (a *CopilotAdapter) sendUpdate(update AgentEvent) { // 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) + // Marshal once for debug logging and tracing + a.lastRawData, _ = json.Marshal(evt) + if len(a.lastRawData) > 0 { + shared.LogRawEvent(shared.ProtocolCopilot, a.cfg.AgentID, string(evt.Type), a.lastRawData) } a.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 6b7d046d33..6a87e559ca 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" v1 "github.com/kandev/kandev/pkg/api/v1" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -77,6 +78,11 @@ type Adapter struct { // Maps toolCallId -> NormalizedPayload so we can update with results activeToolCalls map[string]*streams.NormalizedPayload + // OTel tracing: active prompt span context. + // Notification spans become children of the prompt span for visual grouping. + promptTraceCtx context.Context + promptTraceMu sync.RWMutex + // Synchronization mu sync.RWMutex closed bool @@ -141,6 +147,9 @@ func (a *Adapter) Initialize(ctx context.Context) error { a.acpConn.SetLogger(slog.Default().With("component", "acp-conn")) // Perform ACP handshake - this exchanges capabilities with the agent + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "initialize") + defer span.End() + resp, err := a.acpConn.Initialize(ctx, acp.InitializeRequest{ ProtocolVersion: acp.ProtocolVersionNumber, ClientInfo: &acp.Implementation{ @@ -149,6 +158,7 @@ func (a *Adapter) Initialize(ctx context.Context) error { }, }) if err != nil { + span.RecordError(err) return fmt.Errorf("ACP initialize handshake failed: %w", err) } @@ -162,6 +172,13 @@ func (a *Adapter) Initialize(ctx context.Context) error { a.agentInfo.Version = resp.AgentInfo.Version } a.capabilities = resp.AgentCapabilities + + span.SetAttributes( + attribute.String("agent_name", a.agentInfo.Name), + attribute.String("agent_version", a.agentInfo.Version), + attribute.Bool("supports_load_session", a.capabilities.LoadSession), + ) + a.logger.Info("ACP adapter initialized", zap.String("agent_name", a.agentInfo.Name), zap.String("agent_version", a.agentInfo.Version), @@ -178,40 +195,51 @@ func (a *Adapter) GetAgentInfo() *AgentInfo { // NewSession creates a new agent session. func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) { a.mu.Lock() - defer a.mu.Unlock() + conn := a.acpConn + a.mu.Unlock() - if a.acpConn == nil { + if conn == nil { return "", fmt.Errorf("adapter not initialized") } - resp, err := a.acpConn.NewSession(ctx, acp.NewSessionRequest{ + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.new") + defer span.End() + + resp, err := conn.NewSession(ctx, acp.NewSessionRequest{ Cwd: a.cfg.WorkDir, McpServers: toACPMcpServers(mcpServers), }) if err != nil { + span.RecordError(err) return "", fmt.Errorf("failed to create session: %w", err) } + a.mu.Lock() a.sessionID = string(resp.SessionId) - a.logger.Info("created new session", zap.String("session_id", a.sessionID)) + sessionID := a.sessionID + a.mu.Unlock() + + span.SetAttributes(attribute.String("session_id", sessionID)) + a.logger.Info("created new session", zap.String("session_id", sessionID)) + + // Emit initial session mode if the agent returned mode state + if resp.Modes != nil { + a.emitInitialModeState(resp.Modes) + } // Emit session status event to normalize with other adapters. // This eliminates the need for ReportsStatusViaStream flag. - select { - case a.updatesCh <- AgentEvent{ + a.sendUpdate(AgentEvent{ Type: streams.EventTypeSessionStatus, - SessionID: a.sessionID, + SessionID: sessionID, SessionStatus: streams.SessionStatusNew, Data: map[string]any{ "session_status": streams.SessionStatusNew, "init": true, }, - }: - default: - a.logger.Warn("updates channel full, could not emit session_status event") - } + }) - return a.sessionID, nil + return sessionID, nil } func toACPMcpServers(servers []types.McpServer) []acp.McpServer { @@ -247,42 +275,55 @@ func toACPMcpServers(servers []types.McpServer) []acp.McpServer { // Returns an error if the agent does not support session loading (LoadSession capability). func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { a.mu.Lock() - defer a.mu.Unlock() + conn := a.acpConn + supportsLoad := a.capabilities.LoadSession + a.mu.Unlock() - if a.acpConn == nil { + if conn == nil { return fmt.Errorf("adapter not initialized") } // Check if the agent supports session loading - if !a.capabilities.LoadSession { + if !supportsLoad { return fmt.Errorf("agent does not support session loading (LoadSession capability is false)") } - _, err := a.acpConn.LoadSession(ctx, acp.LoadSessionRequest{ - SessionId: acp.SessionId(sessionID), + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.load") + defer span.End() + + resp, err := conn.LoadSession(ctx, acp.LoadSessionRequest{ + SessionId: acp.SessionId(sessionID), + Cwd: a.cfg.WorkDir, + McpServers: []acp.McpServer{}, // MCPs configured via CLI args; empty satisfies the required field }) if err != nil { + span.RecordError(err) return fmt.Errorf("failed to load session: %w", err) } + a.mu.Lock() a.sessionID = sessionID - a.logger.Info("loaded session", zap.String("session_id", a.sessionID)) + a.mu.Unlock() + + span.SetAttributes(attribute.String("session_id", sessionID)) + a.logger.Info("loaded session", zap.String("session_id", sessionID)) + + // Emit initial session mode if the agent returned mode state + if resp.Modes != nil { + a.emitInitialModeState(resp.Modes) + } // Emit session status event to normalize with other adapters. // This eliminates the need for ReportsStatusViaStream flag. - select { - case a.updatesCh <- AgentEvent{ + a.sendUpdate(AgentEvent{ Type: streams.EventTypeSessionStatus, - SessionID: a.sessionID, + SessionID: sessionID, SessionStatus: streams.SessionStatusResumed, Data: map[string]any{ "session_status": streams.SessionStatusResumed, "init": true, }, - }: - default: - a.logger.Warn("updates channel full, could not emit session_status event") - } + }) return nil } @@ -322,31 +363,50 @@ func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.M } } + // Start prompt span — notification spans become children via getPromptTraceCtx() + promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "prompt") + promptSpan.SetAttributes( + attribute.String("session_id", sessionID), + attribute.Int("prompt_length", len(finalMessage)), + attribute.Int("image_count", len(attachments)), + ) + a.setPromptTraceCtx(promptCtx) + a.logger.Info("sending prompt", zap.String("session_id", sessionID), zap.Int("content_blocks", len(contentBlocks)), zap.Int("image_attachments", len(attachments))) - _, err := conn.Prompt(ctx, acp.PromptRequest{ + resp, err := conn.Prompt(promptCtx, acp.PromptRequest{ SessionId: acp.SessionId(sessionID), Prompt: contentBlocks, }) + + // Clear prompt context and end span regardless of outcome + a.clearPromptTraceCtx() + stopReason := "" + if err != nil { + promptSpan.RecordError(err) + } else { + stopReason = string(resp.StopReason) + promptSpan.SetAttributes(attribute.String("stop_reason", stopReason)) + } + promptSpan.End() + if err != nil { return err } - // Emit complete event via the stream. + // Emit complete event via the stream, including the StopReason from the agent. // This normalizes ACP behavior to match other adapters (stream-json, amp, copilot, opencode). - // All adapters now emit complete events, eliminating the need for ReportsStatusViaStream flag. - a.logger.Debug("emitting complete event after prompt", zap.String("session_id", sessionID)) - select { - case a.updatesCh <- AgentEvent{ + a.logger.Debug("emitting complete event after prompt", + zap.String("session_id", sessionID), + zap.String("stop_reason", stopReason)) + a.sendUpdate(AgentEvent{ Type: streams.EventTypeComplete, SessionID: sessionID, - }: - default: - a.logger.Warn("updates channel full, could not emit complete event") - } + Data: map[string]any{"stop_reason": stopReason}, + }) return nil } @@ -361,6 +421,7 @@ func (a *Adapter) SetPendingContext(context string) { } // Cancel cancels the current operation. +// Per ACP spec, the client must immediately mark non-finished tool calls as cancelled. func (a *Adapter) Cancel(ctx context.Context) error { a.mu.RLock() conn := a.acpConn @@ -371,11 +432,44 @@ func (a *Adapter) Cancel(ctx context.Context) error { return fmt.Errorf("adapter not initialized") } + ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "cancel") + defer span.End() + span.SetAttributes(attribute.String("session_id", sessionID)) + a.logger.Info("cancelling session", zap.String("session_id", sessionID)) - return conn.Cancel(ctx, acp.CancelNotification{ + // Mark all active tool calls as cancelled before sending cancel to agent. + a.cancelActiveToolCalls(sessionID) + + err := conn.Cancel(ctx, acp.CancelNotification{ SessionId: acp.SessionId(sessionID), }) + if err != nil { + span.RecordError(err) + } + return err +} + +// cancelActiveToolCalls emits cancelled tool_update events for all in-flight tool calls +// and clears the activeToolCalls map. +func (a *Adapter) cancelActiveToolCalls(sessionID string) { + a.mu.Lock() + toolCalls := a.activeToolCalls + a.activeToolCalls = make(map[string]*streams.NormalizedPayload) + a.mu.Unlock() + + for toolCallID, normalized := range toolCalls { + a.logger.Debug("cancelling active tool call", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID)) + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + ToolCallID: toolCallID, + ToolStatus: "cancelled", + NormalizedPayload: normalized, + }) + } } // Updates returns the channel for agent events. @@ -404,6 +498,21 @@ func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { a.permissionHandler = handler } +// sendUpdate safely sends an event to the updates channel. +// It checks the closed flag under read-lock to prevent panics on closed channels. +func (a *Adapter) sendUpdate(event AgentEvent) { + a.mu.RLock() + defer a.mu.RUnlock() + if a.closed { + return + } + select { + case a.updatesCh <- event: + default: + a.logger.Warn("updates channel full, dropping event", zap.String("type", event.Type)) + } +} + // Close releases resources held by the adapter. func (a *Adapter) Close() error { a.mu.Lock() @@ -430,6 +539,31 @@ func (a *Adapter) RequiresProcessKill() bool { return false } +// getPromptTraceCtx returns the current prompt span context for child-span linking. +// Returns context.Background() if no prompt is active. +func (a *Adapter) getPromptTraceCtx() context.Context { + a.promptTraceMu.RLock() + defer a.promptTraceMu.RUnlock() + if a.promptTraceCtx != nil { + return a.promptTraceCtx + } + return context.Background() +} + +// setPromptTraceCtx stores the prompt span context. +func (a *Adapter) setPromptTraceCtx(ctx context.Context) { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = ctx +} + +// clearPromptTraceCtx clears the prompt span context. +func (a *Adapter) clearPromptTraceCtx() { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = nil +} + // GetACPConnection returns the underlying ACP connection for advanced usage. func (a *Adapter) GetACPConnection() *acp.ClientSideConnection { a.mu.RLock() @@ -439,19 +573,24 @@ func (a *Adapter) GetACPConnection() *acp.ClientSideConnection { // handleACPUpdate converts ACP SessionNotification to protocol-agnostic AgentEvent. func (a *Adapter) handleACPUpdate(n acp.SessionNotification) { + // Marshal once for both debug logging and tracing + rawData, _ := json.Marshal(n) + // Log raw event for debugging - if rawData, err := json.Marshal(n); err == nil { + if len(rawData) > 0 { shared.LogRawEvent(shared.ProtocolACP, a.agentID, "session_notification", rawData) } event := a.convertNotification(n) if event != nil { shared.LogNormalizedEvent(shared.ProtocolACP, a.agentID, event) - select { - case a.updatesCh <- *event: - default: - a.logger.Warn("updates channel full, dropping notification") - } + shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolACP, a.agentID, + event.Type, rawData, event) + a.sendUpdate(*event) + } else if updateJSON, err := json.Marshal(n.Update); err == nil { + a.logger.Warn("unhandled ACP session notification", + zap.String("session_id", string(n.SessionId)), + zap.String("update_json", string(updateJSON))) } } @@ -462,17 +601,12 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { switch { case u.AgentMessageChunk != nil: - if u.AgentMessageChunk.Content.Text != nil { - return &AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - Text: u.AgentMessageChunk.Content.Text.Text, - } - } + return a.convertMessageChunk(sessionID, u.AgentMessageChunk.Content, "assistant") + + case u.UserMessageChunk != nil: + return a.convertMessageChunk(sessionID, u.UserMessageChunk.Content, "user") case u.AgentThoughtChunk != nil: - // Agent thinking/reasoning - map to the reasoning type - // Note: Only models with extended thinking (e.g., Opus 4.5) send agent_thought_chunk if u.AgentThoughtChunk.Content.Text != nil { return &AgentEvent{ Type: streams.EventTypeReasoning, @@ -503,23 +637,157 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { } case u.AvailableCommandsUpdate != nil: - commands := make([]streams.AvailableCommand, len(u.AvailableCommandsUpdate.AvailableCommands)) - for i, cmd := range u.AvailableCommandsUpdate.AvailableCommands { - commands[i] = streams.AvailableCommand{ - Name: cmd.Name, - Description: cmd.Description, - } - } + return a.convertAvailableCommands(sessionID, u.AvailableCommandsUpdate) + + case u.CurrentModeUpdate != nil: return &AgentEvent{ - Type: streams.EventTypeAvailableCommands, - SessionID: sessionID, - AvailableCommands: commands, + Type: streams.EventTypeSessionMode, + SessionID: sessionID, + CurrentModeID: string(u.CurrentModeUpdate.CurrentModeId), } } return nil } +// convertMessageChunk converts an ACP ContentBlock to an AgentEvent, handling multimodal content. +// For text-only messages, sets the Text field for backward compatibility. +// For non-text content, populates ContentBlocks. +func (a *Adapter) convertMessageChunk(sessionID string, content acp.ContentBlock, role string) *AgentEvent { + event := &AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: sessionID, + } + + // Only set Role for user messages (assistant is the default) + if role == "user" { + event.Role = role + } + + // Text content goes directly into the Text field for backward compatibility + if content.Text != nil { + event.Text = content.Text.Text + return event + } + + // Non-text content uses the shared converter + cb := a.convertContentBlockToStreams(content) + if cb == nil { + return nil + } + event.ContentBlocks = []streams.ContentBlock{*cb} + return event +} + +// convertAvailableCommands converts an ACP AvailableCommandsUpdate to an AgentEvent, +// including input hints when available. +func (a *Adapter) convertAvailableCommands(sessionID string, update *acp.SessionAvailableCommandsUpdate) *AgentEvent { + commands := make([]streams.AvailableCommand, len(update.AvailableCommands)) + for i, cmd := range update.AvailableCommands { + ac := streams.AvailableCommand{ + Name: cmd.Name, + Description: cmd.Description, + } + if cmd.Input != nil && cmd.Input.UnstructuredCommandInput != nil { + ac.InputHint = cmd.Input.UnstructuredCommandInput.Hint + } + commands[i] = ac + } + return &AgentEvent{ + Type: streams.EventTypeAvailableCommands, + SessionID: sessionID, + AvailableCommands: commands, + } +} + +// emitInitialModeState emits a session_mode event from the session response's Modes field. +// Called after session/new and session/load to provide the initial mode state. +func (a *Adapter) emitInitialModeState(modes *acp.SessionModeState) { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeSessionMode, + SessionID: a.sessionID, + CurrentModeID: string(modes.CurrentModeId), + }) +} + +// derefStr safely dereferences a string pointer, returning empty string if nil. +func derefStr(s *string) string { + if s != nil { + return *s + } + return "" +} + +// convertToolCallContents converts ACP ToolCallContent items to our protocol-agnostic type. +func (a *Adapter) convertToolCallContents(contents []acp.ToolCallContent) []streams.ToolCallContentItem { + if len(contents) == 0 { + return nil + } + items := make([]streams.ToolCallContentItem, 0, len(contents)) + for _, c := range contents { + switch { + case c.Diff != nil: + items = append(items, streams.ToolCallContentItem{ + Type: "diff", + Path: c.Diff.Path, + OldText: c.Diff.OldText, + NewText: c.Diff.NewText, + }) + case c.Content != nil: + cb := a.convertContentBlockToStreams(c.Content.Content) + if cb != nil { + items = append(items, streams.ToolCallContentItem{ + Type: "content", + Content: cb, + }) + } + case c.Terminal != nil: + items = append(items, streams.ToolCallContentItem{ + Type: "terminal", + TerminalID: c.Terminal.TerminalId, + }) + } + } + if len(items) == 0 { + return nil + } + return items +} + +// convertContentBlockToStreams converts an ACP ContentBlock to a streams.ContentBlock. +func (a *Adapter) convertContentBlockToStreams(cb acp.ContentBlock) *streams.ContentBlock { + switch { + case cb.Text != nil: + return &streams.ContentBlock{Type: "text", Text: cb.Text.Text} + case cb.Image != nil: + return &streams.ContentBlock{Type: "image", Data: cb.Image.Data, MimeType: cb.Image.MimeType, URI: derefStr(cb.Image.Uri)} + case cb.Audio != nil: + return &streams.ContentBlock{Type: "audio", Data: cb.Audio.Data, MimeType: cb.Audio.MimeType} + case cb.ResourceLink != nil: + return &streams.ContentBlock{ + Type: "resource_link", URI: cb.ResourceLink.Uri, Name: cb.ResourceLink.Name, + MimeType: derefStr(cb.ResourceLink.MimeType), Title: derefStr(cb.ResourceLink.Title), + Description: derefStr(cb.ResourceLink.Description), Size: cb.ResourceLink.Size, + } + case cb.Resource != nil: + block := &streams.ContentBlock{Type: "resource"} + res := cb.Resource.Resource + switch { + case res.TextResourceContents != nil: + block.URI = res.TextResourceContents.Uri + block.Text = res.TextResourceContents.Text + block.MimeType = derefStr(res.TextResourceContents.MimeType) + case res.BlobResourceContents != nil: + block.URI = res.BlobResourceContents.Uri + block.Data = res.BlobResourceContents.Blob + block.MimeType = derefStr(res.BlobResourceContents.MimeType) + } + return block + default: + return nil + } +} + // convertToolCallUpdate converts a ToolCall notification to an AgentEvent. func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateToolCall) *AgentEvent { args := map[string]any{} @@ -559,7 +827,7 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT status := string(tc.Status) if status == "" { - status = "running" + status = "in_progress" } return &AgentEvent{ @@ -570,6 +838,7 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT ToolTitle: tc.Title, ToolStatus: status, NormalizedPayload: normalizedPayload, + ToolCallContents: a.convertToolCallContents(tc.Content), } } @@ -611,6 +880,7 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session ToolCallID: toolCallID, ToolStatus: status, NormalizedPayload: normalizedPayload, + ToolCallContents: a.convertToolCallContents(tcu.Content), } } @@ -620,9 +890,15 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session func (a *Adapter) handlePermissionRequest(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error) { a.mu.RLock() handler := a.permissionHandler - sessionID := a.sessionID + fallbackSessionID := a.sessionID a.mu.RUnlock() + // Prefer session ID from the request; fall back to adapter-level session ID + sessionID := req.SessionID + if sessionID == "" { + sessionID = fallbackSessionID + } + // Emit a tool_call event so a message is created in the database. // This is needed because permission requests bypass the normal ToolCall notification flow. // Without this, when the tool completes (ToolCallUpdate), there's no message to update. @@ -636,13 +912,9 @@ func (a *Adapter) handlePermissionRequest(ctx context.Context, req *PermissionRe } // Emit the tool_call event - select { - case a.updatesCh <- toolCallEvent: - a.logger.Debug("emitted tool_call event for permission request", - zap.String("tool_call_id", req.ToolCallID)) - default: - a.logger.Warn("updates channel full, could not emit tool_call event for permission") - } + a.sendUpdate(toolCallEvent) + a.logger.Debug("emitted tool_call event for permission request", + zap.String("tool_call_id", req.ToolCallID)) if handler == nil { // Auto-approve if no handler diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go new file mode 100644 index 0000000000..14950786f7 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go @@ -0,0 +1,767 @@ +package acp + +import ( + "testing" + + "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/logger" +) + +// newTestAdapter creates a minimal Adapter suitable for unit testing conversion functions. +// It uses a nop logger and a buffered updates channel so tests can drain events. +func newTestAdapter() *Adapter { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + OutputPath: "stderr", + }) + cfg := &shared.Config{ + AgentID: "test-agent", + WorkDir: "/tmp/test", + } + return NewAdapter(cfg, log) +} + +// drainEvents reads all buffered events from the adapter's updates channel. +func drainEvents(a *Adapter) []AgentEvent { + var events []AgentEvent + for { + select { + case ev := <-a.updatesCh: + events = append(events, ev) + default: + return events + } + } +} + +// --- derefStr --- + +func TestDerefStr_NilPointer(t *testing.T) { + result := derefStr(nil) + if result != "" { + t.Errorf("derefStr(nil) = %q, want empty string", result) + } +} + +func TestDerefStr_NonNilPointer(t *testing.T) { + s := "hello" + result := derefStr(&s) + if result != "hello" { + t.Errorf("derefStr(&%q) = %q, want %q", s, result, s) + } +} + +func TestDerefStr_EmptyString(t *testing.T) { + s := "" + result := derefStr(&s) + if result != "" { + t.Errorf("derefStr(&%q) = %q, want empty string", s, result) + } +} + +// --- convertContentBlockToStreams --- + +func TestConvertContentBlockToStreams_Text(t *testing.T) { + a := newTestAdapter() + cb := acp.TextBlock("hello world") + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for text content block") + } + if result.Type != "text" { + t.Errorf("Type = %q, want %q", result.Type, "text") + } + if result.Text != "hello world" { + t.Errorf("Text = %q, want %q", result.Text, "hello world") + } +} + +func TestConvertContentBlockToStreams_Image(t *testing.T) { + a := newTestAdapter() + uri := "https://example.com/img.png" + cb := acp.ContentBlock{ + Image: &acp.ContentBlockImage{ + Data: "base64data", + MimeType: "image/png", + Type: "image", + Uri: &uri, + }, + } + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for image content block") + } + if result.Type != "image" { + t.Errorf("Type = %q, want %q", result.Type, "image") + } + if result.Data != "base64data" { + t.Errorf("Data = %q, want %q", result.Data, "base64data") + } + if result.MimeType != "image/png" { + t.Errorf("MimeType = %q, want %q", result.MimeType, "image/png") + } + if result.URI != "https://example.com/img.png" { + t.Errorf("URI = %q, want %q", result.URI, "https://example.com/img.png") + } +} + +func TestConvertContentBlockToStreams_ImageWithoutURI(t *testing.T) { + a := newTestAdapter() + cb := acp.ImageBlock("base64data", "image/jpeg") + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for image content block") + } + if result.Type != "image" { + t.Errorf("Type = %q, want %q", result.Type, "image") + } + if result.URI != "" { + t.Errorf("URI = %q, want empty string for image without URI", result.URI) + } +} + +func TestConvertContentBlockToStreams_Audio(t *testing.T) { + a := newTestAdapter() + cb := acp.AudioBlock("audiodata", "audio/mp3") + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for audio content block") + } + if result.Type != "audio" { + t.Errorf("Type = %q, want %q", result.Type, "audio") + } + if result.Data != "audiodata" { + t.Errorf("Data = %q, want %q", result.Data, "audiodata") + } + if result.MimeType != "audio/mp3" { + t.Errorf("MimeType = %q, want %q", result.MimeType, "audio/mp3") + } +} + +func TestConvertContentBlockToStreams_ResourceLink(t *testing.T) { + a := newTestAdapter() + mime := "text/plain" + title := "My Resource" + desc := "A description" + size := 1024 + cb := acp.ContentBlock{ + ResourceLink: &acp.ContentBlockResourceLink{ + Uri: "file:///path/to/file.txt", + Name: "file.txt", + MimeType: &mime, + Title: &title, + Description: &desc, + Size: &size, + Type: "resource_link", + }, + } + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for resource_link content block") + } + if result.Type != "resource_link" { + t.Errorf("Type = %q, want %q", result.Type, "resource_link") + } + if result.URI != "file:///path/to/file.txt" { + t.Errorf("URI = %q, want %q", result.URI, "file:///path/to/file.txt") + } + if result.Name != "file.txt" { + t.Errorf("Name = %q, want %q", result.Name, "file.txt") + } + if result.MimeType != "text/plain" { + t.Errorf("MimeType = %q, want %q", result.MimeType, "text/plain") + } + if result.Title != "My Resource" { + t.Errorf("Title = %q, want %q", result.Title, "My Resource") + } + if result.Description != "A description" { + t.Errorf("Description = %q, want %q", result.Description, "A description") + } + if result.Size == nil || *result.Size != 1024 { + t.Errorf("Size = %v, want 1024", result.Size) + } +} + +func TestConvertContentBlockToStreams_ResourceLinkNilOptionals(t *testing.T) { + a := newTestAdapter() + cb := acp.ResourceLinkBlock("file.txt", "file:///path") + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for resource_link content block") + } + if result.Type != "resource_link" { + t.Errorf("Type = %q, want %q", result.Type, "resource_link") + } + if result.MimeType != "" { + t.Errorf("MimeType = %q, want empty when nil", result.MimeType) + } + if result.Title != "" { + t.Errorf("Title = %q, want empty when nil", result.Title) + } + if result.Description != "" { + t.Errorf("Description = %q, want empty when nil", result.Description) + } + if result.Size != nil { + t.Errorf("Size = %v, want nil", result.Size) + } +} + +func TestConvertContentBlockToStreams_ResourceWithTextContents(t *testing.T) { + a := newTestAdapter() + mime := "text/plain" + cb := acp.ResourceBlock(acp.EmbeddedResourceResource{ + TextResourceContents: &acp.TextResourceContents{ + Uri: "file:///readme.md", + Text: "# Hello", + MimeType: &mime, + }, + }) + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for resource content block") + } + if result.Type != "resource" { + t.Errorf("Type = %q, want %q", result.Type, "resource") + } + if result.URI != "file:///readme.md" { + t.Errorf("URI = %q, want %q", result.URI, "file:///readme.md") + } + if result.Text != "# Hello" { + t.Errorf("Text = %q, want %q", result.Text, "# Hello") + } + if result.MimeType != "text/plain" { + t.Errorf("MimeType = %q, want %q", result.MimeType, "text/plain") + } +} + +func TestConvertContentBlockToStreams_ResourceWithBlobContents(t *testing.T) { + a := newTestAdapter() + mime := "application/octet-stream" + cb := acp.ResourceBlock(acp.EmbeddedResourceResource{ + BlobResourceContents: &acp.BlobResourceContents{ + Uri: "file:///data.bin", + Blob: "blobdata", + MimeType: &mime, + }, + }) + + result := a.convertContentBlockToStreams(cb) + + if result == nil { + t.Fatal("expected non-nil result for resource content block") + } + if result.Type != "resource" { + t.Errorf("Type = %q, want %q", result.Type, "resource") + } + if result.URI != "file:///data.bin" { + t.Errorf("URI = %q, want %q", result.URI, "file:///data.bin") + } + if result.Data != "blobdata" { + t.Errorf("Data = %q, want %q", result.Data, "blobdata") + } + if result.MimeType != "application/octet-stream" { + t.Errorf("MimeType = %q, want %q", result.MimeType, "application/octet-stream") + } +} + +func TestConvertContentBlockToStreams_UnknownType(t *testing.T) { + a := newTestAdapter() + // Empty ContentBlock with no variant set + cb := acp.ContentBlock{} + + result := a.convertContentBlockToStreams(cb) + + if result != nil { + t.Errorf("expected nil for unknown content block type, got %+v", result) + } +} + +// --- convertToolCallContents --- + +func TestConvertToolCallContents_EmptyInput(t *testing.T) { + a := newTestAdapter() + + result := a.convertToolCallContents(nil) + if result != nil { + t.Errorf("expected nil for nil input, got %v", result) + } + + result = a.convertToolCallContents([]acp.ToolCallContent{}) + if result != nil { + t.Errorf("expected nil for empty input, got %v", result) + } +} + +func TestConvertToolCallContents_DiffItem(t *testing.T) { + a := newTestAdapter() + oldText := "old content" + contents := []acp.ToolCallContent{ + { + Diff: &acp.ToolCallContentDiff{ + Path: "src/main.ts", + OldText: &oldText, + NewText: "new content", + Type: "diff", + }, + }, + } + + result := a.convertToolCallContents(contents) + + if len(result) != 1 { + t.Fatalf("expected 1 item, got %d", len(result)) + } + if result[0].Type != "diff" { + t.Errorf("Type = %q, want %q", result[0].Type, "diff") + } + if result[0].Path != "src/main.ts" { + t.Errorf("Path = %q, want %q", result[0].Path, "src/main.ts") + } + if result[0].OldText == nil || *result[0].OldText != "old content" { + t.Errorf("OldText = %v, want 'old content'", result[0].OldText) + } + if result[0].NewText != "new content" { + t.Errorf("NewText = %q, want %q", result[0].NewText, "new content") + } +} + +func TestConvertToolCallContents_TerminalItem(t *testing.T) { + a := newTestAdapter() + contents := []acp.ToolCallContent{ + { + Terminal: &acp.ToolCallContentTerminal{ + TerminalId: "term-123", + Type: "terminal", + }, + }, + } + + result := a.convertToolCallContents(contents) + + if len(result) != 1 { + t.Fatalf("expected 1 item, got %d", len(result)) + } + if result[0].Type != "terminal" { + t.Errorf("Type = %q, want %q", result[0].Type, "terminal") + } + if result[0].TerminalID != "term-123" { + t.Errorf("TerminalID = %q, want %q", result[0].TerminalID, "term-123") + } +} + +func TestConvertToolCallContents_ContentItemWithTextBlock(t *testing.T) { + a := newTestAdapter() + contents := []acp.ToolCallContent{ + { + Content: &acp.ToolCallContentContent{ + Content: acp.TextBlock("tool output text"), + Type: "content", + }, + }, + } + + result := a.convertToolCallContents(contents) + + if len(result) != 1 { + t.Fatalf("expected 1 item, got %d", len(result)) + } + if result[0].Type != "content" { + t.Errorf("Type = %q, want %q", result[0].Type, "content") + } + if result[0].Content == nil { + t.Fatal("expected Content to be set") + } + if result[0].Content.Type != "text" { + t.Errorf("Content.Type = %q, want %q", result[0].Content.Type, "text") + } + if result[0].Content.Text != "tool output text" { + t.Errorf("Content.Text = %q, want %q", result[0].Content.Text, "tool output text") + } +} + +func TestConvertToolCallContents_MixedItems(t *testing.T) { + a := newTestAdapter() + oldText := "before" + contents := []acp.ToolCallContent{ + { + Diff: &acp.ToolCallContentDiff{ + Path: "file.go", + OldText: &oldText, + NewText: "after", + Type: "diff", + }, + }, + { + Terminal: &acp.ToolCallContentTerminal{ + TerminalId: "term-456", + Type: "terminal", + }, + }, + { + Content: &acp.ToolCallContentContent{ + Content: acp.TextBlock("some text"), + Type: "content", + }, + }, + } + + result := a.convertToolCallContents(contents) + + if len(result) != 3 { + t.Fatalf("expected 3 items, got %d", len(result)) + } + if result[0].Type != "diff" { + t.Errorf("item[0].Type = %q, want %q", result[0].Type, "diff") + } + if result[1].Type != "terminal" { + t.Errorf("item[1].Type = %q, want %q", result[1].Type, "terminal") + } + if result[2].Type != "content" { + t.Errorf("item[2].Type = %q, want %q", result[2].Type, "content") + } +} + +// --- convertMessageChunk --- + +func TestConvertMessageChunk_TextAssistant(t *testing.T) { + a := newTestAdapter() + cb := acp.TextBlock("Hello from assistant") + + result := a.convertMessageChunk("session-1", cb, "assistant") + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Type != streams.EventTypeMessageChunk { + t.Errorf("Type = %q, want %q", result.Type, streams.EventTypeMessageChunk) + } + if result.SessionID != "session-1" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "session-1") + } + if result.Text != "Hello from assistant" { + t.Errorf("Text = %q, want %q", result.Text, "Hello from assistant") + } + if result.Role != "" { + t.Errorf("Role = %q, want empty for assistant messages", result.Role) + } + if len(result.ContentBlocks) != 0 { + t.Errorf("ContentBlocks should be empty for text, got %d", len(result.ContentBlocks)) + } +} + +func TestConvertMessageChunk_TextUser(t *testing.T) { + a := newTestAdapter() + cb := acp.TextBlock("Hello from user") + + result := a.convertMessageChunk("session-2", cb, "user") + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Role != "user" { + t.Errorf("Role = %q, want %q", result.Role, "user") + } + if result.Text != "Hello from user" { + t.Errorf("Text = %q, want %q", result.Text, "Hello from user") + } +} + +func TestConvertMessageChunk_ImageContent(t *testing.T) { + a := newTestAdapter() + cb := acp.ImageBlock("imgdata", "image/png") + + result := a.convertMessageChunk("session-3", cb, "assistant") + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Type != streams.EventTypeMessageChunk { + t.Errorf("Type = %q, want %q", result.Type, streams.EventTypeMessageChunk) + } + if result.Text != "" { + t.Errorf("Text = %q, want empty for image content", result.Text) + } + if len(result.ContentBlocks) != 1 { + t.Fatalf("expected 1 ContentBlock, got %d", len(result.ContentBlocks)) + } + if result.ContentBlocks[0].Type != "image" { + t.Errorf("ContentBlocks[0].Type = %q, want %q", result.ContentBlocks[0].Type, "image") + } + if result.ContentBlocks[0].Data != "imgdata" { + t.Errorf("ContentBlocks[0].Data = %q, want %q", result.ContentBlocks[0].Data, "imgdata") + } +} + +func TestConvertMessageChunk_UnknownContent(t *testing.T) { + a := newTestAdapter() + // Empty ContentBlock with no variant set + cb := acp.ContentBlock{} + + result := a.convertMessageChunk("session-4", cb, "assistant") + + if result != nil { + t.Errorf("expected nil for unknown content type, got %+v", result) + } +} + +func TestConvertMessageChunk_AudioContent(t *testing.T) { + a := newTestAdapter() + cb := acp.AudioBlock("audiodata", "audio/wav") + + result := a.convertMessageChunk("session-5", cb, "assistant") + + if result == nil { + t.Fatal("expected non-nil result") + } + if len(result.ContentBlocks) != 1 { + t.Fatalf("expected 1 ContentBlock, got %d", len(result.ContentBlocks)) + } + if result.ContentBlocks[0].Type != "audio" { + t.Errorf("ContentBlocks[0].Type = %q, want %q", result.ContentBlocks[0].Type, "audio") + } +} + +// --- cancelActiveToolCalls --- + +func TestCancelActiveToolCalls_EmitsEventsAndClears(t *testing.T) { + a := newTestAdapter() + sessionID := "session-cancel" + + // Set up active tool calls + a.activeToolCalls["tc-1"] = &streams.NormalizedPayload{} + a.activeToolCalls["tc-2"] = &streams.NormalizedPayload{} + + a.cancelActiveToolCalls(sessionID) + + // Verify the activeToolCalls map is cleared + if len(a.activeToolCalls) != 0 { + t.Errorf("activeToolCalls should be empty after cancel, got %d entries", len(a.activeToolCalls)) + } + + // Drain events from the channel + events := drainEvents(a) + + if len(events) != 2 { + t.Fatalf("expected 2 cancel events, got %d", len(events)) + } + + // Collect tool call IDs from events + seenIDs := map[string]bool{} + for _, ev := range events { + if ev.Type != streams.EventTypeToolUpdate { + t.Errorf("event Type = %q, want %q", ev.Type, streams.EventTypeToolUpdate) + } + if ev.SessionID != sessionID { + t.Errorf("event SessionID = %q, want %q", ev.SessionID, sessionID) + } + if ev.ToolStatus != "cancelled" { + t.Errorf("event ToolStatus = %q, want %q", ev.ToolStatus, "cancelled") + } + seenIDs[ev.ToolCallID] = true + } + if !seenIDs["tc-1"] { + t.Error("expected cancel event for tc-1") + } + if !seenIDs["tc-2"] { + t.Error("expected cancel event for tc-2") + } +} + +func TestCancelActiveToolCalls_EmptyMap(t *testing.T) { + a := newTestAdapter() + + // Should not panic or emit events when map is empty + a.cancelActiveToolCalls("session-empty") + + events := drainEvents(a) + if len(events) != 0 { + t.Errorf("expected 0 events for empty activeToolCalls, got %d", len(events)) + } +} + +// --- convertAvailableCommands --- + +func TestConvertAvailableCommands_Basic(t *testing.T) { + a := newTestAdapter() + update := &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: []acp.AvailableCommand{ + { + Name: "commit", + Description: "Commit changes", + }, + { + Name: "review", + Description: "Review code", + }, + }, + } + + result := a.convertAvailableCommands("session-cmd", update) + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Type != streams.EventTypeAvailableCommands { + t.Errorf("Type = %q, want %q", result.Type, streams.EventTypeAvailableCommands) + } + if result.SessionID != "session-cmd" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "session-cmd") + } + if len(result.AvailableCommands) != 2 { + t.Fatalf("expected 2 commands, got %d", len(result.AvailableCommands)) + } + if result.AvailableCommands[0].Name != "commit" { + t.Errorf("command[0].Name = %q, want %q", result.AvailableCommands[0].Name, "commit") + } + if result.AvailableCommands[0].Description != "Commit changes" { + t.Errorf("command[0].Description = %q, want %q", result.AvailableCommands[0].Description, "Commit changes") + } + if result.AvailableCommands[1].Name != "review" { + t.Errorf("command[1].Name = %q, want %q", result.AvailableCommands[1].Name, "review") + } +} + +func TestConvertAvailableCommands_WithInputHint(t *testing.T) { + a := newTestAdapter() + update := &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: []acp.AvailableCommand{ + { + Name: "search", + Description: "Search codebase", + Input: &acp.AvailableCommandInput{ + UnstructuredCommandInput: &acp.AvailableCommandUnstructuredCommandInput{ + Hint: "Enter search query", + }, + }, + }, + }, + } + + result := a.convertAvailableCommands("session-hint", update) + + if result == nil { + t.Fatal("expected non-nil result") + } + if len(result.AvailableCommands) != 1 { + t.Fatalf("expected 1 command, got %d", len(result.AvailableCommands)) + } + if result.AvailableCommands[0].InputHint != "Enter search query" { + t.Errorf("InputHint = %q, want %q", result.AvailableCommands[0].InputHint, "Enter search query") + } +} + +func TestConvertAvailableCommands_NoInputHint(t *testing.T) { + a := newTestAdapter() + update := &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: []acp.AvailableCommand{ + { + Name: "help", + Description: "Show help", + Input: nil, + }, + }, + } + + result := a.convertAvailableCommands("session-nohint", update) + + if result.AvailableCommands[0].InputHint != "" { + t.Errorf("InputHint = %q, want empty for nil Input", result.AvailableCommands[0].InputHint) + } +} + +func TestConvertAvailableCommands_Empty(t *testing.T) { + a := newTestAdapter() + update := &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: []acp.AvailableCommand{}, + } + + result := a.convertAvailableCommands("session-empty", update) + + if result == nil { + t.Fatal("expected non-nil result") + } + if len(result.AvailableCommands) != 0 { + t.Errorf("expected 0 commands, got %d", len(result.AvailableCommands)) + } +} + +// --- emitInitialModeState --- + +func TestEmitInitialModeState(t *testing.T) { + a := newTestAdapter() + a.sessionID = "session-mode" + + modes := &acp.SessionModeState{ + CurrentModeId: "architect", + } + + a.emitInitialModeState(modes) + + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != streams.EventTypeSessionMode { + t.Errorf("Type = %q, want %q", events[0].Type, streams.EventTypeSessionMode) + } + if events[0].SessionID != "session-mode" { + t.Errorf("SessionID = %q, want %q", events[0].SessionID, "session-mode") + } + if events[0].CurrentModeID != "architect" { + t.Errorf("CurrentModeID = %q, want %q", events[0].CurrentModeID, "architect") + } +} + +// --- sendUpdate --- + +func TestSendUpdate_NormalOperation(t *testing.T) { + a := newTestAdapter() + event := AgentEvent{ + Type: streams.EventTypeComplete, + SessionID: "session-send", + } + + a.sendUpdate(event) + + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != streams.EventTypeComplete { + t.Errorf("Type = %q, want %q", events[0].Type, streams.EventTypeComplete) + } +} + +func TestSendUpdate_ClosedAdapter(t *testing.T) { + a := newTestAdapter() + // Close the adapter + _ = a.Close() + + // Should not panic when sending to closed adapter + // (sendUpdate checks the closed flag) + // We need a fresh adapter since Close() closes the channel + a2 := newTestAdapter() + a2.mu.Lock() + a2.closed = true + a2.mu.Unlock() + + // This should not panic + a2.sendUpdate(AgentEvent{Type: streams.EventTypeComplete}) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go index d44e25a9f2..e3020b9f3e 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go @@ -93,6 +93,15 @@ type Adapter struct { // Turn completion signaling turnCompleteCh chan turnCompleteResult + // lastRawData holds the raw JSON of the current notification being processed. + // Set in handleNotification before dispatch; used by sendUpdate for OTel tracing. + lastRawData json.RawMessage + + // OTel tracing: active prompt span context. + // Notification spans become children of the prompt span for visual grouping. + promptTraceCtx context.Context + promptTraceMu sync.RWMutex + // Synchronization mu sync.RWMutex closed bool @@ -204,6 +213,8 @@ func (a *Adapter) RequiresProcessKill() bool { // sendUpdate safely sends an event to the updates channel. func (a *Adapter) sendUpdate(update AgentEvent) { shared.LogNormalizedEvent(shared.ProtocolCodex, a.agentID, &update) + shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolCodex, a.agentID, + update.Type, a.lastRawData, &update) select { case a.updatesCh <- update: default: @@ -211,9 +222,35 @@ func (a *Adapter) sendUpdate(update AgentEvent) { } } +// getPromptTraceCtx returns the current prompt span context for child-span linking. +// Returns context.Background() if no prompt is active. +func (a *Adapter) getPromptTraceCtx() context.Context { + a.promptTraceMu.RLock() + defer a.promptTraceMu.RUnlock() + if a.promptTraceCtx != nil { + return a.promptTraceCtx + } + return context.Background() +} + +// setPromptTraceCtx stores the prompt span context. +func (a *Adapter) setPromptTraceCtx(ctx context.Context) { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = ctx +} + +// clearPromptTraceCtx clears the prompt span context. +func (a *Adapter) clearPromptTraceCtx() { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = nil +} + // handleNotification processes Codex notifications and emits AgentEvents. func (a *Adapter) handleNotification(method string, params json.RawMessage) { - // Log raw event for debugging + // Store raw data for tracing and log for debugging + a.lastRawData = params shared.LogRawEvent(shared.ProtocolCodex, a.agentID, method, params) a.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go index 7fa55be635..57b642b23b 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go @@ -10,9 +10,11 @@ import ( "strings" "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types/streams" v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/codex" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -39,6 +41,18 @@ func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.M return err } + // Start prompt span — notification spans become children via getPromptTraceCtx() + promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolCodex, a.agentID, "prompt") + promptSpan.SetAttributes( + attribute.String("session_id", threadID), + attribute.Int("prompt_inputs", len(inputs)), + ) + a.setPromptTraceCtx(promptCtx) + defer func() { + a.clearPromptTraceCtx() + promptSpan.End() + }() + a.logger.Info("sending prompt", zap.String("thread_id", threadID), zap.Int("inputs", len(inputs)), diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go index e499e9aad1..8a7f48b797 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go @@ -21,6 +21,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/opencode" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -99,6 +100,15 @@ type Adapter struct { // Used to filter out user messages from being processed as agent output messageRoles map[string]string + // lastRawData holds the raw JSON of the current event being processed. + // Set in handleSDKEvent before dispatch; used by sendUpdate for OTel tracing. + lastRawData json.RawMessage + + // OTel tracing: active prompt span context. + // Notification spans become children of the prompt span for visual grouping. + promptTraceCtx context.Context + promptTraceMu sync.RWMutex + // Synchronization mu sync.RWMutex closed bool @@ -366,6 +376,18 @@ func (a *Adapter) Prompt(ctx context.Context, message string, _ []v1.MessageAtta return fmt.Errorf("adapter not initialized") } + // Start prompt span — notification spans become children via getPromptTraceCtx() + promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolOpenCode, a.agentID, "prompt") + promptSpan.SetAttributes( + attribute.String("session_id", sessionID), + attribute.Int("prompt_length", len(message)), + ) + a.setPromptTraceCtx(promptCtx) + defer func() { + a.clearPromptTraceCtx() + promptSpan.End() + }() + a.logger.Info("sending prompt", zap.String("session_id", sessionID), zap.String("operation_id", operationID)) @@ -539,6 +561,8 @@ func (a *Adapter) sendUpdate(event AgentEvent) { } shared.LogNormalizedEvent(shared.ProtocolOpenCode, a.agentID, &event) + shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolOpenCode, a.agentID, + event.Type, a.lastRawData, &event) select { case a.updatesCh <- event: default: @@ -547,9 +571,35 @@ func (a *Adapter) sendUpdate(event AgentEvent) { } } +// getPromptTraceCtx returns the current prompt span context for child-span linking. +// Returns context.Background() if no prompt is active. +func (a *Adapter) getPromptTraceCtx() context.Context { + a.promptTraceMu.RLock() + defer a.promptTraceMu.RUnlock() + if a.promptTraceCtx != nil { + return a.promptTraceCtx + } + return context.Background() +} + +// setPromptTraceCtx stores the prompt span context. +func (a *Adapter) setPromptTraceCtx(ctx context.Context) { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = ctx +} + +// clearPromptTraceCtx clears the prompt span context. +func (a *Adapter) clearPromptTraceCtx() { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = nil +} + // handleSDKEvent processes events from the OpenCode SSE stream func (a *Adapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { - // Log raw event for debugging + // Store raw data for tracing and log for debugging + a.lastRawData = event.Properties shared.LogRawEvent(shared.ProtocolOpenCode, a.agentID, event.Type, event.Properties) a.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/tracing.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/tracing.go new file mode 100644 index 0000000000..c88d12549f --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/tracing.go @@ -0,0 +1,108 @@ +package shared + +import ( + "context" + "encoding/json" + + "github.com/kandev/kandev/internal/agentctl/tracing" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +const ( + tracerName = "kandev-agentctl" + maxAttrValueLen = 8192 // 8KB truncation for span event payloads +) + +// noopTracer is a cached no-op tracer to avoid allocating a new one on every call. +var noopTracer = noop.NewTracerProvider().Tracer(tracerName) + +// Tracer returns the package-level tracer for agent protocol tracing. +// Requires KANDEV_DEBUG_AGENT_MESSAGES=true in addition to the OTel endpoint. +// Returns a no-op tracer when debug mode is off. +func Tracer() trace.Tracer { + if !debugMode { + return noopTracer + } + return tracing.Tracer(tracerName) +} + +// ShutdownTracing flushes pending spans and shuts down the provider. +func ShutdownTracing(ctx context.Context) error { + return tracing.Shutdown(ctx) +} + +// TraceProtocolEvent creates a single span for a received protocol notification. +// Two events are attached: "raw" with the original protocol JSON and "normalized" +// with the serialized AgentEvent, allowing side-by-side comparison in Jaeger/Tempo. +func TraceProtocolEvent( + ctx context.Context, + protocol, agentID string, + eventType string, + rawData json.RawMessage, + normalized *streams.AgentEvent, +) { + tracer := Tracer() + spanName := protocol + "." + eventType + + _, span := tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + span.SetAttributes( + attribute.String("protocol", protocol), + attribute.String("agent_id", agentID), + attribute.String("event_type", eventType), + ) + + if normalized != nil { + span.SetAttributes(attribute.String("session_id", normalized.SessionID)) + } + + // Attach raw protocol JSON as an event + if len(rawData) > 0 { + span.AddEvent("raw", trace.WithAttributes( + attribute.String("data", truncate(string(rawData), maxAttrValueLen)), + )) + } + + // Attach normalized AgentEvent as an event + if normalized != nil { + if normJSON, err := json.Marshal(normalized); err == nil { + span.AddEvent("normalized", trace.WithAttributes( + attribute.String("data", truncate(string(normJSON), maxAttrValueLen)), + )) + } + } else { + span.AddEvent("normalized", trace.WithAttributes( + attribute.Bool("conversion_failed", true), + )) + } +} + +// TraceProtocolRequest starts a span for an outgoing protocol request. +// The caller must call span.End() when the request completes, and may add +// attributes to record response data. +func TraceProtocolRequest( + ctx context.Context, + protocol, agentID, name string, +) (context.Context, trace.Span) { + tracer := Tracer() + spanName := protocol + "." + name + + ctx, span := tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindClient)) + span.SetAttributes( + attribute.String("protocol", protocol), + attribute.String("agent_id", agentID), + ) + + return ctx, span +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "...(truncated)" +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/tracing_test.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/tracing_test.go new file mode 100644 index 0000000000..80a752ce0d --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/tracing_test.go @@ -0,0 +1,121 @@ +package shared + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + expected string + }{ + { + name: "returns unchanged when under limit", + input: "short", + maxLen: 10, + expected: "short", + }, + { + name: "returns unchanged when exactly at limit", + input: "exact", + maxLen: 5, + expected: "exact", + }, + { + name: "truncates with suffix when over limit", + input: "this is a long string that exceeds the limit", + maxLen: 10, + expected: "this is a ...(truncated)", + }, + { + name: "handles empty string", + input: "", + maxLen: 10, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncate(tt.input, tt.maxLen) + if got != tt.expected { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.expected) + } + }) + } + + // Verify the suffix is correct for truncated output + t.Run("truncated output ends with expected suffix", func(t *testing.T) { + got := truncate(strings.Repeat("x", 100), 10) + if !strings.HasSuffix(got, "...(truncated)") { + t.Errorf("truncated output should end with '...(truncated)', got %q", got) + } + if !strings.HasPrefix(got, strings.Repeat("x", 10)) { + t.Errorf("truncated output should start with the first 10 chars") + } + }) +} + +func TestTraceProtocolEvent(t *testing.T) { + ctx := context.Background() + + t.Run("nil normalized event does not panic", func(t *testing.T) { + TraceProtocolEvent(ctx, "acp", "agent-1", "message_chunk", + json.RawMessage(`{"text":"hello"}`), nil) + }) + + t.Run("valid normalized event with raw data does not panic", func(t *testing.T) { + event := &streams.AgentEvent{ + Type: "message_chunk", + SessionID: "sess-123", + Text: "hello world", + } + rawData := json.RawMessage(`{"type":"message_chunk","text":"hello world"}`) + + TraceProtocolEvent(ctx, "acp", "agent-1", "message_chunk", rawData, event) + }) + + t.Run("empty raw data does not panic", func(t *testing.T) { + event := &streams.AgentEvent{ + Type: "complete", + SessionID: "sess-456", + } + + TraceProtocolEvent(ctx, "acp", "agent-1", "complete", nil, event) + }) +} + +func TestTracer_ReturnsSameNoopInstance(t *testing.T) { + // When debug mode is off (default), Tracer() should return the same cached + // noop tracer instance to avoid garbage on every call. + t1 := Tracer() + t2 := Tracer() + if t1 != t2 { + t.Error("expected Tracer() to return the same noop tracer instance when debug mode is off") + } +} + +func TestTraceProtocolRequest(t *testing.T) { + t.Run("returns non-nil context and span", func(t *testing.T) { + ctx := context.Background() + + returnedCtx, span := TraceProtocolRequest(ctx, "acp", "agent-1", "session/new") + + if returnedCtx == nil { + t.Error("expected non-nil context, got nil") + } + if span == nil { + t.Error("expected non-nil span, got nil") + } + + // Clean up the span + span.End() + }) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go index d13cc89a1f..c547416e91 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -97,6 +97,15 @@ type Adapter struct { // and reset to false at the start of each prompt. streamingTextSentThisTurn bool + // lastRawData holds the raw JSON of the current message being processed. + // Set in handleMessage before dispatch; used by sendUpdate for OTel tracing. + lastRawData json.RawMessage + + // OTel tracing: active prompt span context. + // Notification spans become children of the prompt span for visual grouping. + promptTraceCtx context.Context + promptTraceMu sync.RWMutex + // Synchronization mu sync.RWMutex closed bool @@ -212,6 +221,8 @@ func (a *Adapter) RequiresProcessKill() bool { // sendUpdate safely sends an event to the updates channel. func (a *Adapter) sendUpdate(update AgentEvent) { shared.LogNormalizedEvent(shared.ProtocolStreamJSON, a.agentID, &update) + shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolStreamJSON, a.agentID, + update.Type, a.lastRawData, &update) select { case a.updatesCh <- update: default: @@ -219,11 +230,37 @@ func (a *Adapter) sendUpdate(update AgentEvent) { } } +// getPromptTraceCtx returns the current prompt span context for child-span linking. +// Returns context.Background() if no prompt is active. +func (a *Adapter) getPromptTraceCtx() context.Context { + a.promptTraceMu.RLock() + defer a.promptTraceMu.RUnlock() + if a.promptTraceCtx != nil { + return a.promptTraceCtx + } + return context.Background() +} + +// setPromptTraceCtx stores the prompt span context. +func (a *Adapter) setPromptTraceCtx(ctx context.Context) { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = ctx +} + +// clearPromptTraceCtx clears the prompt span context. +func (a *Adapter) clearPromptTraceCtx() { + a.promptTraceMu.Lock() + defer a.promptTraceMu.Unlock() + a.promptTraceCtx = nil +} + // handleMessage processes streaming messages from the agent. func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { - // Log raw event for debugging - if rawData, err := json.Marshal(msg); err == nil { - shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, msg.Type, rawData) + // Marshal once for debug logging and tracing + a.lastRawData, _ = json.Marshal(msg) + if len(a.lastRawData) > 0 { + shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, msg.Type, a.lastRawData) } a.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go index 0390e6392d..1d442c05c3 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go @@ -9,8 +9,10 @@ import ( "strings" "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/claudecode" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -44,6 +46,18 @@ func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.M } } + // Start prompt span — notification spans become children via getPromptTraceCtx() + promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolStreamJSON, a.agentID, "prompt") + promptSpan.SetAttributes( + attribute.String("session_id", sessionID), + attribute.Int("prompt_length", len(finalMessage)), + ) + a.setPromptTraceCtx(promptCtx) + defer func() { + a.clearPromptTraceCtx() + promptSpan.End() + }() + a.logger.Info("sending prompt", zap.String("session_id", sessionID), zap.String("operation_id", operationID), diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index 9f88d679ee..32a4903cfb 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -14,6 +14,8 @@ import ( "github.com/kandev/kandev/internal/common/constants" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" "go.uber.org/zap" ) @@ -226,6 +228,11 @@ func (s *Server) runAgentStreamWriter(ctx context.Context, conn *websocket.Conn, // handleAgentStreamRequest dispatches agent operation requests received on the WebSocket stream. func (s *Server) handleAgentStreamRequest(ctx context.Context, msg *ws.Message) *ws.Message { + // Extract remote trace context from WS message metadata for cross-process span linking + if len(msg.Metadata) > 0 { + ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(msg.Metadata)) + } + switch msg.Action { case "agent.initialize": return s.handleWSInitialize(ctx, msg) @@ -390,13 +397,14 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag // Start prompt processing asynchronously. // Completion is signaled via the WebSocket complete event, not this response. - go func() { - promptCtx, cancel := context.WithTimeout(context.Background(), constants.PromptTimeout) + // Pass ctx (which carries remote trace context) so acp.prompt spans become children of the session. + go func(parentCtx context.Context) { + promptCtx, cancel := context.WithTimeout(parentCtx, constants.PromptTimeout) defer cancel() if err := adapter.Prompt(promptCtx, req.Text, req.Attachments); err != nil { s.logger.Error("async prompt failed", zap.Error(err)) } - }() + }(ctx) s.logger.Info("prompt accepted (async)", zap.Int("attachments", len(req.Attachments))) diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 18e7a6885c..7149d2568a 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -871,7 +871,9 @@ func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapte }, nil } -// sendPermissionNotification sends a permission request notification through the updates channel +// sendPermissionNotification sends a permission request notification through the updates channel. +// Uses a blocking send with timeout to ensure delivery. If delivery fails within 5 seconds, +// auto-cancels the permission so the agent doesn't hang waiting for a response. func (m *Manager) sendPermissionNotification(pending *PendingPermission) { // Convert options to streams.PermissionOption (types.PermissionOption is an alias) options := make([]streams.PermissionOption, len(pending.Request.Options)) @@ -893,12 +895,18 @@ func (m *Manager) sendPermissionNotification(pending *PendingPermission) { zap.String("title", pending.Request.Title), zap.String("action_type", pending.Request.ActionType)) + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() select { case m.updatesCh <- event: // Sent successfully - default: - m.logger.Warn("updates channel full, dropping permission notification", + case <-timer.C: + m.logger.Error("failed to deliver permission notification, auto-cancelling", zap.String("pending_id", pending.ID)) + select { + case pending.ResponseCh <- &adapter.PermissionResponse{Cancelled: true}: + default: + } } } diff --git a/apps/backend/internal/agentctl/tracing/otel.go b/apps/backend/internal/agentctl/tracing/otel.go new file mode 100644 index 0000000000..0a95c6425b --- /dev/null +++ b/apps/backend/internal/agentctl/tracing/otel.go @@ -0,0 +1,96 @@ +// Package tracing provides shared OTel tracer initialization for agentctl +// communication layers (both agent protocol and backend<>agentctl transport). +// +// Real tracing requires OTEL_EXPORTER_OTLP_ENDPOINT to be set. +// Without it a no-op tracer is used (zero overhead). +package tracing + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +const serviceName = "kandev-agentctl" + +var ( + initOnce sync.Once + tracerProvider trace.TracerProvider = noop.NewTracerProvider() + sdkProvider *sdktrace.TracerProvider +) + +func initTracing() { + // Always register the W3C propagator so Inject/Extract work + // even if this process doesn't export spans. + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + + endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + if endpoint == "" { + return + } + + ctx := context.Background() + + exporter, err := otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(endpointHost(endpoint)), + otlptracehttp.WithInsecure(), + ) + if err != nil { + fmt.Fprintf(os.Stderr, "kandev-agentctl: failed to initialize OTel exporter: %v\n", err) + return + } + + res, err := resource.New(ctx, + resource.WithAttributes(semconv.ServiceName(serviceName)), + ) + if err != nil { + res = resource.Default() + } + + sdkProvider = sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + tracerProvider = sdkProvider + otel.SetTracerProvider(tracerProvider) +} + +// endpointHost strips the scheme and trailing slashes from the endpoint URL for otlptracehttp. +func endpointHost(endpoint string) string { + for _, prefix := range []string{"https://", "http://"} { + if strings.HasPrefix(endpoint, prefix) { + return strings.TrimRight(endpoint[len(prefix):], "/") + } + } + return strings.TrimRight(endpoint, "/") +} + +// Tracer returns a named tracer. No-op when tracing is disabled. +func Tracer(name string) trace.Tracer { + initOnce.Do(initTracing) + return tracerProvider.Tracer(name) +} + +// Shutdown flushes pending spans and shuts down the provider. +func Shutdown(ctx context.Context) error { + if sdkProvider != nil { + return sdkProvider.Shutdown(ctx) + } + return nil +} diff --git a/apps/backend/internal/agentctl/tracing/transport.go b/apps/backend/internal/agentctl/tracing/transport.go new file mode 100644 index 0000000000..20bf7c6874 --- /dev/null +++ b/apps/backend/internal/agentctl/tracing/transport.go @@ -0,0 +1,184 @@ +package tracing + +import ( + "context" + "encoding/json" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +const transportTracerName = "kandev-transport" + +func transportTracer() trace.Tracer { + return Tracer(transportTracerName) +} + +// TraceSessionStart creates a long-lived span for a task session. +// The caller must call span.End() when the session completes. +// All operations for the session should be created as children of this span's context. +func TraceSessionStart(ctx context.Context, taskID, sessionID, executionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "session", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("task_id", taskID), + attribute.String("session_id", sessionID), + attribute.String("execution_id", executionID), + ) + return ctx, span +} + +// TraceSessionRecovered creates a session span for a recovered execution (after backend restart). +func TraceSessionRecovered(ctx context.Context, taskID, sessionID, executionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "session.recovered", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("task_id", taskID), + attribute.String("session_id", sessionID), + attribute.String("execution_id", executionID), + attribute.Bool("recovered", true), + ) + return ctx, span +} + +// TraceSessionInit creates a short-lived span covering session initialization. +// This span is a child of the session span and ends when init completes, +// ensuring init-phase operations are visible in trace backends while the +// parent session span is still open. +func TraceSessionInit(ctx context.Context, taskID, sessionID, executionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "session.init", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("task_id", taskID), + attribute.String("session_id", sessionID), + attribute.String("execution_id", executionID), + ) + return ctx, span +} + +// TraceHTTPRequest starts a span for an HTTP call to agentctl. +// Caller must call span.End() when the response is received. +func TraceHTTPRequest(ctx context.Context, method, path, executionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "http."+method+" "+path, + trace.WithSpanKind(trace.SpanKindClient), + ) + span.SetAttributes( + attribute.String("http.method", method), + attribute.String("http.path", path), + attribute.String("execution_id", executionID), + ) + return ctx, span +} + +// TraceHTTPResponse records response attributes on the span. +func TraceHTTPResponse(span trace.Span, statusCode int, err error) { + span.SetAttributes(attribute.Int("http.status_code", statusCode)) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } +} + +// TraceWSRequest starts a span for an outgoing WebSocket request. +// Caller must call span.End() when the response arrives. +func TraceWSRequest(ctx context.Context, action, msgID, executionID, sessionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "ws."+action, + trace.WithSpanKind(trace.SpanKindClient), + ) + span.SetAttributes( + attribute.String("ws.action", action), + attribute.String("ws.msg_id", msgID), + attribute.String("execution_id", executionID), + attribute.String("session_id", sessionID), + ) + return ctx, span +} + +// TraceWSResponse records response attributes on the span. +func TraceWSResponse(span trace.Span, responseType string, err error) { + span.SetAttributes(attribute.String("ws.response_type", responseType)) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } +} + +// TraceAgentEvent creates a single span for a received agent event. +// The raw JSON message is attached as a span event for full observability. +func TraceAgentEvent(ctx context.Context, eventType, sessionID, executionID string, rawMessage json.RawMessage) { + _, span := transportTracer().Start(ctx, "agent.event."+eventType, + trace.WithSpanKind(trace.SpanKindInternal), + ) + defer span.End() + + span.SetAttributes( + attribute.String("event_type", eventType), + attribute.String("session_id", sessionID), + attribute.String("execution_id", executionID), + ) + + if len(rawMessage) > 0 { + data := string(rawMessage) + if len(data) > maxEventDataLen { + data = data[:maxEventDataLen] + "...(truncated)" + } + span.AddEvent("event_data", trace.WithAttributes( + attribute.String("data", data), + )) + } +} + +const maxEventDataLen = 8192 + +// TraceWorkspaceEvent creates a single span for a workspace stream event. +func TraceWorkspaceEvent(ctx context.Context, msgType, executionID, sessionID string) { + _, span := transportTracer().Start(ctx, "workspace."+msgType, + trace.WithSpanKind(trace.SpanKindInternal), + ) + defer span.End() + + span.SetAttributes( + attribute.String("ws.msg_type", msgType), + attribute.String("execution_id", executionID), + attribute.String("session_id", sessionID), + ) +} + +// TraceTurnEnd creates a span marking the end of an agent turn. +// The caller should set additional attributes (stop_reason, is_error) before ending the span. +func TraceTurnEnd(ctx context.Context, executionID, sessionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "turn_end", + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("execution_id", executionID), + attribute.String("session_id", sessionID), + ) + return ctx, span +} + +// TraceMCPDispatch starts a span for an MCP request relay. +// Caller must call span.End() when the dispatch completes. +func TraceMCPDispatch(ctx context.Context, action, msgID, executionID string) (context.Context, trace.Span) { + ctx, span := transportTracer().Start(ctx, "mcp.dispatch."+action, + trace.WithSpanKind(trace.SpanKindInternal), + ) + span.SetAttributes( + attribute.String("mcp.action", action), + attribute.String("mcp.msg_id", msgID), + attribute.String("execution_id", executionID), + ) + return ctx, span +} + +// TraceMCPResponse records the result of an MCP dispatch on the span. +func TraceMCPResponse(span trace.Span, err error) { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } +} diff --git a/apps/backend/internal/agentctl/tracing/transport_test.go b/apps/backend/internal/agentctl/tracing/transport_test.go new file mode 100644 index 0000000000..8416d62107 --- /dev/null +++ b/apps/backend/internal/agentctl/tracing/transport_test.go @@ -0,0 +1,246 @@ +package tracing + +import ( + "context" + "fmt" + "testing" +) + +func TestEndpointHost(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "strips http prefix", + input: "http://localhost:4318", + expected: "localhost:4318", + }, + { + name: "strips https prefix", + input: "https://otel.example.com:4318", + expected: "otel.example.com:4318", + }, + { + name: "returns unchanged when no scheme", + input: "localhost:4318", + expected: "localhost:4318", + }, + { + name: "handles empty string", + input: "", + expected: "", + }, + { + name: "strips trailing slash from http URL", + input: "http://localhost:4318/", + expected: "localhost:4318", + }, + { + name: "strips trailing slash from https URL", + input: "https://otel.example.com:4318/", + expected: "otel.example.com:4318", + }, + { + name: "strips trailing slash without scheme", + input: "localhost:4318/", + expected: "localhost:4318", + }, + { + name: "strips multiple trailing slashes", + input: "http://localhost:4318///", + expected: "localhost:4318", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := endpointHost(tt.input) + if got != tt.expected { + t.Errorf("endpointHost(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestTracer(t *testing.T) { + t.Run("returns non-nil tracer", func(t *testing.T) { + tracer := Tracer("test-tracer") + if tracer == nil { + t.Error("expected non-nil tracer") + } + }) + + t.Run("returns no-op tracer without env vars", func(t *testing.T) { + // Without KANDEV_DEBUG_AGENT_MESSAGES=true, we get a no-op tracer + tracer := Tracer("test-noop") + if tracer == nil { + t.Error("expected non-nil tracer") + } + }) +} + +func TestTraceHTTPRequest(t *testing.T) { + ctx := context.Background() + + t.Run("returns non-nil context and span", func(t *testing.T) { + returnedCtx, span := TraceHTTPRequest(ctx, "POST", "/api/v1/start", "exec-123") + if returnedCtx == nil { + t.Error("expected non-nil context") + } + if span == nil { + t.Error("expected non-nil span") + } + span.End() + }) +} + +func TestTraceHTTPResponse(t *testing.T) { + ctx := context.Background() + + t.Run("records success", func(t *testing.T) { + _, span := TraceHTTPRequest(ctx, "GET", "/api/v1/status", "exec-123") + TraceHTTPResponse(span, 200, nil) + span.End() + }) + + t.Run("records error", func(t *testing.T) { + _, span := TraceHTTPRequest(ctx, "POST", "/api/v1/stop", "exec-123") + TraceHTTPResponse(span, 500, fmt.Errorf("server error")) + span.End() + }) +} + +func TestTraceWSRequest(t *testing.T) { + ctx := context.Background() + + t.Run("returns non-nil context and span", func(t *testing.T) { + returnedCtx, span := TraceWSRequest(ctx, "agent.initialize", "msg-123", "exec-456", "sess-789") + if returnedCtx == nil { + t.Error("expected non-nil context") + } + if span == nil { + t.Error("expected non-nil span") + } + TraceWSResponse(span, "response", nil) + span.End() + }) +} + +func TestTraceAgentEvent(t *testing.T) { + ctx := context.Background() + + t.Run("does not panic", func(t *testing.T) { + TraceAgentEvent(ctx, "message_chunk", "sess-123", "exec-456", []byte(`{"type":"message_chunk"}`)) + }) + + t.Run("handles empty values", func(t *testing.T) { + TraceAgentEvent(ctx, "", "", "", nil) + }) +} + +func TestTraceWorkspaceEvent(t *testing.T) { + ctx := context.Background() + + t.Run("does not panic", func(t *testing.T) { + TraceWorkspaceEvent(ctx, "git_status", "exec-123", "sess-456") + }) + + t.Run("handles empty session_id", func(t *testing.T) { + TraceWorkspaceEvent(ctx, "file_change", "exec-123", "") + }) +} + +func TestTraceTurnEnd(t *testing.T) { + ctx := context.Background() + + t.Run("returns non-nil context and span", func(t *testing.T) { + returnedCtx, span := TraceTurnEnd(ctx, "exec-123", "sess-456") + if returnedCtx == nil { + t.Error("expected non-nil context") + } + if span == nil { + t.Error("expected non-nil span") + } + span.End() + }) +} + +func TestTraceMCPDispatch(t *testing.T) { + ctx := context.Background() + + t.Run("returns non-nil context and span", func(t *testing.T) { + returnedCtx, span := TraceMCPDispatch(ctx, "tools/call", "msg-789", "exec-123") + if returnedCtx == nil { + t.Error("expected non-nil context") + } + if span == nil { + t.Error("expected non-nil span") + } + TraceMCPResponse(span, nil) + span.End() + }) + + t.Run("records error", func(t *testing.T) { + _, span := TraceMCPDispatch(ctx, "tools/call", "msg-789", "exec-123") + TraceMCPResponse(span, fmt.Errorf("dispatch failed")) + span.End() + }) +} + +func TestTraceSessionStart(t *testing.T) { + ctx := context.Background() + + t.Run("returns non-nil context and span", func(t *testing.T) { + returnedCtx, span := TraceSessionStart(ctx, "task-1", "sess-1", "exec-1") + if returnedCtx == nil { + t.Error("expected non-nil context") + } + if span == nil { + t.Error("expected non-nil span") + } + span.End() + }) + + t.Run("child span shares trace ID with session", func(t *testing.T) { + sessionCtx, sessionSpan := TraceSessionStart(ctx, "task-2", "sess-2", "exec-2") + defer sessionSpan.End() + + // Create a child span using the session context + childCtx, childSpan := TraceHTTPRequest(sessionCtx, "POST", "/api/v1/start", "exec-2") + defer childSpan.End() + + sessionTraceID := sessionSpan.SpanContext().TraceID() + childTraceID := childSpan.SpanContext().TraceID() + + if sessionTraceID != childTraceID { + t.Errorf("child trace ID %s != session trace ID %s", childTraceID, sessionTraceID) + } + + _ = childCtx + }) +} + +func TestTraceSessionRecovered(t *testing.T) { + ctx := context.Background() + + t.Run("returns non-nil context and span", func(t *testing.T) { + returnedCtx, span := TraceSessionRecovered(ctx, "task-1", "sess-1", "exec-1") + if returnedCtx == nil { + t.Error("expected non-nil context") + } + if span == nil { + t.Error("expected non-nil span") + } + span.End() + }) +} + +func TestShutdown(t *testing.T) { + t.Run("no-op shutdown does not error", func(t *testing.T) { + if err := Shutdown(context.Background()); err != nil { + t.Errorf("expected nil error, got %v", err) + } + }) +} diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index e74e18da9f..608feaa204 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -39,6 +39,9 @@ const ( // EventTypeAvailableCommands indicates available slash commands from the agent. EventTypeAvailableCommands = "available_commands" + + // EventTypeSessionMode indicates the agent's session mode has changed. + EventTypeSessionMode = "session_mode" ) // Session status constants for EventTypeSessionStatus events. @@ -161,6 +164,26 @@ type AgentEvent struct { // AvailableCommands contains the slash commands available from the agent. AvailableCommands []AvailableCommand `json:"available_commands,omitempty"` + + // --- Multimodal content fields --- + + // ContentBlocks contains multimodal content (images, audio, resource links, etc.). + // Text-only messages still use the Text field for backward compatibility. + ContentBlocks []ContentBlock `json:"content_blocks,omitempty"` + + // Role distinguishes user vs assistant messages (e.g., during session/load replay). + // Values: "user", "assistant". Empty defaults to "assistant". + Role string `json:"role,omitempty"` + + // --- Tool call content fields (for "tool_call" and "tool_update" types) --- + + // ToolCallContents contains rich content produced by a tool call (diffs, text, terminals). + ToolCallContents []ToolCallContentItem `json:"tool_call_contents,omitempty"` + + // --- Session mode fields (for "session_mode" type) --- + + // CurrentModeID is the active session mode identifier (e.g., "code", "ask", "architect"). + CurrentModeID string `json:"current_mode_id,omitempty"` } // PlanEntry represents an entry in the agent's execution plan. @@ -182,4 +205,56 @@ type AvailableCommand struct { // Description is a human-readable description of the command. Description string `json:"description,omitempty"` + + // InputHint is a hint displayed when the command expects additional input. + InputHint string `json:"input_hint,omitempty"` +} + +// ContentBlock represents a multimodal content block from the agent. +// Supports text, image, audio, resource_link, and resource types. +type ContentBlock struct { + // Type identifies the content block type: "text", "image", "audio", "resource_link", "resource". + Type string `json:"type"` + + // Text contains text content (for "text" type). + Text string `json:"text,omitempty"` + + // Data contains base64-encoded binary data (for "image" and "audio" types). + Data string `json:"data,omitempty"` + + // MimeType identifies the media type (for "image", "audio", "resource_link" types). + MimeType string `json:"mime_type,omitempty"` + + // URI is the resource location (for "image", "resource_link" types). + URI string `json:"uri,omitempty"` + + // Name identifies the resource (for "resource_link" type). + Name string `json:"name,omitempty"` + + // Title is a human-readable title (for "resource_link" type). + Title string `json:"title,omitempty"` + + // Description provides additional context (for "resource_link" type). + Description string `json:"description,omitempty"` + + // Size is the resource size in bytes (for "resource_link" type). + Size *int `json:"size,omitempty"` +} + +// ToolCallContentItem represents a content item produced by a tool call. +// This is a discriminated union: exactly one of Content, Diff, or Terminal fields will be set. +type ToolCallContentItem struct { + // Type identifies the variant: "content", "diff", or "terminal". + Type string `json:"type"` + + // Content is a standard content block (text, image, resource). Set when Type is "content". + Content *ContentBlock `json:"content,omitempty"` + + // Diff fields are set when Type is "diff". + Path string `json:"path,omitempty"` + OldText *string `json:"old_text,omitempty"` // nil for new files + NewText string `json:"new_text,omitempty"` + + // TerminalID is set when Type is "terminal". + TerminalID string `json:"terminal_id,omitempty"` } diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index de17720197..440842c566 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -138,6 +138,11 @@ const ( AvailableCommandsUpdated = "available_commands.updated" // Available slash commands updated ) +// Event types for session mode +const ( + SessionModeChanged = "session_mode.changed" // Agent session mode changed +) + // BuildShellOutputSubject creates a shell output subject for a specific session func BuildShellOutputSubject(sessionID string) string { return ShellOutput + "." + sessionID @@ -248,3 +253,13 @@ func BuildAvailableCommandsSubject(sessionID string) string { func BuildAvailableCommandsWildcardSubject() string { return AvailableCommandsUpdated + ".*" } + +// BuildSessionModeSubject creates a session mode subject for a specific session +func BuildSessionModeSubject(sessionID string) string { + return SessionModeChanged + "." + sessionID +} + +// BuildSessionModeWildcardSubject creates a wildcard subscription for all session mode events +func BuildSessionModeWildcardSubject() string { + return SessionModeChanged + ".*" +} diff --git a/apps/backend/internal/gateway/websocket/session_notifications.go b/apps/backend/internal/gateway/websocket/session_notifications.go index a53313afae..e9892b23c6 100644 --- a/apps/backend/internal/gateway/websocket/session_notifications.go +++ b/apps/backend/internal/gateway/websocket/session_notifications.go @@ -48,6 +48,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB b.subscribe(eventBus, events.BuildProcessOutputWildcardSubject(), ws.ActionSessionProcessOutput) b.subscribe(eventBus, events.BuildProcessStatusWildcardSubject(), ws.ActionSessionProcessStatus) b.subscribe(eventBus, events.BuildAvailableCommandsWildcardSubject(), ws.ActionSessionAvailableCommands) + b.subscribe(eventBus, events.BuildSessionModeWildcardSubject(), ws.ActionSessionModeChanged) go func() { <-ctx.Done() diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 1334482f8f..299f547fec 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "time" "go.uber.org/zap" @@ -54,6 +55,9 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle case "available_commands": s.handleAvailableCommandsEvent(ctx, payload) + case "session_mode": + s.handleSessionModeEvent(ctx, payload) + case "permission_cancelled": s.handlePermissionCancelledEvent(ctx, payload) @@ -421,6 +425,23 @@ func (s *Service) handleAvailableCommandsEvent(ctx context.Context, payload *lif _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.AvailableCommandsUpdated, "orchestrator", eventPayload)) } +// handleSessionModeEvent broadcasts session_mode events to the WebSocket for the frontend. +func (s *Service) handleSessionModeEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + sessionID := payload.SessionID + if sessionID == "" || s.eventBus == nil || payload.Data.CurrentModeID == "" { + return + } + eventPayload := lifecycle.SessionModeEventPayload{ + TaskID: payload.TaskID, + SessionID: sessionID, + AgentID: payload.AgentID, + CurrentModeID: payload.Data.CurrentModeID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + subject := events.BuildSessionModeSubject(sessionID) + _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModeChanged, "orchestrator", eventPayload)) +} + // handlePermissionCancelledEvent marks the pending permission message as expired. func (s *Service) handlePermissionCancelledEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { sessionID := payload.SessionID diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 3713edfb5e..94652b1422 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -152,6 +152,7 @@ const ( ActionSessionTurnStarted = "session.turn.started" ActionSessionTurnCompleted = "session.turn.completed" ActionSessionAvailableCommands = "session.available_commands" + ActionSessionModeChanged = "session.mode_changed" ActionInputRequested = "input.requested" ActionRepositoryCreated = "repository.created" ActionRepositoryUpdated = "repository.updated" diff --git a/apps/backend/pkg/websocket/message.go b/apps/backend/pkg/websocket/message.go index 254a80eb73..6947f62941 100644 --- a/apps/backend/pkg/websocket/message.go +++ b/apps/backend/pkg/websocket/message.go @@ -18,11 +18,20 @@ const ( // Message is the base envelope for all WebSocket messages type Message struct { - ID string `json:"id,omitempty"` - Type MessageType `json:"type"` - Action string `json:"action"` - Payload json.RawMessage `json:"payload"` - Timestamp time.Time `json:"timestamp"` + ID string `json:"id,omitempty"` + Type MessageType `json:"type"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload"` + Timestamp time.Time `json:"timestamp"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// EnsureMetadata lazily initializes and returns the Metadata map. +func (m *Message) EnsureMetadata() map[string]string { + if m.Metadata == nil { + m.Metadata = make(map[string]string) + } + return m.Metadata } // Request represents a client request message diff --git a/apps/web/components/task/chat/messages/rich-blocks.tsx b/apps/web/components/task/chat/messages/rich-blocks.tsx index 9fe928d9c1..2a3cb322e8 100644 --- a/apps/web/components/task/chat/messages/rich-blocks.tsx +++ b/apps/web/components/task/chat/messages/rich-blocks.tsx @@ -1,9 +1,9 @@ "use client"; -import { IconBrain, IconCode, IconListCheck } from "@tabler/icons-react"; +import { IconBrain, IconCode, IconExternalLink, IconListCheck, IconPhoto } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import type { Message } from "@/lib/types/http"; -import type { RichMetadata } from "@/components/task/chat/types"; +import type { ContentBlock, RichMetadata } from "@/components/task/chat/types"; import { DiffViewBlock } from "@/components/task/chat/messages/diff-view-block"; import { normalizeDiffString } from "@/lib/diff"; import type { FileDiffData } from "@/lib/diff/types"; @@ -79,6 +79,9 @@ export function RichBlocks({ comment }: { comment: Message }) { .filter((item) => item.text); const diffData = resolveDiffPayload(metadata.diff); const diffText = typeof metadata.diff === "string" ? metadata.diff : null; + const contentBlocks = (metadata.content_blocks ?? []).filter( + (b) => b.type === "image" || b.type === "resource_link", + ); return ( <> @@ -126,6 +129,58 @@ export function RichBlocks({ comment }: { comment: Message }) { )} + {contentBlocks.length > 0 && contentBlocks.map((block, i) => ( + + ))} ); } + +function ContentBlockView({ block }: { block: ContentBlock }) { + switch (block.type) { + case 'image': + return ( +
+
+ + Image +
+ {block.data && ( + // eslint-disable-next-line @next/next/no-img-element -- base64 data URIs are not optimizable by next/image + Agent image + )} + {block.uri && !block.data && ( + // eslint-disable-next-line @next/next/no-img-element -- external agent image URIs + Agent image + )} +
+ ); + case 'resource_link': + return ( + + +
+
{block.title || block.name || block.uri}
+ {block.description && ( +
{block.description}
+ )} +
+
+ ); + default: + return null; + } +} diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts index 473a96efab..a9650d6296 100644 --- a/apps/web/components/task/chat/types.ts +++ b/apps/web/components/task/chat/types.ts @@ -108,10 +108,23 @@ export type StatusMetadata = { export type TodoMetadata = { text: string; done?: boolean } | string; +export type ContentBlock = { + type: string; // "text", "image", "audio", "resource_link", "resource" + text?: string; + data?: string; // base64 for image/audio + mime_type?: string; + uri?: string; + name?: string; + title?: string; + description?: string; + size?: number; +}; + export type RichMetadata = { thinking?: string; todos?: TodoMetadata[]; diff?: unknown; + content_blocks?: ContentBlock[]; }; export type DiffPayload = { diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index d067c12251..c7dab680f5 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -248,6 +248,61 @@ const tabComponents: Record(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const validPanels: Record = {}; + for (const [id, panel] of Object.entries(layout.panels)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const comp = (panel as any).contentComponent; + if (comp && VALID_COMPONENTS.has(comp)) { + validPanels[id] = panel; + } else { + invalidIds.add(id); + } + } + + if (invalidIds.size === 0) return layout; + + // Strip invalid panel IDs from grid leaf nodes + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function cleanNode(node: any): any { + if (node.type === "leaf") { + const views = (node.data.views as string[]).filter((v) => !invalidIds.has(v)); + if (views.length === 0) return null; + const activeView = views.includes(node.data.activeView) ? node.data.activeView : views[0]; + return { ...node, data: { ...node.data, views, activeView } }; + } + if (node.type === "branch") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children = (node.data as any[]).map(cleanNode).filter(Boolean); + if (children.length === 0) return null; + return { ...node, data: children }; + } + return node; + } + + const cleanedRoot = cleanNode(layout.grid.root); + if (!cleanedRoot) return null; // Entire layout is empty + + return { + ...layout, + grid: { ...layout.grid, root: cleanedRoot }, + panels: validPanels, + }; +} + /** Try to restore layout from per-session or global localStorage. Returns true if restored. */ function tryRestoreLayout( api: DockviewReadyEvent["api"], @@ -258,7 +313,9 @@ function tryRestoreLayout( try { const sessionLayout = getSessionLayout(currentSessionId); if (sessionLayout) { - api.fromJSON(sessionLayout as SerializedDockview); + const sanitized = sanitizeLayout(sessionLayout); + if (!sanitized) return false; + api.fromJSON(sanitized as SerializedDockview); useDockviewStore.setState(applyLayoutFixups(api)); return true; } @@ -274,7 +331,8 @@ function tryRestoreLayout( try { const saved = localStorage.getItem(LAYOUT_STORAGE_KEY); if (saved) { - const layout = JSON.parse(saved); + const layout = sanitizeLayout(JSON.parse(saved)); + if (!layout) return false; api.fromJSON(layout); useDockviewStore.setState(applyLayoutFixups(api)); return true; diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 003c334ce7..2169934303 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -305,6 +305,22 @@ function RightTopGroupActions() { ); } +function SessionModeBadge() { + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const modeId = useAppStore((state) => { + if (!activeSessionId) return null; + return state.sessionMode.bySessionId[activeSessionId] ?? null; + }); + + if (!modeId) return null; + + return ( + + {modeId} + + ); +} + function CenterRightActions() { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const repository = useAppStore((state) => { @@ -337,18 +353,21 @@ function CenterRightActions() { } }, [addBrowserPanel, hasDevScript, activeSessionId, upsertProcessStatus, setActiveProcess]); - if (!hasDevScript) return null; - return ( - +
+ + {hasDevScript && ( + + )} +
); } diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index 43549ea227..29ff691476 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -79,6 +79,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { contextWindow: { bySessionId: {} }, agents: { agents: [] }, availableCommands: { bySessionId: {} }, + sessionMode: { bySessionId: {} }, userShells: { bySessionId: {}, loading: {}, loaded: {} }, }; @@ -235,5 +236,9 @@ export const createSessionRuntimeSlice: StateCreator< set((draft) => { delete draft.availableCommands.bySessionId[sessionId]; }), + setSessionMode: (sessionId, modeId) => + set((draft) => { + draft.sessionMode.bySessionId[sessionId] = modeId; + }), ...buildUserShellActions(set), }); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 621b0fe345..11d57251bb 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -145,12 +145,17 @@ export type AgentState = { export type AvailableCommand = { name: string; description?: string; + input_hint?: string; }; export type AvailableCommandsState = { bySessionId: Record; }; +export type SessionModeState = { + bySessionId: Record; +}; + export type UserShellInfo = { terminalId: string; processId: string; @@ -176,6 +181,7 @@ export type SessionRuntimeSliceState = { contextWindow: ContextWindowState; agents: AgentState; availableCommands: AvailableCommandsState; + sessionMode: SessionModeState; userShells: UserShellsState; }; @@ -206,6 +212,8 @@ export type SessionRuntimeSliceActions = { // Available commands actions setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; clearAvailableCommands: (sessionId: string) => void; + // Session mode actions + setSessionMode: (sessionId: string, modeId: string) => void; // User shells actions setUserShells: (sessionId: string, shells: UserShellInfo[]) => void; setUserShellsLoading: (sessionId: string, loading: boolean) => void; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 1ee825a54b..9ac62f84a4 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -154,6 +154,7 @@ export type AppState = { contextWindow: (typeof defaultSessionRuntimeState)["contextWindow"]; agents: (typeof defaultSessionRuntimeState)["agents"]; availableCommands: (typeof defaultSessionRuntimeState)["availableCommands"]; + sessionMode: (typeof defaultSessionRuntimeState)["sessionMode"]; userShells: (typeof defaultSessionRuntimeState)["userShells"]; // UI slice @@ -296,6 +297,8 @@ export type AppState = { commands: import("./slices/session-runtime/types").AvailableCommand[], ) => void; clearAvailableCommands: (sessionId: string) => void; + // Session mode actions + setSessionMode: (sessionId: string, modeId: string) => void; // User shells actions setUserShells: ( sessionId: string, @@ -351,6 +354,7 @@ const defaultState = { contextWindow: defaultSessionRuntimeState.contextWindow, agents: defaultSessionRuntimeState.agents, availableCommands: defaultSessionRuntimeState.availableCommands, + sessionMode: defaultSessionRuntimeState.sessionMode, userShells: defaultSessionRuntimeState.userShells, previewPanel: defaultUIState.previewPanel, rightPanel: defaultUIState.rightPanel, @@ -412,6 +416,7 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat sessionCommits: { ...defaultState.sessionCommits, ...initialState.sessionCommits }, contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, agents: { ...defaultState.agents, ...initialState.agents }, + sessionMode: { ...defaultState.sessionMode, ...initialState.sessionMode }, userShells: { ...defaultState.userShells, ...initialState.userShells }, previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, @@ -478,6 +483,7 @@ export function createAppStore(initialState?: Partial) { gitStatus: merged.gitStatus, contextWindow: merged.contextWindow, agents: merged.agents, + sessionMode: merged.sessionMode, userShells: merged.userShells, previewPanel: merged.previewPanel, rightPanel: merged.rightPanel, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 78d7d0643a..83791de652 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -32,6 +32,7 @@ export type BackendMessageType = | "session.turn.started" | "session.turn.completed" | "session.available_commands" + | "session.mode_changed" | "executor.created" | "executor.updated" | "executor.deleted" @@ -326,6 +327,7 @@ export type TurnEventPayload = { export type AvailableCommandPayload = { name: string; description?: string; + input_hint?: string; }; export type AvailableCommandsPayload = { @@ -336,6 +338,14 @@ export type AvailableCommandsPayload = { timestamp: string; }; +export type SessionModeChangedPayload = { + task_id: string; + session_id: string; + agent_id: string; + current_mode_id: string; + timestamp?: string; +}; + export type TaskPlanEventPayload = { id: string; task_id: string; @@ -407,6 +417,7 @@ export type BackendMessageMap = { "session.available_commands", AvailableCommandsPayload >; + "session.mode_changed": BackendMessage<"session.mode_changed", SessionModeChangedPayload>; "executor.created": BackendMessage<"executor.created", ExecutorPayload>; "executor.updated": BackendMessage<"executor.updated", ExecutorPayload>; "executor.deleted": BackendMessage<"executor.deleted", ExecutorPayload>; diff --git a/apps/web/lib/ws/handlers/session-mode.ts b/apps/web/lib/ws/handlers/session-mode.ts new file mode 100644 index 0000000000..8d96657613 --- /dev/null +++ b/apps/web/lib/ws/handlers/session-mode.ts @@ -0,0 +1,18 @@ +import type { StoreApi } from 'zustand'; +import type { AppState } from '@/lib/state/store'; +import type { WsHandlers } from '@/lib/ws/handlers/types'; + +export function registerSessionModeHandlers(store: StoreApi): WsHandlers { + return { + 'session.mode_changed': (message) => { + const payload = message.payload; + if (!payload?.session_id || !payload?.current_mode_id) { + return; + } + const sessionId = payload.session_id as string; + const modeId = payload.current_mode_id as string; + + store.getState().setSessionMode(sessionId, modeId); + }, + }; +} diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts index f778b2b7b7..6cbd36f543 100644 --- a/apps/web/lib/ws/router.ts +++ b/apps/web/lib/ws/router.ts @@ -3,6 +3,7 @@ import type { AppState } from "@/lib/state/store"; import { registerAgentsHandlers } from "@/lib/ws/handlers/agents"; import { registerTaskSessionHandlers } from "@/lib/ws/handlers/agent-session"; import { registerAvailableCommandsHandlers } from "@/lib/ws/handlers/available-commands"; +import { registerSessionModeHandlers } from "@/lib/ws/handlers/session-mode"; import { registerWorkflowsHandlers } from "@/lib/ws/handlers/workflows"; import { registerMessagesHandlers } from "@/lib/ws/handlers/messages"; @@ -33,6 +34,7 @@ export function registerWsHandlers(store: StoreApi) { ...registerAgentsHandlers(store), ...registerTaskSessionHandlers(store), ...registerAvailableCommandsHandlers(store), + ...registerSessionModeHandlers(store), ...registerUsersHandlers(store), ...registerTerminalsHandlers(store), ...registerDiffsHandlers(store), diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 0000000000..2ae2da98d7 --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,120 @@ +# Backend Tracing + +Kandev includes optional OpenTelemetry (OTel) tracing for debugging agent communication. Two tracing layers are available: + +1. **Agent protocol** (`kandev-agentctl`) — traces protocol events between agentctl and the agent subprocess (ACP, StreamJSON, Codex, OpenCode, Amp, Copilot) +2. **Transport** (`kandev-transport`) — traces HTTP, WebSocket, and session lifecycle between the backend and agentctl + +Both layers use a shared OTel initialization in `internal/agentctl/tracing/` but have different activation requirements. + +## Enabling Tracing + +### Transport layer (backend <> agentctl) + +Only requires the OTLP endpoint: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +``` + +### Agent protocol layer (agentctl <> agent subprocess) + +Requires both the OTLP endpoint **and** the debug flag: + +```bash +export KANDEV_DEBUG_AGENT_MESSAGES=true +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +``` + +`KANDEV_DEBUG_AGENT_MESSAGES=true` also enables JSONL debug log files (see below). + +Without `OTEL_EXPORTER_OTLP_ENDPOINT`, all tracers are no-op with zero runtime overhead. + +## Running a Trace Collector + +Any OTLP-compatible collector works. Point `OTEL_EXPORTER_OTLP_ENDPOINT` to its HTTP receiver (typically port 4318). + +### Jaeger (quickstart) + +```bash +docker run --rm --name jaeger \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + -p 5778:5778 \ + -p 9411:9411 \ + cr.jaegertracing.io/jaegertracing/jaeger:2.15.0 +``` + +- UI: http://localhost:16686 +- OTLP HTTP receiver: `http://localhost:4318` + +### SigNoz / Grafana Tempo + +Point `OTEL_EXPORTER_OTLP_ENDPOINT` to the OTLP HTTP receiver (e.g. `http://localhost:4318`). + +## What Gets Traced + +### Session lifecycle + +Each agent session gets a long-lived root span (`session` or `session.recovered` for recovered sessions after backend restart). This span groups all activity for the session and is exported when the session ends. + +A short-lived `session.init` child span covers the initialization phase (stream connection, ACP handshake, session creation). This span ends immediately after init, making init-phase operations visible in the trace backend while the session is still active. + +> **Note:** The root session span appears as "Missing Span" in trace UIs while the session is active. This is expected — the OTel batch exporter only exports completed spans. Once the session ends (stop, completion, or deletion), the root span is exported and the full trace tree becomes visible. + +### Transport layer (`kandev-transport`) + +**HTTP calls** — all backend-to-agentctl HTTP requests are traced with method, path, status code, and execution ID. + +**Agent WebSocket stream** — outgoing requests (initialize, session/new, session/load, prompt, cancel, permission responses) are traced as round-trip spans. Incoming agent events (`agent.event.`) are traced as instant spans with the full raw JSON event attached as a span event. MCP request relays are also traced. + +**Workspace WebSocket stream** — only low-volume events are traced to avoid noise (git operations, file changes, process status, connection/error events). High-volume events (shell I/O, process output, ping/pong) are skipped. + +**Turn lifecycle** — a `turn_end` span is created when an agent turn completes, recording the stop reason and error state. + +All transport spans include `execution_id` and `session_id` attributes for filtering. + +### Agent protocol layer (`kandev-agentctl`) + +Traces protocol events between agentctl and the agent subprocess. Each event span includes both the raw protocol JSON and the normalized `AgentEvent` as span events, enabling side-by-side comparison. Prompt spans include `session_id` and prompt length. + +## Debug Log Files + +When `KANDEV_DEBUG_AGENT_MESSAGES=true` is set, raw and normalized protocol events are also written to JSONL files in the working directory (or `KANDEV_DEBUG_LOG_DIR`): + +- `raw-{protocol}-{agentId}.jsonl` — raw protocol JSON +- `normalized-{protocol}-{agentId}.jsonl` — normalized AgentEvent + +## Architecture + +``` +internal/agentctl/tracing/ +├── otel.go # Shared OTel TracerProvider init (lazy, sync.Once) +└── transport.go # Transport-layer span helpers (session, HTTP, WS, events) + +internal/agentctl/server/adapter/transport/shared/ +├── tracing.go # Protocol-layer tracing (delegates OTel init to tracing/) +└── debug.go # JSONL file logging + protocol constants + +internal/agentctl/client/ +├── client.go # HTTP call tracing +├── client_stream.go # WS request/response tracing +├── agent.go # Agent event + MCP dispatch tracing +└── workspace_stream.go # Workspace event tracing + +internal/agent/lifecycle/ +├── session.go # Session span creation (session.init) +├── manager_lifecycle.go # Recovery span creation (session.recovered) +└── types.go # AgentExecution.SessionTraceContext / EndSessionSpan +``` + +The `executionID` (instance ID) is passed to each `Client` via `WithExecutionID()` and appears on every span, making it easy to filter traces for a specific agent session. + +## Example: Debugging a Failed Resume + +1. Start your trace collector and set the env vars +2. Trigger a session resume in the UI +3. Search for service `kandev-agentctl` or filter by `execution_id=` +4. Look for the `session.init` span — init-phase operations (initialize, session/load, prompt) will be nested under it +5. If a span has an error status, expand it to see the recorded error