mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 01:19:42 +02:00
* fix(agent): stop reporting a failed model discovery as a real catalog (MUL-5549) Selecting the CodeBuddy runtime showed a model list that shares no IDs with what the CLI actually supports, so every pick was an ID codebuddy rejects (GH #6180). The list in the report is codebuddyStaticModels() verbatim: the daemon had fallen back, but nothing downstream could tell. discoverCodebuddyModels returned (staticModels, nil) on all three failure paths, and copilot/cursor/grok do the same. A failed discovery therefore arrived as a successful one, which defeated every guard built to catch it: the daemon reported status "completed", the picker's discovery_failed hint only renders on isError, and cacheableModelCatalog — whose own comment says an empty list means transient failure — waves through a non-empty stand-in and stores it as last-known-good for the full 24h serve window. One blip got pinned as the answer for a day. Discovery now returns a Catalog carrying a Fallback marker, which the daemon forwards as an additive `fallback` field (older servers ignore it; an older daemon omitting it keeps the previous behaviour). A fallback catalog is still rendered — the picker stays populated and manual entry still works — but it is kept out of both the daemon's 60s discovery cache and the server's catalog cache. On the server it maps to Keep rather than Drop: a stand-in is no grounds to evict a real catalog, matching how a `failed` report is treated. Also stop codebuddyHelpOutput swallowing the exec error. CombinedOutput folds in stderr, so a codebuddy whose `#!/usr/bin/env node` interpreter is missing from a GUI-launched daemon's PATH had `env: node: No such file or directory` parsed as help text — and cached as such for 60s. Verified against CodeBuddy CLI v2.130.0: the parser itself is fine (16 models from real --help), so this fixes the reporting of the failure, not the parse. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): run codebuddy --help at most once per model-list request (MUL-5549) Review catch on the previous commit. Model discovery and effort discovery both read `codebuddy --help`, and the effort pass called it independently. That was free while a failed --help was (wrongly) memoised, but once failures correctly stopped being cached, the failure path ran the 35s command twice in a single request — past the server's 60s running timeout, so the request timed out and the late report was then discarded as stale. The user got nothing, not even the fallback list the previous commit exists to preserve. discoverCodebuddyModels now owns the thinking annotation, so the one help capture feeds both catalogs, and the failure path uses codebuddyFallbackCatalog to apply the static effort levels without exec'ing at all: whatever broke --help for the model catalog breaks it for the effort catalog too. Also strengthen the handler tests. They decoded into a struct declared in the test rather than calling ReportModelListResult, so a wrong JSON tag or a mis-wired cache branch would have passed. They now drive the real endpoint with daemon auth and chi params, covering: a fallback report leaving a previously discovered catalog intact, an older daemon omitting the field still warming the cache, and an authoritative empty catalog still dropping the snapshot. Both fixes are mutation-tested — reverting either makes the new tests fail. Co-authored-by: multica-agent <github@multica.ai> * docs(agent): correct codebuddy --help comments after the single-capture refactor (MUL-5549) Review nit. The comments still described the pre-refactor call graph, where both discoverCodebuddyModels and codebuddyEffortSuperset called codebuddyHelpOutput and the cache was what stopped the duplicate run. The effort parser now takes an already-captured string, and the single-invocation guarantee is structural rather than cache-dependent — which matters, because a failed --help is deliberately not cached, so a second caller would re-run the full 35s timeout. Also note on codebuddyHelpOutput that it has exactly one caller and why a new one would reintroduce the bug. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
467 lines
17 KiB
Go
467 lines
17 KiB
Go
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")
|
|
}
|
|
}
|