fix(acp): enrich tool call metadata per agent with structured ACP fields (#1212)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user