Files
kandev/apps/backend/internal/agent/settings/controller/custom_tui_test.go
T
João Salavisa 37f4992ed5 fix(agents): expose kandev MCP tools to custom TUI agents (#2603)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:25:00 +01:00

303 lines
11 KiB
Go

package controller
import (
"context"
"errors"
"slices"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
)
func newCustomTUIController(t *testing.T, st *fakeStore) *Controller {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("logger: %v", err)
}
return &Controller{agentRegistry: registry.NewRegistry(log), repo: st, logger: log}
}
// TestCreateCustomTUIAgent_CommandArgsReachArgv is the test that distinguishes
// the fix from the workaround. Smuggling flags into the space-separated
// Command string cannot express an argument that itself contains a space —
// strings.Fields would split it. Passing it through CommandArgs must deliver
// it to argv as a single element.
func TestCreateCustomTUIAgent_CommandArgsReachArgv(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Spaced Args",
Command: "my-cli",
CommandArgs: []string{"--system-prompt", "you are a helpful agent"},
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("spaced-args")
if !ok {
t.Fatal("agent not registered")
}
want := []string{"my-cli", "--system-prompt", "you are a helpful agent"}
if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, want) {
t.Errorf("argv = %#v, want %#v", got, want)
}
}
// TestCreateCustomTUIAgent_CommandArgsPersisted pins that the args survive a
// restart: they must be written to the stored TUI config, not just handed to
// the in-memory registry.
func TestCreateCustomTUIAgent_CommandArgsPersisted(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
args := []string{"--flag", "value with space"}
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Persisted Args",
Command: "my-cli",
CommandArgs: args,
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
stored, ok := st.byName["persisted-args"]
if !ok {
t.Fatal("agent not persisted")
}
if stored.TUIConfig == nil {
t.Fatal("stored agent has no TUI config")
}
if got := stored.TUIConfig.CommandArgs; !slices.Equal(got, args) {
t.Errorf("stored CommandArgs = %#v, want %#v", got, args)
}
}
// TestCreateCustomTUIAgent_NoCommandArgs keeps the existing behaviour intact
// when the caller omits the field.
func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Plain",
Command: "my-cli --verbose",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("plain")
if !ok {
t.Fatal("agent not registered")
}
want := []string{"my-cli", "--verbose"}
if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, want) {
t.Errorf("argv = %#v, want %#v", got, want)
}
if got := st.byName["plain"].TUIConfig.CommandArgs; len(got) != 0 {
t.Errorf("stored CommandArgs = %#v, want empty", got)
}
}
// --- MCP strategy ------------------------------------------------------------
// TestCreateCustomTUIAgent_MCPStrategyEnablesInjection is the test that
// distinguishes the fix from the bug in issue #2474. Before the fix a custom
// TUI agent was registered with no MCP strategy and supports_mcp=0, so
// applyPassthroughMCP was a no-op and the mcpconfig service refused the profile
// — kandev's own session tools were unreachable with no way to turn them on.
func TestCreateCustomTUIAgent_MCPStrategyEnablesInjection(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Fuel Claude",
Command: "fuelclaude",
MCPStrategy: mcpconfig.StrategyKeyClaude,
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("fuel-claude")
if !ok {
t.Fatal("agent not registered")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("agent does not implement PassthroughAgent")
}
// The strategy is what applyPassthroughMCP keys off; nil means the CLI is
// never told the per-session MCP server exists.
if pt.PassthroughConfig().MCPStrategy == nil {
t.Error("PassthroughConfig.MCPStrategy = nil, want the Claude strategy")
}
// supports_mcp gates the profile MCP editor and the mcpconfig service.
if stored := st.byName["fuel-claude"]; !stored.SupportsMCP {
t.Error("stored SupportsMCP = false, want true")
}
if got := st.byName["fuel-claude"].TUIConfig.MCPStrategy; got != mcpconfig.StrategyKeyClaude {
t.Errorf("stored MCPStrategy = %q, want %q", got, mcpconfig.StrategyKeyClaude)
}
}
// TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff pins that the default is
// unchanged: a plain TUI tool that never speaks MCP must not advertise support.
func TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Plain Tool",
Command: "k9s",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, _ := c.agentRegistry.Get("plain-tool")
if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil {
t.Error("MCPStrategy set, want nil for an agent created without one")
}
if st.byName["plain-tool"].SupportsMCP {
t.Error("stored SupportsMCP = true, want false")
}
}
// TestCreateCustomTUIAgent_RejectsUnknownStrategy pins that a bad key fails at
// creation. Accepting it would persist an agent whose MCP is silently off
// forever, with no error surfaced anywhere.
func TestCreateCustomTUIAgent_RejectsUnknownStrategy(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Typo Agent",
Command: "some-cli",
MCPStrategy: "cluade",
})
if !errors.Is(err, ErrUnknownMCPStrategy) {
t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
}
if _, registered := c.agentRegistry.Get("typo-agent"); registered {
t.Error("agent was registered despite the rejected strategy")
}
if _, stored := st.byName["typo-agent"]; stored {
t.Error("agent was persisted despite the rejected strategy")
}
}
// TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent covers the upgrade path:
// every custom TUI agent created before this feature has an empty strategy, and
// there is no route to edit a tui_config. Without this they could only get MCP
// by being deleted and rebuilt, losing their profiles.
func TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Legacy Agent",
Command: "legacy-cli --flag",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyCodex)
if err != nil {
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
}
if !got.SupportsMCP {
t.Error("returned SupportsMCP = false, want true")
}
ag, ok := c.agentRegistry.Get("legacy-agent")
if !ok {
t.Fatal("agent missing from registry after re-register")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("agent does not implement PassthroughAgent")
}
if pt.PassthroughConfig().MCPStrategy == nil {
t.Error("MCPStrategy = nil after opting in")
}
// The rest of the definition must survive the rebuild — the re-register
// reconstructs the agent from stored config, so a dropped field here would
// silently change the command the user launches.
want := []string{"legacy-cli", "--flag"}
if argv := ag.Runtime().Cmd.Args(); !slices.Equal(argv, want) {
t.Errorf("argv = %#v, want %#v", argv, want)
}
if stored := st.byName["legacy-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyCodex {
t.Errorf("stored strategy = %q, want %q", stored.TUIConfig.MCPStrategy, mcpconfig.StrategyKeyCodex)
}
}
// TestSetCustomTUIAgentMCPStrategy_TurnsOff pins the reverse direction: clearing
// the strategy must also clear supports_mcp, or the profile MCP editor would
// stay visible for an agent that can no longer receive any servers.
func TestSetCustomTUIAgentMCPStrategy_TurnsOff(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Toggle Agent",
Command: "toggle-cli",
MCPStrategy: mcpconfig.StrategyKeyClaude,
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyNone)
if err != nil {
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
}
if got.SupportsMCP {
t.Error("returned SupportsMCP = true, want false after clearing the strategy")
}
ag, _ := c.agentRegistry.Get("toggle-agent")
if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil {
t.Error("MCPStrategy still set after clearing")
}
}
func TestSetCustomTUIAgentMCPStrategy_RejectsNonTUIAgent(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
builtin := &models.Agent{Name: "claude-acp"}
if err := st.CreateAgent(context.Background(), builtin); err != nil {
t.Fatalf("CreateAgent: %v", err)
}
_, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), builtin.ID, mcpconfig.StrategyKeyClaude)
if !errors.Is(err, ErrNotCustomTUIAgent) {
t.Fatalf("err = %v, want ErrNotCustomTUIAgent", err)
}
}
func TestSetCustomTUIAgentMCPStrategy_RejectsUnknownStrategy(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Guard Agent",
Command: "guard-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if _, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, "nope"); !errors.Is(err, ErrUnknownMCPStrategy) {
t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
}
if stored := st.byName["guard-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyNone {
t.Errorf("stored strategy = %q, want unchanged", stored.TUIConfig.MCPStrategy)
}
}