Files
multica/server/internal/handler/runtime_models_redis_store_test.go
a075e58b80 MUL-6961 feat(agent): discover Claude Code's model catalog from the CLI (#7917)
* feat(agent): discover Claude Code's model catalog from the CLI

Claude was the last first-party runtime answering "what models do you
have?" from a hand-maintained list. A static catalog cannot know what the
CLI in front of it can run, so every new Anthropic model opened a window
where Multica offered a model that 400s:

    API Error: 400 Claude Code 2.1.246 does not support this model;
    version 2.1.251 or newer is required.

Claude Code does have a discovery hook, just not on the command line: the
stream-json control protocol answers a `list_models` control request with
the rows its own /model picker renders. Ask the installed binary and the
window closes at the source — a model it cannot run is either absent or
flagged disabled, computed against the real CLI version and the logged-in
account.

Verified against 2.1.223/228/246/258. The three older builds return Fable
5.1 as a disabled row carrying "Update to 2.1.255+ to use Fable 5.1";
2.1.258 returns it as a normal pick. Those rows are the reason no
per-model minimum-version table is needed here: upstream already computes
one, including the remedy copy, which we forward verbatim and grey out.

No version gate guards the request. An unsupported subtype is answered,
not ignored — every build tested replies "Unsupported control request
subtype" in ~2s and exits 0 — so an old CLI costs one cheap round trip and
falls back. Gating would mean hand-maintaining exactly the kind of number
this change removes.

Notes:

- The static catalog stays, now as the fallback and as the server-side
  accept list. It is flagged Catalog.Fallback so a discovery failure is
  not pinned in the server's day-long cache (MUL-5549).
- Discovery replaces rather than merges, as with every other dynamic
  provider. The picker narrows to the CLI's own list; agents already
  pinned to an older model keep working, and manual entry still reaches
  them.
- Effort levels now come from each row instead of scraping `claude
  --help` and narrowing through the hand-kept claudeModelEffortAllow.
- Catalog ids keep the context-window tag, because `claude-opus-5[1m]` is
  what the CLI would really run for that row. That exposed a latent bug
  in two capability lookups, which normalized the query side but not the
  catalog side; both now normalize both, or a model would fail to find
  its own effort picker.

MUL-6961

Co-authored-by: multica-agent <[email protected]>

* fix(agent): keep unrunnable Claude models out of the selectable catalog

Review found the first cut carried "this CLI cannot run it" as a flag on
the model itself. That was wrong in a way a flag cannot fix.

Only the create-agent dropdown learned the flag. The inspector picker and
the agent builder read the same list and kept offering the row, so
editing an existing agent still reached the 400 this change exists to
prevent. And the part no amount of teaching fixes: an already installed
desktop client has never heard of the field, so it renders the row as an
ordinary model and persists a model id the CLI rejects — exactly what the
repo's mixed-version API rule forbids.

Unavailable models now travel in their own list, end to end: daemon
report, store, catalog cache, and the client result. `models` contains
only selectable entries. The inspector and the builder needed no changes
to become correct — they read `models`, and the rows are no longer in it
— and an old client is correct for the same reason. Both pickers render
the advisory rows through one shared component that takes no click
handler, so nothing can turn them back into controls.

Two more review findings:

- The default row was dropped when no sibling row named the same model,
  which an org-restricted install can produce. That is not one missing
  picker entry: ValidateThinkingLevelWith resolves an empty model — the
  common "follow the CLI default" configuration — through the entry
  flagged Default, and fails closed without one, silently discarding the
  user's effort on every such task. It is now materialised, keeping the
  row's own capabilities. The test that claimed to cover this asserted
  the opposite of its own comment; rewritten, plus a case proving an
  unrunnable default is never materialised.

- "One cheap round trip" for a CLI without list_models was one round trip
  PER TASK: every Claude task carrying a thinking_level loads the catalog
  before it starts, and cachedDiscovery refuses to memoise a fallback.
  The structured unsupported-subtype answer is now remembered per
  (command, CLI version) — a property of the binary, not a bad moment —
  so an old CLI is probed once. Transient failures stay uncached and
  retryable, and the version in the key makes an upgrade take effect at
  once rather than after a TTL. Detecting it costs ~0.01s against the
  ~1.7s it saves, and the fallback path already paid for it.

The discovery timeout keeps its 20s ceiling, now with the measurements
that justify it: 1.57-2.19s over five runs against 2.1.258, 1.64-1.79s
against 2.1.246. The ceiling is for a CLI that never answers, not a slow
one.

Verified against real binaries: 2.1.246 offers four selectable models and
one unavailable row carrying upstream's own upgrade hint, 2.1.258 offers
Fable 5.1 normally, a missing binary falls back to the flagged static
catalog.

MUL-6961

Co-authored-by: multica-agent <[email protected]>

* docs(agent): record the downgrade case as an accepted boundary

Dynamic discovery governs selection, and only selection. An agent already
configured with a model whose CLI is later downgraded still starts and
still gets the upstream 400 — discovery cannot reach a value already
persisted.

