mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
809 lines
31 KiB
Go
809 lines
31 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"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")
|
|
// Real omp reads the piped prompt to EOF before emitting events, so the
|
|
// fake has to drain stdin too: one that exits without reading closes the
|
|
// read end while the backend is still writing, and the EPIPE surfaces as
|
|
// "omp prompt write failed: broken pipe" (MUL-7244). It must drain with a
|
|
// shell builtin, not `cat` — PATH is replaced with fakeDir below so the
|
|
// backend has to resolve "omp" by name, which leaves no external command
|
|
// on PATH for the fixture to call. A `cat` drain here fails silently with
|
|
// "cat: not found" and the script runs straight through to its exit,
|
|
// reopening the very race the drain was added to close.
|
|
script := "#!/bin/sh\n" +
|
|
"while IFS= read -r _; do :; done\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")
|
|
if err := os.WriteFile(sessionPath, []byte("{}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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()
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte("{}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := backend.Execute(ctx, "prompt-ignored", 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)
|
|
}
|
|
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
|
|
// a discovery error 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.Fatal("expected model discovery failure with a reason")
|
|
}
|
|
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
|
|
// reports a discovery 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.Fatal("expected model discovery failure with a reason")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// levelValues flattens a ModelThinking's advertised levels for comparison.
|
|
func levelValues(thinking *ModelThinking) []string {
|
|
if thinking == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(thinking.SupportedLevels))
|
|
for _, level := range thinking.SupportedLevels {
|
|
out = append(out, level.Value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestOmpThinkingFromCatalogEntry pins omp's own rules for what a catalog entry
|
|
// means, verified against can1357/oh-my-pi v18.2.0. Two of them are easy to get
|
|
// backwards, and both produce the silent mismatch MUL-7412 exists to remove:
|
|
// `reasoning: true` with no effort array means "no controllable dial" (not
|
|
// "assume the usual levels"), and `off` is honoured for every reasoning model
|
|
// even though it is never a member of the effort array.
|
|
func TestOmpThinkingFromCatalogEntry(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
reasoning bool
|
|
thinking string
|
|
wantLevels []string
|
|
wantNoPicker bool
|
|
}{
|
|
{
|
|
// The reporter's model in GH #8458. `off` is added on top: omp's
|
|
// resolveThinkingLevelForModel returns it before clamping runs, so it
|
|
// really is selectable even though the array never lists it.
|
|
name: "advertised efforts, plus off",
|
|
reasoning: true,
|
|
thinking: `["medium","high","max"]`,
|
|
wantLevels: []string{"off", "medium", "high", "max"},
|
|
},
|
|
{
|
|
// getSupportedEfforts defines this as "reasons, no controllable effort
|
|
// surface". Inferring low/medium/high here would offer levels omp then
|
|
// clamps away.
|
|
name: "reasoning with null thinking offers only off",
|
|
reasoning: true,
|
|
thinking: `null`,
|
|
wantLevels: []string{"off"},
|
|
},
|
|
{
|
|
name: "reasoning with absent thinking offers only off",
|
|
reasoning: true,
|
|
thinking: ``,
|
|
wantLevels: []string{"off"},
|
|
},
|
|
{
|
|
name: "reasoning with an empty array offers only off",
|
|
reasoning: true,
|
|
thinking: `[]`,
|
|
wantLevels: []string{"off"},
|
|
},
|
|
{
|
|
// No reasoning at all: an off-only control would be inert, so Multica
|
|
// hides the picker exactly as it does for pi.
|
|
name: "not a reasoning model has no picker",
|
|
reasoning: false,
|
|
thinking: ``,
|
|
wantNoPicker: true,
|
|
},
|
|
{
|
|
name: "not a reasoning model stays hidden even with efforts listed",
|
|
reasoning: false,
|
|
thinking: `["high"]`,
|
|
wantNoPicker: true,
|
|
},
|
|
{
|
|
// omp keeps `auto` as a session-level sentinel, never a per-model
|
|
// effort, so it must not reach the picker.
|
|
name: "auto is never offered",
|
|
reasoning: true,
|
|
thinking: `["medium","high","auto"]`,
|
|
wantLevels: []string{"off", "medium", "high"},
|
|
},
|
|
{
|
|
// Canonical order, not the order omp happened to list.
|
|
name: "levels come back in canonical order",
|
|
reasoning: true,
|
|
thinking: `["max","low","high"]`,
|
|
wantLevels: []string{"off", "low", "high", "max"},
|
|
},
|
|
{
|
|
// Only tokens Multica knows survive, so a future omp effort cannot
|
|
// reach the picker before the server's enum accepts it.
|
|
name: "unknown tokens are dropped",
|
|
reasoning: true,
|
|
thinking: `["medium","ultra","hyper"]`,
|
|
wantLevels: []string{"off", "medium"},
|
|
},
|
|
{
|
|
// `models --json` never emits an object. If one ever appears it is not
|
|
// evidence of support: fall back to off-only rather than mining it.
|
|
name: "an unreadable shape proves nothing",
|
|
reasoning: true,
|
|
thinking: `{"mode":"effort","efforts":["medium","high","max"]}`,
|
|
wantLevels: []string{"off"},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := ompThinkingFromCatalogEntry(tc.reasoning, []byte(tc.thinking))
|
|
if tc.wantNoPicker {
|
|
if got != nil {
|
|
t.Fatalf("ompThinkingFromCatalogEntry = %+v, want nil", got)
|
|
}
|
|
return
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("ompThinkingFromCatalogEntry = nil, want levels %v", tc.wantLevels)
|
|
}
|
|
if values := levelValues(got); !slices.Equal(values, tc.wantLevels) {
|
|
t.Errorf("levels = %v, want %v", values, tc.wantLevels)
|
|
}
|
|
// omp's `models --json` carries no default level, so we must never
|
|
// invent one for the picker to preselect.
|
|
if got.DefaultLevel != "" {
|
|
t.Errorf("DefaultLevel = %q, want empty", got.DefaultLevel)
|
|
}
|
|
for _, level := range got.SupportedLevels {
|
|
if level.Label == "" {
|
|
t.Errorf("level %q has an empty label", level.Value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseOmpModelsCarriesThinkingCatalog is the end-to-end catalog assertion:
|
|
// a real `omp models --json` payload must reach Model.Thinking, because that is
|
|
// what the picker renders and what the daemon's per-model guard validates
|
|
// against before injecting --thinking (MUL-7412).
|
|
func TestParseOmpModelsCarriesThinkingCatalog(t *testing.T) {
|
|
t.Parallel()
|
|
sample := `{"models":[` +
|
|
`{"provider":"devin","id":"swe-2","selector":"devin/swe-2","name":"SWE-2","reasoning":true,"thinking":["medium","high","max"]},` +
|
|
`{"provider":"anthropic","id":"claude-sonnet-5","selector":"anthropic/claude-sonnet-5","name":"Sonnet 5","reasoning":true,"thinking":null},` +
|
|
`{"provider":"openai","id":"gpt-5","selector":"openai/gpt-5","name":"GPT-5","reasoning":false,"thinking":null}` +
|
|
`]}`
|
|
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))
|
|
}
|
|
if got := levelValues(models[0].Thinking); !slices.Equal(got, []string{"off", "medium", "high", "max"}) {
|
|
t.Errorf("devin/swe-2 levels = %v, want [off medium high max]", got)
|
|
}
|
|
// Reasons, but omp exposes no effort dial for it: off only, nothing inferred.
|
|
if got := levelValues(models[1].Thinking); !slices.Equal(got, []string{"off"}) {
|
|
t.Errorf("anthropic/claude-sonnet-5 levels = %v, want [off]", got)
|
|
}
|
|
// No reasoning at all: no picker, rather than an inert one.
|
|
if models[2].Thinking != nil {
|
|
t.Errorf("openai/gpt-5 Thinking = %+v, want nil", models[2].Thinking)
|
|
}
|
|
}
|
|
|
|
// TestOmpAdvertisedLevelsArePersistable pins the catalog → API contract for omp:
|
|
// every level discovery can advertise must survive the server's Create/Update
|
|
// enum gate. Otherwise the picker offers a level the server 400s on save, which
|
|
// is the mirror image of the MUL-7412 defect.
|
|
func TestOmpAdvertisedLevelsArePersistable(t *testing.T) {
|
|
t.Parallel()
|
|
for _, value := range piThinkingLevelOrder {
|
|
if !IsKnownThinkingValue("omp", value) {
|
|
t.Errorf("omp advertises %q but the server enum rejects it", value)
|
|
}
|
|
}
|
|
if IsKnownThinkingValue("omp", "auto") {
|
|
t.Error("omp must not accept `auto`: it is deliberately not exposed (MUL-7412)")
|
|
}
|
|
}
|
|
|
|
// TestValidateThinkingLevelOmpEmptyModel pins the omp agent that pins no model:
|
|
// the level must fail closed. omp's catalog marks no default entry, and at task
|
|
// time omp resolves its own default role model and clamps the level to what
|
|
// THAT model supports — so passing a level because some other catalog entry
|
|
// advertises it would let a user save `max` and silently run lower, which is
|
|
// the mismatch MUL-7412 exists to remove.
|
|
func TestValidateThinkingLevelOmpEmptyModel(t *testing.T) {
|
|
t.Parallel()
|
|
load := func() (Catalog, error) {
|
|
return Catalog{Models: []Model{
|
|
{ID: "devin/swe-2", Label: "SWE-2", Provider: "devin",
|
|
Thinking: ompThinkingFromCatalogEntry(true, []byte(`["medium","high","max"]`))},
|
|
{ID: "anthropic/claude-sonnet-5", Label: "Sonnet 5", Provider: "anthropic",
|
|
Thinking: ompThinkingFromCatalogEntry(true, []byte(`null`))},
|
|
}}, nil
|
|
}
|
|
for _, tc := range []struct {
|
|
value string
|
|
want bool
|
|
}{
|
|
{value: "", want: true}, // "use the runtime default" is always fine
|
|
// Advertised by devin/swe-2, but that is not necessarily the model omp
|
|
// will run, so an unpinned agent must not carry it.
|
|
{value: "max", want: false},
|
|
{value: "off", want: false},
|
|
} {
|
|
got, err := ValidateThinkingLevelWith(load, "omp", "", tc.value)
|
|
if err != nil {
|
|
t.Fatalf("ValidateThinkingLevelWith(omp, \"\", %q): %v", tc.value, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("ValidateThinkingLevelWith(omp, \"\", %q) = %v, want %v", tc.value, got, tc.want)
|
|
}
|
|
}
|
|
// A pinned model still resolves against that model's own catalog.
|
|
for _, tc := range []struct {
|
|
model string
|
|
value string
|
|
want bool
|
|
}{
|
|
{model: "devin/swe-2", value: "max", want: true},
|
|
{model: "devin/swe-2", value: "off", want: true},
|
|
{model: "devin/swe-2", value: "low", want: false},
|
|
// Reasons with no effort dial: off is real, a concrete effort is not.
|
|
{model: "anthropic/claude-sonnet-5", value: "off", want: true},
|
|
{model: "anthropic/claude-sonnet-5", value: "high", want: false},
|
|
} {
|
|
got, err := ValidateThinkingLevelWith(load, "omp", tc.model, tc.value)
|
|
if err != nil {
|
|
t.Fatalf("ValidateThinkingLevelWith(omp, %q, %q): %v", tc.model, tc.value, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("ValidateThinkingLevelWith(omp, %q, %q) = %v, want %v", tc.model, tc.value, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestValidateThinkingLevelOmpEmptyModelIsDeterministic pins the ORDER of the
|
|
// empty-model rejection, not just its result. The check must happen before any
|
|
// catalog read: on a discovery error the daemon's guard passes the level through
|
|
// to the CLI, so an omp agent with no pinned model would still have shipped an
|
|
// effort omp resolves against a different model. Asserting the loader is never
|
|
// called is the only way to pin that ordering — a test that merely returns false
|
|
// would pass with the check on either side of the load.
|
|
func TestValidateThinkingLevelOmpEmptyModelIsDeterministic(t *testing.T) {
|
|
t.Parallel()
|
|
called := false
|
|
failing := func() (Catalog, error) {
|
|
called = true
|
|
return Catalog{}, errors.New("omp discovery failed")
|
|
}
|
|
got, err := ValidateThinkingLevelWith(failing, "omp", "", "max")
|
|
if err != nil {
|
|
t.Fatalf("ValidateThinkingLevelWith(omp, \"\", max) returned error %v, want a deterministic false", err)
|
|
}
|
|
if got {
|
|
t.Error("ValidateThinkingLevelWith(omp, \"\", max) = true, want false")
|
|
}
|
|
if called {
|
|
t.Error("catalog loader was called; the empty-model rejection must precede discovery")
|
|
}
|
|
// The empty "use the runtime default" sentinel still short-circuits ahead of
|
|
// everything, so it must not reach the loader either.
|
|
called = false
|
|
got, err = ValidateThinkingLevelWith(failing, "omp", "", "")
|
|
if err != nil || !got {
|
|
t.Errorf("ValidateThinkingLevelWith(omp, \"\", \"\") = (%v, %v), want (true, nil)", got, err)
|
|
}
|
|
if called {
|
|
t.Error("empty thinking level must not trigger discovery")
|
|
}
|
|
}
|
|
|
|
// TestThinkingRequiresExplicitModelPredicates pins which providers need a pinned
|
|
// model, and the narrower set whose invalid combination the API refuses to store.
|
|
// codex is deliberately in the first set only: agents already hold an effort with
|
|
// an empty model, so 400ing that pair would block unrelated edits to them.
|
|
func TestThinkingRequiresExplicitModelPredicates(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct {
|
|
provider string
|
|
needsModel bool
|
|
rejectedAtAPI bool
|
|
}{
|
|
{provider: "omp", needsModel: true, rejectedAtAPI: true},
|
|
{provider: "codex", needsModel: true, rejectedAtAPI: false},
|
|
{provider: "pi", needsModel: false, rejectedAtAPI: false},
|
|
{provider: "claude", needsModel: false, rejectedAtAPI: false},
|
|
{provider: "opencode", needsModel: false, rejectedAtAPI: false},
|
|
{provider: "", needsModel: false, rejectedAtAPI: false},
|
|
} {
|
|
if got := ThinkingRequiresExplicitModel(tc.provider); got != tc.needsModel {
|
|
t.Errorf("ThinkingRequiresExplicitModel(%q) = %v, want %v", tc.provider, got, tc.needsModel)
|
|
}
|
|
if got := ThinkingLevelRejectedWithoutModel(tc.provider); got != tc.rejectedAtAPI {
|
|
t.Errorf("ThinkingLevelRejectedWithoutModel(%q) = %v, want %v", tc.provider, got, tc.rejectedAtAPI)
|
|
}
|
|
// The API-level refusal is a subset: anything it rejects must also be a
|
|
// provider that genuinely needs a pinned model.
|
|
if ThinkingLevelRejectedWithoutModel(tc.provider) && !ThinkingRequiresExplicitModel(tc.provider) {
|
|
t.Errorf("provider %q is rejected at the API but does not require an explicit model", tc.provider)
|
|
}
|
|
}
|
|
}
|