Files
kandev/apps/backend/internal/agent/settings/controller/reconciler_test.go
T

281 lines
9.0 KiB
Go

package controller
import (
"context"
"database/sql"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/internal/common/logger"
)
// fakeCapReader returns pre-baked AgentCapabilities for a fixed agent type.
type fakeCapReader struct {
caps map[string]hostutility.AgentCapabilities
}
func (f *fakeCapReader) Get(agentType string) (hostutility.AgentCapabilities, bool) {
c, ok := f.caps[agentType]
return c, ok
}
// fakeStore implements just enough of store.Repository for the reconciler.
type fakeStore struct {
agents map[string]*models.Agent // keyed by DB ID
byName map[string]*models.Agent // keyed by Name
profiles map[string][]*models.AgentProfile // keyed by DB agent ID
created []*models.AgentProfile
updated []*models.AgentProfile
softDeleted []string
nextAgentID int
nextProfID int
getByNameErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{
agents: map[string]*models.Agent{},
byName: map[string]*models.Agent{},
profiles: map[string][]*models.AgentProfile{},
}
}
func (f *fakeStore) CreateAgent(_ context.Context, a *models.Agent) error {
f.nextAgentID++
a.ID = "agent-" + itoa(f.nextAgentID)
f.agents[a.ID] = a
f.byName[a.Name] = a
return nil
}
func (f *fakeStore) GetAgent(_ context.Context, id string) (*models.Agent, error) {
return f.agents[id], nil
}
func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agent, error) {
if f.getByNameErr != nil {
return nil, f.getByNameErr
}
if a, ok := f.byName[name]; ok {
return a, nil
}
return nil, sql.ErrNoRows
}
func (f *fakeStore) UpdateAgent(context.Context, *models.Agent) error { return nil }
func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil }
func (f *fakeStore) ListAgents(_ context.Context) ([]*models.Agent, error) {
out := make([]*models.Agent, 0, len(f.agents))
for _, a := range f.agents {
out = append(out, a)
}
return out, nil
}
func (f *fakeStore) ListTUIAgents(context.Context) ([]*models.Agent, error) {
return nil, nil
}
func (f *fakeStore) GetAgentProfileMcpConfig(context.Context, string) (*models.AgentProfileMcpConfig, error) {
return nil, nil
}
func (f *fakeStore) UpsertAgentProfileMcpConfig(context.Context, *models.AgentProfileMcpConfig) error {
return nil
}
func (f *fakeStore) CreateAgentProfile(_ context.Context, p *models.AgentProfile) error {
f.nextProfID++
p.ID = "profile-" + itoa(f.nextProfID)
f.profiles[p.AgentID] = append(f.profiles[p.AgentID], p)
f.created = append(f.created, p)
return nil
}
func (f *fakeStore) UpdateAgentProfile(_ context.Context, p *models.AgentProfile) error {
f.updated = append(f.updated, p)
return nil
}
func (f *fakeStore) DeleteAgentProfile(_ context.Context, id string) error {
f.softDeleted = append(f.softDeleted, id)
return nil
}
func (f *fakeStore) GetAgentProfile(_ context.Context, id string) (*models.AgentProfile, error) {
for _, list := range f.profiles {
for _, p := range list {
if p.ID == id {
return p, nil
}
}
}
return nil, sql.ErrNoRows
}
func (f *fakeStore) GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) {
// Delegate to the deleted_at-aware lookup; the fake does not model
// soft-delete state, so both methods see the same rows. This keeps
// nil dereferences out of any test that calls the new method.
return f.GetAgentProfile(ctx, id)
}
func (f *fakeStore) ListAgentProfiles(_ context.Context, agentID string) ([]*models.AgentProfile, error) {
return f.profiles[agentID], nil
}
func (f *fakeStore) Close() error { return nil }
func itoa(n int) string {
if n == 0 {
return "0"
}
out := ""
for n > 0 {
out = string(rune('0'+n%10)) + out
n /= 10
}
return out
}
// mockInferenceAgent is a minimal fake agent for the registry.
type mockInferenceAgent struct {
id string
displayName string
enabled bool
}
func (m *mockInferenceAgent) ID() string { return m.id }
func (m *mockInferenceAgent) Name() string { return m.id }
func (m *mockInferenceAgent) DisplayName() string { return m.displayName }
func (m *mockInferenceAgent) Description() string { return "" }
func (m *mockInferenceAgent) Enabled() bool { return m.enabled }
func (m *mockInferenceAgent) DisplayOrder() int { return 0 }
func (m *mockInferenceAgent) Logo(agents.LogoVariant) []byte { return nil }
func (m *mockInferenceAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) {
return &agents.DiscoveryResult{Available: true}, nil
}
func (m *mockInferenceAgent) BuildCommand(agents.CommandOptions) agents.Command {
return agents.Command{}
}
func (m *mockInferenceAgent) PermissionSettings() map[string]agents.PermissionSetting { return nil }
func (m *mockInferenceAgent) Runtime() *agents.RuntimeConfig { return &agents.RuntimeConfig{} }
func (m *mockInferenceAgent) BillingType() usage.BillingType { return usage.BillingTypeAPIKey }
func (m *mockInferenceAgent) RemoteAuth() *agents.RemoteAuth { return nil }
func (m *mockInferenceAgent) InstallScript() string { return "" }
func (m *mockInferenceAgent) InferenceConfig() *agents.InferenceConfig {
return &agents.InferenceConfig{Supported: true, Command: agents.NewCommand("x")}
}
func newReconciler(t *testing.T, st *fakeStore, caps *fakeCapReader, ag agents.Agent) *ProfileReconciler {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("logger: %v", err)
}
reg := registry.NewRegistry(log)
if err := reg.Register(ag); err != nil {
t.Fatalf("register: %v", err)
}
return NewProfileReconciler(caps, reg, st, log)
}
func TestProfileReconciler_SeedsDefaultProfile(t *testing.T) {
st := newFakeStore()
ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true}
caps := &fakeCapReader{
caps: map[string]hostutility.AgentCapabilities{
"claude-acp": {
AgentType: "claude-acp",
Status: hostutility.StatusOK,
Models: []hostutility.Model{{ID: "claude-sonnet", Name: "Sonnet"}},
CurrentModelID: "claude-sonnet",
CurrentModeID: "default",
},
},
}
r := newReconciler(t, st, caps, ag)
if err := r.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if len(st.created) != 1 {
t.Fatalf("expected 1 created profile, got %d", len(st.created))
}
p := st.created[0]
if p.Model != "claude-sonnet" {
t.Errorf("model = %q, want claude-sonnet", p.Model)
}
if p.Mode != "default" {
t.Errorf("mode = %q, want default", p.Mode)
}
}
func TestProfileReconciler_HealsStaleModel(t *testing.T) {
st := newFakeStore()
// Seed an existing DB agent and profile with a stale model.
dbAgent := &models.Agent{Name: "claude-acp"}
_ = st.CreateAgent(context.Background(), dbAgent)
existing := &models.AgentProfile{
AgentID: dbAgent.ID,
Name: "Claude",
Model: "claude-gone",
Mode: "",
}
_ = st.CreateAgentProfile(context.Background(), existing)
ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true}
caps := &fakeCapReader{
caps: map[string]hostutility.AgentCapabilities{
"claude-acp": {
AgentType: "claude-acp",
Status: hostutility.StatusOK,
Models: []hostutility.Model{{ID: "claude-new", Name: "New"}},
CurrentModelID: "claude-new",
Modes: []hostutility.Mode{{ID: "default", Name: "Default"}},
CurrentModeID: "default",
},
},
}
r := newReconciler(t, st, caps, ag)
if err := r.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if len(st.updated) != 1 {
t.Fatalf("expected 1 updated profile, got %d", len(st.updated))
}
updated := st.updated[0]
if updated.Model != "claude-new" {
t.Errorf("healed model = %q, want claude-new", updated.Model)
}
if updated.Mode != "default" {
t.Errorf("backfilled mode = %q, want default", updated.Mode)
}
}
func TestProfileReconciler_CleansOrphanProfiles(t *testing.T) {
st := newFakeStore()
// Seed a DB row for an agent that is NOT registered in the registry.
orphanAgent := &models.Agent{Name: "removed-old-agent"}
_ = st.CreateAgent(context.Background(), orphanAgent)
orphanProfile := &models.AgentProfile{
AgentID: orphanAgent.ID,
Name: "legacy",
Model: "x",
}
_ = st.CreateAgentProfile(context.Background(), orphanProfile)
ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true}
caps := &fakeCapReader{caps: map[string]hostutility.AgentCapabilities{}}
r := newReconciler(t, st, caps, ag)
if err := r.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if len(st.softDeleted) != 1 || st.softDeleted[0] != orphanProfile.ID {
t.Fatalf("expected orphan profile to be soft-deleted, got %v", st.softDeleted)
}
}