Files
multica/server/pkg/agent/omp_test.go
T
20ceaccc43 MUL-6471: fix(daemon): let pi and opencode reach custom providers (#7312)
* fix(daemon): let pi and opencode reach custom providers (MUL-6471)

A self-host setup whose pi/opencode providers point at an
OpenAI/Anthropic-compatible gateway could not run a Multica task on either
runtime, while the same work succeeded when the CLI was invoked by hand in
the same container (GH #7300). Gateway-style providers register model ids
that themselves contain a slash — `claude/claude-opus-5` under provider
`multica-anthropic` — and both failures came from treating that slash as a
provider boundary, or from having no provider at all.

pi: buildPiArgs split the model id on the first slash and passed the leading
segment as --provider, so a slash-shaped id became `--provider claude --model
claude-opus-5`. pi answers an unknown --provider with a hard error before it
ever looks at --model. The selector now goes to --model whole and --provider
is never synthesized: pi's own resolver accepts `provider/id`, a bare id, and
an id containing a slash, so passing less is strictly more capable. Verified
against pi 0.67.2 — the old argv reproduces `Unknown provider "claude"`, the
new one resolves to provider=multica-anthropic, model=claude/claude-opus-5.

opencode: --model is `provider/model`, and an id persisted without its
provider is rejected outright (`UnknownError`, reproduced against opencode
1.18.14). The daemon cannot invent the missing segment, so it asks the
runtime's own catalog: an id that exactly one provider claims is promoted to
the catalog's canonical selector, and anything uncertain — a static fallback
catalog, an already-canonical id, two providers exposing the same bare id —
is passed through untouched for the CLI to judge.

Two reported side effects fall out of the same root cause. `thinking_level`
was dropped for both runtimes because the capability lookup is keyed on the
model id and missed every catalog entry; it now matches. And `starting agent`
logged entry.Model, the env-var tier alone, so every task whose model came
from agent.model announced itself with an empty model — the resolution now
happens before the log.

The task row's `model: null` is not part of this: it is read from the run's
usage rows, which a run that fails before its first provider call never
produces.

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

* fix(daemon): gate selector qualification on the runtimes that can use it

Review follow-up on MUL-6471. The first pass ran catalog discovery for every
task that pinned a model, on the argument that the path already takes one
memoized lookup for thinking_level / service_tier. That argument does not
hold: cachedDiscovery deliberately skips the cache for empty and fallback
results (#3729, MUL-5549), precisely so a transient failure can retry. A
logged-out or failing runtime would therefore pay its full discovery ceiling —
15s for opencode/pi/omp, 30s for openclaw — on every task, including runtimes
whose bare model ids could never be rewritten anyway.

Qualification is now an explicit runtime capability. ModelIDsAreProviderQualified
is true only where Model.Provider is literally the ID's own prefix: pi builds
`provider + "/" + id`, opencode reads the provider back off the id. Runtimes
carrying a bare id next to a display-name Provider — claude's "anthropic",
copilot's "openai", dsh's "DeepSeek" against a `deepseek-official/...` id —
never reach discovery. Built-in runtime identities resolve through their
protocol family, so a pi-family fork like omp inherits the capability from its
descriptor rather than a name check.

The daemon side moves into qualifyTaskModel so the call boundary is testable.
TestQualifyTaskModelSelectorsAndDiscoveryBoundary pins both halves: pi,
opencode and omp resolve to the selector their CLI accepts, and claude, codex
and an empty model never reach listModels at all.
TestModelIDsAreProviderQualifiedMatchesCatalogShape keeps the capability
honest by deriving the expected answer from the real catalog producers instead
of restating the list — it fails if the predicate and the emitted ID shape
ever disagree.

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

* fix(daemon): read a runtime's model catalog at most once per task

Second review round on MUL-6471. Two findings, one root cause: the daemon
treated catalog discovery as cheap when cachedDiscovery deliberately makes it
anything but — an empty or fallback result is never memoized (#3729,
MUL-5549), so every read costs a logged-out or timing-out runtime its full
15-30s ceiling again.

A single task could pay that twice. qualifyTaskModel read the catalog to
promote the model selector, then ValidateThinkingLevel read it again to check
the level — back to back, on exactly the reporter's configuration (pi or
opencode plus thinking_level=high). The two now share one lazily-loaded,
task-scoped catalog: resolveTaskModelSelection owns it, and the validators
take that loader through ValidateThinkingLevelWith / ValidateServiceTierWith
rather than reaching for ListModels themselves. The global immediate-retry
contract is untouched — the reuse lives and dies with the task.

The second finding removes the read entirely for pi. Qualification is not a
launch requirement there: buildPiArgs already proves pi's resolver takes a
canonical selector, a bare id, and an id containing a slash, so a pi task with
no capability override now starts without spawning a discovery subprocess at
all. ModelIDsAreProviderQualified is replaced by
ModelSelectorMustBeProviderQualified, which states the contract that actually
justifies the cost — the CLI refuses an unprefixed id — and is therefore true
only for opencode and its DevEco fork. That also retires the catalog-shape
claim the old predicate made, which deveco was already a counterexample to.

The resulting matrix, pinned by
TestResolveTaskModelSelectionReadsTheCatalogAtMostOnce: pi/opencode with a
thinking level qualify and validate on one read; pi, omp, claude and
unpinned-model tasks read nothing; opencode without an override still reads,
because it cannot launch otherwise; codex without a model still fails both
checks closed without reading. A failed read is not retried inside the task.
Verified the count assertion bites by removing the memoization and watching
the pi and opencode cases report 2 reads.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:01:53 +08:00

519 lines
19 KiB
Go

package agent
import (
"context"
"log/slog"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestOmpNewDispatchesToPiBackend asserts that ResolveBackend("omp") dispatches to the
// pi backend via the descriptor registry — the core contract that omp is a
// runtime identity on the pi protocol, not a separate protocol family.
// IsSupportedType("omp") is false (it's not a protocol family), but New
// resolves it through BuiltinRuntimes to the pi backend with the correct
// executable and label overrides.
func TestOmpNewDispatchesToPiBackend(t *testing.T) {
if IsSupportedType("omp") {
t.Errorf("omp must not be in SupportedTypes (it is a runtime identity, not a protocol family)")
}
b, err := ResolveBackend("omp", Config{Logger: slog.Default()})
if err != nil {
t.Fatalf("New(omp) returned error: %v", err)
}
pb, ok := b.(*piBackend)
if !ok {
t.Fatalf("New(omp) returned %T, want *piBackend", b)
}
if pb.defaultExecutable != "omp" {
t.Errorf("defaultExecutable = %q, want %q", pb.defaultExecutable, "omp")
}
if pb.providerLabel != "omp" {
t.Errorf("providerLabel = %q, want %q", pb.providerLabel, "omp")
}
}
// TestNewRuntimeIsSeparateEntryPoint verifies that NewRuntime (the typed
// entry point for runtime identities) produces the same backend as New()
// when called with "omp" — both should dispatch through the descriptor.
func TestNewRuntimeIsSeparateEntryPoint(t *testing.T) {
b, err := NewRuntime("omp", Config{Logger: slog.Default()})
if err != nil {
t.Fatalf("NewRuntime(omp): %v", err)
}
pb, ok := b.(*piBackend)
if !ok {
t.Fatalf("NewRuntime(omp) returned %T, want *piBackend", b)
}
if pb.defaultExecutable != "omp" {
t.Errorf("defaultExecutable = %q, want %q", pb.defaultExecutable, "omp")
}
if pb.providerLabel != "omp" {
t.Errorf("providerLabel = %q, want %q", pb.providerLabel, "omp")
}
}
// TestNewRuntimeRejectsUnknownID verifies that NewRuntime fails for an
// unregistered runtime identity.
func TestNewRuntimeRejectsUnknownID(t *testing.T) {
if _, err := NewRuntime("definitely-not-a-runtime", Config{}); err == nil {
t.Fatal("expected error for unknown runtime identity")
}
}
// TestNewRejectsRuntimeID verifies that New() (the protocol-family factory)
// rejects a runtime identity like "omp". This pins the contract that New()
// means exactly one thing: family-only. The daemon calls ResolveBackend,
// which routes runtime identities through NewRuntime.
func TestNewRejectsRuntimeID(t *testing.T) {
if _, err := New("omp", Config{Logger: slog.Default()}); err == nil {
t.Fatal("New(\"omp\") should reject runtime identities (it is family-only)")
}
}
// TestOmpExecuteDefaultsToOmpBinary verifies that ResolveBackend("omp", Config{}) with
// an empty ExecutablePath resolves to the "omp" binary name (not "pi") when
// the daemon hasn't pinned a path. This is the contract the daemon relies on
// when it constructs a backend from a probe result that carries no path.
func TestOmpExecuteDefaultsToOmpBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakeDir := t.TempDir()
fakePath := filepath.Join(fakeDir, "omp")
script := "#!/bin/sh\n" +
"cat > /dev/null\n" +
"printf '%s\\n' '{\"type\":\"agent_start\"}'\n" +
"printf '%s\\n' '{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"model\":\"test\",\"usage\":{\"input\":1,\"output\":1}}}'\n" +
"exit 0\n"
writeTestExecutable(t, fakePath, []byte(script))
t.Setenv("PATH", fakeDir)
backend, err := ResolveBackend("omp", Config{Logger: slog.Default()})
if err != nil {
t.Fatalf("New(omp): %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "test prompt", ExecOptions{
Timeout: 5 * time.Second,
ResumeSessionID: sessionPath,
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (error=%q)", result.Status, result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// TestOmpExecuteRejectsEmptyPrompt verifies the error message uses the "omp"
// label, not "pi". This is the user-facing contract: an omp agent that fails
// must say "omp prompt must not be empty", not "pi prompt must not be empty".
func TestOmpExecuteRejectsEmptyPrompt(t *testing.T) {
t.Parallel()
backend, err := ResolveBackend("omp", Config{ExecutablePath: "/does/not/need/to/exist", Logger: slog.Default()})
if err != nil {
t.Fatalf("New(omp): %v", err)
}
if _, err := backend.Execute(t.Context(), " \n\t ", ExecOptions{}); err == nil {
t.Fatalf("expected empty-prompt error")
} else {
if !strings.Contains(err.Error(), "omp prompt must not be empty") {
t.Fatalf("error = %q, want it to contain %q", err.Error(), "omp prompt must not be empty")
}
if strings.Contains(err.Error(), "pi prompt") {
t.Fatalf("error should not contain hardcoded 'pi prompt': %q", err.Error())
}
}
}
// TestOmpExecuteLabelsErrorsAsOmp verifies that a binary-not-found error uses
// the "omp" label, not "pi".
func TestOmpExecuteLabelsErrorsAsOmp(t *testing.T) {
t.Parallel()
backend, err := ResolveBackend("omp", Config{ExecutablePath: "/nonexistent/omp-binary", Logger: slog.Default()})
if err != nil {
t.Fatalf("New(omp): %v", err)
}
_, err = backend.Execute(t.Context(), "prompt", ExecOptions{})
if err == nil {
t.Fatal("expected error for nonexistent binary")
}
if !strings.Contains(err.Error(), "omp executable not found") {
t.Fatalf("error = %q, want it to contain %q", err.Error(), "omp executable not found")
}
if strings.Contains(err.Error(), "pi executable") {
t.Fatalf("error should not say 'pi executable': %q", err.Error())
}
}
// TestOmpExecuteCompletesFromEventStream verifies that an omp event stream
// (same JSON protocol as pi) drives a task to completion through the pi
// backend. This is the end-to-end protocol compatibility contract.
func TestOmpExecuteCompletesFromEventStream(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
events := []string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"hello from omp"}}`,
`{"type":"turn_end","message":{"role":"assistant","model":"omp-test","usage":{"input":10,"output":5}}}`,
}
fakePath := filepath.Join(t.TempDir(), "omp")
writeTestExecutable(t, fakePath, []byte(piEventStreamScript(events)))
backend, err := ResolveBackend("omp", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("New(omp): %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (error=%q)", result.Status, result.Error)
}
if result.Output != "hello from omp" {
t.Fatalf("Output = %q, want %q", result.Output, "hello from omp")
}
if len(result.Usage) != 1 {
t.Fatalf("expected 1 usage entry, got %d", len(result.Usage))
}
u, ok := result.Usage["omp-test"]
if !ok {
t.Fatalf("expected usage for model %q, got %v", "omp-test", result.Usage)
}
if u.InputTokens != 10 || u.OutputTokens != 5 {
t.Fatalf("usage: input=%d output=%d, want input=10 output=5", u.InputTokens, u.OutputTokens)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// TestParseOmpModels parses a real `omp models --json` output. omp emits
// an object wrapper {"models":[...]} where each entry has separate `provider`,
// `id`, `selector` (provider/id), and `name` fields. The persistable Model.ID
// is the selector (provider/id), matching the convention parsePiModels uses
// so buildPiArgs can hand the whole selector to --model.
func TestParseOmpModels(t *testing.T) {
sample := `{"models":[` +
`{"provider":"anthropic","id":"claude-sonnet-5","selector":"anthropic/claude-sonnet-5","name":"Claude Sonnet 5","contextWindow":200000,"maxTokens":64000,"reasoning":true},` +
`{"provider":"openai","id":"gpt-5","selector":"openai/gpt-5","name":"GPT-5"},` +
`{"provider":"","id":"local-model","selector":"local-model","name":"Local Model"}` +
`]}`
models, err := parseOmpModels([]byte(sample))
if err != nil {
t.Fatalf("parseOmpModels: %v", err)
}
if len(models) != 3 {
t.Fatalf("expected 3 models, got %d", len(models))
}
// Model.ID is the selector (provider/id), NOT the bare id.
if models[0].ID != "anthropic/claude-sonnet-5" || models[0].Provider != "anthropic" || models[0].Label != "Claude Sonnet 5" {
t.Errorf("models[0] = %+v", models[0])
}
if models[1].ID != "openai/gpt-5" || models[1].Provider != "openai" || models[1].Label != "GPT-5" {
t.Errorf("models[1] = %+v", models[1])
}
// Bare model id with empty provider: selector is the bare id.
if models[2].ID != "local-model" || models[2].Provider != "" || models[2].Label != "Local Model" {
t.Errorf("models[2] = %+v", models[2])
}
}
// TestParseOmpModelsEmptyCatalog verifies an empty {"models":[]} wrapper
// degrades gracefully to an empty model list (not an error). Uses the real
// wrapper shape, not a bare top-level array.
func TestParseOmpModelsEmptyCatalog(t *testing.T) {
models, err := parseOmpModels([]byte(`{"models":[]}`))
if err != nil {
t.Fatalf("parseOmpModels({\"models\":[]}): %v", err)
}
if len(models) != 0 {
t.Fatalf("expected 0 models, got %d", len(models))
}
}
// TestParseOmpModelsInvalidJSON verifies a non-JSON output (e.g. usage text
// from a flag mismatch or an old omp that doesn't support --json) degrades
// to an empty list, not a panic.
func TestParseOmpModelsInvalidJSON(t *testing.T) {
models, err := parseOmpModels([]byte("Error: unknown flag --json\nRun `omp --help` for usage."))
if err != nil {
t.Fatalf("parseOmpModels(invalid): %v", err)
}
if len(models) != 0 {
t.Fatalf("expected 0 models for invalid JSON, got %d", len(models))
}
}
// TestParseOmpModelsDeduplicates verifies that duplicate model selectors are
// collapsed, matching the pi discovery behaviour.
func TestParseOmpModelsDeduplicates(t *testing.T) {
sample := `{"models":[` +
`{"provider":"anthropic","id":"claude-sonnet-5","selector":"anthropic/claude-sonnet-5","name":"Sonnet"},` +
`{"provider":"anthropic","id":"claude-sonnet-5","selector":"anthropic/claude-sonnet-5","name":"Sonnet Dup"}` +
`]}`
models, _ := parseOmpModels([]byte(sample))
if len(models) != 1 {
t.Fatalf("expected 1 model (deduplicated), got %d", len(models))
}
}
// TestOmpModelsJSONShape verifies that parseOmpModels correctly parses the
// real omp models --json output shape: {"models":[{...}]} with provider/id
// as separate fields, and that Model.ID is the selector.
func TestOmpModelsJSONShape(t *testing.T) {
raw := `{"models":[{"provider":"zai","id":"glm-4.6","selector":"zai/glm-4.6","name":"GLM 4.6","contextWindow":128000,"maxTokens":4096},{"provider":"xai","id":"grok-4-fast","selector":"xai/grok-4-fast","name":"Grok 4 Fast"}]}`
models, err := parseOmpModels([]byte(raw))
if err != nil {
t.Fatalf("parseOmpModels: %v", err)
}
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d", len(models))
}
// Model.ID is the selector, not the bare id.
if models[0].ID != "zai/glm-4.6" || models[0].Provider != "zai" || models[0].Label != "GLM 4.6" {
t.Errorf("models[0] = %+v", models[0])
}
if models[1].ID != "xai/grok-4-fast" || models[1].Provider != "xai" || models[1].Label != "Grok 4 Fast" {
t.Errorf("models[1] = %+v", models[1])
}
}
// TestOmpSelectorSurvivesToBuildPiArgs is the regression test the review
// asked for: a real `omp models --json` fixture → parseOmpModels →
// buildPiArgs should hand the selector to --model whole. This pins the
// contract that Model.ID is the selector (provider/id) and that the selector
// reaches the CLI intact — the pi-family resolver takes it from there.
func TestOmpSelectorSurvivesToBuildPiArgs(t *testing.T) {
raw := `{"models":[{"provider":"anthropic","id":"claude-sonnet-5","selector":"anthropic/claude-sonnet-5","name":"Claude Sonnet 5"}]}`
models, err := parseOmpModels([]byte(raw))
if err != nil {
t.Fatalf("parseOmpModels: %v", err)
}
if len(models) != 1 {
t.Fatalf("expected 1 model, got %d", len(models))
}
// The model ID is "anthropic/claude-sonnet-5" (the selector), and that is
// exactly what --model receives. Splitting it into --provider anthropic
// --model claude-sonnet-5 resolves identically for a real provider prefix
// but breaks slash-shaped model ids, so the split is gone (GH #7300).
args := buildPiArgs("/tmp/session.jsonl", ExecOptions{Model: models[0].ID}, slog.Default())
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--model anthropic/claude-sonnet-5") {
t.Errorf("args missing --model anthropic/claude-sonnet-5: %s", joined)
}
if strings.Contains(joined, "--provider") {
t.Errorf("args should not synthesize --provider: %s", joined)
}
}
// TestOmpAndPiCanCoexist verifies that both "pi" and "omp" can be constructed
// and used side by side — the core contract the issue (#3989) asks for.
func TestOmpAndPiCanCoexist(t *testing.T) {
t.Parallel()
piBe, err := New("pi", Config{ExecutablePath: "/fake/pi", Logger: slog.Default()})
if err != nil {
t.Fatalf("New(pi): %v", err)
}
ompBe, err := ResolveBackend("omp", Config{ExecutablePath: "/fake/omp", Logger: slog.Default()})
if err != nil {
t.Fatalf("New(omp): %v", err)
}
if _, ok := piBe.(*piBackend); !ok {
t.Fatalf("pi backend is %T, want *piBackend", piBe)
}
if _, ok := ompBe.(*piBackend); !ok {
t.Fatalf("omp backend is %T, want *piBackend", ompBe)
}
pb := piBe.(*piBackend)
ob := ompBe.(*piBackend)
if pb.defaultExecutable != "" {
t.Errorf("pi defaultExecutable = %q, want empty", pb.defaultExecutable)
}
if ob.defaultExecutable != "omp" {
t.Errorf("omp defaultExecutable = %q, want %q", ob.defaultExecutable, "omp")
}
if pb.providerLabel != "" {
t.Errorf("pi providerLabel = %q, want empty", pb.providerLabel)
}
if ob.providerLabel != "omp" {
t.Errorf("omp providerLabel = %q, want %q", ob.providerLabel, "omp")
}
}
// TestDiscoverOmpModelsNonZeroExit verifies that discoverOmpModels returns
// an empty catalog when the omp binary exits non-zero (e.g. an old omp that
// doesn't support `models --json` and prints usage to stderr). This is the
// fake-executable integration test the review asked for.
func TestDiscoverOmpModelsNonZeroExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "omp")
script := "#!/bin/sh\n" +
"echo 'Error: unknown command \"models\"' >&2\n" +
"echo 'Run `omp --help` for usage.' >&2\n" +
"exit 1\n"
writeTestExecutable(t, fakePath, []byte(script))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
models, err := discoverOmpModels(ctx, Command{Path: fakePath})
if err != nil {
t.Fatalf("discoverOmpModels: %v", err)
}
if len(models) != 0 {
t.Fatalf("expected 0 models for non-zero-exit omp, got %d", len(models))
}
}
// TestDiscoverOmpModelsMissingBinary verifies that a missing omp binary
// degrades to an empty catalog, not an error.
func TestDiscoverOmpModelsMissingBinary(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
models, err := discoverOmpModels(ctx, Command{Path: "/nonexistent/omp-binary"})
if err != nil {
t.Fatalf("discoverOmpModels: %v", err)
}
if len(models) != 0 {
t.Fatalf("expected 0 models for missing binary, got %d", len(models))
}
}
// TestOmpAndPiRegisterSideBySide is the daemon-level registration test the
// review asked for: with both binaries present, probing should discover two
// runtimes with the right commands. This exercises the probe path (not just
// backend construction), verifying that the descriptor-driven probe loop in
// agents_probe.go finds both "pi" and "omp" independently.
func TestOmpAndPiRegisterSideBySide(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakeDir := t.TempDir()
// Create fake pi and omp binaries.
for _, name := range []string{"pi", "omp"} {
fakePath := filepath.Join(fakeDir, name)
script := "#!/bin/sh\nexit 0\n"
writeTestExecutable(t, fakePath, []byte(script))
}
t.Setenv("PATH", fakeDir)
// Probe both CLIs the way the daemon would — using the same env vars
// the descriptor-driven probe loop uses.
for _, tc := range []struct {
envPath string
envModel string
cmd string
id string
}{
{"MULTICA_PI_PATH", "MULTICA_PI_MODEL", "pi", "pi"},
{"MULTICA_OMP_PATH", "MULTICA_OMP_MODEL", "omp", "omp"},
} {
t.Run(tc.id, func(t *testing.T) {
// Verify the descriptor's env prefix and command match what
// the probe loop would use.
desc, ok := BuiltinRuntimeByID(tc.id)
if tc.id == "omp" {
if !ok {
t.Fatalf("BuiltinRuntimeByID(%q) not found", tc.id)
}
if desc.EnvPrefix != tc.envPath[:len(tc.envPath)-5] {
t.Errorf("descriptor EnvPrefix = %q, want %q", desc.EnvPrefix, tc.envPath[:len(tc.envPath)-5])
}
if desc.DefaultCommand != tc.cmd {
t.Errorf("descriptor DefaultCommand = %q, want %q", desc.DefaultCommand, tc.cmd)
}
}
// The backend should construct successfully.
b, err := ResolveBackend(tc.id, Config{ExecutablePath: filepath.Join(fakeDir, tc.cmd), Logger: slog.Default()})
if err != nil {
t.Fatalf("New(%q): %v", tc.id, err)
}
pb, ok := b.(*piBackend)
if !ok {
t.Fatalf("New(%q) = %T, want *piBackend", tc.id, b)
}
if tc.id == "omp" {
if pb.defaultExecutable != "omp" {
t.Errorf("defaultExecutable = %q, want %q", pb.defaultExecutable, "omp")
}
if pb.providerLabel != "omp" {
t.Errorf("providerLabel = %q, want %q", pb.providerLabel, "omp")
}
}
})
}
}
// TestBuiltinRuntimeDescriptorFieldsAreConsumed guards against descriptor
// fields going unused (the round-3 review caught this). Every field in
// BuiltinRuntime must have at least one consumer outside the descriptor file
// itself, or it's dead code.
func TestBuiltinRuntimeDescriptorFieldsAreConsumed(t *testing.T) {
// The omp descriptor must have all fields populated.
desc, ok := BuiltinRuntimeByID("omp")
if !ok {
t.Fatal("omp descriptor not found")
}
// Every field must be non-empty (they're all consumed by daemon code).
checks := map[string]string{
"ID": desc.ID,
"ProtocolFamily": desc.ProtocolFamily,
"DefaultCommand": desc.DefaultCommand,
"EnvPrefix": desc.EnvPrefix,
"DisplayName": desc.DisplayName,
"SkillsDir": desc.SkillsDir,
"UserSkillsDir": desc.UserSkillsDir,
"LaunchHeader": desc.LaunchHeader,
"DefaultExecutable": desc.DefaultExecutable,
"ProviderLabel": desc.ProviderLabel,
}
for name, val := range checks {
if val == "" {
t.Errorf("descriptor field %s is empty", name)
}
}
// ModelDiscovery must be set (omp uses a different discovery command).
if desc.ModelDiscovery == nil {
t.Error("descriptor field ModelDiscovery is nil")
}
}