That is deliberate, not an oversight, so it is written down next to the
code that invites the wrong fix. Clearing the configured model turns a
legible error into a silent config change the user did not make;
substituting a runnable one is worse, because it succeeds using a model
nobody chose. The downgrade is rare and the upstream error is accurate,
so failing loudly at task start is a fine way to report it.

MUL-6961

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 15:48:38 +08:00

326 lines
9.5 KiB
Go

package handler
import (
"context"
"sync"
"testing"
"time"
)
// Reuses the newRedisTestClient helper from
// runtime_local_skills_redis_store_test.go: same Redis instance, same gating
// on REDIS_TEST_URL, same FlushDB-per-test isolation.
// TestRedisModelListStore_EnvelopePersistsRunStartedAt is a pure marshal/
// unmarshal round-trip — no Redis required. Pins the regression that the
// `json:"-"` tag on ModelListRequest.RunStartedAt was silently dropping the
// field on persistence, which broke the running-timeout escape hatch
// across nodes (CI failure for TestRedisModelListStore_RunningTimeout
// before this fix).
func TestRedisModelListStore_EnvelopePersistsRunStartedAt(t *testing.T) {
store := &RedisModelListStore{}
now := time.Now().UTC().Truncate(time.Microsecond) // JSON loses sub-µs precision
req := &ModelListRequest{
ID: "id-1",
RuntimeID: "rt-1",
Status: ModelListRunning,
Supported: true,
CreatedAt: now.Add(-time.Second),
UpdatedAt: now,
RunStartedAt: &now,
}
data, err := store.marshalRequest(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
got, err := store.unmarshalRequest(data)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.RunStartedAt == nil {
t.Fatal("RunStartedAt lost on round trip — running timeout would never fire across nodes")
}
if !got.RunStartedAt.Equal(now) {
t.Errorf("RunStartedAt drifted: got %s, want %s", got.RunStartedAt, now)
}
if got.Status != ModelListRunning {
t.Errorf("Status lost: got %s", got.Status)
}
if got.ID != "id-1" || got.RuntimeID != "rt-1" {
t.Errorf("identifiers lost: %+v", got)
}
}
func TestRedisModelListStore_CreateGetComplete(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
store := NewRedisModelListStore(rdb)
req, err := store.Create(ctx, "runtime-1")
if err != nil {
t.Fatalf("create: %v", err)
}
if req.Status != ModelListPending {
t.Fatalf("initial status = %s", req.Status)
}
got, err := store.Get(ctx, req.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil || got.ID != req.ID {
t.Fatalf("round trip lost id: got=%v", got)
}
models := []ModelEntry{
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", Provider: "anthropic"},
}
if err := store.Complete(ctx, req.ID, models, nil, true); err != nil {
t.Fatalf("complete: %v", err)
}
got, err = store.Get(ctx, req.ID)
if err != nil {
t.Fatalf("get after complete: %v", err)
}
if got.Status != ModelListCompleted {
t.Fatalf("status after complete = %s", got.Status)
}
if len(got.Models) != 2 {
t.Fatalf("models not persisted: %+v", got.Models)
}
if !got.Models[0].Default {
t.Fatalf("default flag lost on round trip: %+v", got.Models[0])
}
if !got.Supported {
t.Fatalf("supported flag lost on round trip")
}
}
func TestRedisModelListStore_CreateWithoutMultiPermission(t *testing.T) {
rdb := newRedisTestClientWithoutMulti(t)
ctx := context.Background()
store := NewRedisModelListStore(rdb)
req, err := store.Create(ctx, "runtime-no-multi")
if err != nil {
t.Fatalf("create without MULTI permission: %v", err)
}
got, err := store.Get(ctx, req.ID)
if err != nil {
t.Fatalf("get created request: %v", err)
}
if got == nil || got.ID != req.ID {
t.Fatalf("created request was not persisted: %+v", got)
}
pending, err := store.HasPending(ctx, "runtime-no-multi")
if err != nil {
t.Fatalf("check pending request: %v", err)
}
if !pending {
t.Fatal("created request was not queued")
}
}
// TestRedisModelListStore_PopPendingAcrossInstances is the regression test
// for the exact bug this PR fixes: two API replicas share one Redis, one
// receives the POST that creates the request, the other receives the daemon
// heartbeat that PopPending-s it. Before this change the in-memory store made
// node B see nothing, the request timed out, and the picker showed
// "No models available" forever.
func TestRedisModelListStore_PopPendingAcrossInstances(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
nodeA := NewRedisModelListStore(rdb)
nodeB := NewRedisModelListStore(rdb)
req, err := nodeA.Create(ctx, "runtime-cross")
if err != nil {
t.Fatalf("node A create: %v", err)
}
popped, err := nodeB.PopPending(ctx, "runtime-cross")
if err != nil {
t.Fatalf("node B pop: %v", err)
}
if popped == nil {
t.Fatal("node B did not see node A's pending request")
}
if popped.ID != req.ID {
t.Fatalf("popped id = %s, want %s", popped.ID, req.ID)
}
if popped.Status != ModelListRunning {
t.Fatalf("popped status = %s, want running", popped.Status)
}
if popped.RunStartedAt == nil {
t.Fatal("run_started_at not set after pop")
}
// A third pop must see nothing (claim was atomic).
again, err := nodeB.PopPending(ctx, "runtime-cross")
if err != nil {
t.Fatalf("node B second pop: %v", err)
}
if again != nil {
t.Fatalf("expected no more pending, got %+v", again)
}
}
// TestRedisModelListStore_PopPendingConcurrent asserts the ZREM-wins race
// guard: N concurrent PopPending calls against a single pending request
// return exactly one winner.
func TestRedisModelListStore_PopPendingConcurrent(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
store := NewRedisModelListStore(rdb)
req, err := store.Create(ctx, "runtime-race")
if err != nil {
t.Fatalf("create: %v", err)
}
const N = 8
var wg sync.WaitGroup
results := make(chan *ModelListRequest, N)
errs := make(chan error, N)
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
popped, err := store.PopPending(ctx, "runtime-race")
if err != nil {
errs <- err
return
}
results <- popped
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent pop error: %v", err)
}
winners := 0
for popped := range results {
if popped != nil {
winners++
if popped.ID != req.ID {
t.Fatalf("winner popped wrong id: %s", popped.ID)
}
}
}
if winners != 1 {
t.Fatalf("expected exactly one winner, got %d", winners)
}
}
// TestRedisModelListStore_PendingTimeout pins the lazy timeout sweep — a
// pending request whose CreatedAt has aged past the 30s threshold MUST
// transition to Timeout on the next Get and be evicted from the pending
// zset so a subsequent PopPending doesn't re-claim it.
func TestRedisModelListStore_PendingTimeout(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
store := NewRedisModelListStore(rdb)
req, err := store.Create(ctx, "runtime-timeout")
if err != nil {
t.Fatalf("create: %v", err)
}
// Rewind CreatedAt so the pending threshold is blown — simulates 31s of
// daemon silence without actually blocking the test that long.
req.CreatedAt = time.Now().Add(-modelListPendingTimeout - time.Second)
if err := store.persistRequest(ctx, req); err != nil {
t.Fatalf("persist rewound: %v", err)
}
got, err := store.Get(ctx, req.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Status != ModelListTimeout {
t.Fatalf("status = %s, want timeout", got.Status)
}
// A subsequent PopPending must NOT return a timed-out request.
popped, err := store.PopPending(ctx, "runtime-timeout")
if err != nil {
t.Fatalf("pop after timeout: %v", err)
}
if popped != nil {
t.Fatalf("expected no pending after timeout, got %+v", popped)
}
}
// TestRedisModelListStore_RunningTimeout pins the second escape hatch — a
// claimed request whose RunStartedAt has aged past the 60s threshold MUST
// flip to Timeout so the UI's polling loop terminates instead of waiting
// for the retention sweep.
func TestRedisModelListStore_RunningTimeout(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
store := NewRedisModelListStore(rdb)
req, err := store.Create(ctx, "runtime-running-timeout")
if err != nil {
t.Fatalf("create: %v", err)
}
popped, err := store.PopPending(ctx, "runtime-running-timeout")
if err != nil {
t.Fatalf("pop: %v", err)
}
if popped == nil || popped.Status != ModelListRunning {
t.Fatalf("expected running, got %+v", popped)
}
// Rewind RunStartedAt past the running threshold.
aged := time.Now().Add(-modelListRunningTimeout - time.Second)
popped.RunStartedAt = &aged
if err := store.persistRequest(ctx, popped); err != nil {
t.Fatalf("persist rewound: %v", err)
}
got, err := store.Get(ctx, req.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Status != ModelListTimeout {
t.Fatalf("status = %s, want timeout", got.Status)
}
}
// TestRedisModelListStore_HasPending pins the cheap probe used by the
// heartbeat hot path so a slow Redis can't stall every connected daemon.
func TestRedisModelListStore_HasPending(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
store := NewRedisModelListStore(rdb)
if has, err := store.HasPending(ctx, "rt-empty"); err != nil || has {
t.Fatalf("empty store should not report pending: has=%v err=%v", has, err)
}
if _, err := store.Create(ctx, "rt-1"); err != nil {
t.Fatalf("create: %v", err)
}
if has, err := store.HasPending(ctx, "rt-1"); err != nil || !has {
t.Fatalf("expected pending=true after Create: has=%v err=%v", has, err)
}
if has, err := store.HasPending(ctx, "rt-other"); err != nil || has {
t.Fatalf("expected pending=false for unrelated runtime: has=%v err=%v", has, err)
}
if _, err := store.PopPending(ctx, "rt-1"); err != nil {
t.Fatalf("pop: %v", err)
}
if has, err := store.HasPending(ctx, "rt-1"); err != nil || has {
t.Fatalf("expected pending=false after PopPending: has=%v err=%v", has, err)
}
}