Files
tutti/services/tuttid/service/agent/composer_live_model_cache_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

236 lines
8.6 KiB
Go

package agent
import (
"context"
"slices"
"testing"
"time"
"github.com/tutti-os/tutti/services/tuttid/biz/agentprovider"
)
func TestGetLiveComposerModelOptionsClaudeExpiresForRediscovery(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
service := &Service{}
cachedAt := time.Now().UTC()
service.setLiveComposerModelOptions("claude-code", "ws-1", "/repo", cachedAt, []ComposerConfigOptionValue{
{Value: "default", Label: "Default"},
{Value: "claude-fable-5[1m]", Label: "Fable"},
})
if _, ok := service.getLiveComposerModelOptions("claude-code", "ws-1", "/repo", cachedAt.Add(24*time.Hour)); ok {
t.Fatal("claude live model cache did not expire")
}
}
// Cursor keeps its live model cache for the daemon's lifetime too: it has no
// probe session at all, so an expired entry could only be re-discovered by a
// running conversation — until then the picker would collapse to the single
// selected model. Running sessions still override a stale entry.
func TestGetLiveComposerModelOptionsCursorNeverExpires(t *testing.T) {
service := &Service{}
cachedAt := time.Now().UTC()
service.setLiveComposerModelOptions("cursor", "ws-1", "/repo", cachedAt, []ComposerConfigOptionValue{
{Value: "composer-2.5[fast=true]", Label: "composer-2.5"},
{Value: "gpt-5.2[reasoning=medium,fast=false]", Label: "gpt-5.2"},
})
got, ok := service.getLiveComposerModelOptions("cursor", "ws-1", "/repo", cachedAt.Add(24*time.Hour))
if !ok {
t.Fatal("cursor live model cache expired, want last-known-good retained")
}
if len(got) != 2 {
t.Fatalf("cached options = %d, want 2", len(got))
}
}
// Switching Claude auth context (e.g. OAuth subscription -> ANTHROPIC_API_KEY
// billing) must not serve the previous context's cached model list: the auth
// fingerprint in the cache key buckets them separately.
func TestGetLiveComposerModelOptionsClaudeAuthScopeIsolatesCache(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
t.Setenv("ANTHROPIC_API_KEY", "")
t.Setenv("ANTHROPIC_AUTH_TOKEN", "")
service := &Service{}
now := time.Now().UTC()
service.setLiveComposerModelOptions("claude-code", "ws-1", "/repo", now, []ComposerConfigOptionValue{
{Value: "default", Label: "Default"},
{Value: "opus[1m]", Label: "Opus"},
})
if _, ok := service.getLiveComposerModelOptions("claude-code", "ws-1", "/repo", now); !ok {
t.Fatal("cache miss under same auth scope, want hit")
}
// Switch to API-key billing: the OAuth-context list must not leak through.
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test")
if _, ok := service.getLiveComposerModelOptions("claude-code", "ws-1", "/repo", now); ok {
t.Fatal("cache hit across auth switch, want miss (cross-auth isolation)")
}
}
// A running Claude session's advertised model list is the freshest source and
// must override a stale cache (and refresh it). Without running-session-first
// ordering, a never-expiring cache would shadow the live session and freeze the
// picker at the stale list until daemon restart.
func TestGetComposerOptionsClaudeRunningSessionOverridesStaleCache(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
runtime := newFakeRuntime()
runtime.sessions["ws-1:session-1"] = ProviderRuntimeSession{
ID: "session-1",
WorkspaceID: "ws-1",
Provider: "claude-code",
Status: "ready",
RuntimeContext: map[string]any{
"configOptions": []any{
map[string]any{
"id": "model",
"currentValue": "default",
"effectiveValue": "claude-opus-4-6",
"options": []any{
map[string]any{"name": "Default", "value": "default"},
map[string]any{"name": "Opus", "value": "opus[1m]"},
map[string]any{"name": "Fable", "value": "claude-fable-5[1m]"},
},
},
},
},
}
service := newIsolatedAgentService(runtime)
// Seed a stale cache that predates the running session's newer list.
service.setLiveComposerModelOptions("claude-code", "ws-1", "/repo", time.Now().UTC().Add(-time.Hour), []ComposerConfigOptionValue{
{Value: "default", Label: "Default"},
{Value: "sonnet", Label: "Sonnet"},
})
options, err := service.GetComposerOptions(context.Background(), ComposerOptionsInput{
Provider: "claude-code",
WorkspaceID: "ws-1",
Cwd: "/repo",
})
if err != nil {
t.Fatalf("GetComposerOptions returned error: %v", err)
}
if len(runtime.startCalls) != 0 {
t.Fatalf("start calls = %d, want no hidden discovery beside running session", len(runtime.startCalls))
}
wantValues := []string{"default", "opus[1m]", "claude-fable-5[1m]"}
if got := composerConfigOptionModelValues(options.ModelConfig.Options); !slices.Equal(got, wantValues) {
t.Fatalf("model options = %v, want newer running-session list %v", got, wantValues)
}
if options.ModelConfig.CurrentValue != "default" ||
options.ModelConfig.EffectiveValue != "claude-opus-4-6" {
t.Fatalf(
"model config values = (%q, %q), want requested default and effective opus",
options.ModelConfig.CurrentValue,
options.ModelConfig.EffectiveValue,
)
}
if options.RuntimeContext["modelCatalogSource"] != runtimeLiveModelCatalogSource {
t.Fatalf("modelCatalogSource = %#v, want %s", options.RuntimeContext["modelCatalogSource"], runtimeLiveModelCatalogSource)
}
// The live session must have refreshed the cache, not the reverse.
cached, ok := service.getLiveComposerModelOptions("claude-code", "ws-1", "/repo", time.Now().UTC())
if !ok {
t.Fatal("cache missing after refresh")
}
if got := composerConfigOptionModelValues(cached); !slices.Equal(got, wantValues) {
t.Fatalf("cache after refresh = %v, want %v", got, wantValues)
}
}
func TestGetComposerOptionsDoesNotReuseOlderEffectiveModel(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
runtime := newFakeRuntime()
modelConfig := func(effectiveValue string) map[string]any {
option := map[string]any{
"id": "model",
"currentValue": "default",
"options": []any{
map[string]any{"name": "Default", "value": "default"},
map[string]any{"name": "Opus", "value": "opus"},
map[string]any{"name": "Sonnet", "value": "sonnet"},
},
}
if effectiveValue != "" {
option["effectiveValue"] = effectiveValue
}
return option
}
runtime.sessions["ws-1:older"] = ProviderRuntimeSession{
ID: "older",
WorkspaceID: "ws-1",
Provider: "claude-code",
Status: "ready",
UpdatedAtUnixMS: 100,
RuntimeContext: map[string]any{
"configOptions": []any{modelConfig("claude-sonnet-4-6")},
},
}
runtime.sessions["ws-1:newer"] = ProviderRuntimeSession{
ID: "newer",
WorkspaceID: "ws-1",
Provider: "claude-code",
Status: "ready",
UpdatedAtUnixMS: 200,
RuntimeContext: map[string]any{
"configOptions": []any{modelConfig("")},
},
}
options, err := newIsolatedAgentService(runtime).GetComposerOptions(
context.Background(),
ComposerOptionsInput{
Provider: "claude-code",
WorkspaceID: "ws-1",
Cwd: "/repo",
},
)
if err != nil {
t.Fatalf("GetComposerOptions returned error: %v", err)
}
if options.ModelConfig.EffectiveValue != "" {
t.Fatalf(
"effective model = %q, want unknown until the newest session reports it",
options.ModelConfig.EffectiveValue,
)
}
}
func TestInvalidateLiveComposerModelsDropsCacheAndAttemptMarkers(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
service := &Service{}
now := time.UnixMilli(1000)
options := []ComposerConfigOptionValue{{ID: "opus", Label: "Opus", Value: "opus"}}
service.setLiveComposerModelOptions(agentprovider.ClaudeCode, "ws-1", "/repo", now, options)
cacheKey := composerLiveModelCacheKey(agentprovider.ClaudeCode, "ws-1", "/repo", liveModelAuthScope(agentprovider.ClaudeCode))
if !service.markLiveModelDiscoveryAttempted(cacheKey) {
t.Fatal("first markLiveModelDiscoveryAttempted must succeed")
}
service.InvalidateLiveComposerModels(agentprovider.ClaudeCode)
if _, ok := service.getLiveComposerModelOptions(agentprovider.ClaudeCode, "ws-1", "/repo", now); ok {
t.Fatal("cached live models must be dropped after invalidation")
}
if !service.markLiveModelDiscoveryAttempted(cacheKey) {
t.Fatal("discovery attempt marker must be cleared after invalidation")
}
}
func TestInvalidateLiveComposerModelsKeepsOtherProviders(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
service := &Service{}
now := time.UnixMilli(1000)
options := []ComposerConfigOptionValue{{ID: "opus", Label: "Opus", Value: "opus"}}
service.setLiveComposerModelOptions(agentprovider.ClaudeCode, "ws-1", "/repo", now, options)
service.InvalidateLiveComposerModels(agentprovider.Codex)
if _, ok := service.getLiveComposerModelOptions(agentprovider.ClaudeCode, "ws-1", "/repo", now); !ok {
t.Fatal("claude cache must survive a codex-only invalidation")
}
}