diff --git a/.gitignore b/.gitignore index 4ba36be831..2df5567e12 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ node_modules/ # TypeScript *.tsbuildinfo apps/landing/next-env.d.ts + +# agent message logs +raw-*.jsonl +normalized-*.jsonl diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index ed1721c90c..6ba316e53c 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -237,7 +237,8 @@ func (a *orchestratorAdapter) ResumeTaskSession(ctx context.Context, taskID, tas // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { - svc *taskservice.Service + svc *taskservice.Service + logger *logger.Logger } // CreateAgentMessage creates a message with author_type="agent" @@ -264,33 +265,84 @@ func (a *messageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, c return err } +// toolKindToMessageType maps the normalized tool kind to a frontend message type. +// This allows the frontend to use specialized renderers for different tool categories. +// Returns the message type and whether a warning should be logged (for unknown kinds). +func toolKindToMessageType(kind streams.ToolKind) (string, bool) { + switch kind { + case streams.ToolKindReadFile: + return "tool_read", false + case streams.ToolKindCodeSearch: + return "tool_search", false + case streams.ToolKindModifyFile: + return "tool_edit", false + case streams.ToolKindShellExec: + return "tool_execute", false + case streams.ToolKindHttpRequest: + return "tool_call", false + case streams.ToolKindGeneric: + return "tool_call", false + case streams.ToolKindCreateTask: + return "tool_call", false + case streams.ToolKindSubagentTask: + return "tool_call", false + case streams.ToolKindShowPlan: + return "tool_call", false + case streams.ToolKindManageTodos: + return "todo", false + case streams.ToolKindMisc: + return "tool_call", false + default: + // Unknown kind - this shouldn't happen, log a warning + return "tool_call", true + } +} + // CreateToolCallMessage creates a message for a tool call -func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { +func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { metadata := map[string]interface{}{ "tool_call_id": toolCallID, "title": title, "status": status, } + // Add parent tool call ID for subagent nesting (if present) + if parentToolCallID != "" { + metadata["parent_tool_call_id"] = parentToolCallID + } // Add normalized tool data to metadata for frontend consumption if normalized != nil { metadata["normalized"] = normalized } + // Determine message type from the normalized tool kind + msgType := "tool_call" + if normalized != nil { + var warn bool + msgType, warn = toolKindToMessageType(normalized.Kind()) + if warn { + a.logger.Warn("unknown tool kind falling back to tool_call", + zap.String("kind", string(normalized.Kind())), + zap.String("tool_call_id", toolCallID), + zap.String("title", title)) + } + } + _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: title, AuthorType: "agent", - Type: "tool_call", + Type: msgType, Metadata: metadata, }) return err } -// UpdateToolCallMessage updates a tool call message's status -func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, normalized *streams.NormalizedPayload) error { - return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, normalized) +// UpdateToolCallMessage updates a tool call message's status. +// If the message doesn't exist, it creates it using taskID, turnID, and msgType. +func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error { + return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType) } // CreateSessionMessage creates a message for non-chat session updates (status/progress/error/etc). diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 239eb1165c..b2683eac8b 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -36,7 +36,6 @@ import ( editorhandlers "github.com/kandev/kandev/internal/editors/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" - notificationservice "github.com/kandev/kandev/internal/notifications/service" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" @@ -199,7 +198,7 @@ func main() { log.Info("Task Service initialized") userCtrl := usercontroller.NewController(userSvc) - var notificationSvc *notificationservice.Service + var gateway *gateways.Gateway var notificationCtrl *notificationcontroller.Controller editorCtrl := editorcontroller.NewController(editorSvc) promptCtrl := promptcontroller.NewController(promptSvc) @@ -278,7 +277,7 @@ func main() { // WEBSOCKET GATEWAY (All communication via WebSocket) // ============================================ log.Info("Initializing WebSocket Gateway...") - gateway, _, _, err := provideGateway( + gateway, _, notificationCtrl, err = provideGateway( ctx, log, eventBus, @@ -294,31 +293,11 @@ func main() { log.Fatal("Failed to initialize WebSocket gateway", zap.Error(err)) } - // Start the WebSocket hub - go gateway.Hub.Run(ctx) - gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) - gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log) + // Note: Hub is started and TaskNotifications/UserNotifications/TaskSessionStateChanged + // are already registered by provideGateway. + // Only register SessionStreamNotifications here as it's not part of provideGateway gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log) - notificationSvc = notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log) - notificationCtrl = notificationcontroller.NewController(notificationSvc) - if eventBus != nil { - _, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error { - data, ok := event.Data.(map[string]interface{}) - if !ok { - return nil - } - taskID, _ := data["task_id"].(string) - sessionID, _ := data["session_id"].(string) - newState, _ := data["new_state"].(string) - notificationSvc.HandleTaskSessionStateChanged(ctx, taskID, sessionID, newState) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to task session notifications", zap.Error(err)) - } - } - // Set up session data provider for session subscriptions // Sends initial git status when a client subscribes to a session gateway.Hub.SetSessionDataProvider(func(ctx context.Context, sessionID string) ([]*ws.Message, error) { diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 3840aa02d9..4914ef4eab 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -36,7 +36,7 @@ func provideOrchestrator( orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, log) taskSvc.SetExecutionStopper(orchestratorSvc) - msgCreator := &messageCreatorAdapter{svc: taskSvc} + msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log} orchestratorSvc.SetMessageCreator(msgCreator) orchestratorSvc.SetTurnService(newTurnServiceAdapter(taskSvc)) diff --git a/apps/backend/docs/debugging_normalization.md b/apps/backend/docs/debugging_normalization.md new file mode 100644 index 0000000000..7fca9a0e62 --- /dev/null +++ b/apps/backend/docs/debugging_normalization.md @@ -0,0 +1,325 @@ +# Debugging Tool Normalization Issues + +This guide explains how to debug and fix issues where tool calls from agents are not rendering correctly in the frontend (e.g., a shell command showing as generic `tool_call` instead of `tool_execute`). + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Tool Call Normalization Flow │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Agent CLI Adapter Lifecycle Orchestrator │ +│ ┌────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │ +│ │ Codex │──JSON──│ Codex │──Event──│ Manager │──Event──│ Event │ │ +│ │ Claude │ │ Adapter │ │ │ │ Handlers │ │ +│ │ etc. │ └────┬─────┘ └────┬────┘ └─────┬──────┘ │ +│ └────────┘ │ │ │ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ NormalizedPayload AgentEvent Message DB │ +│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ kind: ... │ ───▶ │Normalized│ ──────▶│type: ... │ │ +│ │ shell_exec │ │Payload │ │tool_exec │ │ +│ └─────────────┘ └──────────┘ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ WebSocket Event │ +│ ┌──────────────┐ │ +│ │type:tool_exec│ │ +│ └──────┬───────┘ │ +│ │ │ +│ ▼ │ +│ Frontend │ +│ ┌──────────────┐ │ +│ │ToolExecute │ │ +│ │ Message.tsx │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Key Files + +| Layer | File | Purpose | +|-------|------|---------| +| Types | `internal/agentctl/types/streams/tool_payload.go` | `NormalizedPayload` struct and factory functions | +| Codex Adapter | `internal/agentctl/server/adapter/transport/codex/adapter.go` | Converts Codex events to `AgentEvent` | +| Codex Normalizer | `internal/agentctl/server/adapter/transport/codex/normalize.go` | Creates `NormalizedPayload` for Codex tools | +| StreamJSON Adapter | `internal/agentctl/server/adapter/transport/streamjson/adapter.go` | Converts Claude Code events to `AgentEvent` | +| StreamJSON Normalizer | `internal/agentctl/server/adapter/transport/streamjson/normalize.go` | Creates `NormalizedPayload` for Claude tools | +| OpenCode Normalizer | `internal/agentctl/server/adapter/transport/opencode/normalize.go` | Creates `NormalizedPayload` for OpenCode tools | +| Lifecycle Events | `internal/agent/lifecycle/events.go` | Publishes `AgentStreamEvent` to event bus | +| Orchestrator Handlers | `internal/orchestrator/event_handlers.go` | Handles events and creates DB messages | +| Message Creator | `cmd/kandev/adapters.go` | `toolKindToMessageType()` maps kind to message type | +| Frontend Renderer | `apps/web/components/task/chat/message-renderer.tsx` | Routes message types to components | + +## Tool Kind to Message Type Mapping + +The `NormalizedPayload.Kind()` determines which frontend component renders the tool: + +| Tool Kind | Message Type | Frontend Component | +|-----------|-------------|-------------------| +| `read_file` | `tool_read` | `ToolReadMessage` | +| `code_search` | `tool_search` | `ToolSearchMessage` | +| `modify_file` | `tool_edit` | `ToolEditMessage` | +| `shell_exec` | `tool_execute` | `ToolExecuteMessage` | +| `manage_todos` | `todo` | `TodoMessage` | +| `subagent_task` | `tool_call` | `ToolSubagentMessage` | +| `generic` | `tool_call` | `ToolCallMessage` | +| (nil/unknown) | `tool_call` | `ToolCallMessage` | + +## Debugging Steps + +### 1. Check Raw Events + +Raw events are logged to `raw--.jsonl` files in the backend directory: + +```bash +# View raw Codex events +tail -f raw-codex-codex.jsonl | jq . + +# View raw Claude events +tail -f raw-streamjson-claude.jsonl | jq . +``` + +### 2. Check Normalized Events + +Normalized events show what the adapter produced: + +```bash +# View normalized events +tail -f normalized-codex-codex.jsonl | jq . +``` + +Look for the `normalized` field in `tool_call` events: +```json +{ + "event": { + "type": "tool_call", + "normalized": { + "kind": "shell_exec", + "shell_exec": { + "command": "ls -la", + "work_dir": "/path/to/dir" + } + } + } +} +``` + +If `normalized` is missing or `kind` is wrong, the issue is in the adapter. + +### 3. Check Database Message Type + +Query the database to see what message type was stored: + +```sql +SELECT id, type, metadata->>'normalized' as normalized +FROM task_session_messages +WHERE metadata->>'tool_call_id' = '' +ORDER BY created_at DESC LIMIT 1; +``` + +If the type is `tool_call` but should be `tool_execute`, the issue is in the message creation flow. + +### 4. Check WebSocket Events + +Use browser DevTools Network tab to inspect WebSocket messages. Look for `session.message.added` events and check the `type` field. + +## Common Issues + +### Issue 1: Missing NormalizedPayload in tool_update Events + +**Symptom:** Tool calls render as generic `tool_call` instead of specialized type. + +**Cause:** The adapter sends `tool_update` events (e.g., on completion) without the `NormalizedPayload`. When there's a race condition and the message doesn't exist yet, the fallback creation uses `tool_call` as default. + +**Example (Codex):** +```go +// BAD - tool_update without normalized payload +update := AgentEvent{ + Type: streams.EventTypeToolUpdate, + ToolCallID: p.Item.ID, + ToolStatus: "complete", +} + +// GOOD - tool_update with normalized payload +update := AgentEvent{ + Type: streams.EventTypeToolUpdate, + ToolCallID: p.Item.ID, + ToolStatus: "complete", + NormalizedPayload: a.normalizer.NormalizeToolCall("commandExecution", args), +} +``` + +**Fix Location:** Adapter's notification handler (e.g., `NotifyItemCompleted` in Codex adapter). + +### Issue 2: Wrong Tool Name Passed to Normalizer + +**Symptom:** Tool renders as `tool_call` (generic) instead of specialized type. + +**Cause:** The normalizer's switch statement doesn't match the tool name. + +**Example:** +```go +// Normalizer expects "commandExecution" but receives "command_execution" +switch toolName { +case CodexItemCommandExecution: // "commandExecution" + return n.normalizeCommand(args) +default: + return n.normalizeGeneric(toolName, args) // Falls through here +} +``` + +**Fix:** Check the exact tool name string in the protocol and match it in the normalizer. + +### Issue 3: Factory Function Not Setting Kind + +**Symptom:** `normalized.Kind()` returns empty or wrong value. + +**Cause:** Factory function doesn't set the `kind` field correctly. + +**Example:** +```go +// BAD - missing kind +func NewShellExec(...) *NormalizedPayload { + return &NormalizedPayload{ + shellExec: &ShellExecPayload{...}, + } +} + +// GOOD - kind is set +func NewShellExec(...) *NormalizedPayload { + return &NormalizedPayload{ + kind: ToolKindShellExec, + shellExec: &ShellExecPayload{...}, + } +} +``` + +**Fix Location:** `internal/agentctl/types/streams/tool_payload.go` + +### Issue 4: toolKindToMessageType Not Handling New Kind + +**Symptom:** New tool kind renders as generic `tool_call`. + +**Cause:** The `toolKindToMessageType` function doesn't have a case for the new kind. + +**Fix Location:** Two places need updating: +1. `internal/orchestrator/event_handlers.go` - `toolKindToMessageType()` +2. `cmd/kandev/adapters.go` - `toolKindToMessageType()` + +### Issue 5: Missing UnmarshalJSON for NormalizedPayload + +**Symptom:** All tool calls render as `tool_call` even though adapter logs show correct `kind`. + +**Cause:** The `NormalizedPayload` struct has unexported fields. Without a custom `UnmarshalJSON` method, the standard library cannot populate these fields when deserializing events from WebSocket/JSON. + +**Example:** +```go +// NormalizedPayload with unexported fields requires both methods: +func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { ... } +func (p *NormalizedPayload) UnmarshalJSON(data []byte) error { ... } // REQUIRED! +``` + +**Fix Location:** `internal/agentctl/types/streams/tool_payload.go` + +**How to verify:** Run the round-trip test: +```bash +go test ./internal/agentctl/types/streams/... -v -run MarshalRoundTrip +``` + +## Adding a New Tool Kind + +1. **Add kind constant** in `internal/agentctl/types/streams/tool_payload.go`: + ```go + const ( + ToolKindMyNewTool ToolKind = "my_new_tool" + ) + ``` + +2. **Add payload struct** with fields: + ```go + type MyNewToolPayload struct { + SomeField string `json:"some_field"` + } + ``` + +3. **Add unexported field** to `NormalizedPayload`: + ```go + type NormalizedPayload struct { + kind ToolKind + myNewTool *MyNewToolPayload + // ... + } + ``` + +4. **Add getter method**: + ```go + func (p *NormalizedPayload) MyNewTool() *MyNewToolPayload { return p.myNewTool } + ``` + +5. **Add to MarshalJSON**: + ```go + type jsonPayload struct { + MyNewTool *MyNewToolPayload `json:"my_new_tool,omitempty"` + // ... + } + ``` + +6. **Add factory function**: + ```go + func NewMyNewTool(someField string) *NormalizedPayload { + return &NormalizedPayload{ + kind: ToolKindMyNewTool, + myNewTool: &MyNewToolPayload{ + SomeField: someField, + }, + } + } + ``` + +7. **Update normalizers** in each adapter to call the factory function. + +8. **Update toolKindToMessageType** in both locations: + ```go + case streams.ToolKindMyNewTool: + return "tool_my_new" + ``` + +9. **Add frontend component** and update `message-renderer.tsx`: + ```tsx + { + matches: (comment) => comment.type === 'tool_my_new', + render: (comment) => , + }, + ``` + +## Testing + +### Unit Tests + +Run normalizer tests: +```bash +go test ./internal/agentctl/server/adapter/transport/streamjson/... -v +go test ./internal/agentctl/server/adapter/transport/codex/... -v +``` + +### Integration Testing + +1. Start a session with the agent +2. Trigger the tool you're testing +3. Check normalized events file +4. Check database message type +5. Verify frontend renders correct component + +## Checklist for Fixing Normalization Issues + +- [ ] Check raw events to confirm agent sends expected data +- [ ] Check normalized events to confirm adapter produces correct payload +- [ ] Verify `NormalizedPayload.Kind()` returns correct kind +- [ ] Verify `tool_call` event includes `NormalizedPayload` +- [ ] Verify `tool_update` event includes `NormalizedPayload` (for race condition handling) +- [ ] Check `toolKindToMessageType` handles the kind in both files +- [ ] Verify database stores correct message type +- [ ] Verify frontend `message-renderer.tsx` has matching adapter diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 9641f843e8..5daa235b1c 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -50,6 +50,10 @@ type AgentStreamEventData struct { SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events Data interface{} `json:"data,omitempty"` + // ParentToolCallID identifies the parent Task tool call when this event + // comes from a subagent. Used for visual nesting in the UI. + ParentToolCallID string `json:"parent_tool_call_id,omitempty"` + // Normalized contains the typed tool payload data. // This is used to populate message metadata with structured tool information. Normalized *streams.NormalizedPayload `json:"normalized,omitempty"` diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 7016194f6a..35afa2a062 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -115,17 +115,18 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even // Build the nested event data // event.SessionID is the ACP session ID (internal agent protocol session) eventData := &AgentStreamEventData{ - Type: event.Type, - ACPSessionID: event.SessionID, - Text: event.Text, - ToolCallID: event.ToolCallID, - ToolName: event.ToolName, - ToolTitle: event.ToolTitle, - ToolStatus: event.ToolStatus, - Error: event.Error, - SessionStatus: event.SessionStatus, - Data: event.Data, - Normalized: event.NormalizedPayload, + Type: event.Type, + ACPSessionID: event.SessionID, + Text: event.Text, + ToolCallID: event.ToolCallID, + ParentToolCallID: event.ParentToolCallID, + ToolName: event.ToolName, + ToolTitle: event.ToolTitle, + ToolStatus: event.ToolStatus, + Error: event.Error, + SessionStatus: event.SessionStatus, + Data: event.Data, + Normalized: event.NormalizedPayload, } // Build agent event message payload diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index d0dcaccade..1fa9be1672 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -13,7 +13,7 @@ "cmd": [ "npx", "-y", - "@augmentcode/auggie@0.14.0", + "@augmentcode/auggie@0.15.0", "--acp" ], "passthrough_config": { @@ -688,7 +688,10 @@ }, "model_config": { "default_model": "anthropic/claude-sonnet-4-20250514", - "dynamic_models_cmd": ["opencode", "models"], + "dynamic_models_cmd": [ + "opencode", + "models" + ], "dynamic_models_timeout_ms": 30000, "available_models": [ { 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 915968f9a0..12b6c5bfe0 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -72,6 +72,10 @@ type Adapter struct { // When set, this context will be prepended to the first prompt sent to the session. pendingContext string + // Tool call tracking for result normalization + // Maps toolCallId -> NormalizedPayload so we can update with results + activeToolCalls map[string]*streams.NormalizedPayload + // Synchronization mu sync.RWMutex closed bool @@ -82,11 +86,12 @@ type Adapter struct { // cfg.AgentID is required for debug file naming. func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { return &Adapter{ - cfg: cfg, - logger: log.WithFields(zap.String("adapter", "acp"), zap.String("agent_id", cfg.AgentID)), - agentID: cfg.AgentID, - normalizer: NewNormalizer(), - updatesCh: make(chan AgentEvent, 100), + cfg: cfg, + logger: log.WithFields(zap.String("adapter", "acp"), zap.String("agent_id", cfg.AgentID)), + agentID: cfg.AgentID, + normalizer: NewNormalizer(), + updatesCh: make(chan AgentEvent, 100), + activeToolCalls: make(map[string]*streams.NormalizedPayload), } } @@ -446,6 +451,12 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { toolKind := string(u.ToolCall.Kind) normalizedPayload := a.normalizer.NormalizeToolCall(toolKind, args) + // Store the payload for later result updates + toolCallID := string(u.ToolCall.ToolCallId) + a.mu.Lock() + a.activeToolCalls[toolCallID] = normalizedPayload + a.mu.Unlock() + // Detect tool type for logging toolType := DetectToolOperationType(toolKind, args) _ = toolType // Used for normalization @@ -459,7 +470,7 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { return &AgentEvent{ Type: streams.EventTypeToolCall, SessionID: string(n.SessionId), - ToolCallID: string(u.ToolCall.ToolCallId), + ToolCallID: toolCallID, ToolName: string(u.ToolCall.Kind), // Kind is effectively the tool name ToolTitle: u.ToolCall.Title, ToolStatus: status, @@ -467,6 +478,7 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { } case u.ToolCallUpdate != nil: + toolCallID := string(u.ToolCallUpdate.ToolCallId) status := "" if u.ToolCallUpdate.Status != nil { status = string(*u.ToolCallUpdate.Status) @@ -475,11 +487,35 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { if status == "completed" { status = "complete" } + + // Look up the stored payload and update with result if we have rawOutput + var normalizedPayload *streams.NormalizedPayload + if u.ToolCallUpdate.RawOutput != nil { + a.mu.Lock() + if payload, ok := a.activeToolCalls[toolCallID]; ok { + // Update the payload with the result + a.normalizer.NormalizeToolResult(payload, u.ToolCallUpdate.RawOutput) + normalizedPayload = payload + // Clean up if completed + if status == "complete" || status == "error" { + delete(a.activeToolCalls, toolCallID) + } + } + a.mu.Unlock() + } else if status == "complete" || status == "error" { + // Clean up even without output + a.mu.Lock() + normalizedPayload = a.activeToolCalls[toolCallID] + delete(a.activeToolCalls, toolCallID) + a.mu.Unlock() + } + return &AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: string(n.SessionId), - ToolCallID: string(u.ToolCallUpdate.ToolCallId), - ToolStatus: status, + Type: streams.EventTypeToolUpdate, + SessionID: string(n.SessionId), + ToolCallID: toolCallID, + ToolStatus: status, + NormalizedPayload: normalizedPayload, } case u.Plan != nil: diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go index 1c00a54ab6..5b2005d9db 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -1,6 +1,7 @@ package acp import ( + "strconv" "strings" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" @@ -16,6 +17,12 @@ func DetectToolOperationType(toolKind string, args map[string]any) string { case "edit": return "tool_edit" case "read": + // Check if this is a directory read (file listing) + if rawInput, ok := args["raw_input"].(map[string]any); ok { + if readType, ok := rawInput["type"].(string); ok && readType == "directory" { + return "tool_search" + } + } return "tool_read" case "execute": return "tool_execute" @@ -30,6 +37,8 @@ func DetectToolOperationType(toolKind string, args map[string]any) string { return "tool_read" case "execute", "bash", "run": return "tool_execute" + case "glob", "grep", "search": + return "tool_search" default: return "tool_call" // Generic fallback } @@ -67,18 +76,131 @@ func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *st // NormalizeToolResult updates the payload with tool result data. func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - switch payload.Kind { + // Extract rawOutput.output if result is wrapped + output := extractRawOutput(result) + + switch payload.Kind() { + case streams.ToolKindReadFile: + if payload.ReadFile() != nil && output != "" { + lines := strings.Count(output, "\n") + if !strings.HasSuffix(output, "\n") && len(output) > 0 { + lines++ // Count the last line if it doesn't end with newline + } + payload.ReadFile().Output = &streams.ReadFileOutput{ + Content: output, + LineCount: lines, + } + } + case streams.ToolKindCodeSearch: + if payload.CodeSearch() != nil && output != "" { + // Parse output as file listing (one file per line) + files := parseFileList(output) + payload.CodeSearch().Output = &streams.CodeSearchOutput{ + Files: files, + FileCount: len(files), + } + } case streams.ToolKindShellExec: - if payload.ShellExec != nil { - shared.NormalizeShellResult(payload.ShellExec, result) + if payload.ShellExec() != nil && output != "" { + // Parse ACP's XML-like shell output format + exitCode, stdout, stderr := parseShellOutput(output) + payload.ShellExec().Output = &streams.ShellExecOutput{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + } } case streams.ToolKindGeneric: - if payload.Generic != nil { - payload.Generic.Output = result + if payload.Generic() != nil { + payload.Generic().Output = result } } } +// extractRawOutput gets the output string from ACP result data. +// ACP wraps results in {"rawOutput": {"output": "..."}} +func extractRawOutput(result any) string { + if result == nil { + return "" + } + + // Try direct string + if s, ok := result.(string); ok { + return s + } + + // Try rawOutput.output pattern + resultMap, ok := result.(map[string]any) + if !ok { + return "" + } + + // Check for rawOutput wrapper + if rawOutput, ok := resultMap["rawOutput"].(map[string]any); ok { + if output, ok := rawOutput["output"].(string); ok { + return output + } + } + + // Check for direct output field + if output, ok := resultMap["output"].(string); ok { + return output + } + + return "" +} + +// parseFileList parses a newline-separated file listing into a slice of paths. +func parseFileList(output string) []string { + var files []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Skip header lines that don't look like paths + if strings.HasPrefix(line, "Here's") || strings.HasPrefix(line, "Files") { + continue + } + files = append(files, line) + } + return files +} + +// parseShellOutput parses ACP's XML-like shell output format. +// Format: "...N........." +// Returns exit code, stdout, and stderr (stderr from tag if present). +func parseShellOutput(output string) (exitCode int, stdout, stderr string) { + // Extract return code + if start := strings.Index(output, ""); start != -1 { + start += len("") + if end := strings.Index(output[start:], ""); end != -1 { + codeStr := strings.TrimSpace(output[start : start+end]) + if code, err := strconv.Atoi(codeStr); err == nil { + exitCode = code + } + } + } + + // Extract stdout from tag + if start := strings.Index(output, ""); start != -1 { + start += len("") + if end := strings.Index(output[start:], ""); end != -1 { + stdout = strings.TrimSpace(output[start : start+end]) + } + } + + // Extract stderr from tag if present + if start := strings.Index(output, ""); start != -1 { + start += len("") + if end := strings.Index(output[start:], ""); end != -1 { + stderr = strings.TrimSpace(output[start : start+end]) + } + } + + return exitCode, stdout, stderr +} + // normalizeEdit converts ACP edit tool data. func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { rawInput, _ := args["raw_input"].(map[string]any) @@ -92,17 +214,11 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo path = extractPathFromLocations(args) } - payload := &streams.NormalizedPayload{ - Kind: streams.ToolKindModifyFile, - ModifyFile: &streams.ModifyFilePayload{ - FilePath: path, - Mutations: []streams.FileMutation{}, - }, - } + var mutations []streams.FileMutation // Check if this is file creation (has file_content) vs str_replace if fileContent, ok := rawInput["file_content"].(string); ok { - payload.ModifyFile.Mutations = append(payload.ModifyFile.Mutations, streams.FileMutation{ + mutations = append(mutations, streams.FileMutation{ Type: streams.MutationCreate, Content: fileContent, }) @@ -130,13 +246,15 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo mutation.Diff = shared.GenerateUnifiedDiff(oldStr, newStr, path, mutation.StartLine) } - payload.ModifyFile.Mutations = append(payload.ModifyFile.Mutations, mutation) + mutations = append(mutations, mutation) } - return payload + // Use factory function + return streams.NewModifyFile(path, mutations) } // normalizeRead converts ACP read tool data. +// If rawInput.type is "directory", this becomes a code search (file listing) operation. func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { rawInput, _ := args["raw_input"].(map[string]any) if rawInput == nil { @@ -148,6 +266,11 @@ func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPaylo path = extractPathFromLocations(args) } + // Check if this is a directory read - treat as code search (file listing) + if readType := shared.GetString(rawInput, "type"); readType == "directory" { + return streams.NewCodeSearch("", "", path, "") + } + return streams.NewReadFile(path, 0, 0) } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go index 963cee6828..6b3762659f 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_test.go @@ -32,16 +32,24 @@ func TestACPNormalization(t *testing.T) { // Verify the Kind is set correctly based on tool type switch toolType { case "tool_edit": - if payload.Kind != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind) + if payload.Kind() != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind()) } case "tool_read": - if payload.Kind != streams.ToolKindReadFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, payload.Kind) + if payload.Kind() != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, payload.Kind()) } case "tool_execute": - if payload.Kind != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind) + if payload.Kind() != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind()) + } + case "tool_search": + if payload.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, payload.Kind()) + } + case "tool_call": + if payload.Kind() != streams.ToolKindGeneric { + t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, payload.Kind()) } } }) @@ -105,9 +113,15 @@ func TestDetectToolOperationType(t *testing.T) { want: "tool_execute", }, { - name: "unknown kind falls back to tool_call", + name: "search kind returns tool_search", toolKind: "search", args: map[string]any{"kind": "search"}, + want: "tool_search", + }, + { + name: "unknown kind falls back to tool_call", + toolKind: "custom_tool", + args: map[string]any{"kind": "custom_tool"}, want: "tool_call", }, { @@ -122,6 +136,17 @@ func TestDetectToolOperationType(t *testing.T) { args: map[string]any{"kind": "edit"}, want: "tool_edit", }, + { + name: "read with directory type returns tool_search", + toolKind: "read", + args: map[string]any{ + "kind": "read", + "raw_input": map[string]any{ + "type": "directory", + }, + }, + want: "tool_search", + }, } for _, tt := range tests { @@ -246,19 +271,19 @@ func TestNormalizerEdit(t *testing.T) { } result := normalizer.NormalizeToolCall("edit", args) - if result.Kind != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind) + if result.Kind() != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) } - if result.ModifyFile == nil { + if result.ModifyFile() == nil { t.Fatal("expected ModifyFile to be set") } - if result.ModifyFile.FilePath != "file.ts" { - t.Errorf("expected FilePath 'file.ts', got %q", result.ModifyFile.FilePath) + if result.ModifyFile().FilePath != "file.ts" { + t.Errorf("expected FilePath 'file.ts', got %q", result.ModifyFile().FilePath) } - if len(result.ModifyFile.Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile.Mutations)) + if len(result.ModifyFile().Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) } - mutation := result.ModifyFile.Mutations[0] + mutation := result.ModifyFile().Mutations[0] if mutation.OldContent != "old code" { t.Errorf("expected OldContent 'old code', got %q", mutation.OldContent) } @@ -287,8 +312,8 @@ func TestNormalizerEdit(t *testing.T) { } result := normalizer.NormalizeToolCall("edit", args) - if result.ModifyFile.FilePath != "/workspace/fallback.ts" { - t.Errorf("expected FilePath '/workspace/fallback.ts', got %q", result.ModifyFile.FilePath) + if result.ModifyFile().FilePath != "/workspace/fallback.ts" { + t.Errorf("expected FilePath '/workspace/fallback.ts', got %q", result.ModifyFile().FilePath) } }) } @@ -305,14 +330,14 @@ func TestNormalizerRead(t *testing.T) { }, } result := normalizer.NormalizeToolCall("read", args) - if result.Kind != streams.ToolKindReadFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind) + if result.Kind() != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind()) } - if result.ReadFile == nil { + if result.ReadFile() == nil { t.Fatal("expected ReadFile to be set") } - if result.ReadFile.FilePath != "config.json" { - t.Errorf("expected FilePath 'config.json', got %q", result.ReadFile.FilePath) + if result.ReadFile().FilePath != "config.json" { + t.Errorf("expected FilePath 'config.json', got %q", result.ReadFile().FilePath) } }) @@ -327,8 +352,133 @@ func TestNormalizerRead(t *testing.T) { }, } result := normalizer.NormalizeToolCall("read", args) - if result.ReadFile.FilePath != "/workspace/README.md" { - t.Errorf("expected FilePath '/workspace/README.md', got %q", result.ReadFile.FilePath) + if result.ReadFile().FilePath != "/workspace/README.md" { + t.Errorf("expected FilePath '/workspace/README.md', got %q", result.ReadFile().FilePath) + } + }) + + t.Run("directory type returns code search", func(t *testing.T) { + args := map[string]any{ + "kind": "read", + "raw_input": map[string]any{ + "path": ".", + "type": "directory", + }, + } + result := normalizer.NormalizeToolCall("read", args) + if result.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) + } + if result.CodeSearch() == nil { + t.Fatal("expected CodeSearch to be set") + } + if result.CodeSearch().Path != "." { + t.Errorf("expected Path '.', got %q", result.CodeSearch().Path) + } + }) +} + +// TestNormalizerResult tests the normalizer's result handling. +func TestNormalizerResult(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("handles read file result", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "raw_input": map[string]any{ + "path": "file.txt", + }, + }) + + normalizer.NormalizeToolResult(payload, map[string]any{ + "rawOutput": map[string]any{ + "output": "line 1\nline 2\nline 3", + }, + }) + + if payload.ReadFile().Output == nil { + t.Fatal("expected Output to be set") + } + if payload.ReadFile().Output.Content != "line 1\nline 2\nline 3" { + t.Errorf("expected Content, got %q", payload.ReadFile().Output.Content) + } + if payload.ReadFile().Output.LineCount != 3 { + t.Errorf("expected LineCount 3, got %d", payload.ReadFile().Output.LineCount) + } + }) + + t.Run("handles directory listing result", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "raw_input": map[string]any{ + "path": ".", + "type": "directory", + }, + }) + + normalizer.NormalizeToolResult(payload, map[string]any{ + "rawOutput": map[string]any{ + "output": "Here's the files:\n./file1.ts\n./file2.go\n./src/main.ts", + }, + }) + + if payload.CodeSearch().Output == nil { + t.Fatal("expected Output to be set") + } + // Should skip "Here's the files:" header + if payload.CodeSearch().Output.FileCount != 3 { + t.Errorf("expected FileCount 3, got %d", payload.CodeSearch().Output.FileCount) + } + }) + + t.Run("handles shell execution result", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("execute", map[string]any{ + "kind": "execute", + "raw_input": map[string]any{ + "command": "pwd", + "cwd": ".", + }, + }) + + normalizer.NormalizeToolResult(payload, map[string]any{ + "rawOutput": map[string]any{ + "output": "Here are the results from executing the command.\n\n0\n\n\n/Users/cfl/project\n\n", + }, + }) + + if payload.ShellExec().Output == nil { + t.Fatal("expected Output to be set") + } + if payload.ShellExec().Output.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", payload.ShellExec().Output.ExitCode) + } + if payload.ShellExec().Output.Stdout != "/Users/cfl/project" { + t.Errorf("expected Stdout '/Users/cfl/project', got %q", payload.ShellExec().Output.Stdout) + } + }) + + t.Run("handles shell execution with stderr", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("execute", map[string]any{ + "kind": "execute", + "raw_input": map[string]any{ + "command": "cat nonexistent", + }, + }) + + normalizer.NormalizeToolResult(payload, map[string]any{ + "rawOutput": map[string]any{ + "output": "\n1\n\n\n\n\ncat: nonexistent: No such file or directory\n", + }, + }) + + if payload.ShellExec().Output == nil { + t.Fatal("expected Output to be set") + } + if payload.ShellExec().Output.ExitCode != 1 { + t.Errorf("expected ExitCode 1, got %d", payload.ShellExec().Output.ExitCode) + } + if payload.ShellExec().Output.Stderr != "cat: nonexistent: No such file or directory" { + t.Errorf("expected Stderr, got %q", payload.ShellExec().Output.Stderr) } }) } @@ -347,20 +497,20 @@ func TestNormalizerExecute(t *testing.T) { }, } result := normalizer.NormalizeToolCall("execute", args) - if result.Kind != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind) + if result.Kind() != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) } - if result.ShellExec == nil { + if result.ShellExec() == nil { t.Fatal("expected ShellExec to be set") } - if result.ShellExec.Command != "npm test" { - t.Errorf("expected Command 'npm test', got %q", result.ShellExec.Command) + if result.ShellExec().Command != "npm test" { + t.Errorf("expected Command 'npm test', got %q", result.ShellExec().Command) } - if result.ShellExec.WorkDir != "/workspace" { - t.Errorf("expected WorkDir '/workspace', got %q", result.ShellExec.WorkDir) + if result.ShellExec().WorkDir != "/workspace" { + t.Errorf("expected WorkDir '/workspace', got %q", result.ShellExec().WorkDir) } - if result.ShellExec.Timeout != 30 { - t.Errorf("expected Timeout 30, got %d", result.ShellExec.Timeout) + if result.ShellExec().Timeout != 30 { + t.Errorf("expected Timeout 30, got %d", result.ShellExec().Timeout) } }) @@ -373,7 +523,7 @@ func TestNormalizerExecute(t *testing.T) { }, } result := normalizer.NormalizeToolCall("execute", args) - if !result.ShellExec.Background { + if !result.ShellExec().Background { t.Error("expected Background to be true when wait is false") } }) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl index c2e2632f70..aaff9b1183 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-messages.jsonl @@ -7,4 +7,6 @@ {"key":"execute","input":{"tool_name":"execute","args":{"kind":"execute","raw_input":{"command":"npm test","cwd":"/workspace","max_wait_seconds":30}}},"expected":{"tool_type":"tool_execute","command":"npm test","cwd":"/workspace","timeout":30}} {"key":"execute:2","input":{"tool_name":"execute","args":{"kind":"execute","raw_input":{"command":"go build ./...","cwd":"","max_wait_seconds":0}}},"expected":{"tool_type":"tool_execute","command":"go build ./..."}} {"key":"execute:3","input":{"tool_name":"execute","args":{"kind":"execute","raw_input":{"command":"ls -la","cwd":"/tmp","max_wait_seconds":10}}},"expected":{"tool_type":"tool_execute","command":"ls -la","cwd":"/tmp","timeout":10}} -{"key":"search","input":{"tool_name":"search","args":{"kind":"search","raw_input":{"query":"TODO"}}},"expected":{"tool_type":"tool_call"}} +{"key":"search","input":{"tool_name":"search","args":{"kind":"search","raw_input":{"query":"TODO"}}},"expected":{"tool_type":"tool_search"}} +{"key":"read:directory","input":{"tool_name":"read","args":{"kind":"read","raw_input":{"path":".","type":"directory"}}},"expected":{"tool_type":"tool_search","file_path":"."}} +{"key":"unknown","input":{"tool_name":"custom_tool","args":{"kind":"custom_tool","raw_input":{"foo":"bar"}}},"expected":{"tool_type":"tool_call"}} 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 94bd34e519..4973916b0b 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go @@ -79,8 +79,9 @@ type Adapter struct { stderrProvider StderrProvider // Accumulators for streaming content - messageBuffer string - reasoningBuffer string + messageBuffer string + reasoningBuffer string + currentReasoningItemID string // track current reasoning item for separator insertion // Turn completion signaling turnCompleteCh chan turnCompleteResult @@ -388,6 +389,7 @@ func (a *Adapter) Prompt(ctx context.Context, message string) error { // Reset accumulators for new turn a.messageBuffer = "" a.reasoningBuffer = "" + a.currentReasoningItemID = "" // Create channel to wait for turn completion a.turnCompleteCh = make(chan turnCompleteResult, 1) a.mu.Unlock() @@ -595,6 +597,11 @@ func (a *Adapter) handleNotification(method string, params json.RawMessage) { return } a.mu.Lock() + // Add separator when switching to a new reasoning item + if p.ItemID != a.currentReasoningItemID && a.reasoningBuffer != "" { + a.reasoningBuffer += "\n\n" + } + a.currentReasoningItemID = p.ItemID a.reasoningBuffer += p.Delta a.mu.Unlock() a.sendUpdate(AgentEvent{ @@ -613,6 +620,11 @@ func (a *Adapter) handleNotification(method string, params json.RawMessage) { return } a.mu.Lock() + // Add separator when switching to a new reasoning item + if p.ItemID != a.currentReasoningItemID && a.reasoningBuffer != "" { + a.reasoningBuffer += "\n\n" + } + a.currentReasoningItemID = p.ItemID a.reasoningBuffer += p.Delta a.mu.Unlock() a.sendUpdate(AgentEvent{ @@ -840,6 +852,30 @@ func (a *Adapter) handleNotification(method string, params json.RawMessage) { ToolCallID: p.Item.ID, ToolStatus: status, } + + // Include normalized payload for fallback message creation + // This ensures the correct message type is used if the message doesn't exist yet + switch p.Item.Type { + case "commandExecution": + args := map[string]any{ + "command": p.Item.Command, + "cwd": p.Item.Cwd, + } + update.NormalizedPayload = a.normalizer.NormalizeToolCall("commandExecution", args) + case "fileChange": + changesArgs := make([]any, 0, len(p.Item.Changes)) + for _, c := range p.Item.Changes { + changesArgs = append(changesArgs, map[string]any{ + "path": c.Path, + "diff": c.Diff, + }) + } + args := map[string]any{ + "changes": changesArgs, + } + update.NormalizedPayload = a.normalizer.NormalizeToolCall("fileChange", args) + } + // Include diff for file changes if p.Item.Type == "fileChange" && len(p.Item.Changes) > 0 { var diffs []string diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go index dc5ccec939..95758da287 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go @@ -38,21 +38,21 @@ func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *st // NormalizeToolResult updates the payload with tool result data. func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - switch payload.Kind { + switch payload.Kind() { case streams.ToolKindShellExec: - if payload.ShellExec != nil { - n.normalizeCommandResult(payload.ShellExec, result) + if payload.ShellExec() != nil { + n.normalizeCommandResult(payload.ShellExec(), result) } case streams.ToolKindModifyFile: // File changes are typically completed with a diff in the update - if payload.ModifyFile != nil && len(payload.ModifyFile.Mutations) > 0 { + if payload.ModifyFile() != nil && len(payload.ModifyFile().Mutations) > 0 { if diffStr, ok := result.(string); ok && diffStr != "" { - payload.ModifyFile.Mutations[0].Diff = diffStr + payload.ModifyFile().Mutations[0].Diff = diffStr } } case streams.ToolKindGeneric: - if payload.Generic != nil { - payload.Generic.Output = result + if payload.Generic() != nil { + payload.Generic().Output = result } } } @@ -62,13 +62,8 @@ func (n *Normalizer) normalizeCommand(args map[string]any) *streams.NormalizedPa command := shared.GetString(args, "command") workDir := shared.GetString(args, "cwd") - return &streams.NormalizedPayload{ - Kind: streams.ToolKindShellExec, - ShellExec: &streams.ShellExecPayload{ - Command: command, - WorkDir: workDir, - }, - } + // Use factory function + return streams.NewShellExec(command, workDir, "", 0, false) } // normalizeFileChange converts Codex fileChange item data. @@ -76,9 +71,8 @@ func (n *Normalizer) normalizeFileChange(args map[string]any) *streams.Normalize // Codex sends changes as an array in the item changes, _ := args["changes"].([]any) - payload := &streams.ModifyFilePayload{ - Mutations: []streams.FileMutation{}, - } + var filePath string + var mutations []streams.FileMutation for _, change := range changes { changeMap, ok := change.(map[string]any) @@ -87,8 +81,8 @@ func (n *Normalizer) normalizeFileChange(args map[string]any) *streams.Normalize } path := shared.GetString(changeMap, "path") - if payload.FilePath == "" { - payload.FilePath = path + if filePath == "" { + filePath = path } mutation := streams.FileMutation{ @@ -100,33 +94,25 @@ func (n *Normalizer) normalizeFileChange(args map[string]any) *streams.Normalize mutation.Diff = diff } - payload.Mutations = append(payload.Mutations, mutation) + mutations = append(mutations, mutation) } // If no changes array, try single file fields - if len(payload.Mutations) == 0 { - path := shared.GetString(args, "path") - payload.FilePath = path - payload.Mutations = append(payload.Mutations, streams.FileMutation{ + if len(mutations) == 0 { + filePath = shared.GetString(args, "path") + mutations = append(mutations, streams.FileMutation{ Type: streams.MutationPatch, }) } - return &streams.NormalizedPayload{ - Kind: streams.ToolKindModifyFile, - ModifyFile: payload, - } + // Use factory function + return streams.NewModifyFile(filePath, mutations) } // normalizeGeneric wraps unknown items as generic. func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { - return &streams.NormalizedPayload{ - Kind: streams.ToolKindGeneric, - Generic: &streams.GenericPayload{ - Name: toolName, - Input: args, - }, - } + // Use factory function + return streams.NewGeneric(toolName, args) } // normalizeCommandResult updates command payload with result data. 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 8e4da05a4a..a8704fec7f 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go @@ -714,18 +714,32 @@ func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, ope status = state.Status } - title := state.Title - if title == "" { - title = toolName + // Build ToolState for normalization (do this first so we can use input for title) + toolState := &ToolState{ + Output: state.Output, + Title: state.Title, } - - var args map[string]any if state.Input != nil { - _ = json.Unmarshal(state.Input, &args) // Ignore error - args are optional + toolState.Input = make(map[string]any) + _ = json.Unmarshal(state.Input, &toolState.Input) // Ignore error - input is optional + } + if state.Metadata != nil { + toolState.Metadata = make(map[string]any) + _ = json.Unmarshal(state.Metadata, &toolState.Metadata) // Ignore error - metadata is optional } // Generate normalized payload - normalizedPayload := a.normalizer.NormalizeToolCall(toolName, args) + normalizedPayload := a.normalizer.NormalizeToolCall(toolName, toolState) + + // Determine title: prefer derived title (command/path), then state.Title, fallback to toolName + // For tools like bash, the command is more useful than the description + title := deriveToolTitle(toolName, toolState.Input) + if title == "" { + title = state.Title + } + if title == "" { + title = toolName + } // Track if we've seen this tool call before a.mu.Lock() @@ -766,6 +780,37 @@ func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, ope } } +// deriveToolTitle derives a user-friendly title from tool input. +// Returns the most relevant field for display (e.g., command for bash, path for read). +func deriveToolTitle(toolName string, input map[string]any) string { + if input == nil { + return "" + } + + switch toolName { + case OpenCodeToolBash: + // For bash, use the command + if cmd, ok := input["command"].(string); ok && cmd != "" { + return cmd + } + case OpenCodeToolGlob, OpenCodeToolGrep: + // For search tools, use the pattern + if pattern, ok := input["pattern"].(string); ok && pattern != "" { + return pattern + } + case OpenCodeToolRead, OpenCodeToolEdit: + // For file tools, use the path + if path, ok := input["path"].(string); ok && path != "" { + return path + } + if path, ok := input["file_path"].(string); ok && path != "" { + return path + } + } + + return "" +} + // handleMessageUpdated processes message.updated events (for token tracking) func (a *Adapter) handleMessageUpdated(props json.RawMessage, sessionID string) { parsed, err := opencode.ParseMessageUpdated(props) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go index 0195057956..d60073cb89 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go @@ -1,6 +1,8 @@ package opencode import ( + "strings" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types/streams" ) @@ -17,6 +19,9 @@ const ( OpenCodeToolBash = "bash" OpenCodeToolEdit = "edit" OpenCodeToolWebFetch = "webfetch" + OpenCodeToolGlob = "glob" + OpenCodeToolGrep = "grep" + OpenCodeToolRead = "read" ) // Normalizer converts OpenCode protocol tool data to NormalizedPayload. @@ -27,65 +32,95 @@ func NewNormalizer() *Normalizer { return &Normalizer{} } +// ToolState contains the full tool state for normalization +type ToolState struct { + Input map[string]any + Output string + Metadata map[string]any + Title string +} + // NormalizeToolCall converts OpenCode tool call data to NormalizedPayload. -func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { +func (n *Normalizer) NormalizeToolCall(toolName string, state *ToolState) *streams.NormalizedPayload { + if state == nil { + state = &ToolState{} + } + if state.Input == nil { + state.Input = make(map[string]any) + } + // OpenCode tool names are typically lowercase switch toolName { case OpenCodeToolBash: - return n.normalizeBash(args) + return n.normalizeBash(state) case OpenCodeToolEdit: - return n.normalizeEdit(args) + return n.normalizeEdit(state) case OpenCodeToolWebFetch: - return n.normalizeWebFetch(args) + return n.normalizeWebFetch(state) + case OpenCodeToolGlob: + return n.normalizeGlob(state) + case OpenCodeToolGrep: + return n.normalizeGrep(state) + case OpenCodeToolRead: + return n.normalizeRead(state) default: - return n.normalizeGeneric(toolName, args) + return n.normalizeGeneric(toolName, state) } } // NormalizeToolResult updates the payload with tool result data. +// Deprecated: Result data is now handled in NormalizeToolCall via ToolState func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - switch payload.Kind { + // Keep for backwards compatibility but normalization now happens in NormalizeToolCall + switch payload.Kind() { case streams.ToolKindShellExec: - if payload.ShellExec != nil { - n.normalizeBashResult(payload.ShellExec, result) + if payload.ShellExec() != nil && payload.ShellExec().Output == nil { + n.normalizeBashResult(payload.ShellExec(), result) } case streams.ToolKindHttpRequest: - if payload.HttpRequest != nil { + if payload.HttpRequest() != nil { if r, ok := result.(string); ok { - payload.HttpRequest.Response = r + payload.HttpRequest().Response = r } } case streams.ToolKindGeneric: - if payload.Generic != nil { - payload.Generic.Output = result + if payload.Generic() != nil && payload.Generic().Output == nil { + payload.Generic().Output = result } } } // normalizeBash converts OpenCode bash tool data. -func (n *Normalizer) normalizeBash(args map[string]any) *streams.NormalizedPayload { - command := shared.GetString(args, "command") +func (n *Normalizer) normalizeBash(state *ToolState) *streams.NormalizedPayload { + command := shared.GetString(state.Input, "command") + description := shared.GetString(state.Input, "description") + timeout := shared.GetInt(state.Input, "timeout") + background := shared.GetBool(state.Input, "background") - // OpenCode tool state might have the command - if command == "" { - if state, ok := args["state"].(map[string]any); ok { - command = shared.GetString(state, "command") + // Use factory function + payload := streams.NewShellExec(command, "", description, timeout, background) + + // Add output if available + if state.Output != "" || state.Metadata != nil { + payload.ShellExec().Output = &streams.ShellExecOutput{ + Stdout: state.Output, + } + // Extract exit code from metadata + if state.Metadata != nil { + if exitCode, ok := state.Metadata["exit"].(float64); ok { + payload.ShellExec().Output.ExitCode = int(exitCode) + } } } - return &streams.NormalizedPayload{ - Kind: streams.ToolKindShellExec, - ShellExec: &streams.ShellExecPayload{ - Command: command, - }, - } + return payload } // normalizeEdit converts OpenCode edit tool data. -func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { +func (n *Normalizer) normalizeEdit(state *ToolState) *streams.NormalizedPayload { // OpenCode edit args might be in "input" or directly in args - input := args - if inputMap, ok := args["input"].(map[string]any); ok { + input := state.Input + if inputMap, ok := state.Input["input"].(map[string]any); ok { input = inputMap } @@ -94,54 +129,179 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo filePath = shared.GetString(input, "file_path") } - payload := &streams.ModifyFilePayload{ - FilePath: filePath, - Mutations: []streams.FileMutation{}, - } - - // OpenCode might have diff or content + // Build mutations + var mutations []streams.FileMutation if diff, ok := input["diff"].(string); ok && diff != "" { - payload.Mutations = append(payload.Mutations, streams.FileMutation{ + mutations = append(mutations, streams.FileMutation{ Type: streams.MutationPatch, Diff: diff, }) } else { - payload.Mutations = append(payload.Mutations, streams.FileMutation{ + mutations = append(mutations, streams.FileMutation{ Type: streams.MutationPatch, }) } - return &streams.NormalizedPayload{ - Kind: streams.ToolKindModifyFile, - ModifyFile: payload, - } + // Use factory function + return streams.NewModifyFile(filePath, mutations) } // normalizeWebFetch converts OpenCode webfetch tool data. -func (n *Normalizer) normalizeWebFetch(args map[string]any) *streams.NormalizedPayload { - url := shared.GetString(args, "url") +func (n *Normalizer) normalizeWebFetch(state *ToolState) *streams.NormalizedPayload { + url := shared.GetString(state.Input, "url") - return &streams.NormalizedPayload{ - Kind: streams.ToolKindHttpRequest, - HttpRequest: &streams.HttpRequestPayload{ - URL: url, - Method: "GET", - }, + // Use factory function + payload := streams.NewHttpRequest(url, "GET") + + // Add response if available + if state.Output != "" { + payload.HttpRequest().Response = state.Output } + + return payload +} + +// normalizeGlob converts OpenCode glob tool data to code_search. +func (n *Normalizer) normalizeGlob(state *ToolState) *streams.NormalizedPayload { + pattern := shared.GetString(state.Input, "pattern") + path := shared.GetString(state.Input, "path") + + // Use factory function: NewCodeSearch(query, pattern, path, glob) + // For glob, pattern goes to both pattern and glob fields + payload := streams.NewCodeSearch("", pattern, path, pattern) + + // Parse output (newline-separated file list) and metadata + if state.Output != "" || state.Metadata != nil { + payload.CodeSearch().Output = &streams.CodeSearchOutput{} + + // Parse file list from output + if state.Output != "" { + lines := strings.Split(strings.TrimSpace(state.Output), "\n") + // Filter out empty lines and the truncation message + var files []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "(Results are truncated") { + files = append(files, line) + } + } + payload.CodeSearch().Output.Files = files + } + + // Extract metadata + if state.Metadata != nil { + if count, ok := state.Metadata["count"].(float64); ok { + payload.CodeSearch().Output.FileCount = int(count) + } + if truncated, ok := state.Metadata["truncated"].(bool); ok { + payload.CodeSearch().Output.Truncated = truncated + } + } + } + + return payload +} + +// normalizeGrep converts OpenCode grep tool data to code_search. +func (n *Normalizer) normalizeGrep(state *ToolState) *streams.NormalizedPayload { + pattern := shared.GetString(state.Input, "pattern") + path := shared.GetString(state.Input, "path") + query := shared.GetString(state.Input, "query") + + // Use factory function: NewCodeSearch(query, pattern, path, glob) + payload := streams.NewCodeSearch(query, pattern, path, "") + + // Parse output and metadata + if state.Output != "" || state.Metadata != nil { + payload.CodeSearch().Output = &streams.CodeSearchOutput{} + + // Parse file list from output (grep typically outputs file:line format) + if state.Output != "" { + lines := strings.Split(strings.TrimSpace(state.Output), "\n") + fileSet := make(map[string]bool) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Extract file path (before first colon for grep output) + if idx := strings.Index(line, ":"); idx > 0 { + fileSet[line[:idx]] = true + } else { + fileSet[line] = true + } + } + var files []string + for f := range fileSet { + files = append(files, f) + } + payload.CodeSearch().Output.Files = files + payload.CodeSearch().Output.FileCount = len(files) + } + + // Extract metadata + if state.Metadata != nil { + if truncated, ok := state.Metadata["truncated"].(bool); ok { + payload.CodeSearch().Output.Truncated = truncated + } + } + } + + return payload +} + +// normalizeRead converts OpenCode read tool data. +func (n *Normalizer) normalizeRead(state *ToolState) *streams.NormalizedPayload { + filePath := shared.GetString(state.Input, "path") + if filePath == "" { + filePath = shared.GetString(state.Input, "file_path") + } + + offset := shared.GetInt(state.Input, "offset") + limit := shared.GetInt(state.Input, "limit") + + // Use factory function + payload := streams.NewReadFile(filePath, offset, limit) + + // Add output if available + if state.Output != "" || state.Metadata != nil { + payload.ReadFile().Output = &streams.ReadFileOutput{ + Content: state.Output, + } + + if state.Metadata != nil { + if lineCount, ok := state.Metadata["line_count"].(float64); ok { + payload.ReadFile().Output.LineCount = int(lineCount) + } + if truncated, ok := state.Metadata["truncated"].(bool); ok { + payload.ReadFile().Output.Truncated = truncated + } + } + + // Calculate line count from content if not in metadata + if payload.ReadFile().Output.LineCount == 0 && state.Output != "" { + payload.ReadFile().Output.LineCount = strings.Count(state.Output, "\n") + 1 + } + } + + return payload } // normalizeGeneric wraps unknown tools as generic. -func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { - return &streams.NormalizedPayload{ - Kind: streams.ToolKindGeneric, - Generic: &streams.GenericPayload{ - Name: toolName, - Input: args, - }, +func (n *Normalizer) normalizeGeneric(toolName string, state *ToolState) *streams.NormalizedPayload { + // Use factory function + payload := streams.NewGeneric(toolName, state.Input) + + // Add output if available + if state.Output != "" { + payload.Generic().Output = state.Output } + + return payload } // normalizeBashResult updates bash payload with result data. +// Deprecated: Use ToolState in NormalizeToolCall instead func (n *Normalizer) normalizeBashResult(payload *streams.ShellExecPayload, result any) { if payload.Output == nil { payload.Output = &streams.ShellExecOutput{} @@ -159,4 +319,3 @@ func (n *Normalizer) normalizeBashResult(payload *streams.ShellExecPayload, resu } } } - diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize_test.go new file mode 100644 index 0000000000..bbe25a1adc --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize_test.go @@ -0,0 +1,496 @@ +package opencode + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// TestNormalizerBash tests the normalizer's bash handling. +func TestNormalizerBash(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts command, description, timeout, background", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "command": "ls -la", + "description": "List files", + "timeout": float64(30000), + "background": true, + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolBash, state) + if result.Kind() != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) + } + if result.ShellExec() == nil { + t.Fatal("expected ShellExec to be set") + } + if result.ShellExec().Command != "ls -la" { + t.Errorf("expected Command 'ls -la', got %q", result.ShellExec().Command) + } + if result.ShellExec().Description != "List files" { + t.Errorf("expected Description 'List files', got %q", result.ShellExec().Description) + } + if result.ShellExec().Timeout != 30000 { + t.Errorf("expected Timeout 30000, got %d", result.ShellExec().Timeout) + } + if !result.ShellExec().Background { + t.Error("expected Background to be true") + } + }) + + t.Run("includes output with exit code from metadata", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "command": "echo hello", + }, + Output: "hello", + Metadata: map[string]any{ + "exit": float64(0), + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolBash, state) + if result.ShellExec().Output == nil { + t.Fatal("expected Output to be set") + } + if result.ShellExec().Output.Stdout != "hello" { + t.Errorf("expected Stdout 'hello', got %q", result.ShellExec().Output.Stdout) + } + if result.ShellExec().Output.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", result.ShellExec().Output.ExitCode) + } + }) + + t.Run("handles nil state gracefully", func(t *testing.T) { + result := normalizer.NormalizeToolCall(OpenCodeToolBash, nil) + if result.Kind() != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) + } + }) +} + +// TestNormalizerEdit tests the normalizer's edit handling. +func TestNormalizerEdit(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts path from input directly", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "path": "/workspace/file.ts", + "diff": "@@ -1,1 +1,1 @@\n-old\n+new\n", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) + if result.Kind() != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) + } + if result.ModifyFile() == nil { + t.Fatal("expected ModifyFile to be set") + } + if result.ModifyFile().FilePath != "/workspace/file.ts" { + t.Errorf("expected FilePath '/workspace/file.ts', got %q", result.ModifyFile().FilePath) + } + if len(result.ModifyFile().Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) + } + if result.ModifyFile().Mutations[0].Diff != "@@ -1,1 +1,1 @@\n-old\n+new\n" { + t.Error("expected diff to be preserved") + } + }) + + t.Run("extracts path from nested input map", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "input": map[string]any{ + "path": "/workspace/nested.ts", + }, + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) + if result.ModifyFile().FilePath != "/workspace/nested.ts" { + t.Errorf("expected FilePath '/workspace/nested.ts', got %q", result.ModifyFile().FilePath) + } + }) + + t.Run("falls back to file_path key", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "file_path": "/workspace/fallback.ts", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) + if result.ModifyFile().FilePath != "/workspace/fallback.ts" { + t.Errorf("expected FilePath '/workspace/fallback.ts', got %q", result.ModifyFile().FilePath) + } + }) + + t.Run("creates empty mutation when no diff", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "path": "/workspace/file.ts", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) + if len(result.ModifyFile().Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) + } + if result.ModifyFile().Mutations[0].Type != streams.MutationPatch { + t.Errorf("expected MutationType %q, got %q", streams.MutationPatch, result.ModifyFile().Mutations[0].Type) + } + }) +} + +// TestNormalizerWebFetch tests the normalizer's webfetch handling. +func TestNormalizerWebFetch(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts URL and uses GET method", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "url": "https://example.com/api", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolWebFetch, state) + if result.Kind() != streams.ToolKindHttpRequest { + t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind()) + } + if result.HttpRequest() == nil { + t.Fatal("expected HttpRequest to be set") + } + if result.HttpRequest().URL != "https://example.com/api" { + t.Errorf("expected URL 'https://example.com/api', got %q", result.HttpRequest().URL) + } + if result.HttpRequest().Method != "GET" { + t.Errorf("expected Method 'GET', got %q", result.HttpRequest().Method) + } + }) + + t.Run("includes response when output available", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "url": "https://example.com", + }, + Output: "Response body content", + } + result := normalizer.NormalizeToolCall(OpenCodeToolWebFetch, state) + if result.HttpRequest().Response != "Response body content" { + t.Errorf("expected Response 'Response body content', got %q", result.HttpRequest().Response) + } + }) +} + +// TestNormalizerGlob tests the normalizer's glob handling. +func TestNormalizerGlob(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts pattern and path", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "**/*.ts", + "path": "/workspace", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) + if result.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) + } + if result.CodeSearch() == nil { + t.Fatal("expected CodeSearch to be set") + } + if result.CodeSearch().Glob != "**/*.ts" { + t.Errorf("expected Glob '**/*.ts', got %q", result.CodeSearch().Glob) + } + if result.CodeSearch().Path != "/workspace" { + t.Errorf("expected Path '/workspace', got %q", result.CodeSearch().Path) + } + }) + + t.Run("parses file list from output", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "**/*.ts", + }, + Output: "file1.ts\nfile2.ts\nsrc/file3.ts", + } + result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) + if result.CodeSearch().Output == nil { + t.Fatal("expected Output to be set") + } + if len(result.CodeSearch().Output.Files) != 3 { + t.Errorf("expected 3 files, got %d", len(result.CodeSearch().Output.Files)) + } + }) + + t.Run("filters out truncation message from output", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "**/*", + }, + Output: "file1.ts\n(Results are truncated...)", + } + result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) + if len(result.CodeSearch().Output.Files) != 1 { + t.Errorf("expected 1 file (truncation msg filtered), got %d", len(result.CodeSearch().Output.Files)) + } + }) + + t.Run("extracts count and truncated from metadata", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "**/*", + }, + Output: "file1.ts", + Metadata: map[string]any{ + "count": float64(100), + "truncated": true, + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) + if result.CodeSearch().Output.FileCount != 100 { + t.Errorf("expected FileCount 100, got %d", result.CodeSearch().Output.FileCount) + } + if !result.CodeSearch().Output.Truncated { + t.Error("expected Truncated to be true") + } + }) +} + +// TestNormalizerGrep tests the normalizer's grep handling. +func TestNormalizerGrep(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts pattern, path, and query", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "func.*Error", + "path": "/workspace", + "query": "error handling", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolGrep, state) + if result.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) + } + if result.CodeSearch() == nil { + t.Fatal("expected CodeSearch to be set") + } + if result.CodeSearch().Pattern != "func.*Error" { + t.Errorf("expected Pattern 'func.*Error', got %q", result.CodeSearch().Pattern) + } + if result.CodeSearch().Query != "error handling" { + t.Errorf("expected Query 'error handling', got %q", result.CodeSearch().Query) + } + }) + + t.Run("parses grep output with file:line format", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "func", + }, + Output: "file1.go:10:func main()\nfile1.go:20:func helper()\nfile2.go:5:func test()", + } + result := normalizer.NormalizeToolCall(OpenCodeToolGrep, state) + if result.CodeSearch().Output == nil { + t.Fatal("expected Output to be set") + } + // Should deduplicate files + if result.CodeSearch().Output.FileCount != 2 { + t.Errorf("expected FileCount 2 (deduplicated), got %d", result.CodeSearch().Output.FileCount) + } + }) + + t.Run("handles files-only output", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "pattern": "test", + }, + Output: "file1.go\nfile2.go\nfile3.go", + } + result := normalizer.NormalizeToolCall(OpenCodeToolGrep, state) + if len(result.CodeSearch().Output.Files) != 3 { + t.Errorf("expected 3 files, got %d", len(result.CodeSearch().Output.Files)) + } + }) +} + +// TestNormalizerRead tests the normalizer's read handling. +func TestNormalizerRead(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("extracts path, offset, limit", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "path": "/workspace/config.json", + "offset": float64(10), + "limit": float64(50), + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) + if result.Kind() != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind()) + } + if result.ReadFile() == nil { + t.Fatal("expected ReadFile to be set") + } + if result.ReadFile().FilePath != "/workspace/config.json" { + t.Errorf("expected FilePath '/workspace/config.json', got %q", result.ReadFile().FilePath) + } + if result.ReadFile().Offset != 10 { + t.Errorf("expected Offset 10, got %d", result.ReadFile().Offset) + } + if result.ReadFile().Limit != 50 { + t.Errorf("expected Limit 50, got %d", result.ReadFile().Limit) + } + }) + + t.Run("falls back to file_path key", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "file_path": "/workspace/fallback.json", + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) + if result.ReadFile().FilePath != "/workspace/fallback.json" { + t.Errorf("expected FilePath '/workspace/fallback.json', got %q", result.ReadFile().FilePath) + } + }) + + t.Run("includes output with content and line count", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "path": "/workspace/file.txt", + }, + Output: "line1\nline2\nline3", + } + result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) + if result.ReadFile().Output == nil { + t.Fatal("expected Output to be set") + } + if result.ReadFile().Output.Content != "line1\nline2\nline3" { + t.Errorf("expected Content, got %q", result.ReadFile().Output.Content) + } + // Line count should be calculated from content + if result.ReadFile().Output.LineCount != 3 { + t.Errorf("expected LineCount 3, got %d", result.ReadFile().Output.LineCount) + } + }) + + t.Run("extracts line_count and truncated from metadata", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "path": "/workspace/large.txt", + }, + Output: "content here", + Metadata: map[string]any{ + "line_count": float64(1000), + "truncated": true, + }, + } + result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) + if result.ReadFile().Output.LineCount != 1000 { + t.Errorf("expected LineCount 1000, got %d", result.ReadFile().Output.LineCount) + } + if !result.ReadFile().Output.Truncated { + t.Error("expected Truncated to be true") + } + }) +} + +// TestNormalizerGeneric tests the normalizer's generic handling. +func TestNormalizerGeneric(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("wraps unknown tools as generic", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{ + "custom_field": "custom_value", + }, + } + result := normalizer.NormalizeToolCall("custom_tool", state) + if result.Kind() != streams.ToolKindGeneric { + t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, result.Kind()) + } + if result.Generic() == nil { + t.Fatal("expected Generic to be set") + } + if result.Generic().Name != "custom_tool" { + t.Errorf("expected Name 'custom_tool', got %q", result.Generic().Name) + } + }) + + t.Run("includes output when available", func(t *testing.T) { + state := &ToolState{ + Input: map[string]any{}, + Output: "tool output", + } + result := normalizer.NormalizeToolCall("unknown", state) + if result.Generic().Output != "tool output" { + t.Errorf("expected Output 'tool output', got %v", result.Generic().Output) + } + }) +} + +// TestNormalizeToolResult tests the deprecated result handler. +func TestNormalizeToolResult(t *testing.T) { + normalizer := NewNormalizer() + + t.Run("updates shell exec output from string", func(t *testing.T) { + payload := streams.NewShellExec("ls", "", "", 0, false) + normalizer.NormalizeToolResult(payload, "file1\nfile2") + if payload.ShellExec().Output == nil { + t.Fatal("expected Output to be set") + } + if payload.ShellExec().Output.Stdout != "file1\nfile2" { + t.Errorf("expected Stdout, got %q", payload.ShellExec().Output.Stdout) + } + }) + + t.Run("updates shell exec output from map", func(t *testing.T) { + payload := streams.NewShellExec("cmd", "", "", 0, false) + normalizer.NormalizeToolResult(payload, map[string]any{ + "output": "stdout content", + "error": "stderr content", + }) + if payload.ShellExec().Output.Stdout != "stdout content" { + t.Errorf("expected Stdout 'stdout content', got %q", payload.ShellExec().Output.Stdout) + } + if payload.ShellExec().Output.Stderr != "stderr content" { + t.Errorf("expected Stderr 'stderr content', got %q", payload.ShellExec().Output.Stderr) + } + }) + + t.Run("updates http request response", func(t *testing.T) { + payload := streams.NewHttpRequest("https://example.com", "GET") + normalizer.NormalizeToolResult(payload, "response body") + if payload.HttpRequest().Response != "response body" { + t.Errorf("expected Response 'response body', got %q", payload.HttpRequest().Response) + } + }) + + t.Run("updates generic output", func(t *testing.T) { + payload := streams.NewGeneric("tool", nil) + normalizer.NormalizeToolResult(payload, map[string]any{"data": "value"}) + if payload.Generic().Output == nil { + t.Fatal("expected Output to be set") + } + }) + + t.Run("does not overwrite existing output", func(t *testing.T) { + payload := streams.NewShellExec("ls", "", "", 0, false) + payload.ShellExec().Output = &streams.ShellExecOutput{Stdout: "existing"} + normalizer.NormalizeToolResult(payload, "new output") + // Should not overwrite + if payload.ShellExec().Output.Stdout != "existing" { + t.Errorf("expected Stdout to remain 'existing', got %q", payload.ShellExec().Output.Stdout) + } + }) +} + +// TestNewNormalizer tests the constructor. +func TestNewNormalizer(t *testing.T) { + normalizer := NewNormalizer() + if normalizer == nil { + t.Fatal("expected non-nil normalizer") + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go index b9cc50c7c0..65a97abb22 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/debug.go @@ -24,6 +24,10 @@ var debugLogDir = resolveDebugLogDir() // debugLogMu protects concurrent writes to debug log files. var debugLogMu sync.Mutex +// initializedFiles tracks which log files have been truncated in this session. +// First write to a file truncates it; subsequent writes append. +var initializedFiles = make(map[string]bool) + // Protocol constants for debug file naming const ( ProtocolACP = "acp" @@ -78,6 +82,7 @@ func LogNormalizedEvent(protocol, agentID string, event *streams.AgentEvent) { } // writeJSONLine writes a JSON entry as a line to the specified file. +// On first write to a file in this session, the file is truncated. func writeJSONLine(logFile string, entry any) { entryJSON, err := json.Marshal(entry) if err != nil { @@ -88,7 +93,16 @@ func writeJSONLine(logFile string, entry any) { debugLogMu.Lock() defer debugLogMu.Unlock() - f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + // Determine open flags: truncate on first write, append on subsequent + flags := os.O_CREATE | os.O_WRONLY + if initializedFiles[logFile] { + flags |= os.O_APPEND + } else { + flags |= os.O_TRUNC + initializedFiles[logFile] = true + } + + f, err := os.OpenFile(logFile, flags, 0644) if err != nil { log.Printf("[DEBUG] Failed to open log file %s: %v", logFile, err) return diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go index c33aa544b8..99bf5775e9 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/normalize_helpers.go @@ -75,6 +75,72 @@ func NormalizeShellResult(payload *streams.ShellExecPayload, result any) { } } +// MaxContentLength is the maximum length for tool output content before truncation. +const MaxContentLength = 50000 + +// MaxFileCount is the maximum number of files to include in code search results. +const MaxFileCount = 500 + +// TruncateIfNeeded truncates a string if it exceeds maxLen. +func TruncateIfNeeded(s string, maxLen int) (string, bool) { + if len(s) <= maxLen { + return s, false + } + return s[:maxLen], true +} + +// NormalizeReadResult populates ReadFilePayload.Output with result content. +func NormalizeReadResult(payload *streams.ReadFilePayload, result string) { + lines := SplitLines(result) + content, truncated := TruncateIfNeeded(result, MaxContentLength) + + payload.Output = &streams.ReadFileOutput{ + Content: content, + LineCount: len(lines), + Truncated: truncated, + Language: DetectLanguage(payload.FilePath), + } +} + +// NormalizeCodeSearchResult populates CodeSearchPayload.Output with result content. +func NormalizeCodeSearchResult(payload *streams.CodeSearchPayload, result string) { + result = strings.TrimSpace(result) + if result == "" { + payload.Output = &streams.CodeSearchOutput{ + Files: []string{}, + FileCount: 0, + } + return + } + + files := strings.Split(result, "\n") + truncated := false + if len(files) > MaxFileCount { + files = files[:MaxFileCount] + truncated = true + } + + payload.Output = &streams.CodeSearchOutput{ + Files: files, + FileCount: len(files), + Truncated: truncated, + } +} + +// NormalizeModifyResult updates ModifyFilePayload with result content for Write operations. +func NormalizeModifyResult(payload *streams.ModifyFilePayload, result string) { + // For Write tool, store the written content confirmation + // The tool result confirms what was written + if len(payload.Mutations) > 0 { + mut := &payload.Mutations[0] + // If Content not already set from input args (for create), use result + if mut.Content == "" && mut.NewContent == "" && result != "" { + content, _ := TruncateIfNeeded(result, MaxContentLength) + mut.Content = content + } + } +} + // DetectLanguage maps file extension to language identifier. // Used for syntax highlighting in diffs. func DetectLanguage(path string) string { 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 2c77e5addc..c455996b2b 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -67,7 +67,8 @@ type Adapter struct { sessionStatusSent bool // Whether we've sent the session status event // Track pending tool calls to auto-complete on result - pendingToolCalls map[string]bool + // Maps tool_use_id to the NormalizedPayload for enrichment with results + pendingToolCalls map[string]*streams.NormalizedPayload // Agent info agentInfo *AgentInfo @@ -117,7 +118,7 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { cancel: cancel, updatesCh: make(chan AgentEvent, 100), mainModelContextWindow: defaultContextWindow, - pendingToolCalls: make(map[string]bool), + pendingToolCalls: make(map[string]*streams.NormalizedPayload), } } @@ -534,6 +535,9 @@ func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { case claudecode.MessageTypeAssistant: a.handleAssistantMessage(msg, sessionID, operationID) + case claudecode.MessageTypeUser: + a.handleUserMessage(msg, sessionID, operationID) + case claudecode.MessageTypeResult: a.handleResultMessage(msg, sessionID, operationID) @@ -583,6 +587,9 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, return } + // Extract parent tool use ID for subagent nesting + parentToolUseID := msg.ParentToolUseID + // Log content block types for debugging blockTypes := make([]string, 0, len(msg.Message.Content)) for _, block := range msg.Message.Content { @@ -590,7 +597,8 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, } a.logger.Info("processing assistant message", zap.Int("num_blocks", len(msg.Message.Content)), - zap.Strings("block_types", blockTypes)) + zap.Strings("block_types", blockTypes), + zap.String("parent_tool_use_id", parentToolUseID)) // Update agent version and track main model name from model info if msg.Message.Model != "" && a.agentInfo != nil { @@ -649,9 +657,9 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, zap.String("tool_type", toolType), zap.String("title", toolTitle)) - // Track this tool call as pending + // Track this tool call as pending with its payload for result enrichment a.mu.Lock() - a.pendingToolCalls[block.ID] = true + a.pendingToolCalls[block.ID] = normalizedPayload a.mu.Unlock() a.sendUpdate(AgentEvent{ @@ -659,33 +667,13 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, SessionID: sessionID, OperationID: operationID, ToolCallID: block.ID, + ParentToolCallID: parentToolUseID, ToolName: block.Name, ToolTitle: toolTitle, ToolStatus: "running", NormalizedPayload: normalizedPayload, }) - case "tool_result": - status := "complete" - if block.IsError { - status = "error" - } - a.logger.Info("tool_result block received", - zap.String("tool_call_id", block.ToolUseID), - zap.String("status", status)) - - // Remove from pending - a.mu.Lock() - delete(a.pendingToolCalls, block.ToolUseID) - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ToolUseID, - ToolStatus: status, - }) } } @@ -720,6 +708,47 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, } } +// handleUserMessage processes user messages containing tool results. +// Claude Code sends tool results back as user messages with tool_result content blocks. +func (a *Adapter) handleUserMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + if msg.Message == nil { + return + } + + // Process content blocks looking for tool_result + for _, block := range msg.Message.Content { + if block.Type != "tool_result" { + continue + } + + // Get and enrich the pending payload with result content + a.mu.Lock() + payload := a.pendingToolCalls[block.ToolUseID] + delete(a.pendingToolCalls, block.ToolUseID) + a.mu.Unlock() + + // Enrich payload with result content + if payload != nil && block.Content != "" { + a.normalizer.NormalizeToolResult(payload, block.Content) + } + + // Determine status + status := "complete" + if block.IsError { + status = "error" + } + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + OperationID: operationID, + ToolCallID: block.ToolUseID, + ToolStatus: status, + NormalizedPayload: payload, + }) + } +} + // handleResultMessage processes result (completion) messages. func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { a.logger.Info("received result message", @@ -741,7 +770,7 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope for toolID := range a.pendingToolCalls { pendingTools = append(pendingTools, toolID) } - a.pendingToolCalls = make(map[string]bool) // Clear pending + a.pendingToolCalls = make(map[string]*streams.NormalizedPayload) // Clear pending a.mu.Unlock() for _, toolID := range pendingTools { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go index 478ec736d2..c3a1a82115 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go @@ -57,20 +57,34 @@ func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *st // NormalizeToolResult updates the payload with tool result data. func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - switch payload.Kind { + resultStr, _ := result.(string) + + switch payload.Kind() { + case streams.ToolKindReadFile: + if payload.ReadFile() != nil && resultStr != "" { + shared.NormalizeReadResult(payload.ReadFile(), resultStr) + } + case streams.ToolKindCodeSearch: + if payload.CodeSearch() != nil && resultStr != "" { + shared.NormalizeCodeSearchResult(payload.CodeSearch(), resultStr) + } + case streams.ToolKindModifyFile: + if payload.ModifyFile() != nil && resultStr != "" { + shared.NormalizeModifyResult(payload.ModifyFile(), resultStr) + } case streams.ToolKindShellExec: - if payload.ShellExec != nil { - shared.NormalizeShellResult(payload.ShellExec, result) + if payload.ShellExec() != nil { + shared.NormalizeShellResult(payload.ShellExec(), result) } case streams.ToolKindHttpRequest: - if payload.HttpRequest != nil { + if payload.HttpRequest() != nil { if r, ok := result.(string); ok { - payload.HttpRequest.Response = r + payload.HttpRequest().Response = r } } case streams.ToolKindGeneric: - if payload.Generic != nil { - payload.Generic.Output = result + if payload.Generic() != nil { + payload.Generic().Output = result } } } @@ -82,14 +96,11 @@ func (n *Normalizer) normalizeEdit(toolName string, args map[string]any) *stream newString := shared.GetString(args, "new_string") content := shared.GetString(args, "content") - payload := &streams.ModifyFilePayload{ - FilePath: filePath, - Mutations: []streams.FileMutation{}, - } + var mutations []streams.FileMutation if toolName == claudecode.ToolWrite { // Write is a full file creation/replacement - payload.Mutations = append(payload.Mutations, streams.FileMutation{ + mutations = append(mutations, streams.FileMutation{ Type: streams.MutationCreate, Content: content, }) @@ -106,13 +117,11 @@ func (n *Normalizer) normalizeEdit(toolName string, args map[string]any) *stream mutation.Diff = shared.GenerateUnifiedDiff(oldString, newString, filePath, 0) } - payload.Mutations = append(payload.Mutations, mutation) + mutations = append(mutations, mutation) } - return &streams.NormalizedPayload{ - Kind: streams.ToolKindModifyFile, - ModifyFile: payload, - } + // Use factory function + return streams.NewModifyFile(filePath, mutations) } // normalizeRead converts stream-json Read tool data. @@ -192,15 +201,12 @@ func (n *Normalizer) normalizeManageTodos(toolName string, args map[string]any) operation = "write" } - payload := &streams.ManageTodosPayload{ - Operation: operation, - } - // Extract items if present - if items, ok := args["items"].([]any); ok { - for _, item := range items { + var items []streams.TodoItem + if rawItems, ok := args["items"].([]any); ok { + for _, item := range rawItems { if itemMap, ok := item.(map[string]any); ok { - payload.Items = append(payload.Items, streams.TodoItem{ + items = append(items, streams.TodoItem{ ID: shared.GetString(itemMap, "id"), Description: shared.GetString(itemMap, "description"), Status: shared.GetString(itemMap, "status"), @@ -209,10 +215,8 @@ func (n *Normalizer) normalizeManageTodos(toolName string, args map[string]any) } } - return &streams.NormalizedPayload{ - Kind: streams.ToolKindManageTodos, - ManageTodos: payload, - } + // Use factory function + return streams.NewManageTodos(operation, items) } // normalizeGeneric wraps unknown tools as generic. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go index 08ae7d7596..6ca553e5f9 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go @@ -33,16 +33,16 @@ func TestStreamJSONNormalization(t *testing.T) { // Verify the Kind is set correctly based on tool type switch toolType { case "tool_edit": - if payload.Kind != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind) + if payload.Kind() != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind()) } case "tool_read": - if payload.Kind != streams.ToolKindReadFile && payload.Kind != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q or %q, got %q", streams.ToolKindReadFile, streams.ToolKindCodeSearch, payload.Kind) + if payload.Kind() != streams.ToolKindReadFile && payload.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q or %q, got %q", streams.ToolKindReadFile, streams.ToolKindCodeSearch, payload.Kind()) } case "tool_execute": - if payload.Kind != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind) + if payload.Kind() != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind()) } } }) @@ -183,19 +183,19 @@ func TestNormalizerEdit(t *testing.T) { "new_string": "const x = 2;", } result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) - if result.Kind != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind) + if result.Kind() != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) } - if result.ModifyFile == nil { + if result.ModifyFile() == nil { t.Fatal("expected ModifyFile to be set") } - if result.ModifyFile.FilePath != "/workspace/app.ts" { - t.Errorf("expected FilePath '/workspace/app.ts', got %q", result.ModifyFile.FilePath) + if result.ModifyFile().FilePath != "/workspace/app.ts" { + t.Errorf("expected FilePath '/workspace/app.ts', got %q", result.ModifyFile().FilePath) } - if len(result.ModifyFile.Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile.Mutations)) + if len(result.ModifyFile().Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) } - mutation := result.ModifyFile.Mutations[0] + mutation := result.ModifyFile().Mutations[0] if mutation.OldContent != "const x = 1;" { t.Errorf("expected OldContent 'const x = 1;', got %q", mutation.OldContent) } @@ -211,10 +211,10 @@ func TestNormalizerEdit(t *testing.T) { "new_string": "const x = 2;", } result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) - if result.ModifyFile == nil || len(result.ModifyFile.Mutations) == 0 { + if result.ModifyFile() == nil || len(result.ModifyFile().Mutations) == 0 { t.Fatal("expected mutation") } - if result.ModifyFile.Mutations[0].Diff == "" { + if result.ModifyFile().Mutations[0].Diff == "" { t.Error("expected diff to be generated") } }) @@ -226,10 +226,10 @@ func TestNormalizerEdit(t *testing.T) { "new_string": "new content", } result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) - if result.ModifyFile == nil || len(result.ModifyFile.Mutations) == 0 { + if result.ModifyFile() == nil || len(result.ModifyFile().Mutations) == 0 { t.Fatal("expected mutation") } - if result.ModifyFile.Mutations[0].Diff != "" { + if result.ModifyFile().Mutations[0].Diff != "" { t.Error("expected no diff when old_string is empty") } }) @@ -240,19 +240,19 @@ func TestNormalizerEdit(t *testing.T) { "content": "# Contributing\n\nThank you for your interest!", } result := normalizer.NormalizeToolCall(claudecode.ToolWrite, args) - if result.Kind != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind) + if result.Kind() != streams.ToolKindModifyFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) } - if result.ModifyFile == nil { + if result.ModifyFile() == nil { t.Fatal("expected ModifyFile to be set") } - if result.ModifyFile.FilePath != "/workspace/CONTRIBUTING.md" { - t.Errorf("expected FilePath '/workspace/CONTRIBUTING.md', got %q", result.ModifyFile.FilePath) + if result.ModifyFile().FilePath != "/workspace/CONTRIBUTING.md" { + t.Errorf("expected FilePath '/workspace/CONTRIBUTING.md', got %q", result.ModifyFile().FilePath) } - if len(result.ModifyFile.Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile.Mutations)) + if len(result.ModifyFile().Mutations) != 1 { + t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) } - mutation := result.ModifyFile.Mutations[0] + mutation := result.ModifyFile().Mutations[0] if mutation.Type != streams.MutationCreate { t.Errorf("expected MutationType %q, got %q", streams.MutationCreate, mutation.Type) } @@ -273,20 +273,20 @@ func TestNormalizerRead(t *testing.T) { "limit": float64(50), } result := normalizer.NormalizeToolCall(claudecode.ToolRead, args) - if result.Kind != streams.ToolKindReadFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind) + if result.Kind() != streams.ToolKindReadFile { + t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind()) } - if result.ReadFile == nil { + if result.ReadFile() == nil { t.Fatal("expected ReadFile to be set") } - if result.ReadFile.FilePath != "/workspace/config.json" { - t.Errorf("expected FilePath '/workspace/config.json', got %q", result.ReadFile.FilePath) + if result.ReadFile().FilePath != "/workspace/config.json" { + t.Errorf("expected FilePath '/workspace/config.json', got %q", result.ReadFile().FilePath) } - if result.ReadFile.Offset != 10 { - t.Errorf("expected Offset 10, got %d", result.ReadFile.Offset) + if result.ReadFile().Offset != 10 { + t.Errorf("expected Offset 10, got %d", result.ReadFile().Offset) } - if result.ReadFile.Limit != 50 { - t.Errorf("expected Limit 50, got %d", result.ReadFile.Limit) + if result.ReadFile().Limit != 50 { + t.Errorf("expected Limit 50, got %d", result.ReadFile().Limit) } }) } @@ -301,17 +301,17 @@ func TestNormalizerExecute(t *testing.T) { "description": "List files", } result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) - if result.Kind != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind) + if result.Kind() != streams.ToolKindShellExec { + t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) } - if result.ShellExec == nil { + if result.ShellExec() == nil { t.Fatal("expected ShellExec to be set") } - if result.ShellExec.Command != "ls -la" { - t.Errorf("expected Command 'ls -la', got %q", result.ShellExec.Command) + if result.ShellExec().Command != "ls -la" { + t.Errorf("expected Command 'ls -la', got %q", result.ShellExec().Command) } - if result.ShellExec.Description != "List files" { - t.Errorf("expected Description 'List files', got %q", result.ShellExec.Description) + if result.ShellExec().Description != "List files" { + t.Errorf("expected Description 'List files', got %q", result.ShellExec().Description) } }) @@ -321,11 +321,11 @@ func TestNormalizerExecute(t *testing.T) { "timeout": float64(30000), } result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) - if result.ShellExec == nil { + if result.ShellExec() == nil { t.Fatal("expected ShellExec to be set") } - if result.ShellExec.Timeout != 30000 { - t.Errorf("expected Timeout 30000, got %d", result.ShellExec.Timeout) + if result.ShellExec().Timeout != 30000 { + t.Errorf("expected Timeout 30000, got %d", result.ShellExec().Timeout) } }) @@ -335,10 +335,10 @@ func TestNormalizerExecute(t *testing.T) { "run_in_background": true, } result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) - if result.ShellExec == nil { + if result.ShellExec() == nil { t.Fatal("expected ShellExec to be set") } - if !result.ShellExec.Background { + if !result.ShellExec().Background { t.Error("expected Background to be true") } }) @@ -354,17 +354,17 @@ func TestNormalizerCodeSearch(t *testing.T) { "path": "/workspace", } result := normalizer.NormalizeToolCall(claudecode.ToolGlob, args) - if result.Kind != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind) + if result.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) } - if result.CodeSearch == nil { + if result.CodeSearch() == nil { t.Fatal("expected CodeSearch to be set") } - if result.CodeSearch.Glob != "**/*.ts" { - t.Errorf("expected Glob '**/*.ts', got %q", result.CodeSearch.Glob) + if result.CodeSearch().Glob != "**/*.ts" { + t.Errorf("expected Glob '**/*.ts', got %q", result.CodeSearch().Glob) } - if result.CodeSearch.Path != "/workspace" { - t.Errorf("expected Path '/workspace', got %q", result.CodeSearch.Path) + if result.CodeSearch().Path != "/workspace" { + t.Errorf("expected Path '/workspace', got %q", result.CodeSearch().Path) } }) @@ -374,14 +374,14 @@ func TestNormalizerCodeSearch(t *testing.T) { "path": "/workspace", } result := normalizer.NormalizeToolCall(claudecode.ToolGrep, args) - if result.Kind != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind) + if result.Kind() != streams.ToolKindCodeSearch { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) } - if result.CodeSearch == nil { + if result.CodeSearch() == nil { t.Fatal("expected CodeSearch to be set") } - if result.CodeSearch.Pattern != "func.*Error" { - t.Errorf("expected Pattern 'func.*Error', got %q", result.CodeSearch.Pattern) + if result.CodeSearch().Pattern != "func.*Error" { + t.Errorf("expected Pattern 'func.*Error', got %q", result.CodeSearch().Pattern) } }) } @@ -395,17 +395,17 @@ func TestNormalizerHttpRequest(t *testing.T) { "url": "https://example.com/api", } result := normalizer.NormalizeToolCall(claudecode.ToolWebFetch, args) - if result.Kind != streams.ToolKindHttpRequest { - t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind) + if result.Kind() != streams.ToolKindHttpRequest { + t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind()) } - if result.HttpRequest == nil { + if result.HttpRequest() == nil { t.Fatal("expected HttpRequest to be set") } - if result.HttpRequest.URL != "https://example.com/api" { - t.Errorf("expected URL 'https://example.com/api', got %q", result.HttpRequest.URL) + if result.HttpRequest().URL != "https://example.com/api" { + t.Errorf("expected URL 'https://example.com/api', got %q", result.HttpRequest().URL) } - if result.HttpRequest.Method != "GET" { - t.Errorf("expected Method 'GET', got %q", result.HttpRequest.Method) + if result.HttpRequest().Method != "GET" { + t.Errorf("expected Method 'GET', got %q", result.HttpRequest().Method) } }) @@ -414,17 +414,17 @@ func TestNormalizerHttpRequest(t *testing.T) { "query": "golang testing best practices", } result := normalizer.NormalizeToolCall(claudecode.ToolWebSearch, args) - if result.Kind != streams.ToolKindHttpRequest { - t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind) + if result.Kind() != streams.ToolKindHttpRequest { + t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind()) } - if result.HttpRequest == nil { + if result.HttpRequest() == nil { t.Fatal("expected HttpRequest to be set") } - if result.HttpRequest.URL != "golang testing best practices" { - t.Errorf("expected URL 'golang testing best practices', got %q", result.HttpRequest.URL) + if result.HttpRequest().URL != "golang testing best practices" { + t.Errorf("expected URL 'golang testing best practices', got %q", result.HttpRequest().URL) } - if result.HttpRequest.Method != "SEARCH" { - t.Errorf("expected Method 'SEARCH', got %q", result.HttpRequest.Method) + if result.HttpRequest().Method != "SEARCH" { + t.Errorf("expected Method 'SEARCH', got %q", result.HttpRequest().Method) } }) } @@ -439,20 +439,20 @@ func TestNormalizerSubagentTask(t *testing.T) { "subagent_type": "Explore", } result := normalizer.NormalizeToolCall(claudecode.ToolTask, args) - if result.Kind != streams.ToolKindSubagentTask { - t.Errorf("expected Kind %q, got %q", streams.ToolKindSubagentTask, result.Kind) + if result.Kind() != streams.ToolKindSubagentTask { + t.Errorf("expected Kind %q, got %q", streams.ToolKindSubagentTask, result.Kind()) } - if result.SubagentTask == nil { + if result.SubagentTask() == nil { t.Fatal("expected SubagentTask to be set") } - if result.SubagentTask.Description != "Find all test files" { - t.Errorf("expected Description 'Find all test files', got %q", result.SubagentTask.Description) + if result.SubagentTask().Description != "Find all test files" { + t.Errorf("expected Description 'Find all test files', got %q", result.SubagentTask().Description) } - if result.SubagentTask.Prompt != "Search for test files in the codebase" { - t.Errorf("expected Prompt 'Search for test files in the codebase', got %q", result.SubagentTask.Prompt) + if result.SubagentTask().Prompt != "Search for test files in the codebase" { + t.Errorf("expected Prompt 'Search for test files in the codebase', got %q", result.SubagentTask().Prompt) } - if result.SubagentTask.SubagentType != "Explore" { - t.Errorf("expected SubagentType 'Explore', got %q", result.SubagentTask.SubagentType) + if result.SubagentTask().SubagentType != "Explore" { + t.Errorf("expected SubagentType 'Explore', got %q", result.SubagentTask().SubagentType) } } @@ -465,17 +465,17 @@ func TestNormalizerCreateTask(t *testing.T) { "description": "The login flow fails when...", } result := normalizer.NormalizeToolCall(claudecode.ToolTaskCreate, args) - if result.Kind != streams.ToolKindCreateTask { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCreateTask, result.Kind) + if result.Kind() != streams.ToolKindCreateTask { + t.Errorf("expected Kind %q, got %q", streams.ToolKindCreateTask, result.Kind()) } - if result.CreateTask == nil { + if result.CreateTask() == nil { t.Fatal("expected CreateTask to be set") } - if result.CreateTask.Title != "Fix authentication bug" { - t.Errorf("expected Title 'Fix authentication bug', got %q", result.CreateTask.Title) + if result.CreateTask().Title != "Fix authentication bug" { + t.Errorf("expected Title 'Fix authentication bug', got %q", result.CreateTask().Title) } - if result.CreateTask.Description != "The login flow fails when..." { - t.Errorf("expected Description 'The login flow fails when...', got %q", result.CreateTask.Description) + if result.CreateTask().Description != "The login flow fails when..." { + t.Errorf("expected Description 'The login flow fails when...', got %q", result.CreateTask().Description) } } @@ -489,28 +489,28 @@ func TestNormalizerManageTodos(t *testing.T) { "status": "completed", } result := normalizer.NormalizeToolCall(claudecode.ToolTaskUpdate, args) - if result.Kind != streams.ToolKindManageTodos { - t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind) + if result.Kind() != streams.ToolKindManageTodos { + t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind()) } - if result.ManageTodos == nil { + if result.ManageTodos() == nil { t.Fatal("expected ManageTodos to be set") } - if result.ManageTodos.Operation != "update" { - t.Errorf("expected Operation 'update', got %q", result.ManageTodos.Operation) + if result.ManageTodos().Operation != "update" { + t.Errorf("expected Operation 'update', got %q", result.ManageTodos().Operation) } }) t.Run("TaskList operation", func(t *testing.T) { args := map[string]any{} result := normalizer.NormalizeToolCall(claudecode.ToolTaskList, args) - if result.Kind != streams.ToolKindManageTodos { - t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind) + if result.Kind() != streams.ToolKindManageTodos { + t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind()) } - if result.ManageTodos == nil { + if result.ManageTodos() == nil { t.Fatal("expected ManageTodos to be set") } - if result.ManageTodos.Operation != "list" { - t.Errorf("expected Operation 'list', got %q", result.ManageTodos.Operation) + if result.ManageTodos().Operation != "list" { + t.Errorf("expected Operation 'list', got %q", result.ManageTodos().Operation) } }) @@ -525,20 +525,20 @@ func TestNormalizerManageTodos(t *testing.T) { }, } result := normalizer.NormalizeToolCall(claudecode.ToolTodoWrite, args) - if result.Kind != streams.ToolKindManageTodos { - t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind) + if result.Kind() != streams.ToolKindManageTodos { + t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind()) } - if result.ManageTodos == nil { + if result.ManageTodos() == nil { t.Fatal("expected ManageTodos to be set") } - if result.ManageTodos.Operation != "write" { - t.Errorf("expected Operation 'write', got %q", result.ManageTodos.Operation) + if result.ManageTodos().Operation != "write" { + t.Errorf("expected Operation 'write', got %q", result.ManageTodos().Operation) } - if len(result.ManageTodos.Items) != 1 { - t.Fatalf("expected 1 item, got %d", len(result.ManageTodos.Items)) + if len(result.ManageTodos().Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(result.ManageTodos().Items)) } - if result.ManageTodos.Items[0].ID != "todo-1" { - t.Errorf("expected item ID 'todo-1', got %q", result.ManageTodos.Items[0].ID) + if result.ManageTodos().Items[0].ID != "todo-1" { + t.Errorf("expected item ID 'todo-1', got %q", result.ManageTodos().Items[0].ID) } }) } @@ -551,14 +551,14 @@ func TestNormalizerGeneric(t *testing.T) { "custom_field": "custom_value", } result := normalizer.NormalizeToolCall("UnknownTool", args) - if result.Kind != streams.ToolKindGeneric { - t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, result.Kind) + if result.Kind() != streams.ToolKindGeneric { + t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, result.Kind()) } - if result.Generic == nil { + if result.Generic() == nil { t.Fatal("expected Generic to be set") } - if result.Generic.Name != "UnknownTool" { - t.Errorf("expected Name 'UnknownTool', got %q", result.Generic.Name) + if result.Generic().Name != "UnknownTool" { + t.Errorf("expected Name 'UnknownTool', got %q", result.Generic().Name) } } @@ -567,58 +567,50 @@ func TestNormalizerToolResult(t *testing.T) { normalizer := NewNormalizer() t.Run("shell exec result from string", func(t *testing.T) { - payload := &streams.NormalizedPayload{ - Kind: streams.ToolKindShellExec, - ShellExec: &streams.ShellExecPayload{Command: "ls"}, - } + // Use factory function to create payload + payload := streams.NewShellExec("ls", "", "", 0, false) normalizer.NormalizeToolResult(payload, "file1.txt\nfile2.txt") - if payload.ShellExec.Output == nil { + if payload.ShellExec().Output == nil { t.Fatal("expected Output to be set") } - if payload.ShellExec.Output.Stdout != "file1.txt\nfile2.txt" { - t.Errorf("expected Stdout 'file1.txt\\nfile2.txt', got %q", payload.ShellExec.Output.Stdout) + if payload.ShellExec().Output.Stdout != "file1.txt\nfile2.txt" { + t.Errorf("expected Stdout 'file1.txt\\nfile2.txt', got %q", payload.ShellExec().Output.Stdout) } }) t.Run("shell exec result from map", func(t *testing.T) { - payload := &streams.NormalizedPayload{ - Kind: streams.ToolKindShellExec, - ShellExec: &streams.ShellExecPayload{Command: "npm test"}, - } + // Use factory function to create payload + payload := streams.NewShellExec("npm test", "", "", 0, false) normalizer.NormalizeToolResult(payload, map[string]any{ "stdout": "All tests passed", "stderr": "", "exit_code": float64(0), }) - if payload.ShellExec.Output == nil { + if payload.ShellExec().Output == nil { t.Fatal("expected Output to be set") } - if payload.ShellExec.Output.Stdout != "All tests passed" { - t.Errorf("expected Stdout 'All tests passed', got %q", payload.ShellExec.Output.Stdout) + if payload.ShellExec().Output.Stdout != "All tests passed" { + t.Errorf("expected Stdout 'All tests passed', got %q", payload.ShellExec().Output.Stdout) } - if payload.ShellExec.Output.ExitCode != 0 { - t.Errorf("expected ExitCode 0, got %d", payload.ShellExec.Output.ExitCode) + if payload.ShellExec().Output.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", payload.ShellExec().Output.ExitCode) } }) t.Run("http request result", func(t *testing.T) { - payload := &streams.NormalizedPayload{ - Kind: streams.ToolKindHttpRequest, - HttpRequest: &streams.HttpRequestPayload{URL: "https://example.com"}, - } + // Use factory function to create payload + payload := streams.NewHttpRequest("https://example.com", "GET") normalizer.NormalizeToolResult(payload, "Response body here") - if payload.HttpRequest.Response != "Response body here" { - t.Errorf("expected Response 'Response body here', got %q", payload.HttpRequest.Response) + if payload.HttpRequest().Response != "Response body here" { + t.Errorf("expected Response 'Response body here', got %q", payload.HttpRequest().Response) } }) t.Run("generic result", func(t *testing.T) { - payload := &streams.NormalizedPayload{ - Kind: streams.ToolKindGeneric, - Generic: &streams.GenericPayload{Name: "CustomTool"}, - } + // Use factory function to create payload + payload := streams.NewGeneric("CustomTool", nil) normalizer.NormalizeToolResult(payload, map[string]any{"data": "value"}) - if payload.Generic.Output == nil { + if payload.Generic().Output == nil { t.Fatal("expected Output to be set") } }) diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index cb49cb3c4c..30aa619d41 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -1147,9 +1147,18 @@ func (wt *WorkspaceTracker) buildFileTreeNode(fullPath, relPath string, info os. // GetFileContent returns the content of a file func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error) { - // Resolve the full path with path traversal protection - fullPath := filepath.Join(wt.workDir, filepath.Clean(reqPath)) cleanWorkDir := filepath.Clean(wt.workDir) + cleanReqPath := filepath.Clean(reqPath) + + // Resolve the full path - if already absolute and within workDir, use directly + var fullPath string + if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) { + fullPath = cleanReqPath + } else { + fullPath = filepath.Join(wt.workDir, cleanReqPath) + } + + // Path traversal protection if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { return "", 0, fmt.Errorf("path traversal detected") } diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index 6544aacb69..9d2726853a 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -75,6 +75,10 @@ type AgentEvent struct { // ToolCallID uniquely identifies the tool invocation. ToolCallID string `json:"tool_call_id,omitempty"` + // ParentToolCallID identifies the parent Task tool call when this event + // comes from a subagent. Used for visual nesting in the UI. + ParentToolCallID string `json:"parent_tool_call_id,omitempty"` + // ToolName is the name of the tool being invoked. ToolName string `json:"tool_name,omitempty"` diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go index e2eec4d2dd..7c6c040ab7 100644 --- a/apps/backend/internal/agentctl/types/streams/tool_payload.go +++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go @@ -1,5 +1,7 @@ package streams +import "encoding/json" + // ToolKind categorizes the normalized tool operation type ToolKind string @@ -19,50 +21,121 @@ const ( // NormalizedPayload is the normalized tool data (discriminated union). // Exactly one of the kind-specific fields will be set based on Kind. +// Fields are unexported to enforce use of factory functions (NewReadFile, NewShellExec, etc.) type NormalizedPayload struct { - Kind ToolKind `json:"kind"` + kind ToolKind + readFile *ReadFilePayload + modifyFile *ModifyFilePayload + shellExec *ShellExecPayload + codeSearch *CodeSearchPayload + httpRequest *HttpRequestPayload + generic *GenericPayload + createTask *CreateTaskPayload + subagentTask *SubagentTaskPayload + showPlan *ShowPlanPayload + manageTodos *ManageTodosPayload + misc *MiscPayload +} - // read_file - ReadFile *ReadFilePayload `json:"read_file,omitempty"` +// --- Getters for NormalizedPayload --- - // modify_file - ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` +func (p *NormalizedPayload) Kind() ToolKind { return p.kind } +func (p *NormalizedPayload) ReadFile() *ReadFilePayload { return p.readFile } +func (p *NormalizedPayload) ModifyFile() *ModifyFilePayload { return p.modifyFile } +func (p *NormalizedPayload) ShellExec() *ShellExecPayload { return p.shellExec } +func (p *NormalizedPayload) CodeSearch() *CodeSearchPayload { return p.codeSearch } +func (p *NormalizedPayload) HttpRequest() *HttpRequestPayload { return p.httpRequest } +func (p *NormalizedPayload) Generic() *GenericPayload { return p.generic } +func (p *NormalizedPayload) CreateTask() *CreateTaskPayload { return p.createTask } +func (p *NormalizedPayload) SubagentTask() *SubagentTaskPayload { return p.subagentTask } +func (p *NormalizedPayload) ShowPlan() *ShowPlanPayload { return p.showPlan } +func (p *NormalizedPayload) ManageTodos() *ManageTodosPayload { return p.manageTodos } +func (p *NormalizedPayload) Misc() *MiscPayload { return p.misc } - // shell_exec - ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` +// MarshalJSON implements custom JSON marshaling for NormalizedPayload. +func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { + type jsonPayload struct { + Kind ToolKind `json:"kind"` + ReadFile *ReadFilePayload `json:"read_file,omitempty"` + ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` + ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` + CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` + HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` + Generic *GenericPayload `json:"generic,omitempty"` + CreateTask *CreateTaskPayload `json:"create_task,omitempty"` + SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` + ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` + ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` + Misc *MiscPayload `json:"misc,omitempty"` + } + return json.Marshal(jsonPayload{ + Kind: p.kind, + ReadFile: p.readFile, + ModifyFile: p.modifyFile, + ShellExec: p.shellExec, + CodeSearch: p.codeSearch, + HttpRequest: p.httpRequest, + Generic: p.generic, + CreateTask: p.createTask, + SubagentTask: p.subagentTask, + ShowPlan: p.showPlan, + ManageTodos: p.manageTodos, + Misc: p.misc, + }) +} - // code_search - CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` - - // http_request - HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` - - // generic (fallback) - Generic *GenericPayload `json:"generic,omitempty"` - - // create_task - CreateTask *CreateTaskPayload `json:"create_task,omitempty"` - - // subagent_task - SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` - - // show_plan - ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` - - // manage_todos - ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` - - // misc - Misc *MiscPayload `json:"misc,omitempty"` +// UnmarshalJSON implements custom JSON unmarshaling for NormalizedPayload. +// This is required because the struct has unexported fields. +func (p *NormalizedPayload) UnmarshalJSON(data []byte) error { + type jsonPayload struct { + Kind ToolKind `json:"kind"` + ReadFile *ReadFilePayload `json:"read_file,omitempty"` + ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` + ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` + CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` + HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` + Generic *GenericPayload `json:"generic,omitempty"` + CreateTask *CreateTaskPayload `json:"create_task,omitempty"` + SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` + ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` + ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` + Misc *MiscPayload `json:"misc,omitempty"` + } + var jp jsonPayload + if err := json.Unmarshal(data, &jp); err != nil { + return err + } + p.kind = jp.Kind + p.readFile = jp.ReadFile + p.modifyFile = jp.ModifyFile + p.shellExec = jp.ShellExec + p.codeSearch = jp.CodeSearch + p.httpRequest = jp.HttpRequest + p.generic = jp.Generic + p.createTask = jp.CreateTask + p.subagentTask = jp.SubagentTask + p.showPlan = jp.ShowPlan + p.manageTodos = jp.ManageTodos + p.misc = jp.Misc + return nil } // --- Kind-specific payloads --- +// ReadFileOutput contains the result of a file read operation. +type ReadFileOutput struct { + Content string `json:"content,omitempty"` + LineCount int `json:"line_count,omitempty"` + Truncated bool `json:"truncated,omitempty"` + Language string `json:"language,omitempty"` +} + // ReadFilePayload contains normalized data for file read operations. type ReadFilePayload struct { - FilePath string `json:"file_path"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` + FilePath string `json:"file_path"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + Output *ReadFileOutput `json:"output,omitempty"` } // ModifyFilePayload contains normalized data for file modification operations. @@ -111,12 +184,20 @@ type ShellExecOutput struct { Stderr string `json:"stderr,omitempty"` } +// CodeSearchOutput contains the result of a code search operation. +type CodeSearchOutput struct { + Files []string `json:"files,omitempty"` + FileCount int `json:"file_count,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + // CodeSearchPayload contains normalized data for code search operations. type CodeSearchPayload struct { - Query string `json:"query,omitempty"` - Pattern string `json:"pattern,omitempty"` - Path string `json:"path,omitempty"` - Glob string `json:"glob,omitempty"` + Query string `json:"query,omitempty"` + Pattern string `json:"pattern,omitempty"` + Path string `json:"path,omitempty"` + Glob string `json:"glob,omitempty"` + Output *CodeSearchOutput `json:"output,omitempty"` } // HttpRequestPayload contains normalized data for HTTP request operations. @@ -171,13 +252,14 @@ type MiscPayload struct { Details any `json:"details,omitempty"` } -// --- Constructor functions for NormalizedPayload --- +// --- Factory functions for NormalizedPayload --- +// These are the ONLY way to create NormalizedPayload instances. // NewReadFile creates a NormalizedPayload for file read operations. func NewReadFile(filePath string, offset, limit int) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindReadFile, - ReadFile: &ReadFilePayload{ + kind: ToolKindReadFile, + readFile: &ReadFilePayload{ FilePath: filePath, Offset: offset, Limit: limit, @@ -188,8 +270,8 @@ func NewReadFile(filePath string, offset, limit int) *NormalizedPayload { // NewModifyFile creates a NormalizedPayload for file modification operations. func NewModifyFile(filePath string, mutations []FileMutation) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindModifyFile, - ModifyFile: &ModifyFilePayload{ + kind: ToolKindModifyFile, + modifyFile: &ModifyFilePayload{ FilePath: filePath, Mutations: mutations, }, @@ -199,8 +281,8 @@ func NewModifyFile(filePath string, mutations []FileMutation) *NormalizedPayload // NewShellExec creates a NormalizedPayload for shell command execution. func NewShellExec(command, workDir, description string, timeout int, background bool) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindShellExec, - ShellExec: &ShellExecPayload{ + kind: ToolKindShellExec, + shellExec: &ShellExecPayload{ Command: command, WorkDir: workDir, Description: description, @@ -213,8 +295,8 @@ func NewShellExec(command, workDir, description string, timeout int, background // NewCodeSearch creates a NormalizedPayload for code search operations. func NewCodeSearch(query, pattern, path, glob string) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindCodeSearch, - CodeSearch: &CodeSearchPayload{ + kind: ToolKindCodeSearch, + codeSearch: &CodeSearchPayload{ Query: query, Pattern: pattern, Path: path, @@ -226,8 +308,8 @@ func NewCodeSearch(query, pattern, path, glob string) *NormalizedPayload { // NewHttpRequest creates a NormalizedPayload for HTTP request operations. func NewHttpRequest(url, method string) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindHttpRequest, - HttpRequest: &HttpRequestPayload{ + kind: ToolKindHttpRequest, + httpRequest: &HttpRequestPayload{ URL: url, Method: method, }, @@ -237,8 +319,8 @@ func NewHttpRequest(url, method string) *NormalizedPayload { // NewGeneric creates a NormalizedPayload for unrecognized tools. func NewGeneric(name string, input any) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindGeneric, - Generic: &GenericPayload{ + kind: ToolKindGeneric, + generic: &GenericPayload{ Name: name, Input: input, }, @@ -248,8 +330,8 @@ func NewGeneric(name string, input any) *NormalizedPayload { // NewCreateTask creates a NormalizedPayload for task creation operations. func NewCreateTask(title, description string) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindCreateTask, - CreateTask: &CreateTaskPayload{ + kind: ToolKindCreateTask, + createTask: &CreateTaskPayload{ Title: title, Description: description, }, @@ -259,8 +341,8 @@ func NewCreateTask(title, description string) *NormalizedPayload { // NewSubagentTask creates a NormalizedPayload for subagent task invocations. func NewSubagentTask(description, prompt, subagentType string) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindSubagentTask, - SubagentTask: &SubagentTaskPayload{ + kind: ToolKindSubagentTask, + subagentTask: &SubagentTaskPayload{ Description: description, Prompt: prompt, SubagentType: subagentType, @@ -271,8 +353,8 @@ func NewSubagentTask(description, prompt, subagentType string) *NormalizedPayloa // NewShowPlan creates a NormalizedPayload for plan display operations. func NewShowPlan(summary string, steps []string) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindShowPlan, - ShowPlan: &ShowPlanPayload{ + kind: ToolKindShowPlan, + showPlan: &ShowPlanPayload{ Summary: summary, Steps: steps, }, @@ -282,8 +364,8 @@ func NewShowPlan(summary string, steps []string) *NormalizedPayload { // NewManageTodos creates a NormalizedPayload for todo management operations. func NewManageTodos(operation string, items []TodoItem) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindManageTodos, - ManageTodos: &ManageTodosPayload{ + kind: ToolKindManageTodos, + manageTodos: &ManageTodosPayload{ Operation: operation, Items: items, }, @@ -293,8 +375,8 @@ func NewManageTodos(operation string, items []TodoItem) *NormalizedPayload { // NewMisc creates a NormalizedPayload for miscellaneous operations. func NewMisc(label string, details any) *NormalizedPayload { return &NormalizedPayload{ - Kind: ToolKindMisc, - Misc: &MiscPayload{ + kind: ToolKindMisc, + misc: &MiscPayload{ Label: label, Details: details, }, diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload_test.go b/apps/backend/internal/agentctl/types/streams/tool_payload_test.go new file mode 100644 index 0000000000..6f9a7ff205 --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/tool_payload_test.go @@ -0,0 +1,87 @@ +package streams + +import ( + "encoding/json" + "testing" +) + +func TestNormalizedPayloadMarshalRoundTrip(t *testing.T) { + tests := []struct { + name string + payload *NormalizedPayload + }{ + { + name: "shell_exec", + payload: NewShellExec("ls -la", "/home/user", "list files", 30000, false), + }, + { + name: "read_file", + payload: NewReadFile("/path/to/file.go", 0, 100), + }, + { + name: "modify_file", + payload: NewModifyFile("/path/to/file.go", []FileMutation{{Type: MutationPatch, Diff: "- old\n+ new"}}), + }, + { + name: "code_search", + payload: NewCodeSearch("query", "pattern", "/path", "*.go"), + }, + { + name: "generic", + payload: NewGeneric("custom_tool", map[string]any{"key": "value"}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal + data, err := json.Marshal(tt.payload) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal + var result NormalizedPayload + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + // Check kind matches + if result.Kind() != tt.payload.Kind() { + t.Errorf("Kind mismatch: got %q, want %q", result.Kind(), tt.payload.Kind()) + } + + // Check specific fields based on kind + switch tt.payload.Kind() { + case ToolKindShellExec: + if result.ShellExec() == nil { + t.Error("ShellExec() is nil after unmarshal") + } else if result.ShellExec().Command != tt.payload.ShellExec().Command { + t.Errorf("ShellExec.Command mismatch: got %q, want %q", + result.ShellExec().Command, tt.payload.ShellExec().Command) + } + case ToolKindReadFile: + if result.ReadFile() == nil { + t.Error("ReadFile() is nil after unmarshal") + } else if result.ReadFile().FilePath != tt.payload.ReadFile().FilePath { + t.Errorf("ReadFile.FilePath mismatch: got %q, want %q", + result.ReadFile().FilePath, tt.payload.ReadFile().FilePath) + } + case ToolKindModifyFile: + if result.ModifyFile() == nil { + t.Error("ModifyFile() is nil after unmarshal") + } else if result.ModifyFile().FilePath != tt.payload.ModifyFile().FilePath { + t.Errorf("ModifyFile.FilePath mismatch: got %q, want %q", + result.ModifyFile().FilePath, tt.payload.ModifyFile().FilePath) + } + case ToolKindGeneric: + if result.Generic() == nil { + t.Error("Generic() is nil after unmarshal") + } else if result.Generic().Name != tt.payload.Generic().Name { + t.Errorf("Generic.Name mismatch: got %q, want %q", + result.Generic().Name, tt.payload.Generic().Name) + } + } + }) + } +} diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 5bcfaa6ce3..76daf0b6b1 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -486,12 +486,15 @@ func (a *testMessageCreatorAdapter) CreateUserMessage(ctx context.Context, taskI return err } -func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { +func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { metadata := map[string]interface{}{ "tool_call_id": toolCallID, "title": title, "status": status, } + if parentToolCallID != "" { + metadata["parent_tool_call_id"] = parentToolCallID + } if normalized != nil { metadata["normalized"] = normalized } @@ -507,8 +510,8 @@ func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, t return err } -func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, normalized *streams.NormalizedPayload) error { - return a.svc.UpdateToolCallMessage(ctx, agentSessionID, toolCallID, status, result, title, normalized) +func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error { + return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType) } func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error { diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 05dad157a5..22b59127a2 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/watcher" @@ -15,6 +16,27 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +// toolKindToMessageType maps the normalized tool kind to a frontend message type. +func toolKindToMessageType(normalized *streams.NormalizedPayload) string { + if normalized == nil { + return "tool_call" + } + switch normalized.Kind() { + case streams.ToolKindReadFile: + return "tool_read" + case streams.ToolKindCodeSearch: + return "tool_search" + case streams.ToolKindModifyFile: + return "tool_edit" + case streams.ToolKindShellExec: + return "tool_execute" + case streams.ToolKindManageTodos: + return "todo" + default: + return "tool_call" + } +} + // Event handlers func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPSessionEventData) { @@ -679,6 +701,7 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag ctx, payload.TaskID, payload.Data.ToolCallID, + payload.Data.ParentToolCallID, // Pass parent for subagent nesting payload.Data.ToolTitle, payload.Data.ToolStatus, payload.SessionID, @@ -831,6 +854,9 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. return } + // Determine message type from normalized payload for fallback creation + msgType := toolKindToMessageType(payload.Data.Normalized) + // Handle all status updates (running, complete, error) switch payload.Data.ToolStatus { case "running", "complete", "completed", "success", "error", "failed": @@ -838,11 +864,14 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. ctx, payload.TaskID, payload.Data.ToolCallID, + payload.Data.ParentToolCallID, // Pass parent for subagent nesting payload.Data.ToolStatus, "", // result - no longer used, tool results in NormalizedPayload payload.SessionID, - payload.Data.ToolTitle, // Include title from update event - payload.Data.Normalized, // Pass normalized tool data for message metadata + payload.Data.ToolTitle, // Include title from update event + s.getActiveTurnID(payload.SessionID), // Turn ID for fallback creation + msgType, // Message type for fallback creation + payload.Data.Normalized, // Pass normalized tool data for message metadata ); err != nil { s.logger.Warn("failed to update tool call message", zap.String("task_id", payload.TaskID), diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index b8aa420a97..4146829e20 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -57,10 +57,13 @@ type MessageCreator interface { CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error // CreateToolCallMessage creates a message for a tool call. // normalized contains the typed tool payload data. - CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error + // parentToolCallID is the parent Task tool call ID for subagent nesting (empty for top-level). + CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error // UpdateToolCallMessage updates a tool call message's status and optionally its normalized data. + // If the message doesn't exist, it creates it using taskID, turnID, and msgType. // normalized contains the typed tool payload data. - UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID, title string, normalized *streams.NormalizedPayload) error + // parentToolCallID is the parent Task tool call ID for subagent nesting (empty for top-level). + UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index b54494b9dc..823bf63271 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -427,6 +427,9 @@ func (r *Repository) initSchema() error { if err := r.ensureColumn("task_session_messages", "task_session_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := r.ensureColumn("task_session_messages", "turn_id", "TEXT DEFAULT ''"); err != nil { + return err + } // Ensure container_id column exists for existing databases if err := r.ensureColumn("task_sessions", "container_id", "TEXT NOT NULL DEFAULT ''"); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index d20f95038d..3b33ff0336 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -220,7 +220,8 @@ func (r *Repository) DeleteMessage(ctx context.Context, id string) error { return nil } -// GetMessageByToolCallID retrieves a tool_call message by session ID and tool_call_id in metadata +// GetMessageByToolCallID retrieves a tool message by session ID and tool_call_id in metadata +// Searches all tool types: tool_call, tool_read, tool_edit, tool_execute, tool_search func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { message := &models.Message{} var requestsInput int @@ -228,7 +229,8 @@ func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, tool var metadataJSON string err := r.db.QueryRowContext(ctx, ` SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? AND type = 'tool_call' AND json_extract(metadata, '$.tool_call_id') = ? + FROM task_session_messages WHERE task_session_id = ? AND type LIKE 'tool_%' AND json_extract(metadata, '$.tool_call_id') = ? + ORDER BY created_at ASC LIMIT 1 `, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) if err != nil { diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index e528cbb1cd..7c24799b65 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2002,8 +2002,16 @@ func (s *Service) AppendThinkingContent(ctx context.Context, messageID, addition // UpdateToolCallMessage updates a tool call message's status, optionally title and normalized data. // It includes retry logic to handle race conditions where the complete event // may arrive before the message has been created by the start event. +// If the message is not found after retries and taskID/turnID/msgType are provided, it creates the message. // The normalized parameter contains typed tool payload data that gets added to metadata. func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, normalized *streams.NormalizedPayload) error { + return s.UpdateToolCallMessageWithCreate(ctx, sessionID, toolCallID, "", status, result, title, normalized, "", "", "") +} + +// UpdateToolCallMessageWithCreate is like UpdateToolCallMessage but can create the message if not found. +// If taskID, turnID, and msgType are provided, the message will be created if it doesn't exist. +// parentToolCallID is used for subagent nesting (empty for top-level). +func (s *Service) UpdateToolCallMessageWithCreate(ctx context.Context, sessionID, toolCallID, parentToolCallID, status, result, title string, normalized *streams.NormalizedPayload, taskID, turnID, msgType string) error { const maxRetries = 5 const retryDelay = 100 * time.Millisecond @@ -2034,6 +2042,51 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall } } + // If message not found and we have enough info to create it, do so + if err != nil && taskID != "" && msgType != "" { + s.logger.Info("tool call message not found, creating it", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.String("task_id", taskID), + zap.String("msg_type", msgType)) + + metadata := map[string]interface{}{ + "tool_call_id": toolCallID, + "title": title, + "status": status, + } + // Add parent tool call ID for subagent nesting (if present) + if parentToolCallID != "" { + metadata["parent_tool_call_id"] = parentToolCallID + } + if normalized != nil { + metadata["normalized"] = normalized + } + + msg, createErr := s.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + Content: title, + AuthorType: "agent", + Type: msgType, + Metadata: metadata, + }) + if createErr != nil { + s.logger.Error("failed to create tool call message as fallback", + zap.String("session_id", sessionID), + zap.String("tool_call_id", toolCallID), + zap.Error(createErr)) + return createErr + } + + s.logger.Info("created tool call message as fallback", + zap.String("message_id", msg.ID), + zap.String("tool_call_id", toolCallID), + zap.String("status", status)) + return nil + } + if err != nil { s.logger.Warn("tool call message not found for update after retries", zap.String("session_id", sessionID), @@ -2056,14 +2109,12 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall message.Metadata["normalized"] = normalized } - // Update title if provided and current title is just the tool name (not yet filled) - // This handles the case where the first event only had the tool name - if title != "" { - currentTitle, _ := message.Metadata["title"].(string) - if currentTitle == "" || currentTitle == message.Metadata["tool_name"] { - message.Content = title - message.Metadata["title"] = title - } + // Update title/content if provided and different from current + // This handles the case where the first event only had the tool name (e.g., "bash") + // and subsequent events have the actual command/path (e.g., "ls" or "/path/to/file") + if title != "" && title != message.Content { + message.Content = title + message.Metadata["title"] = title } if err := s.repo.UpdateMessage(ctx, message); err != nil { @@ -2330,6 +2381,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes "message_id": message.ID, "session_id": message.TaskSessionID, "task_id": message.TaskID, + "turn_id": message.TurnID, "author_type": string(message.AuthorType), "author_id": message.AuthorID, "content": models.StripSystemContent(message.Content), diff --git a/apps/backend/pkg/claudecode/types.go b/apps/backend/pkg/claudecode/types.go index 0b8d6816a4..782b729b20 100644 --- a/apps/backend/pkg/claudecode/types.go +++ b/apps/backend/pkg/claudecode/types.go @@ -56,6 +56,9 @@ type CLIMessage struct { SessionID string `json:"session_id,omitempty"` SessionStatus string `json:"session_status,omitempty"` + // For subagent context (when running inside a Task tool call) + ParentToolUseID string `json:"parent_tool_use_id,omitempty"` + // For assistant messages Message *AssistantMessage `json:"message,omitempty"` diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b7801d2b6b..83d0455100 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -135,7 +135,7 @@ * { @apply border-border outline-ring/50; scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + scrollbar-color: var(--scrollbar-thumb) transparent; } body { @apply bg-background text-foreground; diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index c2163f8ca8..f4155b5204 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -1,6 +1,6 @@ "use client" -import * as React from "react" +import { memo, useState } from "react" import { IconCheck, IconSelector } from "@tabler/icons-react" import { cn } from "@/lib/utils" @@ -39,7 +39,7 @@ interface ComboboxProps { triggerClassName?: string } -export function Combobox({ +export const Combobox = memo(function Combobox({ options, value, onValueChange, @@ -51,7 +51,7 @@ export function Combobox({ className, triggerClassName, }: ComboboxProps) { - const [open, setOpen] = React.useState(false) + const [open, setOpen] = useState(false) const selectedOption = options.find((option) => option.value === value) @@ -113,4 +113,4 @@ export function Combobox({ ) -} +}); diff --git a/apps/web/components/grid-spinner.tsx b/apps/web/components/grid-spinner.tsx new file mode 100644 index 0000000000..881c0970a6 --- /dev/null +++ b/apps/web/components/grid-spinner.tsx @@ -0,0 +1,21 @@ +'use client'; + +type GridSpinnerProps = { + className?: string; +}; + +export function GridSpinner({ className }: GridSpinnerProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 2b65055e19..b1944ef2c9 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState, FormEvent } from 'react'; +import { useEffect, useRef, useState, FormEvent, memo, useMemo, useCallback } from 'react'; import Link from 'next/link'; import { IconSettings } from '@tabler/icons-react'; import { @@ -88,7 +88,7 @@ type RepositorySelectorProps = { emptyMessage: string; }; -function RepositorySelector({ +const RepositorySelector = memo(function RepositorySelector({ options, value, onValueChange, @@ -110,7 +110,7 @@ function RepositorySelector({ className={disabled ? undefined : 'cursor-pointer'} /> ); -} +}); type BranchOption = { value: string; @@ -128,7 +128,7 @@ type BranchSelectorProps = { emptyMessage: string; }; -function BranchSelector({ +const BranchSelector = memo(function BranchSelector({ options, value, onValueChange, @@ -150,7 +150,7 @@ function BranchSelector({ className={disabled ? undefined : 'cursor-pointer'} /> ); -} +}); type AgentSelectorProps = { options: Array<{ value: string; label: string; renderLabel: () => React.ReactNode }>; @@ -161,7 +161,7 @@ type AgentSelectorProps = { triggerClassName?: string; }; -function AgentSelector({ +const AgentSelector = memo(function AgentSelector({ options, value, onValueChange, @@ -183,7 +183,122 @@ function AgentSelector({ triggerClassName={triggerClassName} /> ); -} +}); + +// Memoized text inputs to prevent re-rendering the entire dialog on every keystroke +type TaskFormInputsProps = { + isSessionMode: boolean; + initialTitle: string; + initialDescription: string; + onTitleChange: (hasContent: boolean) => void; + onDescriptionChange: (hasContent: boolean) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + titleValueRef: React.RefObject<{ getValue: () => string } | null>; + descriptionValueRef: React.RefObject<{ getValue: () => string } | null>; +}; + +const TaskFormInputs = memo(function TaskFormInputs({ + isSessionMode, + initialTitle, + initialDescription, + onTitleChange, + onDescriptionChange, + onKeyDown, + titleValueRef, + descriptionValueRef, +}: TaskFormInputsProps) { + const [title, setTitle] = useState(initialTitle); + const [description, setDescription] = useState(initialDescription); + const textareaRef = useRef(null); + + // Expose getValue methods via refs passed from parent + // This is an imperative handle pattern - refs are intentionally mutated + useEffect(() => { + const ref = titleValueRef as React.MutableRefObject<{ getValue: () => string } | null>; + if (ref) { + ref.current = { getValue: () => title }; + } + }, [title, titleValueRef]); + + useEffect(() => { + const ref = descriptionValueRef as React.MutableRefObject<{ getValue: () => string } | null>; + if (ref) { + // eslint-disable-next-line react-hooks/immutability + ref.current = { getValue: () => description }; + } + }, [description, descriptionValueRef]); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }, [description]); + + const handleTitleChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value; + const hadContent = title.trim().length > 0; + const hasContent = newValue.trim().length > 0; + setTitle(newValue); + if (hadContent !== hasContent) { + onTitleChange(hasContent); + } + }, [title, onTitleChange]); + + const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value; + const hadContent = description.trim().length > 0; + const hasContent = newValue.trim().length > 0; + setDescription(newValue); + if (hadContent !== hasContent) { + onDescriptionChange(hasContent); + } + }, [description, onDescriptionChange]); + + return ( + <> + {!isSessionMode && ( +
+ +
+ )} + {isSessionMode && ( +
+ + +
+ )} +
+ {isSessionMode && } +