Files
kandev/apps/backend/internal/agent/hostutility/model_config_test.go
T
2026-08-08 17:13:50 +01:00

372 lines
11 KiB
Go

package hostutility
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/registry"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/stretchr/testify/require"
)
func TestModelConfigCacheKeyCanonicalizesOptionMap(t *testing.T) {
first := ModelConfigResolutionRequest{
Model: "model",
Mode: "build",
ConfigOptions: map[string]string{
"zeta": "last",
"alpha": "first",
},
}
second := ModelConfigResolutionRequest{
Model: "model",
Mode: "build",
ConfigOptions: map[string]string{
"alpha": "first",
"zeta": "last",
},
}
require.Equal(t, modelConfigCacheKey("agent", first), modelConfigCacheKey("agent", second))
require.NotEqual(t, modelConfigCacheKey("other-agent", first), modelConfigCacheKey("agent", second))
}
func TestModelConfigCacheExpiresAndClonesValues(t *testing.T) {
cache := newModelConfigCache()
now := time.Unix(100, 0)
resolution := ModelConfigResolution{
AgentType: "agent",
Model: "model",
Status: StatusOK,
ConfigOptions: []ConfigOption{{
ID: "effort",
Options: []ConfigOptionChoice{{Value: "high"}},
}},
}
cache.set("key", resolution, now)
got, ok := cache.get("key", now.Add(time.Second))
require.True(t, ok)
got.ConfigOptions[0].Options[0].Value = "mutated"
again, ok := cache.get("key", now.Add(time.Second))
require.True(t, ok)
require.Equal(t, "high", again.ConfigOptions[0].Options[0].Value)
_, ok = cache.get("key", now.Add(modelConfigCacheTTL))
require.False(t, ok)
}
func TestModelConfigCacheSetSweepsExpiredEntriesAndCapsSize(t *testing.T) {
cache := newModelConfigCache()
now := time.Unix(100, 0)
cache.set("expired", ModelConfigResolution{AgentType: "agent"}, now.Add(-modelConfigCacheTTL))
cache.set("fresh", ModelConfigResolution{AgentType: "agent"}, now)
cache.mu.RLock()
if _, ok := cache.items["expired"]; ok {
cache.mu.RUnlock()
t.Fatal("expired entry remains after a set")
}
cache.mu.RUnlock()
for i := 0; i < modelConfigCacheMaxEntries+1; i++ {
cache.set("key-"+fmt.Sprint(i), ModelConfigResolution{AgentType: "agent"}, now)
}
cache.mu.RLock()
defer cache.mu.RUnlock()
if len(cache.items) > modelConfigCacheMaxEntries {
t.Fatalf("cache size = %d, want at most %d", len(cache.items), modelConfigCacheMaxEntries)
}
}
func TestResolveModelConfigSendsSelectedModel(t *testing.T) {
log := newTestLogger(t)
reg := registry.NewRegistry(log)
const agentType = "dynamic-acp"
agent := &installedInferenceAgent{id: agentType}
require.NoError(t, reg.Register(agent))
var requested agentctlutil.ProbeRequest
probeCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/api/v1/inference/probe":
probeCalls++
require.NoError(t, json.NewDecoder(r.Body).Decode(&requested))
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{
Success: true,
ConfigOptions: []agentctlutil.ProbeConfigOption{{
ID: "reasoning_effort",
Name: "Reasoning effort",
Type: "select",
CurrentValue: "high",
}},
}))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
host, port := serverHostPort(t, server)
mgr := NewManager(reg, host, port, nil, log)
mgr.instances[agentType] = &instance{
agentType: agentType,
workDir: t.TempDir(),
client: agentctl.NewClient(host, port, log),
}
resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, ModelConfigResolutionRequest{
Model: "model-with-effort",
})
require.NoError(t, err)
require.Equal(t, StatusOK, resolved.Status)
require.Equal(t, "model-with-effort", resolved.Model)
require.Len(t, resolved.ConfigOptions, 1)
require.Equal(t, "reasoning_effort", resolved.ConfigOptions[0].ID)
require.Equal(t, "model-with-effort", requested.Model)
second, err := mgr.ResolveModelConfig(context.Background(), agentType, ModelConfigResolutionRequest{
Model: "model-with-effort",
})
require.NoError(t, err)
require.Equal(t, resolved.ConfigOptions[0].ID, second.ConfigOptions[0].ID)
require.Equal(t, 1, probeCalls)
}
func TestResolveModelConfigDeduplicatesConcurrentProbes(t *testing.T) {
log := newTestLogger(t)
reg := registry.NewRegistry(log)
const agentType = "concurrent-dynamic-acp"
require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType}))
probeStarted := make(chan struct{})
releaseProbe := make(chan struct{})
var probeCalls atomic.Int32
var startOnce sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/api/v1/inference/probe":
probeCalls.Add(1)
startOnce.Do(func() { close(probeStarted) })
<-releaseProbe
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{
Success: true,
}))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
host, port := serverHostPort(t, server)
mgr := NewManager(reg, host, port, nil, log)
mgr.instances[agentType] = &instance{
agentType: agentType,
workDir: t.TempDir(),
client: agentctl.NewClient(host, port, log),
}
request := ModelConfigResolutionRequest{Model: "shared-model"}
firstContext, cancelFirst := context.WithCancel(context.Background())
defer cancelFirst()
results := make(chan ModelConfigResolution, 2)
errors := make(chan error, 2)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
resolved, err := mgr.ResolveModelConfig(firstContext, agentType, request)
results <- resolved
errors <- err
}()
<-probeStarted
wg.Add(1)
go func() {
defer wg.Done()
resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, request)
results <- resolved
errors <- err
}()
cancelFirst()
close(releaseProbe)
wg.Wait()
close(results)
close(errors)
for err := range errors {
require.NoError(t, err)
}
for range results {
}
require.Equal(t, int32(1), probeCalls.Load())
}
func TestResolveModelConfigRefreshStartsNewGenerationAndWinsOverOlderProbe(t *testing.T) {
log := newTestLogger(t)
reg := registry.NewRegistry(log)
const agentType = "refresh-generation-acp"
require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType}))
firstStarted := make(chan struct{})
secondStarted := make(chan struct{})
releaseFirst := make(chan struct{})
releaseSecond := make(chan struct{})
var probeCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/api/v1/inference/probe":
call := probeCalls.Add(1)
switch call {
case 1:
close(firstStarted)
<-releaseFirst
case 2:
close(secondStarted)
<-releaseSecond
}
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{
Success: true,
ConfigOptions: []agentctlutil.ProbeConfigOption{{
ID: "generation",
CurrentValue: fmt.Sprint(call),
}},
}))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
host, port := serverHostPort(t, server)
mgr := NewManager(reg, host, port, nil, log)
mgr.instances[agentType] = &instance{
agentType: agentType,
workDir: t.TempDir(),
client: agentctl.NewClient(host, port, log),
}
request := ModelConfigResolutionRequest{Model: "refresh-model"}
firstResult := make(chan ModelConfigResolution, 1)
firstError := make(chan error, 1)
go func() {
resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, request)
firstResult <- resolved
firstError <- err
}()
<-firstStarted
refreshResult := make(chan ModelConfigResolution, 1)
refreshError := make(chan error, 1)
go func() {
resolved, err := mgr.ResolveModelConfig(context.Background(), agentType, ModelConfigResolutionRequest{
Model: request.Model,
Refresh: true,
})
refreshResult <- resolved
refreshError <- err
}()
select {
case <-secondStarted:
case <-time.After(2 * time.Second):
t.Fatal("refresh joined the older in-flight probe")
}
close(releaseSecond)
select {
case err := <-refreshError:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("refresh probe did not finish")
}
refreshed := <-refreshResult
require.Equal(t, "2", refreshed.ConfigOptions[0].CurrentValue)
close(releaseFirst)
require.NoError(t, <-firstError)
<-firstResult
cached, err := mgr.ResolveModelConfig(context.Background(), agentType, request)
require.NoError(t, err)
require.Equal(t, "2", cached.ConfigOptions[0].CurrentValue)
require.Equal(t, int32(2), probeCalls.Load())
}
func TestResolveModelConfigDoesNotCacheFailures(t *testing.T) {
log := newTestLogger(t)
reg := registry.NewRegistry(log)
const agentType = "failure-dynamic-acp"
require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType}))
var probeCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/api/v1/inference/probe":
call := probeCalls.Add(1)
w.Header().Set("Content-Type", "application/json")
if call == 1 {
require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{
Success: false,
Error: "provider failed",
}))
return
}
require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{Success: true}))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
host, port := serverHostPort(t, server)
mgr := NewManager(reg, host, port, nil, log)
mgr.instances[agentType] = &instance{
agentType: agentType,
workDir: t.TempDir(),
client: agentctl.NewClient(host, port, log),
}
request := ModelConfigResolutionRequest{Model: "retry-model"}
first, err := mgr.ResolveModelConfig(context.Background(), agentType, request)
require.NoError(t, err)
require.Equal(t, StatusFailed, first.Status)
second, err := mgr.ResolveModelConfig(context.Background(), agentType, request)
require.NoError(t, err)
require.Equal(t, StatusOK, second.Status)
require.Equal(t, int32(2), probeCalls.Load())
}
func TestModelConfigCacheInvalidatesOneAgent(t *testing.T) {
cache := newModelConfigCache()
now := time.Unix(100, 0)
cache.set("agent-key", ModelConfigResolution{AgentType: "agent", Status: StatusOK}, now)
cache.set("other-key", ModelConfigResolution{AgentType: "other", Status: StatusOK}, now)
cache.invalidateAgent("agent")
_, ok := cache.get("agent-key", now.Add(time.Second))
require.False(t, ok)
_, ok = cache.get("other-key", now.Add(time.Second))
require.True(t, ok)
}