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) => (
+