fix(acp): enrich tool call metadata per agent with structured ACP fields (#1212)

This commit is contained in:
Carlos Florêncio
2026-06-01 19:12:32 +01:00
committed by GitHub
parent aea266ee71
commit 0f87f7a56b
16 changed files with 1182 additions and 153 deletions
@@ -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),
@@ -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,
}
}
@@ -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)
}
})
}
}
@@ -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)
}
@@ -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))
}
@@ -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 "", ""
}
}
@@ -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) {
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
@@ -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.
@@ -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 {
@@ -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")
+4 -3
View File
@@ -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)
}
@@ -24,6 +24,7 @@ type ReadFilePayload = {
type ToolReadMetadata = {
tool_call_id?: string;
title?: string;
status?: "pending" | "running" | "complete" | "error";
normalized?: { read_file?: ReadFilePayload };
};
@@ -25,6 +25,7 @@ type CodeSearchPayload = {
type ToolSearchMetadata = {
tool_call_id?: string;
title?: string;
status?: "pending" | "running" | "complete" | "error";
normalized?: { code_search?: CodeSearchPayload };
};