package handler import ( "context" "encoding/json" "errors" "sync" "testing" "time" ) func sampleCatalog() []ModelEntry { return []ModelEntry{ {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true}, {ID: "claude-opus-5", Label: "Claude Opus 5", Provider: "anthropic"}, } } // TestInMemoryModelCatalogCache_RoundTrip is the happy path behind the // stale-while-revalidate fast path (MUL-5444): a completed discovery is // remembered so the next picker open answers without a daemon round trip. func TestInMemoryModelCatalogCache_RoundTrip(t *testing.T) { ctx := context.Background() cache := NewInMemoryModelCatalogCache() if got, err := cache.Get(ctx, "rt-1"); err != nil || got != nil { t.Fatalf("cold cache should miss: got=%+v err=%v", got, err) } if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil { t.Fatalf("put: %v", err) } got, err := cache.Get(ctx, "rt-1") if err != nil { t.Fatalf("get: %v", err) } if got == nil { t.Fatal("expected a cached snapshot") } if len(got.Models) != 2 || got.Models[0].ID != "claude-sonnet-4-6" { t.Fatalf("unexpected models: %+v", got.Models) } if !got.Models[0].Default { t.Error("the default badge must survive the cache round trip") } if !got.Supported { t.Error("supported flag lost") } if got.Age(time.Now()) < 0 { t.Error("snapshot age must not be negative") } // Other runtimes keep their own entry — a catalog is per machine + per CLI // install, never shareable across runtimes. if other, err := cache.Get(ctx, "rt-2"); err != nil || other != nil { t.Fatalf("unrelated runtime should miss: got=%+v err=%v", other, err) } } // TestInMemoryModelCatalogCache_ReturnsIndependentCopies stops a caller that // mutates the response (e.g. filtering the list) from corrupting the shared // cache for every later reader. func TestInMemoryModelCatalogCache_ReturnsIndependentCopies(t *testing.T) { ctx := context.Background() cache := NewInMemoryModelCatalogCache() if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil { t.Fatalf("put: %v", err) } first, err := cache.Get(ctx, "rt-1") if err != nil || first == nil { t.Fatalf("get: %+v %v", first, err) } first.Models[0].ID = "mutated" second, err := cache.Get(ctx, "rt-1") if err != nil || second == nil { t.Fatalf("get: %+v %v", second, err) } if second.Models[0].ID != "claude-sonnet-4-6" { t.Fatalf("cache was corrupted by a caller mutation: %+v", second.Models[0]) } } // TestInMemoryModelCatalogCache_IsolatesNestedFields extends the copy guarantee // to everything a ModelEntry points at. A shallow slice copy would leave the // *ModelThinking, its level slice, and ServiceTiers aliasing the cached objects, // which also made this backend behave differently from the Redis one (JSON // round-trip always yields an independent value). func TestInMemoryModelCatalogCache_IsolatesNestedFields(t *testing.T) { ctx := context.Background() cache := NewInMemoryModelCatalogCache() source := []ModelEntry{{ ID: "gpt-5.6-sol", Label: "GPT-5.6-Sol", Thinking: &ModelThinking{ DefaultLevel: "low", SupportedLevels: []ThinkingLevel{{Value: "low", Label: "Low"}}, }, ServiceTiers: []ModelServiceTier{{ID: "fast", Name: "Fast"}}, }} if err := cache.Put(ctx, "rt-1", source, true); err != nil { t.Fatalf("put: %v", err) } // Mutating the caller's own slice after Put must not reach the cache. source[0].Thinking.DefaultLevel = "mutated-by-writer" source[0].Thinking.SupportedLevels[0].Label = "mutated-by-writer" source[0].ServiceTiers[0].Name = "mutated-by-writer" first, err := cache.Get(ctx, "rt-1") if err != nil || first == nil { t.Fatalf("get: %+v %v", first, err) } if first.Models[0].Thinking.DefaultLevel != "low" || first.Models[0].Thinking.SupportedLevels[0].Label != "Low" || first.Models[0].ServiceTiers[0].Name != "Fast" { t.Fatalf("writer mutation leaked into the cache: %+v", first.Models[0]) } // Mutating a returned snapshot must not reach the cache either. first.Models[0].Thinking.DefaultLevel = "mutated-by-reader" first.Models[0].Thinking.SupportedLevels[0].Value = "mutated-by-reader" first.Models[0].ServiceTiers[0].ID = "mutated-by-reader" second, err := cache.Get(ctx, "rt-1") if err != nil || second == nil { t.Fatalf("get: %+v %v", second, err) } if second.Models[0].Thinking == first.Models[0].Thinking { t.Error("thinking pointer is shared between snapshots") } if second.Models[0].Thinking.DefaultLevel != "low" || second.Models[0].Thinking.SupportedLevels[0].Value != "low" || second.Models[0].ServiceTiers[0].ID != "fast" { t.Fatalf("reader mutation leaked into the cache: %+v", second.Models[0]) } } // TestInMemoryModelCatalogCache_SkipsUncacheableResults pins the same rule the // daemon's own discovery cache uses: an empty catalog is a transient failure // (CLI not logged in, timeout), and caching it would pin the picker empty for // the whole serve window. func TestInMemoryModelCatalogCache_SkipsUncacheableResults(t *testing.T) { ctx := context.Background() cache := NewInMemoryModelCatalogCache() if err := cache.Put(ctx, "rt-empty", nil, true); err != nil { t.Fatalf("put empty: %v", err) } if got, _ := cache.Get(ctx, "rt-empty"); got != nil { t.Fatalf("empty catalog must not be cached: %+v", got) } if err := cache.Put(ctx, "rt-unsupported", sampleCatalog(), false); err != nil { t.Fatalf("put unsupported: %v", err) } if got, _ := cache.Get(ctx, "rt-unsupported"); got != nil { t.Fatalf("unsupported runtime must not be cached: %+v", got) } if err := cache.Put(ctx, "", sampleCatalog(), true); err != nil { t.Fatalf("put empty runtime id: %v", err) } if got, _ := cache.Get(ctx, ""); got != nil { t.Fatalf("empty runtime id must not be cached: %+v", got) } } // TestInMemoryModelCatalogCache_ExpiresAndInvalidates bounds how stale an // answer the fast path can serve, and proves an explicit drop works. func TestInMemoryModelCatalogCache_ExpiresAndInvalidates(t *testing.T) { ctx := context.Background() cache := NewInMemoryModelCatalogCache() cache.retainFor = 20 * time.Millisecond if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil { t.Fatalf("put: %v", err) } time.Sleep(40 * time.Millisecond) if got, err := cache.Get(ctx, "rt-1"); err != nil || got != nil { t.Fatalf("expected expiry past the serve window: got=%+v err=%v", got, err) } cache.retainFor = modelCatalogServeWindow if err := cache.Put(ctx, "rt-1", sampleCatalog(), true); err != nil { t.Fatalf("re-put: %v", err) } if err := cache.Invalidate(ctx, "rt-1"); err != nil { t.Fatalf("invalidate: %v", err) } if got, _ := cache.Get(ctx, "rt-1"); got != nil { t.Fatalf("expected a miss after Invalidate: %+v", got) } } // TestModelCatalogServeWindow_ServesDayOldSnapshotAndRevalidates pins the // day-scale serve window (MUL-5444). Nothing keeps a snapshot warm in the // background and the browser's own react-query cache dies with the tab, so a // minutes-scale window turned every first-open-of-the-day into a cold daemon // round trip — the multi-second wait this cache exists to remove. Freshness is // not traded away for it: a snapshot past modelCatalogRevalidateAfter is still // served, but serving it queues the refresh that makes the next open correct. func TestModelCatalogServeWindow_ServesDayOldSnapshotAndRevalidates(t *testing.T) { ctx := context.Background() if modelCatalogRevalidateAfter >= modelCatalogServeWindow { t.Fatalf("revalidate threshold %s must stay well below the serve window %s, otherwise nothing refreshes", modelCatalogRevalidateAfter, modelCatalogServeWindow) } if modelCatalogServeWindow < 12*time.Hour { t.Fatalf("serve window %s is not day-scale; agent CLIs are upgraded on a scale of days, so a shorter window only makes the first open of each day slow", modelCatalogServeWindow) } cache := NewInMemoryModelCatalogCache() store := NewInMemoryModelListStore() rec := &pendingWorkRecorder{} h := &Handler{ModelCatalogCache: cache, ModelListStore: store, DaemonPendingWork: rec} seed := func(runtimeID string, age time.Duration) { cache.mu.Lock() defer cache.mu.Unlock() cache.entries[runtimeID] = ModelCatalogSnapshot{ RuntimeID: runtimeID, Models: sampleCatalog(), Supported: true, StoredAt: time.Now().Add(-age), } } for _, tc := range []struct { name string age time.Duration served bool }{ {name: "hours old is still served", age: 2 * time.Hour, served: true}, {name: "just inside the window is served", age: modelCatalogServeWindow - time.Minute, served: true}, {name: "past the window is a miss", age: modelCatalogServeWindow + time.Minute, served: false}, } { t.Run(tc.name, func(t *testing.T) { runtimeID := "rt-" + tc.name seed(runtimeID, tc.age) got := h.cachedModelCatalog(ctx, runtimeID) if tc.served && got == nil { t.Fatalf("snapshot aged %s should still answer without waiting for the daemon", tc.age) } if !tc.served && got != nil { t.Fatalf("snapshot aged %s is past the serve window and must not be served: %+v", tc.age, got) } }) } // Serving an aged snapshot must still queue the refresh — the whole reason a // long window is safe. seed("rt-revalidate", 2*time.Hour) served := h.cachedModelCatalog(ctx, "rt-revalidate") if served == nil { t.Fatal("expected the aged snapshot to be served") } if age := served.Age(time.Now()); age < modelCatalogRevalidateAfter { t.Fatalf("test fixture is younger than the revalidate threshold (%s < %s)", age, modelCatalogRevalidateAfter) } h.revalidateModelCatalog(ctx, "rt-revalidate") pending, err := store.HasPending(ctx, "rt-revalidate") if err != nil { t.Fatalf("has pending: %v", err) } if !pending { t.Fatal("serving an aged snapshot must enqueue a background refresh") } if rec.count() != 1 { t.Fatalf("expected exactly 1 pending-work hint, got %d", rec.count()) } } // failingModelCatalogCache reports a backend error on every read. type failingModelCatalogCache struct{} func (failingModelCatalogCache) Get(context.Context, string) (*ModelCatalogSnapshot, error) { return nil, errors.New("redis down") } func (failingModelCatalogCache) Put(context.Context, string, []ModelEntry, bool) error { return nil } func (failingModelCatalogCache) Invalidate(context.Context, string) error { return nil } // TestCachedModelCatalog_DegradesToMiss proves the cache can never fail a // request: a nil cache, a backend error, or a snapshot that is no longer // serveable all fall back to the normal daemon round trip. func TestCachedModelCatalog_DegradesToMiss(t *testing.T) { ctx := context.Background() if got := (&Handler{}).cachedModelCatalog(ctx, "rt-1"); got != nil { t.Fatalf("nil cache should miss, got %+v", got) } h := &Handler{ModelCatalogCache: failingModelCatalogCache{}} if got := h.cachedModelCatalog(ctx, "rt-1"); got != nil { t.Fatalf("failing cache should miss, got %+v", got) } // A snapshot that somehow holds an unusable catalog is treated as a miss // rather than answering the picker with an empty list. inmem := NewInMemoryModelCatalogCache() inmem.entries["rt-1"] = ModelCatalogSnapshot{RuntimeID: "rt-1", Supported: true, StoredAt: time.Now()} h = &Handler{ModelCatalogCache: inmem} if got := h.cachedModelCatalog(ctx, "rt-1"); got != nil { t.Fatalf("empty cached catalog should miss, got %+v", got) } } // pendingWorkRecorder records the runtime-scoped hints a handler pushes. type pendingWorkRecorder struct { mu sync.Mutex hints []string } func (r *pendingWorkRecorder) NotifyPendingWork(runtimeID, kind string) { r.mu.Lock() defer r.mu.Unlock() r.hints = append(r.hints, runtimeID+":"+kind) } func (r *pendingWorkRecorder) count() int { r.mu.Lock() defer r.mu.Unlock() return len(r.hints) } // TestRevalidateModelCatalog_EnqueuesAndHints covers the "revalidate" half of // stale-while-revalidate: serving a stale snapshot must queue a refresh AND // push the wakeup hint, so the next open is both fast and fresh. func TestRevalidateModelCatalog_EnqueuesAndHints(t *testing.T) { ctx := context.Background() store := NewInMemoryModelListStore() rec := &pendingWorkRecorder{} h := &Handler{ModelListStore: store, DaemonPendingWork: rec} h.revalidateModelCatalog(ctx, "rt-1") pending, err := store.HasPending(ctx, "rt-1") if err != nil { t.Fatalf("has pending: %v", err) } if !pending { t.Fatal("expected a background refresh request to be enqueued") } if rec.count() != 1 { t.Fatalf("expected exactly 1 pending-work hint, got %d", rec.count()) } // Stampede control: a second open while the first refresh is still queued // must not pile on another request. h.revalidateModelCatalog(ctx, "rt-1") if rec.count() != 1 { t.Fatalf("expected the queued refresh to suppress a second hint, got %d", rec.count()) } } // TestRequestDaemonPendingWork_PrefersNotifier keeps the notifier optional: a // handler without one must not panic (single-node deployments fall back to the // local hub, which is nil in unit tests). func TestRequestDaemonPendingWork_PrefersNotifier(t *testing.T) { rec := &pendingWorkRecorder{} h := &Handler{DaemonPendingWork: rec} h.requestDaemonPendingWork("rt-1", "model_list") if rec.count() != 1 { t.Fatalf("expected the notifier to receive the hint, got %d", rec.count()) } h.requestDaemonPendingWork("", "model_list") if rec.count() != 1 { t.Fatalf("an empty runtime id must not produce a hint, got %d", rec.count()) } // No notifier and no hub: still a no-op, never a panic. (&Handler{}).requestDaemonPendingWork("rt-1", "model_list") } // TestCacheableModelCatalog pins which completed discovery results are worth // remembering. It is also the branch ReportModelListResult uses to decide // between warming the cache and dropping a snapshot the runtime no longer // advertises. func TestCacheableModelCatalog(t *testing.T) { if !cacheableModelCatalog(sampleCatalog(), true, false) { t.Error("a supported, non-empty catalog must be cacheable") } if cacheableModelCatalog(sampleCatalog(), false, false) { t.Error("a runtime that ignores model selection must not be cached") } if cacheableModelCatalog(nil, true, false) { t.Error("an empty catalog is a transient failure, not a cacheable answer") } if cacheableModelCatalog([]ModelEntry{}, true, false) { t.Error("an empty (non-nil) catalog is not cacheable either") } // The MUL-5549 hole: a fallback catalog is non-empty and `supported`, so // both checks above pass it. Only the fallback flag keeps a static // stand-in out of the 24h serve window. if cacheableModelCatalog(sampleCatalog(), true, true) { t.Error("a fallback catalog must never be cached as the runtime's real catalog") } } // TestCachedModelListResponse_WireShape pins what a cache hit looks like on the // wire. Existing clients only branch on status/models, so the response must be // indistinguishable from a completed live discovery apart from the additive // `cached` marker. func TestCachedModelListResponse_WireShape(t *testing.T) { storedAt := time.Now().Add(-2 * time.Minute) resp := &ModelListRequest{ ID: randomID(), RuntimeID: "rt-1", Status: ModelListCompleted, Models: sampleCatalog(), Supported: true, CreatedAt: storedAt, UpdatedAt: storedAt, Cached: true, CachedAt: &storedAt, } raw, err := json.Marshal(resp) if err != nil { t.Fatalf("marshal: %v", err) } var decoded struct { ID string `json:"id"` Status ModelListStatus `json:"status"` Models []ModelEntry `json:"models"` Supported bool `json:"supported"` Cached bool `json:"cached"` CachedAt *time.Time `json:"cached_at"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } if decoded.Status != ModelListCompleted { t.Fatalf("status = %q, want completed so clients stop polling", decoded.Status) } if decoded.ID == "" { t.Error("a cache hit still needs a request id for client-side bookkeeping") } if len(decoded.Models) != 2 || !decoded.Supported { t.Fatalf("cache hit must carry the catalog: %+v supported=%v", decoded.Models, decoded.Supported) } if !decoded.Cached || decoded.CachedAt == nil { t.Fatalf("cache hit must be marked: cached=%v cached_at=%v", decoded.Cached, decoded.CachedAt) } // A live (non-cached) response must not carry the markers at all, so older // clients see exactly the previous payload. live, err := json.Marshal(&ModelListRequest{ID: "x", RuntimeID: "rt-1", Status: ModelListPending, Supported: true}) if err != nil { t.Fatalf("marshal live: %v", err) } var liveFields map[string]any if err := json.Unmarshal(live, &liveFields); err != nil { t.Fatalf("unmarshal live: %v", err) } if _, ok := liveFields["cached"]; ok { t.Error("live responses must omit the cached marker") } if _, ok := liveFields["cached_at"]; ok { t.Error("live responses must omit cached_at") } }