From 0f87f7a56b305e9c3f0ec8c7c4308df477613011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 1 Jun 2026 19:12:32 +0100 Subject: [PATCH] fix(acp): enrich tool call metadata per agent with structured ACP fields (#1212) --- .../server/adapter/transport/acp/adapter.go | 2 +- .../adapter/transport/acp/adapter_tools.go | 63 ++-- .../transport/acp/adapter_tools_test.go | 86 +++++ .../server/adapter/transport/acp/enricher.go | 109 ++++++ .../adapter/transport/acp/enricher_claude.go | 63 ++++ .../adapter/transport/acp/enricher_codex.go | 168 +++++++++ .../adapter/transport/acp/enricher_cursor.go | 10 + .../transport/acp/enricher_opencode.go | 41 +++ .../adapter/transport/acp/enricher_test.go | 336 ++++++++++++++++++ .../server/adapter/transport/acp/normalize.go | 238 +++++++++---- .../adapter/transport/acp/normalize_test.go | 146 +++++--- .../adapter/transport/acp/subagent_test.go | 24 +- .../transport/acp/tool_call_update_test.go | 40 +++ apps/backend/internal/debug/fixture.go | 7 +- .../task/chat/messages/tool-read-message.tsx | 1 + .../chat/messages/tool-search-message.tsx | 1 + 16 files changed, 1182 insertions(+), 153 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/enricher.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_claude.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_codex.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_cursor.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_opencode.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_test.go 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 0e4c52ed28..51e243efb8 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -194,7 +194,7 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { cfg: cfg, logger: l, agentID: cfg.AgentID, - normalizer: NewNormalizer(), + normalizer: NewNormalizer(cfg.AgentID), updatesCh: make(chan AgentEvent, 100), activeToolCalls: make(map[string]*streams.NormalizedPayload), activeMonitors: make(map[string]map[string]string), diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go index bdeef05202..da3d622139 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go @@ -1,8 +1,6 @@ package acp import ( - "strings" - "github.com/coder/acp-go-sdk" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types/streams" @@ -88,16 +86,9 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT } if len(tc.Locations) > 0 { - locations := make([]map[string]any, len(tc.Locations)) - for i, loc := range tc.Locations { - locMap := map[string]any{"path": loc.Path} - if loc.Line != nil { - locMap["line"] = *loc.Line - } - locations[i] = locMap + for k, v := range locationsArgsFromACP(tc.Locations) { + args[k] = v } - args["locations"] = locations - args["path"] = tc.Locations[0].Path } if tc.RawInput != nil { @@ -165,7 +156,7 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session // with a known status, so without a synthesized "in_progress" here those // fields are silently dropped and the message stays on the placeholder // "Terminal" title from the initial pending tool_call. - if status == "" && (tcu.Title != nil || tcu.RawInput != nil || len(tcu.Content) > 0) { + if status == "" && (tcu.Title != nil || tcu.RawInput != nil || len(tcu.Content) > 0 || len(tcu.Locations) > 0) { status = toolStatusInProgress } @@ -198,9 +189,14 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session payload := a.activeToolCalls[toolCallID] // Update stored payload with incremental rawInput (e.g. Claude Code sends - // command/cwd in a tool_call_update after the initial empty tool_call) - if tcu.RawInput != nil && payload != nil { - a.normalizer.UpdatePayloadInput(payload, tcu.RawInput) + // command/cwd in a tool_call_update after the initial empty tool_call). + // OpenCode may send filePath/locations on the update frame only. + supplemental := toolCallUpdateSupplemental(tcu) + if payload != nil && (tcu.RawInput != nil || len(tcu.Locations) > 0) { + a.normalizer.UpdatePayloadInput(payload, tcu.RawInput, supplemental) + } + if payload != nil && (tcu.Title != nil || tcu.Meta != nil || tcu.RawInput != nil || supplemental != nil) { + a.normalizer.EnrichFromToolCallUpdate(payload, tcu.Title, tcu.Meta, tcu.RawInput, supplemental) } // Update stored payload with tool result output. Skip for tracked-Monitor @@ -240,13 +236,6 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session } } - // Enrich read_file payload path from title if still empty. - if payload != nil && payload.Kind() == streams.ToolKindReadFile { - if rf := payload.ReadFile(); rf != nil && rf.FilePath == "" && tcu.Title != nil { - rf.FilePath = extractPathFromTitle(*tcu.Title) - } - } - if isTerminal { delete(a.activeToolCalls, toolCallID) // Also drop tracked Monitor: this terminal update is the @@ -327,12 +316,26 @@ func enrichModifyFileFromContents(mf *streams.ModifyFilePayload, contents []acp. } } -// extractPathFromTitle extracts a file path from tool titles like "Read /path/to/file". -func extractPathFromTitle(title string) string { - for _, prefix := range []string{"Read ", "Write ", "Edit "} { - if strings.HasPrefix(title, prefix) { - return strings.TrimPrefix(title, prefix) - } - } - return "" +func toolCallUpdateSupplemental(tcu *acp.SessionToolCallUpdate) map[string]any { + return locationsArgsFromACP(tcu.Locations) +} + +// locationsArgsFromACP builds the locations/path args map shared by initial tool_call +// frames and tool_call_update supplemental maps. +func locationsArgsFromACP(locations []acp.ToolCallLocation) map[string]any { + if len(locations) == 0 { + return nil + } + locMaps := make([]map[string]any, len(locations)) + for i, loc := range locations { + locMap := map[string]any{keyPath: loc.Path} + if loc.Line != nil { + locMap["line"] = *loc.Line + } + locMaps[i] = locMap + } + return map[string]any{ + keyLocations: locMaps, + keyPath: locations[0].Path, + } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools_test.go new file mode 100644 index 0000000000..f64fbda901 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools_test.go @@ -0,0 +1,86 @@ +package acp + +import ( + "testing" + + "github.com/coder/acp-go-sdk" +) + +func TestLocationsArgsFromACP(t *testing.T) { + args := locationsArgsFromACP([]acp.ToolCallLocation{ + {Path: "/workspace/src/index.ts"}, + }) + if args == nil { + t.Fatal("expected args map") + } + if got, _ := args[keyPath].(string); got != "/workspace/src/index.ts" { + t.Fatalf("path = %q, want /workspace/src/index.ts", got) + } +} + +func TestToolCallUpdateSupplemental(t *testing.T) { + tcu := &acp.SessionToolCallUpdate{ + Locations: []acp.ToolCallLocation{ + {Path: "/workspace/src/index.ts"}, + }, + } + + supplemental := toolCallUpdateSupplemental(tcu) + if supplemental == nil { + t.Fatal("expected supplemental map") + } + if got, _ := supplemental["path"].(string); got != "/workspace/src/index.ts" { + t.Fatalf("path = %q, want /workspace/src/index.ts", got) + } + + n := NewNormalizer("") + payload := n.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "raw_input": map[string]any{}, + }) + n.UpdatePayloadInput(payload, nil, supplemental) + if got := payload.ReadFile().FilePath; got != "/workspace/src/index.ts" { + t.Fatalf("ReadFile.FilePath = %q, want /workspace/src/index.ts", got) + } + + searchPayload := n.NormalizeToolCall("search", map[string]any{ + "kind": "search", + "raw_input": map[string]any{}, + }) + n.UpdatePayloadInput(searchPayload, nil, supplemental) + if got := searchPayload.CodeSearch().Path; got != "/workspace/src/index.ts" { + t.Fatalf("CodeSearch.Path = %q, want /workspace/src/index.ts", got) + } +} + +func TestPathFromLocationSlice(t *testing.T) { + tests := []struct { + name string + input any + want string + }{ + { + name: "[]any shape from initial tool_call", + input: []any{ + map[string]any{"path": "/a.go"}, + }, + want: "/a.go", + }, + { + name: "[]map[string]any shape from tool_call_update supplemental", + input: []map[string]any{ + {"path": "/b.go"}, + }, + want: "/b.go", + }, + {name: "empty slice", input: []any{}, want: ""}, + {name: "nil", input: nil, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pathFromLocationSlice(tt.input); got != tt.want { + t.Fatalf("pathFromLocationSlice() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher.go new file mode 100644 index 0000000000..7622f80fc5 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher.go @@ -0,0 +1,109 @@ +package acp + +import ( + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// EnrichFrame carries ACP-first tool-call fields passed into agent enrichers. +// Title is display-only at the UI layer and must not be parsed for file paths +// in the common normalizer. +type EnrichFrame struct { + Title string + Meta map[string]any + RawInput map[string]any + Supplemental map[string]any +} + +type agentEnrichFunc func(payload *streams.NormalizedPayload, frame EnrichFrame) + +var agentEnrichers = map[string]agentEnrichFunc{ + "claude-acp": enrichClaudePayload, + "codex-acp": enrichCodexPayload, + "opencode-acp": enrichOpenCodePayload, + "cursor-acp": enrichCursorPayload, +} + +func applyAgentEnrichment(agentID string, payload *streams.NormalizedPayload, frame EnrichFrame) { + if payload == nil || agentID == "" { + return + } + fn, ok := agentEnrichers[agentID] + if !ok { + return + } + fn(payload, frame) +} + +func enrichFrameFromArgs(args map[string]any) EnrichFrame { + frame := EnrichFrame{} + if args == nil { + return frame + } + frame.Title, _ = args[argKeyTitle].(string) + if meta, ok := args[argKeyMeta].(map[string]any); ok { + frame.Meta = meta + } + if raw, ok := args["raw_input"].(map[string]any); ok { + frame.RawInput = raw + } + frame.Supplemental = supplementalFromArgs(args) + return frame +} + +func supplementalFromArgs(args map[string]any) map[string]any { + if args == nil { + return nil + } + if _, ok := args[keyLocations]; ok { + return map[string]any{keyLocations: args[keyLocations], keyPath: args[keyPath]} + } + if path, _ := args[keyPath].(string); path != "" { + return map[string]any{keyPath: path} + } + return nil +} + +func enrichFrameFromUpdate(title *string, meta map[string]any, rawInput any, supplemental map[string]any) EnrichFrame { + frame := EnrichFrame{Meta: meta, Supplemental: supplemental} + if title != nil { + frame.Title = *title + } + if raw, ok := rawInput.(map[string]any); ok { + frame.RawInput = raw + } + return frame +} + +// fillIfEmpty assigns val to *dest when dest is empty and val is non-empty. +func fillIfEmpty(dest *string, val string) { + if dest == nil || val == "" || *dest != "" { + return + } + *dest = val +} + +func claudeCodeMeta(meta map[string]any) map[string]any { + if meta == nil { + return nil + } + cc, _ := meta["claudeCode"].(map[string]any) + return cc +} + +func firstStructuredPath(rawInput, supplemental map[string]any) string { + if p := pathFromStructuredInput(rawInput); p != "" { + return p + } + return pathFromLocations(supplemental) +} + +func pathFromStructuredInput(rawInput map[string]any) string { + return stringFromMap(rawInput, "path", "file_path", "filePath") +} + +func pathFromLocations(args map[string]any) string { + if args == nil { + return "" + } + return extractPathFromLocations(args) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_claude.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_claude.go new file mode 100644 index 0000000000..5db3986589 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_claude.go @@ -0,0 +1,63 @@ +package acp + +import ( + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func enrichClaudePayload(payload *streams.NormalizedPayload, frame EnrichFrame) { + cc := claudeCodeMeta(frame.Meta) + + switch payload.Kind() { + case streams.ToolKindReadFile: + enrichClaudeRead(payload.ReadFile(), frame, cc) + case streams.ToolKindModifyFile: + enrichClaudeModify(payload.ModifyFile(), frame) + case streams.ToolKindCodeSearch: + enrichClaudeSearch(payload.CodeSearch(), frame) + } +} + +func enrichClaudeRead(rf *streams.ReadFilePayload, frame EnrichFrame, cc map[string]any) { + if rf == nil { + return + } + fillIfEmpty(&rf.FilePath, firstStructuredPath(frame.RawInput, frame.Supplemental)) + if resp, ok := cc["toolResponse"].(map[string]any); ok { + if file, ok := resp["file"].(map[string]any); ok { + if fp, _ := file["filePath"].(string); fp != "" { + fillIfEmpty(&rf.FilePath, fp) + } + } + } +} + +func enrichClaudeModify(mf *streams.ModifyFilePayload, frame EnrichFrame) { + if mf == nil { + return + } + fillIfEmpty(&mf.FilePath, firstStructuredPath(frame.RawInput, frame.Supplemental)) + if frame.RawInput == nil { + return + } + oldStr, _ := frame.RawInput["old_string"].(string) + newStr, _ := frame.RawInput["new_string"].(string) + if oldStr == "" && newStr == "" { + return + } + if len(mf.Mutations) == 0 { + mf.Mutations = []streams.FileMutation{{Type: streams.MutationPatch}} + } + mut := &mf.Mutations[0] + if mut.Diff == "" && (oldStr != "" || newStr != "") { + mut.Diff = shared.GenerateUnifiedDiff(oldStr, newStr, mf.FilePath, mut.StartLine) + } +} + +func enrichClaudeSearch(cs *streams.CodeSearchPayload, frame EnrichFrame) { + if cs == nil || frame.RawInput == nil { + return + } + fillIfEmpty(&cs.Query, stringFromMap(frame.RawInput, "pattern", "query")) + fillIfEmpty(&cs.Path, pathFromStructuredInput(frame.RawInput)) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_codex.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_codex.go new file mode 100644 index 0000000000..8c2668c4df --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_codex.go @@ -0,0 +1,168 @@ +package acp + +import ( + "sort" + "strings" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func enrichCodexPayload(payload *streams.NormalizedPayload, frame EnrichFrame) { + switch payload.Kind() { + case streams.ToolKindReadFile: + enrichCodexRead(payload.ReadFile(), frame) + case streams.ToolKindCodeSearch: + enrichCodexSearch(payload.CodeSearch(), frame) + case streams.ToolKindModifyFile: + enrichCodexModify(payload.ModifyFile(), frame) + } +} + +func enrichCodexRead(rf *streams.ReadFilePayload, frame EnrichFrame) { + if rf == nil { + return + } + fillIfEmpty(&rf.FilePath, firstStructuredPath(frame.RawInput, frame.Supplemental)) + fillIfEmpty(&rf.FilePath, codexParsedCmdPath(frame.RawInput)) + fillIfEmpty(&rf.FilePath, codexReadTitleHint(frame.Title)) +} + +func enrichCodexSearch(cs *streams.CodeSearchPayload, frame EnrichFrame) { + if cs == nil { + return + } + query, path := codexParsedCmdSearch(frame.RawInput) + fillIfEmpty(&cs.Query, query) + fillIfEmpty(&cs.Path, path) + if frame.Title != "" && (cs.Query == "" || cs.Path == "") { + titleQuery, titlePath := codexSearchTitleHints(frame.Title) + fillIfEmpty(&cs.Query, titleQuery) + fillIfEmpty(&cs.Path, titlePath) + } +} + +func enrichCodexModify(mf *streams.ModifyFilePayload, frame EnrichFrame) { + if mf == nil || frame.RawInput == nil { + return + } + changes, ok := frame.RawInput["changes"].(map[string]any) + if !ok || len(changes) == 0 { + return + } + // NormalizedPayload surfaces one file; pick a stable canonical entry. + path, diff := codexCanonicalChange(changes) + fillIfEmpty(&mf.FilePath, path) + if diff == "" { + return + } + if len(mf.Mutations) == 0 { + mf.Mutations = []streams.FileMutation{{Type: streams.MutationPatch, Diff: diff}} + return + } + if mf.Mutations[0].Diff == "" { + mf.Mutations[0].Diff = diff + } +} + +// codexCanonicalChange picks the lexicographically first path with a unified_diff. +// Multi-file Codex edits still collapse to one surfaced file in NormalizedPayload. +func codexCanonicalChange(changes map[string]any) (path, diff string) { + paths := make([]string, 0, len(changes)) + for p := range changes { + paths = append(paths, p) + } + sort.Strings(paths) + for _, p := range paths { + change, ok := changes[p].(map[string]any) + if !ok { + continue + } + d, _ := change["unified_diff"].(string) + if d != "" { + return p, d + } + } + if len(paths) > 0 { + return paths[0], "" + } + return "", "" +} + +func codexParsedCmdPath(rawInput map[string]any) string { + for _, cmd := range codexParsedCommands(rawInput) { + if path, _ := cmd["path"].(string); path != "" { + return path + } + } + return "" +} + +func codexParsedCmdSearch(rawInput map[string]any) (query, path string) { + for _, cmd := range codexParsedCommands(rawInput) { + cmdType, _ := cmd["type"].(string) + if cmdType != toolKindSearch && cmdType != toolKindGrep { + continue + } + q, _ := cmd["query"].(string) + p, _ := cmd["path"].(string) + if q != "" || p != "" { + return q, p + } + } + return "", "" +} + +func codexParsedCommands(rawInput map[string]any) []map[string]any { + if rawInput == nil { + return nil + } + items, ok := rawInput["parsed_cmd"].([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + cmd, ok := item.(map[string]any) + if ok { + out = append(out, cmd) + } + } + return out +} + +// codexReadTitleHint parses codex-acp shell read titles — codex-specific, not ACP. +func codexReadTitleHint(title string) string { + if !strings.HasPrefix(title, "Read ") { + return "" + } + target := strings.TrimSpace(strings.TrimPrefix(title, "Read ")) + if target != "" && !isGenericPathLabel(target) { + return target + } + return "" +} + +func isGenericPathLabel(target string) bool { + switch strings.ToLower(strings.TrimSpace(target)) { + case genericLabelFile, readTypeDirectory, genericLabelFolder: + return true + default: + return false + } +} + +// codexSearchTitleHints parses codex-acp shell titles — codex-specific, not ACP. +func codexSearchTitleHints(title string) (query, path string) { + switch { + case strings.HasPrefix(title, "Search "): + rest := strings.TrimPrefix(title, "Search ") + if idx := strings.LastIndex(rest, " in "); idx >= 0 { + return strings.TrimSpace(rest[:idx]), strings.TrimSpace(rest[idx+4:]) + } + return strings.TrimSpace(rest), "" + case strings.HasPrefix(title, "List "): + return "", strings.TrimSpace(strings.TrimPrefix(title, "List ")) + default: + return "", "" + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_cursor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_cursor.go new file mode 100644 index 0000000000..8feaa0e476 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_cursor.go @@ -0,0 +1,10 @@ +package acp + +import "github.com/kandev/kandev/internal/agentctl/types/streams" + +// enrichCursorPayload is intentionally minimal: Cursor ACP currently emits +// generic titles ("Read File") without locations or structured rawInput on +// updates. We do not infer paths from titles — the UI falls back to title + +// expandable rawOutput. +func enrichCursorPayload(_ *streams.NormalizedPayload, _ EnrichFrame) { +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_opencode.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_opencode.go new file mode 100644 index 0000000000..6fc9027b4c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_opencode.go @@ -0,0 +1,41 @@ +package acp + +import ( + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func enrichOpenCodePayload(payload *streams.NormalizedPayload, frame EnrichFrame) { + switch payload.Kind() { + case streams.ToolKindReadFile: + if rf := payload.ReadFile(); rf != nil { + fillIfEmpty(&rf.FilePath, firstStructuredPath(frame.RawInput, frame.Supplemental)) + } + case streams.ToolKindModifyFile: + enrichOpenCodeModify(payload.ModifyFile(), frame) + case streams.ToolKindCodeSearch: + if cs := payload.CodeSearch(); cs != nil { + fillIfEmpty(&cs.Query, stringFromMap(frame.RawInput, "pattern", "query")) + fillIfEmpty(&cs.Path, pathFromStructuredInput(frame.RawInput)) + } + } +} + +func enrichOpenCodeModify(mf *streams.ModifyFilePayload, frame EnrichFrame) { + if mf == nil || frame.RawInput == nil { + return + } + fillIfEmpty(&mf.FilePath, firstStructuredPath(frame.RawInput, frame.Supplemental)) + oldStr, _ := frame.RawInput["oldString"].(string) + newStr, _ := frame.RawInput["newString"].(string) + if oldStr == "" && newStr == "" { + return + } + if len(mf.Mutations) == 0 { + mf.Mutations = []streams.FileMutation{{Type: streams.MutationPatch}} + } + mut := &mf.Mutations[0] + if mut.Diff == "" { + mut.Diff = shared.GenerateUnifiedDiff(oldStr, newStr, mf.FilePath, mut.StartLine) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_test.go new file mode 100644 index 0000000000..0d7c4a6b5c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/enricher_test.go @@ -0,0 +1,336 @@ +package acp + +import ( + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// Golden-style cases distilled from acpdbg captures (claude/codex/opencode/cursor). +func TestAgentEnrichment_GoldenFixtures(t *testing.T) { + t.Run("claude read update with file_path in rawInput", func(t *testing.T) { + n := NewNormalizer("claude-acp") + payload := n.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read File", + }) + n.EnrichFromToolCallUpdate(payload, strPtr("Read File"), nil, map[string]any{ + "file_path": "/workspace/README.md", + }, nil) + if got := payload.ReadFile().FilePath; got != "/workspace/README.md" { + t.Fatalf("FilePath = %q, want /workspace/README.md", got) + } + }) + + t.Run("claude read update with toolResponse filePath in meta", func(t *testing.T) { + n := NewNormalizer("claude-acp") + payload := n.NormalizeToolCall("read", map[string]any{"kind": "read"}) + n.EnrichFromToolCallUpdate(payload, nil, map[string]any{ + "claudeCode": map[string]any{ + "toolResponse": map[string]any{ + "file": map[string]any{"filePath": "/workspace/src/index.ts"}, + }, + }, + }, nil, nil) + if got := payload.ReadFile().FilePath; got != "/workspace/src/index.ts" { + t.Fatalf("FilePath = %q, want /workspace/src/index.ts", got) + } + }) + + t.Run("opencode read update with filePath", func(t *testing.T) { + n := NewNormalizer("opencode-acp") + payload := n.NormalizeToolCall("read", map[string]any{"kind": "read", "title": "read"}) + n.EnrichFromToolCallUpdate(payload, strPtr("read"), nil, map[string]any{ + "filePath": "/workspace/README.md", + }, nil) + if got := payload.ReadFile().FilePath; got != "/workspace/README.md" { + t.Fatalf("FilePath = %q, want /workspace/README.md", got) + } + }) + + t.Run("codex read from locations on initial frame", func(t *testing.T) { + n := NewNormalizer("codex-acp") + payload := n.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read README.md", + "locations": []any{ + map[string]any{"path": "/workspace/README.md"}, + }, + }) + if got := payload.ReadFile().FilePath; got != "/workspace/README.md" { + t.Fatalf("FilePath = %q, want /workspace/README.md", got) + } + }) + + t.Run("codex read from title when structured fields absent", func(t *testing.T) { + n := NewNormalizer("codex-acp") + payload := n.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read apps/web/lib/utils.ts", + }) + if got := payload.ReadFile().FilePath; got != "apps/web/lib/utils.ts" { + t.Fatalf("FilePath = %q, want apps/web/lib/utils.ts", got) + } + }) + + t.Run("codex search from title", func(t *testing.T) { + n := NewNormalizer("codex-acp") + payload := n.NormalizeToolCall("search", map[string]any{ + "kind": "search", + "title": "Search tool_call in apps/backend", + }) + cs := payload.CodeSearch() + if cs.Query != "tool_call" || cs.Path != "apps/backend" { + t.Fatalf("CodeSearch = (%q, %q), want (tool_call, apps/backend)", cs.Query, cs.Path) + } + }) + + t.Run("codex search fills path from title when query in rawInput", func(t *testing.T) { + n := NewNormalizer("codex-acp") + payload := n.NormalizeToolCall("search", map[string]any{ + "kind": "search", + "title": "Search tool_call in apps/backend", + "raw_input": map[string]any{ + "query": "tool_call", + }, + }) + cs := payload.CodeSearch() + if cs.Query != "tool_call" || cs.Path != "apps/backend" { + t.Fatalf("CodeSearch = (%q, %q), want (tool_call, apps/backend)", cs.Query, cs.Path) + } + }) + + t.Run("codex edit from changes map", func(t *testing.T) { + n := NewNormalizer("codex-acp") + payload := n.NormalizeToolCall("edit", map[string]any{"kind": "edit"}) + n.EnrichFromToolCallUpdate(payload, nil, nil, map[string]any{ + "changes": map[string]any{ + "src/main.go": map[string]any{ + "unified_diff": "--- a/src/main.go\n+++ b/src/main.go\n", + }, + }, + }, nil) + mf := payload.ModifyFile() + if mf.FilePath != "src/main.go" { + t.Fatalf("FilePath = %q, want src/main.go", mf.FilePath) + } + if mf.Mutations[0].Diff == "" { + t.Fatal("expected unified diff from codex changes map") + } + }) + + t.Run("claude modify synthesises unified diff from old_string/new_string", func(t *testing.T) { + n := NewNormalizer("claude-acp") + payload := n.NormalizeToolCall("edit", map[string]any{"kind": "edit"}) + n.EnrichFromToolCallUpdate(payload, nil, nil, map[string]any{ + "file_path": "/workspace/main.go", + "old_string": "foo", + "new_string": "bar", + }, nil) + mf := payload.ModifyFile() + if mf.FilePath != "/workspace/main.go" { + t.Fatalf("FilePath = %q, want /workspace/main.go", mf.FilePath) + } + if len(mf.Mutations) == 0 || mf.Mutations[0].Diff == "" { + t.Fatal("expected non-empty diff from claude enricher") + } + }) + + t.Run("opencode modify synthesises unified diff from oldString/newString", func(t *testing.T) { + n := NewNormalizer("opencode-acp") + payload := n.NormalizeToolCall("edit", map[string]any{"kind": "edit"}) + n.EnrichFromToolCallUpdate(payload, nil, nil, map[string]any{ + "filePath": "/workspace/main.go", + "oldString": "foo", + "newString": "bar", + }, nil) + mf := payload.ModifyFile() + if mf.FilePath != "/workspace/main.go" { + t.Fatalf("FilePath = %q, want /workspace/main.go", mf.FilePath) + } + if len(mf.Mutations) == 0 || mf.Mutations[0].Diff == "" { + t.Fatal("expected non-empty diff from opencode enricher") + } + }) + + t.Run("cursor read does not infer path from generic title", func(t *testing.T) { + n := NewNormalizer("cursor-acp") + payload := n.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read File", + }) + n.EnrichFromToolCallUpdate(payload, strPtr("Read File"), nil, nil, nil) + if got := payload.ReadFile().FilePath; got != "" { + t.Fatalf("FilePath = %q, want empty", got) + } + }) + + t.Run("common layer without agent does not parse codex titles", func(t *testing.T) { + n := NewNormalizer("") + payload := n.NormalizeToolCall("search", map[string]any{ + "kind": "search", + "title": "Search tool_call in apps/backend", + }) + cs := payload.CodeSearch() + if cs.Query != "" || cs.Path != "" { + t.Fatalf("CodeSearch = (%q, %q), want empty fields without enricher", cs.Query, cs.Path) + } + }) +} + +func TestCodexParsedCmdSearch(t *testing.T) { + query, path := codexParsedCmdSearch(map[string]any{ + "parsed_cmd": []any{ + map[string]any{"type": "grep", "query": "first", "path": "a/"}, + map[string]any{"type": "grep", "query": "second", "path": "b/"}, + }, + }) + if query != "first" || path != "a/" { + t.Fatalf("codexParsedCmdSearch() = (%q, %q), want (first, a/)", query, path) + } +} + +func TestCodexCanonicalChange(t *testing.T) { + path, diff := codexCanonicalChange(map[string]any{ + "z.go": map[string]any{"unified_diff": "diff-z"}, + "a.go": map[string]any{"unified_diff": "diff-a"}, + }) + if path != "a.go" || diff != "diff-a" { + t.Fatalf("codexCanonicalChange() = (%q, %q), want (a.go, diff-a)", path, diff) + } +} + +func TestCodexTitleHints(t *testing.T) { + tests := []struct { + name string + readTitle string + wantRead string + searchTitle string + wantQuery string + wantPath string + }{ + { + name: "read rejects generic label", + readTitle: "Read File", + wantRead: "", + }, + { + name: "read accepts path", + readTitle: "Read README.md", + wantRead: "README.md", + }, + { + name: "search query only", + searchTitle: "Search tool_call", + wantQuery: "tool_call", + }, + { + name: "search query and path", + searchTitle: "Search tool_call in apps/backend", + wantQuery: "tool_call", + wantPath: "apps/backend", + }, + { + name: "list directory", + searchTitle: "List /workspace", + wantPath: "/workspace", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.readTitle != "" { + if got := codexReadTitleHint(tt.readTitle); got != tt.wantRead { + t.Errorf("codexReadTitleHint(%q) = %q, want %q", tt.readTitle, got, tt.wantRead) + } + } + if tt.searchTitle != "" { + query, path := codexSearchTitleHints(tt.searchTitle) + if query != tt.wantQuery || path != tt.wantPath { + t.Errorf("codexSearchTitleHints(%q) = (%q, %q), want (%q, %q)", + tt.searchTitle, query, path, tt.wantQuery, tt.wantPath) + } + } + }) + } +} + +func TestNormalizeToolCall_CodexShapes(t *testing.T) { + normalizer := NewNormalizer("codex-acp") + + t.Run("read from file_path raw_input", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read", + "raw_input": map[string]any{"file_path": "/tmp/test.go"}, + }) + if got := payload.ReadFile().FilePath; got != "/tmp/test.go" { + t.Fatalf("FilePath = %q, want /tmp/test.go", got) + } + }) + + t.Run("read from locations", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read README.md", + "locations": []any{ + map[string]any{"path": "/workspace/README.md"}, + }, + }) + if got := payload.ReadFile().FilePath; got != "/workspace/README.md" { + t.Fatalf("FilePath = %q, want /workspace/README.md", got) + } + }) + + t.Run("read from codex title when path fields absent", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read apps/web/lib/utils.ts", + }) + if got := payload.ReadFile().FilePath; got != "apps/web/lib/utils.ts" { + t.Fatalf("FilePath = %q, want apps/web/lib/utils.ts", got) + } + }) + + t.Run("search from codex title", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("search", map[string]any{ + "kind": "search", + "title": "Search tool_call in apps/backend", + }) + cs := payload.CodeSearch() + if cs.Query != "tool_call" || cs.Path != "apps/backend" { + t.Fatalf("CodeSearch = (%q, %q), want (tool_call, apps/backend)", cs.Query, cs.Path) + } + }) + + t.Run("search fills missing path from title when query present in raw_input", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("search", map[string]any{ + "kind": "search", + "title": "Search tool_call in apps/backend", + "raw_input": map[string]any{ + "query": "tool_call", + }, + }) + cs := payload.CodeSearch() + if cs.Query != "tool_call" || cs.Path != "apps/backend" { + t.Fatalf("CodeSearch = (%q, %q), want (tool_call, apps/backend)", cs.Query, cs.Path) + } + }) +} + +func TestNormalizeToolCall_CursorGenericTitle(t *testing.T) { + normalizer := NewNormalizer("cursor-acp") + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "title": "Read File", + }) + if got := payload.ReadFile().FilePath; got != "" { + t.Fatalf("FilePath = %q, want empty", got) + } + if payload.Kind() != streams.ToolKindReadFile { + t.Fatalf("Kind = %q, want read_file", payload.Kind()) + } +} + +func strPtr(s string) *string { + return &s +} 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 4a7c3c0215..e3284edd7c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -30,8 +30,14 @@ const ( // args map keys the adapter stashes so the normalizer can detect subagent // (Task) tool calls without changing NormalizeToolCall's signature. - argKeyTitle = "title" - argKeyMeta = "meta" + argKeyTitle = "title" + argKeyMeta = "meta" + keyLocations = "locations" + keyPath = "path" + + readTypeDirectory = "directory" + genericLabelFile = "file" + genericLabelFolder = "folder" ) // DetectToolOperationType determines the specific tool operation type from ACP tool data. @@ -45,7 +51,7 @@ func DetectToolOperationType(toolKind string, args map[string]any) string { case toolKindRead: // 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" { + if readType, ok := rawInput["type"].(string); ok && readType == readTypeDirectory { return toolTypeSearch } } @@ -71,11 +77,14 @@ func DetectToolOperationType(toolKind string, args map[string]any) string { } // Normalizer converts ACP protocol tool data to NormalizedPayload. -type Normalizer struct{} +type Normalizer struct { + agentID string +} -// NewNormalizer creates a new ACP normalizer. -func NewNormalizer() *Normalizer { - return &Normalizer{} +// NewNormalizer creates a new ACP normalizer. agentID selects per-agent enrichers +// (e.g. "codex-acp"); pass "" for common-layer-only normalization in tests. +func NewNormalizer(agentID string) *Normalizer { + return &Normalizer{agentID: agentID} } // NormalizeToolCall converts ACP tool call data to NormalizedPayload. @@ -92,18 +101,21 @@ func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *st kind = toolName } + var payload *streams.NormalizedPayload switch strings.ToLower(kind) { case toolKindEdit: - return n.normalizeEdit(args) + payload = n.normalizeEdit(args) case toolKindRead, "view": - return n.normalizeRead(args) + payload = n.normalizeRead(args) case toolKindExecute, "bash", "run", "shell": - return n.normalizeExecute(args) + payload = n.normalizeExecute(args) case toolKindGlob, toolKindGrep, toolKindSearch: - return n.normalizeCodeSearch(toolName, args) + payload = n.normalizeCodeSearch(toolName, args) default: - return n.normalizeGeneric(toolName, args) + payload = n.normalizeGeneric(toolName, args) } + applyAgentEnrichment(n.agentID, payload, enrichFrameFromArgs(args)) + return payload } // NormalizeToolResult updates the payload with tool result data. @@ -246,52 +258,104 @@ func parseShellOutput(output string) (exitCode int, stdout, stderr string) { // UpdatePayloadInput updates a stored NormalizedPayload with new rawInput data. // This handles agents (e.g. Claude Code) that send rawInput incrementally -// via tool_call_update events after the initial tool_call. -func (n *Normalizer) UpdatePayloadInput(payload *streams.NormalizedPayload, rawInput any) { +// via tool_call_update events after the initial tool_call. supplemental may +// carry update-only fields such as locations (OpenCode tool_call_update). +func (n *Normalizer) UpdatePayloadInput(payload *streams.NormalizedPayload, rawInput any, supplemental map[string]any) { + if payload == nil { + return + } inputMap, ok := rawInput.(map[string]any) - if !ok || payload == nil { + if rawInput != nil && !ok { + return + } + if inputMap == nil { + inputMap = map[string]any{} + } + if len(inputMap) == 0 && supplemental == nil { return } - if shellExec := payload.ShellExec(); shellExec != nil { - if cmd := shared.GetString(inputMap, "command"); cmd != "" && shellExec.Command == "" { - shellExec.Command = cmd - } - if cwd := shared.GetString(inputMap, "cwd"); cwd != "" && shellExec.WorkDir == "" { - shellExec.WorkDir = cwd - } - if desc := shared.GetString(inputMap, "description"); desc != "" && shellExec.Description == "" { - shellExec.Description = desc - } + if se := payload.ShellExec(); se != nil { + updateShellExecInput(se, inputMap) } - - // Claude ACP sends file_path in incremental rawInput updates + // Claude ACP sends file_path in incremental rawInput updates; OpenCode uses filePath. if mf := payload.ModifyFile(); mf != nil { - if path := shared.GetString(inputMap, "file_path"); path != "" && mf.FilePath == "" { - mf.FilePath = path - } + updateModifyFileInput(mf, supplemental, inputMap) } if rf := payload.ReadFile(); rf != nil { - if path := shared.GetString(inputMap, "file_path"); path != "" && rf.FilePath == "" { - rf.FilePath = path - } + updateReadFileInput(rf, supplemental, inputMap) + } + if cs := payload.CodeSearch(); cs != nil { + updateCodeSearchInput(cs, supplemental, inputMap) } - // Subagent (Task) calls send description/prompt/subagent_type in a later // tool_call_update rawInput (Claude/OpenCode); fill empty fields only. if sa := payload.SubagentTask(); sa != nil { - if v := shared.GetString(inputMap, subagentKeyDescription); v != "" && sa.Description == "" { - sa.Description = v - } - if v := shared.GetString(inputMap, subagentKeyPrompt); v != "" && sa.Prompt == "" { - sa.Prompt = v - } - if v := shared.GetString(inputMap, subagentKeySubagentType); v != "" && sa.SubagentType == "" { - sa.SubagentType = v - } + updateSubagentTaskInput(sa, inputMap) } } +func updateShellExecInput(se *streams.ShellExecPayload, inputMap map[string]any) { + if cmd := shared.GetString(inputMap, "command"); cmd != "" && se.Command == "" { + se.Command = cmd + } + if cwd := shared.GetString(inputMap, "cwd"); cwd != "" && se.WorkDir == "" { + se.WorkDir = cwd + } + if desc := shared.GetString(inputMap, "description"); desc != "" && se.Description == "" { + se.Description = desc + } +} + +func updateModifyFileInput(mf *streams.ModifyFilePayload, supplemental, inputMap map[string]any) { + if path := pathFromArgs(supplemental, inputMap); path != "" && mf.FilePath == "" { + mf.FilePath = path + } +} + +func updateReadFileInput(rf *streams.ReadFilePayload, supplemental, inputMap map[string]any) { + if path := pathFromArgs(supplemental, inputMap); path != "" && rf.FilePath == "" { + rf.FilePath = path + } +} + +func updateCodeSearchInput(cs *streams.CodeSearchPayload, supplemental, inputMap map[string]any) { + if v := stringFromMap(inputMap, "query", "pattern", "search_term"); v != "" && cs.Query == "" { + cs.Query = v + } + if v := stringFromMap(inputMap, "pattern", "glob", "glob_pattern"); v != "" && cs.Pattern == "" && cs.Glob == "" { + cs.Pattern = v + } + if path := pathFromArgs(supplemental, inputMap); path != "" && cs.Path == "" { + cs.Path = path + } +} + +func updateSubagentTaskInput(sa *streams.SubagentTaskPayload, inputMap map[string]any) { + if v := shared.GetString(inputMap, subagentKeyDescription); v != "" && sa.Description == "" { + sa.Description = v + } + if v := shared.GetString(inputMap, subagentKeyPrompt); v != "" && sa.Prompt == "" { + sa.Prompt = v + } + if v := shared.GetString(inputMap, subagentKeySubagentType); v != "" && sa.SubagentType == "" { + sa.SubagentType = v + } +} + +// EnrichFromToolCallUpdate applies agent-specific enrichment from a tool_call_update +// frame (title, meta, rawInput, supplemental locations). Safe to call after +// UpdatePayloadInput; only fills empty normalized fields. +func (n *Normalizer) EnrichFromToolCallUpdate( + payload *streams.NormalizedPayload, + title *string, + meta map[string]any, + rawInput any, + supplemental map[string]any, +) { + applyAgentEnrichment(n.agentID, payload, enrichFrameFromUpdate(title, meta, rawInput, supplemental)) +} + // normalizeEdit converts ACP edit tool data. func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { rawInput, _ := args["raw_input"].(map[string]any) @@ -299,11 +363,7 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo rawInput = args } - // Get path from raw_input or locations - path := shared.GetString(rawInput, "path") - if path == "" { - path = extractPathFromLocations(args) - } + path := pathFromArgs(args, rawInput) var mutations []streams.FileMutation @@ -351,13 +411,10 @@ func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPaylo rawInput = args } - path := shared.GetString(rawInput, "path") - if path == "" { - path = extractPathFromLocations(args) - } + path := pathFromArgs(args, rawInput) // Check if this is a directory read - treat as code search (file listing) - if readType := shared.GetString(rawInput, "type"); readType == "directory" { + if readType := shared.GetString(rawInput, "type"); readType == readTypeDirectory { return streams.NewCodeSearch("", "", path, "") } @@ -391,15 +448,18 @@ func (n *Normalizer) normalizeCodeSearch(toolName string, args map[string]any) * rawInput = args } - path := shared.GetString(rawInput, "path") - pattern := shared.GetString(rawInput, "pattern") + path := pathFromArgs(args, rawInput) + pattern := stringFromMap(rawInput, "pattern", "glob", "glob_pattern") var query, glob string switch strings.ToLower(toolName) { case toolKindGlob: glob = pattern + if glob == "" { + glob = stringFromMap(rawInput, "query", "search_term") + } case toolKindGrep, toolKindSearch: - query = shared.GetString(rawInput, "query") + query = stringFromMap(rawInput, "query", "pattern", "search_term", "regex") } return streams.NewCodeSearch(query, pattern, path, glob) @@ -451,15 +511,61 @@ func (n *Normalizer) EnrichSubagentResult(payload *streams.NormalizedPayload, me // --- Helper functions --- -func extractPathFromLocations(args map[string]any) string { - locations, ok := args["locations"].([]any) - if !ok || len(locations) == 0 { - return "" +func stringFromMap(m map[string]any, keys ...string) string { + for _, key := range keys { + if v := shared.GetString(m, key); v != "" { + return v + } + } + return "" +} + +// pathFromArgs resolves a file path from structured ACP fields only (raw_input +// path/file_path/filePath, top-level path, locations). +func pathFromArgs(args, rawInput map[string]any) string { + if rawInput != nil { + if p := stringFromMap(rawInput, "path", "file_path", "filePath"); p != "" { + return p + } + } + if args != nil { + if p := shared.GetString(args, "path"); p != "" { + return p + } + return extractPathFromLocations(args) + } + return "" +} + +func extractPathFromLocations(args map[string]any) string { + if args == nil { + return "" + } + return pathFromLocationSlice(args[keyLocations]) +} + +// pathFromLocationSlice reads the first path from ACP locations. The adapter +// builds []any on initial tool_call frames and []map[string]any on +// tool_call_update supplemental maps — accept both. +func pathFromLocationSlice(locationsRaw any) string { + switch locations := locationsRaw.(type) { + case []any: + if len(locations) == 0 { + return "" + } + loc, ok := locations[0].(map[string]any) + if !ok { + return "" + } + path, _ := loc["path"].(string) + return path + case []map[string]any: + if len(locations) == 0 { + return "" + } + path, _ := locations[0]["path"].(string) + return path + default: + return "" } - loc, ok := locations[0].(map[string]any) - if !ok { - return "" - } - path, _ := loc["path"].(string) - return path } 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 c57cac90c9..aab8be43a2 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 @@ -13,7 +13,7 @@ import ( // TestACPNormalization runs JSONL-driven tests for ACP protocol normalization. func TestACPNormalization(t *testing.T) { testCases := shared.LoadTestCases(t, "acp-messages.jsonl") - normalizer := NewNormalizer() + normalizer := NewNormalizer("") for i, tc := range testCases { t.Run(fmt.Sprintf("line_%d", i+1), func(t *testing.T) { @@ -61,7 +61,7 @@ func TestACPNormalization(t *testing.T) { // title/meta subagent-detection keys don't leak into a generic (unrecognized) // tool's client payload — only the real tool args should reach GenericPayload.Input. func TestNormalizeGeneric_ExcludesAdapterKeys(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") args := map[string]any{ "kind": "other", "raw_input": map[string]any{"foo": "bar"}, @@ -307,7 +307,7 @@ func TestGenerateUnifiedDiff(t *testing.T) { // TestNormalizerEdit tests the normalizer's edit handling. func TestNormalizerEdit(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("extracts fields from raw_input", func(t *testing.T) { args := map[string]any{ @@ -365,11 +365,39 @@ func TestNormalizerEdit(t *testing.T) { t.Errorf("expected FilePath '/workspace/fallback.ts', got %q", result.ModifyFile().FilePath) } }) + + t.Run("extracts file_path from raw_input", func(t *testing.T) { + result := normalizer.NormalizeToolCall("edit", map[string]any{ + "kind": "edit", + "raw_input": map[string]any{ + "file_path": "/workspace/file.ts", + "old_str_1": "a", + "new_str_1": "b", + }, + }) + if result.ModifyFile().FilePath != "/workspace/file.ts" { + t.Errorf("expected FilePath '/workspace/file.ts', got %q", result.ModifyFile().FilePath) + } + }) + + t.Run("extracts filePath from raw_input", func(t *testing.T) { + result := normalizer.NormalizeToolCall("edit", map[string]any{ + "kind": "edit", + "raw_input": map[string]any{ + "filePath": "src/main.go", + "old_str_1": "a", + "new_str_1": "b", + }, + }) + if result.ModifyFile().FilePath != "src/main.go" { + t.Errorf("expected FilePath 'src/main.go', got %q", result.ModifyFile().FilePath) + } + }) } // TestNormalizerRead tests the normalizer's read handling. func TestNormalizerRead(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("extracts path from raw_input", func(t *testing.T) { args := map[string]any{ @@ -429,7 +457,7 @@ func TestNormalizerRead(t *testing.T) { // TestNormalizerResult tests the normalizer's result handling. func TestNormalizerResult(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("handles read file result", func(t *testing.T) { payload := normalizer.NormalizeToolCall("read", map[string]any{ @@ -534,7 +562,7 @@ func TestNormalizerResult(t *testing.T) { // TestNormalizerExecute tests the normalizer's execute handling. func TestNormalizerExecute(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("extracts command, cwd, timeout", func(t *testing.T) { args := map[string]any{ @@ -581,7 +609,7 @@ func TestNormalizerExecute(t *testing.T) { // TestParseShellOutputPlainString tests that plain string output (Claude Code format) // is correctly treated as stdout. func TestParseShellOutputPlainString(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("plain string rawOutput becomes stdout", func(t *testing.T) { payload := normalizer.NormalizeToolCall("execute", map[string]any{ @@ -626,7 +654,7 @@ func TestParseShellOutputPlainString(t *testing.T) { // TestUpdatePayloadInput tests incremental rawInput updates (Claude Code pattern). func TestUpdatePayloadInput(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("updates empty command from rawInput", func(t *testing.T) { // Initial tool_call with empty rawInput (Claude Code sends this) @@ -643,7 +671,7 @@ func TestUpdatePayloadInput(t *testing.T) { normalizer.UpdatePayloadInput(payload, map[string]any{ "command": "pwd", "description": "Print working directory", - }) + }, nil) if payload.ShellExec().Command != "pwd" { t.Errorf("expected Command 'pwd', got %q", payload.ShellExec().Command) @@ -663,7 +691,7 @@ func TestUpdatePayloadInput(t *testing.T) { normalizer.UpdatePayloadInput(payload, map[string]any{ "command": "pwd", - }) + }, nil) if payload.ShellExec().Command != "ls -la" { t.Errorf("expected Command 'ls -la' (unchanged), got %q", payload.ShellExec().Command) @@ -672,7 +700,7 @@ func TestUpdatePayloadInput(t *testing.T) { t.Run("handles nil payload gracefully", func(t *testing.T) { // Should not panic - normalizer.UpdatePayloadInput(nil, map[string]any{"command": "pwd"}) + normalizer.UpdatePayloadInput(nil, map[string]any{"command": "pwd"}, nil) }) t.Run("handles non-map rawInput gracefully", func(t *testing.T) { @@ -681,7 +709,7 @@ func TestUpdatePayloadInput(t *testing.T) { "raw_input": map[string]any{}, }) // Should not panic - normalizer.UpdatePayloadInput(payload, "not a map") + normalizer.UpdatePayloadInput(payload, "not a map", nil) }) } @@ -784,32 +812,9 @@ func TestEnrichModifyFileFromContents(t *testing.T) { }) } -// TestExtractPathFromTitle tests file path extraction from tool titles. -func TestExtractPathFromTitle(t *testing.T) { - tests := []struct { - title string - want string - }{ - {"Read /path/to/file.ts", "/path/to/file.ts"}, - {"Write /path/to/file.ts", "/path/to/file.ts"}, - {"Edit /path/to/file.ts", "/path/to/file.ts"}, - {"Unknown /path/to/file.ts", ""}, - {"", ""}, - {"Read", ""}, - } - for _, tt := range tests { - t.Run(tt.title, func(t *testing.T) { - got := extractPathFromTitle(tt.title) - if got != tt.want { - t.Errorf("extractPathFromTitle(%q) = %q, want %q", tt.title, got, tt.want) - } - }) - } -} - // TestUpdatePayloadInput_ModifyFile tests incremental rawInput updates for modify_file payloads. func TestUpdatePayloadInput_ModifyFile(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("updates empty file path from rawInput", func(t *testing.T) { payload := normalizer.NormalizeToolCall("edit", map[string]any{ @@ -821,7 +826,7 @@ func TestUpdatePayloadInput_ModifyFile(t *testing.T) { } normalizer.UpdatePayloadInput(payload, map[string]any{ "file_path": "/workspace/file.ts", - }) + }, nil) if payload.ModifyFile().FilePath != "/workspace/file.ts" { t.Errorf("expected FilePath '/workspace/file.ts', got %q", payload.ModifyFile().FilePath) } @@ -836,16 +841,31 @@ func TestUpdatePayloadInput_ModifyFile(t *testing.T) { }) normalizer.UpdatePayloadInput(payload, map[string]any{ "file_path": "other.ts", - }) + }, nil) if payload.ModifyFile().FilePath != "existing.ts" { t.Errorf("expected FilePath to remain 'existing.ts', got %q", payload.ModifyFile().FilePath) } }) + + t.Run("updates empty file path from supplemental locations", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("edit", map[string]any{ + "kind": "edit", + "raw_input": map[string]any{}, + }) + normalizer.UpdatePayloadInput(payload, nil, map[string]any{ + keyLocations: []map[string]any{ + {keyPath: "/workspace/file.ts"}, + }, + }) + if payload.ModifyFile().FilePath != "/workspace/file.ts" { + t.Errorf("expected FilePath '/workspace/file.ts', got %q", payload.ModifyFile().FilePath) + } + }) } // TestUpdatePayloadInput_ReadFile tests incremental rawInput updates for read_file payloads. func TestUpdatePayloadInput_ReadFile(t *testing.T) { - normalizer := NewNormalizer() + normalizer := NewNormalizer("") t.Run("updates empty file path from rawInput", func(t *testing.T) { payload := normalizer.NormalizeToolCall("read", map[string]any{ @@ -857,7 +877,7 @@ func TestUpdatePayloadInput_ReadFile(t *testing.T) { } normalizer.UpdatePayloadInput(payload, map[string]any{ "file_path": "/workspace/README.md", - }) + }, nil) if payload.ReadFile().FilePath != "/workspace/README.md" { t.Errorf("expected FilePath '/workspace/README.md', got %q", payload.ReadFile().FilePath) } @@ -872,11 +892,55 @@ func TestUpdatePayloadInput_ReadFile(t *testing.T) { }) normalizer.UpdatePayloadInput(payload, map[string]any{ "file_path": "other.md", - }) + }, nil) if payload.ReadFile().FilePath != "existing.md" { t.Errorf("expected FilePath to remain 'existing.md', got %q", payload.ReadFile().FilePath) } }) + + t.Run("updates empty file path from filePath rawInput", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "raw_input": map[string]any{}, + }) + normalizer.UpdatePayloadInput(payload, map[string]any{ + "filePath": "/workspace/src/main.go", + }, nil) + if payload.ReadFile().FilePath != "/workspace/src/main.go" { + t.Errorf("expected FilePath '/workspace/src/main.go', got %q", payload.ReadFile().FilePath) + } + }) + + t.Run("updates empty file path from supplemental locations", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "raw_input": map[string]any{}, + }) + normalizer.UpdatePayloadInput(payload, nil, map[string]any{ + "locations": []any{ + map[string]any{"path": "/workspace/README.md"}, + }, + }) + if payload.ReadFile().FilePath != "/workspace/README.md" { + t.Errorf("expected FilePath '/workspace/README.md', got %q", payload.ReadFile().FilePath) + } + }) + + t.Run("updates empty file path from adapter supplemental locations shape", func(t *testing.T) { + payload := normalizer.NormalizeToolCall("read", map[string]any{ + "kind": "read", + "raw_input": map[string]any{}, + }) + // toolCallUpdateSupplemental builds []map[string]any, not []any. + normalizer.UpdatePayloadInput(payload, nil, map[string]any{ + "locations": []map[string]any{ + {"path": "/workspace/README.md", "line": float64(1)}, + }, + }) + if payload.ReadFile().FilePath != "/workspace/README.md" { + t.Errorf("expected FilePath '/workspace/README.md', got %q", payload.ReadFile().FilePath) + } + }) } // TestSplitLines tests the line splitting utility. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go index d9ab8277e6..552a98b507 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go @@ -189,7 +189,7 @@ func TestExtractSubagentResult_Empty(t *testing.T) { // --- Subagent (Task) normalization --- func TestNormalizeToolCall_ClaudeSubagent(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") args := map[string]any{ "meta": map[string]any{"claudeCode": map[string]any{"toolName": "Agent"}}, } @@ -200,7 +200,7 @@ func TestNormalizeToolCall_ClaudeSubagent(t *testing.T) { } func TestNormalizeToolCall_OpenCodeSubagent(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") args := map[string]any{"title": "task"} payload := n.NormalizeToolCall("", args) if payload.Kind() != streams.ToolKindSubagentTask { @@ -209,7 +209,7 @@ func TestNormalizeToolCall_OpenCodeSubagent(t *testing.T) { } func TestNormalizeToolCall_CursorSubagent(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") args := map[string]any{ "title": "Task: Subagent task", "raw_input": map[string]any{"_toolName": "task"}, @@ -221,7 +221,7 @@ func TestNormalizeToolCall_CursorSubagent(t *testing.T) { } func TestNormalizeToolCall_PlainBashNotSubagent(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") args := map[string]any{ "meta": map[string]any{"claudeCode": map[string]any{"toolName": "Bash"}}, "raw_input": map[string]any{"command": "ls"}, @@ -233,13 +233,13 @@ func TestNormalizeToolCall_PlainBashNotSubagent(t *testing.T) { } func TestUpdatePayloadInput_FillsSubagentFields(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("", "", "") n.UpdatePayloadInput(payload, map[string]any{ "description": "Investigate flaky test", "prompt": "Find the root cause", "subagent_type": "general-purpose", - }) + }, nil) sa := payload.SubagentTask() if sa.Description != "Investigate flaky test" { t.Errorf("Description = %q", sa.Description) @@ -273,7 +273,7 @@ func TestParentToolUseID(t *testing.T) { } func TestEnrichSubagentResult_Claude(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("Investigate", "do it", "") meta := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ "agentId": "agent_abc", @@ -303,7 +303,7 @@ func TestEnrichSubagentResult_Claude(t *testing.T) { // (not drop it), so the UI can render the "0 tools" chip. Regression test for // the omitempty + non-zero-guard bug. func TestEnrichSubagentResult_ClaudeZeroToolUses(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("Investigate", "do it", "") meta := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ "status": "completed", @@ -326,7 +326,7 @@ func TestEnrichSubagentResult_ClaudeZeroToolUses(t *testing.T) { // Agents that don't report a tool count (OpenCode/Cursor) must leave the field // omitted, not emit a misleading "0 tools". func TestEnrichSubagentResult_OmitsUnknownToolUseCount(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("Investigate", "do it", "general-purpose") out := map[string]any{"metadata": map[string]any{"sessionId": "child_1"}} n.EnrichSubagentResult(payload, nil, out) @@ -343,7 +343,7 @@ func TestEnrichSubagentResult_OmitsUnknownToolUseCount(t *testing.T) { } func TestEnrichSubagentResult_OpenCode(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("Investigate", "do it", "general-purpose") rawOutput := map[string]any{"metadata": map[string]any{ "sessionId": "ses_child", @@ -371,7 +371,7 @@ func TestEnrichSubagentResult_OpenCodePartialModel(t *testing.T) { {"both", "opencode", "big-pickle", "opencode/big-pickle"}, } { t.Run(tc.name, func(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("d", "p", "general-purpose") rawOutput := map[string]any{"metadata": map[string]any{ "model": map[string]any{"providerID": tc.provider, "modelID": tc.modelID}, @@ -385,7 +385,7 @@ func TestEnrichSubagentResult_OpenCodePartialModel(t *testing.T) { } func TestEnrichSubagentResult_Cursor(t *testing.T) { - n := NewNormalizer() + n := NewNormalizer("") payload := streams.NewSubagentTask("", "", "") n.EnrichSubagentResult(payload, nil, map[string]any{"durationMs": float64(4200), "isBackground": false}) if payload.SubagentTask().DurationMs != 4200 { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go index 5c0701133a..9c7cd4eed4 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go @@ -121,6 +121,46 @@ func TestConvertToolCallResultUpdate_FullyEmptyUpdateKeepsEmptyStatus(t *testing } } +func TestConvertToolCallResultUpdate_StatusLessLocationsOnlyBecomesInProgress(t *testing.T) { + a := newTestAdapter() + seedReadToolCall(t, a, "tc-read-loc") + + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-read-loc", + Locations: []acp.ToolCallLocation{ + {Path: "/workspace/README.md"}, + }, + } + + ev := a.convertToolCallResultUpdate("session-1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.ToolStatus != "in_progress" { + t.Errorf("ToolStatus = %q, want %q (locations-only updates must route through orchestrator)", ev.ToolStatus, "in_progress") + } + if ev.NormalizedPayload == nil || ev.NormalizedPayload.ReadFile() == nil { + t.Fatalf("expected ReadFile payload, got %+v", ev.NormalizedPayload) + } + if got := ev.NormalizedPayload.ReadFile().FilePath; got != "/workspace/README.md" { + t.Errorf("ReadFile.FilePath = %q, want /workspace/README.md", got) + } +} + +func seedReadToolCall(t *testing.T, a *Adapter, toolCallID string) { + t.Helper() + tc := &acp.SessionUpdateToolCall{ + ToolCallId: acp.ToolCallId(toolCallID), + Title: "Read", + Status: acp.ToolCallStatus("pending"), + Kind: acp.ToolKind("read"), + RawInput: map[string]any{}, + } + if ev := a.convertToolCallUpdate("session-1", tc); ev == nil { + t.Fatalf("seed: convertToolCallUpdate returned nil") + } +} + func TestConvertToolCallResultUpdate_ExplicitCompletedStatusUnchanged(t *testing.T) { a := newTestAdapter() seedExecuteToolCall(t, a, "tc-5") diff --git a/apps/backend/internal/debug/fixture.go b/apps/backend/internal/debug/fixture.go index d518a67331..7725a8ef67 100644 --- a/apps/backend/internal/debug/fixture.go +++ b/apps/backend/internal/debug/fixture.go @@ -408,10 +408,10 @@ func countLines(path string) int { } // getNormalizerForProtocol returns the appropriate normalizer for the protocol. -func getNormalizerForProtocol(protocol string) normalizer { +func getNormalizerForProtocol(protocol, agentID string) normalizer { switch protocol { case protocolACP: - return acp.NewNormalizer() + return acp.NewNormalizer(agentID) default: return nil } @@ -420,7 +420,8 @@ func getNormalizerForProtocol(protocol string) normalizer { // normalizeFixtureFile normalizes a specific fixture file. func normalizeFixtureFile(filePath string) ([]NormalizedFixture, error) { protocol := parseProtocolFromFilename(filepath.Base(filePath)) - norm := getNormalizerForProtocol(protocol) + _, _, agent := parseDebugFilename(filepath.Base(filePath)) + norm := getNormalizerForProtocol(protocol, agent) if norm == nil { return nil, fmt.Errorf("unknown protocol: %s", protocol) } diff --git a/apps/web/components/task/chat/messages/tool-read-message.tsx b/apps/web/components/task/chat/messages/tool-read-message.tsx index a9484c930e..7e10415341 100644 --- a/apps/web/components/task/chat/messages/tool-read-message.tsx +++ b/apps/web/components/task/chat/messages/tool-read-message.tsx @@ -24,6 +24,7 @@ type ReadFilePayload = { type ToolReadMetadata = { tool_call_id?: string; + title?: string; status?: "pending" | "running" | "complete" | "error"; normalized?: { read_file?: ReadFilePayload }; }; diff --git a/apps/web/components/task/chat/messages/tool-search-message.tsx b/apps/web/components/task/chat/messages/tool-search-message.tsx index ea593c525c..99b5c73adf 100644 --- a/apps/web/components/task/chat/messages/tool-search-message.tsx +++ b/apps/web/components/task/chat/messages/tool-search-message.tsx @@ -25,6 +25,7 @@ type CodeSearchPayload = { type ToolSearchMetadata = { tool_call_id?: string; + title?: string; status?: "pending" | "running" | "complete" | "error"; normalized?: { code_search?: CodeSearchPayload }; };