diff --git a/apps/backend/internal/slack/trigger_test.go b/apps/backend/internal/slack/trigger_test.go index 8db683b73b..158da36b91 100644 --- a/apps/backend/internal/slack/trigger_test.go +++ b/apps/backend/internal/slack/trigger_test.go @@ -179,11 +179,13 @@ func (f fakeUtility) PreparePromptRequest( return nil, err } agentCLI, model := a.AgentID, a.Model - if agentCLI == "" && defaults != nil { - agentCLI = defaults.AgentID - } - if model == "" && defaults != nil { - model = defaults.Model + if defaults != nil { + if model == "" { + agentCLI = defaults.AgentID + model = defaults.Model + } else if agentCLI == "" { + agentCLI = defaults.AgentID + } } return &utilityservice.PromptRequest{ UtilityID: utilityID, diff --git a/apps/backend/internal/utility/controller/controller.go b/apps/backend/internal/utility/controller/controller.go index 91478d0234..20113fc59b 100644 --- a/apps/backend/internal/utility/controller/controller.go +++ b/apps/backend/internal/utility/controller/controller.go @@ -92,7 +92,8 @@ func (c *Controller) GetTemplateVariables(ctx context.Context) *dto.TemplateVari } // PreparePromptRequest prepares a prompt request by resolving the template. -// If defaults is provided and the utility agent has empty agent_id/model, the defaults are used. +// If defaults is provided and the utility agent has no model, the default +// agent/model pair is used. func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePromptRequest, defaults *service.DefaultUtilitySettings, sessionless bool) (*service.PromptRequest, error) { tmplCtx := &template.Context{ GitDiff: req.GitDiff, diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index cb2c2e6853..d3cd345f62 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -172,7 +172,7 @@ type DefaultUtilitySettings struct { } // PreparePromptRequest prepares a prompt request by resolving the template. -// If the utility agent has empty AgentID/Model, the defaults are used. +// If the utility agent has no Model, the default AgentID/Model pair is used. // When sessionless is true, missing template variables are substituted with // empty strings instead of being left as {{Var}}. func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tmplCtx *template.Context, defaults *DefaultUtilitySettings, sessionless bool) (*PromptRequest, error) { @@ -189,14 +189,18 @@ func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tm return nil, err } - // Use agent's configured values, fall back to defaults if empty + // Use agent-specific values when fully configured. If the model is empty, + // treat the default agent/model as an inseparable pair so a default model + // from one provider is never sent to another provider's ACP config. agentCLI := agent.AgentID model := agent.Model - if agentCLI == "" && defaults != nil { - agentCLI = defaults.AgentID - } - if model == "" && defaults != nil { - model = defaults.Model + if defaults != nil { + if model == "" { + agentCLI = defaults.AgentID + model = defaults.Model + } else if agentCLI == "" { + agentCLI = defaults.AgentID + } } return &PromptRequest{ diff --git a/apps/backend/internal/utility/service/service_test.go b/apps/backend/internal/utility/service/service_test.go new file mode 100644 index 0000000000..809966669f --- /dev/null +++ b/apps/backend/internal/utility/service/service_test.go @@ -0,0 +1,204 @@ +package service + +import ( + "context" + "database/sql" + "testing" + + "github.com/kandev/kandev/internal/utility/models" + "github.com/kandev/kandev/internal/utility/template" +) + +type fakeRepository struct { + agents map[string]*models.UtilityAgent +} + +func (r *fakeRepository) ListAgents(context.Context) ([]*models.UtilityAgent, error) { + out := make([]*models.UtilityAgent, 0, len(r.agents)) + for _, agent := range r.agents { + out = append(out, agent) + } + return out, nil +} + +func (r *fakeRepository) GetAgentByID(_ context.Context, id string) (*models.UtilityAgent, error) { + agent, ok := r.agents[id] + if !ok { + return nil, sql.ErrNoRows + } + return agent, nil +} + +func (r *fakeRepository) GetAgentByName(_ context.Context, name string) (*models.UtilityAgent, error) { + for _, agent := range r.agents { + if agent.Name == name { + return agent, nil + } + } + return nil, sql.ErrNoRows +} + +func (r *fakeRepository) CreateAgent(_ context.Context, agent *models.UtilityAgent) error { + r.agents[agent.ID] = agent + return nil +} + +func (r *fakeRepository) UpdateAgent(_ context.Context, agent *models.UtilityAgent) error { + r.agents[agent.ID] = agent + return nil +} + +func (r *fakeRepository) DeleteAgent(_ context.Context, id string) error { + delete(r.agents, id) + return nil +} + +func (r *fakeRepository) ListCalls(context.Context, string, int) ([]*models.UtilityAgentCall, error) { + return nil, nil +} + +func (r *fakeRepository) GetCallByID(context.Context, string) (*models.UtilityAgentCall, error) { + return nil, sql.ErrNoRows +} + +func (r *fakeRepository) CreateCall(context.Context, *models.UtilityAgentCall) error { + return nil +} + +func (r *fakeRepository) UpdateCall(context.Context, *models.UtilityAgentCall) error { + return nil +} + +func TestPreparePromptRequest_UsesDefaultAgentAndModelAsPairWhenModelIsUnset(t *testing.T) { + t.Parallel() + + svc := NewService(&fakeRepository{agents: map[string]*models.UtilityAgent{ + "builtin-enhance-prompt": { + ID: "builtin-enhance-prompt", + Prompt: "Improve {{UserPrompt}}", + AgentID: "claude-acp", + Model: "", + Builtin: true, + }, + }}) + + req, err := svc.PreparePromptRequest( + context.Background(), + "builtin-enhance-prompt", + &template.Context{UserPrompt: "fix the bug"}, + &DefaultUtilitySettings{ + AgentID: "opencode-acp", + Model: "opencode-go/deepseek-v4-flash", + }, + false, + ) + if err != nil { + t.Fatalf("PreparePromptRequest() error = %v", err) + } + + if req.AgentCLI != "opencode-acp" { + t.Fatalf("AgentCLI = %q, want %q", req.AgentCLI, "opencode-acp") + } + if req.Model != "opencode-go/deepseek-v4-flash" { + t.Fatalf("Model = %q, want %q", req.Model, "opencode-go/deepseek-v4-flash") + } +} + +func TestPreparePromptRequest_UsesDefaultAgentWhenAgentIDIsUnset(t *testing.T) { + t.Parallel() + + svc := NewService(&fakeRepository{agents: map[string]*models.UtilityAgent{ + "custom": { + ID: "custom", + Prompt: "Do {{UserPrompt}}", + AgentID: "", + Model: "custom-model", + }, + }}) + + req, err := svc.PreparePromptRequest( + context.Background(), + "custom", + &template.Context{UserPrompt: "fix the bug"}, + &DefaultUtilitySettings{ + AgentID: "default-acp", + Model: "default-model", + }, + false, + ) + if err != nil { + t.Fatalf("PreparePromptRequest() error = %v", err) + } + + if req.AgentCLI != "default-acp" { + t.Fatalf("AgentCLI = %q, want %q", req.AgentCLI, "default-acp") + } + if req.Model != "custom-model" { + t.Fatalf("Model = %q, want %q", req.Model, "custom-model") + } +} + +func TestPreparePromptRequest_PreservesConfiguredAgentAndModelWithoutDefaults(t *testing.T) { + t.Parallel() + + svc := NewService(&fakeRepository{agents: map[string]*models.UtilityAgent{ + "custom": { + ID: "custom", + Prompt: "Do {{UserPrompt}}", + AgentID: "custom-acp", + Model: "custom-model", + }, + }}) + + req, err := svc.PreparePromptRequest( + context.Background(), + "custom", + &template.Context{UserPrompt: "fix the bug"}, + nil, + false, + ) + if err != nil { + t.Fatalf("PreparePromptRequest() error = %v", err) + } + + if req.AgentCLI != "custom-acp" { + t.Fatalf("AgentCLI = %q, want %q", req.AgentCLI, "custom-acp") + } + if req.Model != "custom-model" { + t.Fatalf("Model = %q, want %q", req.Model, "custom-model") + } +} + +func TestPreparePromptRequest_IgnoresDefaultsWhenAgentAndModelAreConfigured(t *testing.T) { + t.Parallel() + + svc := NewService(&fakeRepository{agents: map[string]*models.UtilityAgent{ + "custom": { + ID: "custom", + Prompt: "Do {{UserPrompt}}", + AgentID: "custom-acp", + Model: "custom-model", + }, + }}) + + req, err := svc.PreparePromptRequest( + context.Background(), + "custom", + &template.Context{UserPrompt: "fix the bug"}, + &DefaultUtilitySettings{ + AgentID: "default-acp", + Model: "default-model", + }, + false, + ) + if err != nil { + t.Fatalf("PreparePromptRequest() error = %v", err) + } + + if req.AgentCLI != "custom-acp" { + t.Fatalf("AgentCLI = %q, want %q", req.AgentCLI, "custom-acp") + } + if req.Model != "custom-model" { + t.Fatalf("Model = %q, want %q", req.Model, "custom-model") + } +}