Files
kandev/apps/backend/internal/agent/settings/controller/custom_tui_test.go
T
2026-09-24 14:45:00 +01:00

857 lines
28 KiB
Go

package controller
import (
"context"
"errors"
"slices"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/dto"
"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)
}
}
func TestCreateCustomTUIAgent_DisableBracketedPasteReachesRuntimeAndStorage(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Raw TUI",
Command: "raw-tui",
DisableBracketedPaste: true,
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("raw-tui")
if !ok {
t.Fatal("custom agent was not registered")
}
pt, ok := ag.(agents.PassthroughAgent)
if !ok {
t.Fatal("custom agent is not a passthrough agent")
}
if !pt.PassthroughConfig().DisableBracketedPaste {
t.Error("runtime DisableBracketedPaste = false, want true")
}
if !st.byName["raw-tui"].TUIConfig.DisableBracketedPaste {
t.Error("stored DisableBracketedPaste = false, want true")
}
}
// 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)
}
}
func newCustomTUIControllerWithDiscovery(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)
}
agentRegistry := registry.NewRegistry(log)
discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log)
if err != nil {
t.Fatalf("load discovery registry: %v", err)
}
return &Controller{
agentRegistry: agentRegistry,
discovery: discoveryRegistry,
repo: st,
logger: log,
}
}
func discoveryLists(t *testing.T, c *Controller, name string) bool {
t.Helper()
resp, err := c.ListDiscovery(context.Background())
if err != nil {
t.Fatalf("ListDiscovery: %v", err)
}
return slices.ContainsFunc(resp.Agents, func(a dto.AgentDiscoveryDTO) bool {
return a.Name == name
})
}
// The Installed Agents list is rendered from the discovery sweep, so a custom
// agent has to enter it on creation and leave it on deletion without a restart.
// Both directions were broken while discovery kept its own copy of the agent
// list: a deleted agent stayed listed (Rescan re-detected its binary from the
// stale list) and a new one stayed missing.
func TestCustomTUIAgentEntersAndLeavesDiscovery(t *testing.T) {
st := newFakeStore()
c := newCustomTUIControllerWithDiscovery(t, st)
ctx := context.Background()
if discoveryLists(t, c, "ghost-cli") {
t.Fatal("ghost-cli reported by discovery before it was created")
}
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
DisplayName: "Ghost CLI",
Command: "ghost-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if !discoveryLists(t, c, "ghost-cli") {
t.Error("ghost-cli missing from discovery after it was created")
}
if err := c.DeleteAgent(ctx, created.ID); err != nil {
t.Fatalf("DeleteAgent: %v", err)
}
if discoveryLists(t, c, "ghost-cli") {
t.Error("ghost-cli still reported by discovery after it was deleted")
}
}
// Discovery writes SupportsMCP back over the agent row on every sweep, so a
// strategy change that discovery cannot see is reverted by the next sweep.
func TestCustomTUIAgentMCPStrategyChangeReachesDiscovery(t *testing.T) {
st := newFakeStore()
c := newCustomTUIControllerWithDiscovery(t, st)
ctx := context.Background()
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
DisplayName: "Strategy CLI",
Command: "strategy-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if discoverySupportsMCP(t, c, "strategy-cli") {
t.Fatal("SupportsMCP = true before a strategy was selected")
}
if _, err := c.SetCustomTUIAgentMCPStrategy(ctx, created.ID, mcpconfig.StrategyKeyClaude); err != nil {
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
}
if !discoverySupportsMCP(t, c, "strategy-cli") {
t.Error("SupportsMCP = false after selecting an MCP strategy")
}
}
func discoverySupportsMCP(t *testing.T, c *Controller, name string) bool {
t.Helper()
resp, err := c.ListDiscovery(context.Background())
if err != nil {
t.Fatalf("ListDiscovery: %v", err)
}
for _, a := range resp.Agents {
if a.Name == name {
return a.SupportsMCP
}
}
t.Fatalf("%s missing from discovery", name)
return false
}
// A custom agent created as ACP must be seeded as a structured profile. A
// passthrough profile launches the command under a PTY, which is what the
// terminal protocol is for — with an ACP command that shows JSON-RPC frames.
func TestCreateCustomACPAgent_SeedsStructuredProfile(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "My Agent",
Command: "my-agent --acp",
Protocol: string(registry.CustomAgentProtocolACP),
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("my-agent")
if !ok {
t.Fatal("agent not registered")
}
if agents.IsPassthroughOnly(ag) {
t.Error("ACP custom agent registered as passthrough-only")
}
stored, ok := st.byName["my-agent"]
if !ok || stored.TUIConfig == nil {
t.Fatal("agent not persisted with a TUI config")
}
if stored.TUIConfig.Protocol != string(registry.CustomAgentProtocolACP) {
t.Errorf("stored protocol = %q, want %q", stored.TUIConfig.Protocol, registry.CustomAgentProtocolACP)
}
if !stored.SupportsMCP {
t.Error("stored SupportsMCP = false; ACP agents receive servers in session/new")
}
profiles := st.profiles[created.ID]
if len(profiles) != 1 {
t.Fatalf("seeded %d profiles, want 1", len(profiles))
}
if profiles[0].CLIPassthrough {
t.Error("seeded profile is CLI passthrough")
}
if profiles[0].Model == "passthrough" {
t.Error(`seeded profile model is "passthrough"; the capability probe fills it`)
}
}
// The terminal protocol is the default, and its seeding must not drift.
func TestCreateCustomTUIAgent_DefaultsToTerminalProfile(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Terminal CLI",
Command: "terminal-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
stored, ok := st.byName["terminal-cli"]
if !ok || stored.TUIConfig == nil {
t.Fatal("agent not persisted with a TUI config")
}
if stored.TUIConfig.Protocol != "" {
t.Errorf("stored protocol = %q, want empty", stored.TUIConfig.Protocol)
}
profiles := st.profiles[created.ID]
if len(profiles) != 1 {
t.Fatalf("seeded %d profiles, want 1", len(profiles))
}
if !profiles[0].CLIPassthrough {
t.Error("seeded profile is not CLI passthrough")
}
}
func TestCreateCustomTUIAgent_RejectsUnknownProtocol(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Bad Protocol",
Command: "bad-protocol",
Protocol: "websocket",
})
if !errors.Is(err, ErrUnknownCustomAgentProtocol) {
t.Fatalf("error = %v, want ErrUnknownCustomAgentProtocol", err)
}
if c.agentRegistry.Exists("bad-protocol") {
t.Error("agent registered despite an unknown protocol")
}
}
func TestCreateCustomACPAgent_RejectsMCPStrategy(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "ACP With Strategy",
Command: "acp-with-strategy --acp",
Protocol: string(registry.CustomAgentProtocolACP),
MCPStrategy: mcpconfig.StrategyKeyClaude,
})
if !errors.Is(err, ErrMCPStrategyNotApplicable) {
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
}
if c.agentRegistry.Exists("acp-with-strategy") {
t.Error("agent registered despite an inapplicable MCP strategy")
}
}
// A stored ACP definition has to replay as an ACP agent after a restart;
// creation, strategy changes, and the boot replay all build their spec here.
func TestCustomAgentSpecFromStored_CarriesProtocol(t *testing.T) {
spec := CustomAgentSpecFromStored("my-agent", &models.TUIConfigJSON{
Command: "my-agent --acp",
DisplayName: "My Agent",
Protocol: string(registry.CustomAgentProtocolACP),
})
if spec.Protocol != registry.CustomAgentProtocolACP {
t.Errorf("spec.Protocol = %q, want %q", spec.Protocol, registry.CustomAgentProtocolACP)
}
if spec.Slug != "my-agent" {
t.Errorf("spec.Slug = %q, want %q", spec.Slug, "my-agent")
}
}
type probeRecordingHostUtility struct {
refreshed chan string
caps hostutility.AgentCapabilities
}
func (h *probeRecordingHostUtility) Get(string) (hostutility.AgentCapabilities, bool) {
return hostutility.AgentCapabilities{}, false
}
func (h *probeRecordingHostUtility) Refresh(
_ context.Context,
agentType string,
) (hostutility.AgentCapabilities, error) {
select {
case h.refreshed <- agentType:
default:
}
return h.caps, nil
}
func (h *probeRecordingHostUtility) ResolveModelConfig(
context.Context,
string,
hostutility.ModelConfigResolutionRequest,
) (hostutility.ModelConfigResolution, error) {
return hostutility.ModelConfigResolution{}, nil
}
// An ACP agent's models and modes come from the capability probe, and the
// probe only runs at boot. Without one kicked at creation, the profile editor
// reports "not_configured" until the user finds the manual refresh.
func TestCreateCustomACPAgent_ProbesCapabilities(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
probe := &probeRecordingHostUtility{refreshed: make(chan string, 1)}
c.hostUtility = probe
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Probed Acp",
Command: "probed-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case agentType := <-probe.refreshed:
if agentType != "probed-acp" {
t.Errorf("probed %q, want %q", agentType, "probed-acp")
}
case <-time.After(5 * time.Second):
t.Fatal("no capability probe was kicked for a new ACP agent")
}
}
// A terminal agent is never probed, so kicking one would spawn the user's CLI
// for nothing and park a failed capability status on the agent.
func TestCreateCustomTUIAgent_DoesNotProbeCapabilities(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
probe := &probeRecordingHostUtility{refreshed: make(chan string, 1)}
c.hostUtility = probe
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Unprobed Terminal",
Command: "unprobed-terminal",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case agentType := <-probe.refreshed:
t.Errorf("probed %q; terminal agents have no ACP server to probe", agentType)
case <-time.After(200 * time.Millisecond):
}
}
// The MCP-strategy route is the one place a stored definition's protocol can be
// paired with a new strategy. An ACP agent has no passthrough config file to
// write, so the pair has to be refused before the row is touched rather than
// written and rolled back.
func TestSetCustomTUIAgentMCPStrategy_RejectedForACPAgent(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
ctx := context.Background()
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
DisplayName: "Strategy Acp",
Command: "strategy-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if _, err := c.SetCustomTUIAgentMCPStrategy(ctx, created.ID, mcpconfig.StrategyKeyClaude); !errors.Is(err, ErrMCPStrategyNotApplicable) {
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
}
stored, ok := st.byName["strategy-acp"]
if !ok || stored.TUIConfig == nil {
t.Fatal("agent not persisted with a TUI config")
}
if stored.TUIConfig.MCPStrategy != "" {
t.Errorf("stored strategy = %q, want empty", stored.TUIConfig.MCPStrategy)
}
if !stored.SupportsMCP {
t.Error("SupportsMCP flipped off by a rejected strategy write")
}
}
// profileUpdateSignalStore publishes each profile model write. The probe runs
// on its own goroutine, and fakeStore is not safe to read while it writes, so
// tests synchronise on the write instead of polling the store.
type profileUpdateSignalStore struct {
*fakeStore
models chan string
}
// profileAdoptionRaceStore simulates a user changing the profile after a
// background probe read it but before the probe can write. The conditional
// model update sees that edit and must leave it untouched. The old full-row
// read/modify/write path overwrote it.
type profileAdoptionRaceStore struct {
*fakeStore
userEditInjected bool
fullRowWrites int
}
func (s *profileAdoptionRaceStore) GetAgentProfile(
ctx context.Context,
profileID string,
) (*models.AgentProfile, error) {
profile, err := s.fakeStore.GetAgentProfile(ctx, profileID)
if err != nil || profile == nil || s.userEditInjected {
return profile, err
}
s.userEditInjected = true
userEdit := copyProfile(profile)
userEdit.Model = "operator-choice"
if err := s.fakeStore.UpdateAgentProfile(ctx, userEdit); err != nil {
return nil, err
}
return profile, nil
}
func (s *profileAdoptionRaceStore) UpdateAgentProfile(
ctx context.Context,
profile *models.AgentProfile,
) error {
s.fullRowWrites++
return s.fakeStore.UpdateAgentProfile(ctx, profile)
}
func (s *profileAdoptionRaceStore) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
if !s.userEditInjected {
s.userEditInjected = true
current, err := s.fakeStore.GetAgentProfile(ctx, profileID)
if err != nil {
return false, err
}
current.Model = "operator-choice"
if err := s.fakeStore.UpdateAgentProfile(ctx, current); err != nil {
return false, err
}
}
current, err := s.fakeStore.GetAgentProfile(ctx, profileID)
if err != nil || current == nil || current.Model != "" {
return false, err
}
current.Model = model
if err := s.fakeStore.UpdateAgentProfile(ctx, current); err != nil {
return false, err
}
return true, nil
}
func (s *profileUpdateSignalStore) UpdateAgentProfile(ctx context.Context, p *models.AgentProfile) error {
err := s.fakeStore.UpdateAgentProfile(ctx, p)
select {
case s.models <- p.Model:
default:
}
return err
}
func (s *profileUpdateSignalStore) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
profile, err := s.GetAgentProfile(ctx, profileID)
if err != nil || profile == nil || profile.Model != "" {
return false, err
}
profile.Model = model
if err := s.UpdateAgentProfile(ctx, profile); err != nil {
return false, err
}
return true, nil
}
func newProbedController(
t *testing.T,
caps hostutility.AgentCapabilities,
) (*Controller, *profileUpdateSignalStore) {
t.Helper()
st := &profileUpdateSignalStore{fakeStore: newFakeStore(), models: make(chan string, 4)}
c := newCustomTUIController(t, st.fakeStore)
c.repo = st
c.hostUtility = &probeRecordingHostUtility{refreshed: make(chan string, 1), caps: caps}
return c, st
}
// The seeded ACP profile carries no model on purpose: the probe is what learns
// one. ProfileReconciler copies a probed default into an empty profile, but it
// runs once during startup, so an agent registered afterwards would keep an
// empty model until the next restart and its sessions would silently take
// whatever the agent defaults to.
func TestCreateCustomACPAgent_AdoptsProbedModel(t *testing.T) {
c, st := newProbedController(t, hostutility.AgentCapabilities{CurrentModelID: "probed-model"})
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Adopting Acp",
Command: "adopting-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case model := <-st.models:
if model != "probed-model" {
t.Errorf("wrote model %q, want the probed default", model)
}
case <-time.After(5 * time.Second):
t.Fatal("the probed model was never written to the seeded profile")
}
}
// A model the operator already chose is theirs. Overwriting it from the probe
// is exactly the silent fallback the reconciler refuses to make.
func TestCreateCustomACPAgent_KeepsAnOperatorModel(t *testing.T) {
c, st := newProbedController(t, hostutility.AgentCapabilities{CurrentModelID: "probed-model"})
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Chosen Acp",
Command: "chosen-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
Model: "operator-choice",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case model := <-st.models:
t.Errorf("overwrote the operator's model with %q", model)
case <-time.After(500 * time.Millisecond):
}
}
func TestAdoptProbedModelDoesNotOverwriteConcurrentProfileEdit(t *testing.T) {
base := newFakeStore()
profile := &models.AgentProfile{AgentID: "agent-1", Name: "Default"}
if err := base.CreateAgentProfile(context.Background(), profile); err != nil {
t.Fatalf("create profile: %v", err)
}
st := &profileAdoptionRaceStore{fakeStore: base}
c := newCustomTUIController(t, base)
c.repo = st
c.adoptProbedModel(context.Background(), profile.ID, hostutility.AgentCapabilities{
CurrentModelID: "probed-model",
})
got, err := base.GetAgentProfile(context.Background(), profile.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if got.Model != "operator-choice" {
t.Fatalf("model = %q, want concurrent operator edit to win", got.Model)
}
if st.fullRowWrites != 0 {
t.Fatalf("full-row writes = %d, want no full-row adoption write", st.fullRowWrites)
}
}