From 14797c29015a8a33f4e75dc6d5d750e9e080683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 18 Aug 2026 22:45:19 +0100 Subject: [PATCH] fix(acp): drop cursor placeholder slash-command descriptions (#2781) --- .../internal/agentctl/acpcompat/cursor.go | 26 +++++++++ .../agentctl/acpcompat/cursor_test.go | 33 +++++++++++ .../adapter/transport/acp/adapter_updates.go | 3 +- .../adapter/transport/acp/conversion_test.go | 57 +++++++++++++++++++ .../agentctl/server/utility/acp_executor.go | 8 ++- 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/apps/backend/internal/agentctl/acpcompat/cursor.go b/apps/backend/internal/agentctl/acpcompat/cursor.go index b9bb11e0ba..46e5642de8 100644 --- a/apps/backend/internal/agentctl/acpcompat/cursor.go +++ b/apps/backend/internal/agentctl/acpcompat/cursor.go @@ -116,3 +116,29 @@ func MigrateCursorModel( } return bareModelID, merged, true } + +// NormalizeCommandDescription drops cursor-agent's placeholder slash-command +// description. cursor-agent emits a bare dash run ("---") as the description +// for project/user slash commands that have no real description; passing it +// through renders a meaningless "---" in the command palette, so treat it as +// absent. The rewrite is Cursor-scoped: another agent may legitimately +// advertise a dashes-only description, so descriptions from non-Cursor agents +// are returned unchanged. +// +// This lives here, rather than next to either caller, because both the live +// session adapter and the host-utility capability probe surface the same +// commands and must agree; a cleanup applied to only one path leaves the other +// rendering the placeholder. +func NormalizeCommandDescription(agentID, description string) string { + if agentID != CursorAgentID { + return description + } + trimmed := strings.TrimSpace(description) + if trimmed == "" { + return description + } + if strings.Trim(trimmed, "-") == "" { + return "" + } + return description +} diff --git a/apps/backend/internal/agentctl/acpcompat/cursor_test.go b/apps/backend/internal/agentctl/acpcompat/cursor_test.go index e14c5bbf65..b02081e6e9 100644 --- a/apps/backend/internal/agentctl/acpcompat/cursor_test.go +++ b/apps/backend/internal/agentctl/acpcompat/cursor_test.go @@ -107,3 +107,36 @@ func TestMigrateCursorModel_PreservesExplicitOptions(t *testing.T) { t.Error("MigrateCursorModel mutated the input options") } } + +func TestNormalizeCommandDescription_CursorClearsDashPlaceholder(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {name: "single dash", in: "---", want: ""}, + {name: "padded dash run", in: " --- ", want: ""}, + {name: "one dash", in: "-", want: ""}, + {name: "real description", in: "Commit changes", want: "Commit changes"}, + {name: "already empty", in: "", want: ""}, + {name: "dash in text kept", in: "foo - bar", want: "foo - bar"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := NormalizeCommandDescription(CursorAgentID, tc.in); got != tc.want { + t.Errorf("NormalizeCommandDescription(cursor, %q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// A dashes-only description from a non-Cursor agent is legitimate and must be +// returned unchanged; the placeholder cleanup is Cursor-scoped. +func TestNormalizeCommandDescription_OtherAgentsUnchanged(t *testing.T) { + for _, agentID := range []string{"claude-acp", "codex-acp", GrokAgentID, ""} { + if got := NormalizeCommandDescription(agentID, "---"); got != "---" { + t.Errorf("NormalizeCommandDescription(%q, %q) = %q, want %q (non-Cursor untouched)", + agentID, "---", got, "---") + } + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go index eb93269bd4..eddd43a3ba 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/acpcompat" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types/streams" "go.uber.org/zap" @@ -654,7 +655,7 @@ func (a *Adapter) convertAvailableCommands(sessionID string, update *acp.Session seen[cmd.Name] = struct{}{} ac := streams.AvailableCommand{ Name: cmd.Name, - Description: cmd.Description, + Description: acpcompat.NormalizeCommandDescription(a.agentID, cmd.Description), } if cmd.Input != nil && cmd.Input.Unstructured != nil { ac.InputHint = cmd.Input.Unstructured.Hint diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go index e62ae0325b..630de2a531 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/acpcompat" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" @@ -1079,6 +1080,62 @@ func TestConvertAvailableCommands_NoDuplicates(t *testing.T) { } } +func TestConvertAvailableCommands_CursorPlaceholderDescriptionCleared(t *testing.T) { + a := newTestAdapterForAgent(acpcompat.CursorAgentID) + // cursor-agent sends a bare dash run for commands with no real description. + update := &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: []acp.AvailableCommand{ + {Name: "bootstrap-go-service", Description: "---"}, + {Name: "preferences", Description: " --- "}, + {Name: "commit", Description: "Commit changes"}, + {Name: "worktree", Description: ""}, + }, + } + + result := a.convertAvailableCommands("session-placeholder", update) + + if len(result.AvailableCommands) != 4 { + t.Fatalf("expected 4 commands, got %d", len(result.AvailableCommands)) + } + if result.AvailableCommands[0].Description != "" { + t.Errorf("command[0].Description = %q, want empty (dash placeholder cleared)", + result.AvailableCommands[0].Description) + } + if result.AvailableCommands[1].Description != "" { + t.Errorf("command[1].Description = %q, want empty (padded dash placeholder cleared)", + result.AvailableCommands[1].Description) + } + if result.AvailableCommands[2].Description != "Commit changes" { + t.Errorf("command[2].Description = %q, want %q (real description preserved)", + result.AvailableCommands[2].Description, "Commit changes") + } + if result.AvailableCommands[3].Description != "" { + t.Errorf("command[3].Description = %q, want empty (already empty preserved)", + result.AvailableCommands[3].Description) + } +} + +// A non-Cursor agent that legitimately advertises a dashes-only description +// keeps it: the placeholder cleanup is Cursor-scoped. +func TestConvertAvailableCommands_NonCursorDescriptionPreserved(t *testing.T) { + a := newTestAdapterForAgent("claude-acp") + update := &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: []acp.AvailableCommand{ + {Name: "rule", Description: "---"}, + }, + } + + result := a.convertAvailableCommands("session-noncursor", update) + + if len(result.AvailableCommands) != 1 { + t.Fatalf("expected 1 command, got %d", len(result.AvailableCommands)) + } + if result.AvailableCommands[0].Description != "---" { + t.Errorf("command[0].Description = %q, want %q (non-Cursor description untouched)", + result.AvailableCommands[0].Description, "---") + } +} + // --- generated usage/session-info updates --- func TestConvertNotification_UsageUpdateFixture(t *testing.T) { diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 0bb1d2fa92..9f8b80d2d2 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -653,6 +653,7 @@ func isOpenCodeModelID(id string) bool { } type acpProbeNotificationState struct { + agentID string mu sync.Mutex commands []ProbeCommand configOptions []acp.SessionConfigOption @@ -661,8 +662,9 @@ type acpProbeNotificationState struct { gotConfigOptions chan struct{} } -func newACPProbeNotificationState() *acpProbeNotificationState { +func newACPProbeNotificationState(agentID string) *acpProbeNotificationState { return &acpProbeNotificationState{ + agentID: agentID, gotCommands: make(chan struct{}, 1), gotConfigOptions: make(chan struct{}, 1), } @@ -685,7 +687,7 @@ func (s *acpProbeNotificationState) handle(n acp.SessionNotification) { for _, command := range update.AvailableCommands { s.commands = append(s.commands, ProbeCommand{ Name: command.Name, - Description: command.Description, + Description: acpcompat.NormalizeCommandDescription(s.agentID, command.Description), }) } s.mu.Unlock() @@ -843,7 +845,7 @@ func (e *ACPInferenceExecutor) probeACPSessionWithContext( mode string, requestedConfigOptions map[string]string, ) (*ProbeResponse, error) { - updates := newACPProbeNotificationState() + updates := newACPProbeNotificationState(agentID) client := acpclient.NewClient( acpclient.WithLogger(e.logger),