| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "github.com/BurntSushi/toml" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func searchAssignmentConfig() *Config { |
| 12 | return &Config{Providers: []ProviderEntry{ |
| 13 | {Name: "first", Kind: "responses", BaseURL: "http://localhost:8000", Models: []string{"m", "org/fast"}, Default: "m", WebSearch: boolPointer(true)}, |
| 14 | {Name: "second", Kind: "anthropic", BaseURL: "http://localhost:8001", Model: "m", WebSearch: boolPointer(true)}, |
| 15 | }} |
| 16 | } |
| 17 | |
| 18 | func TestWebSearchModelAssignment(t *testing.T) { |
| 19 | c := searchAssignmentConfig() |
| 20 | current := c.Providers[0] |
| 21 | current.WebSearch = boolPointer(false) |
| 22 | for _, automatic := range []string{"", "auto", "AUTO"} { |
| 23 | c.Agent.WebSearchModel = automatic |
| 24 | if got := c.ResolveWebSearch(¤t); got.Status != "disabled" { |
| 25 | t.Fatalf("automatic disable: %+v", got) |
| 26 | } |
| 27 | } |
| 28 | if err := c.SetWebSearchModel(" first/org/fast "); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | got := c.ResolveWebSearch(¤t) |
| 32 | if got.Entry == nil || got.Entry.Name != "first" || got.Entry.Model != "org/fast" { |
| 33 | t.Fatalf("explicit assignment: %+v", got) |
| 34 | } |
| 35 | c.Agent.WebSearchModel = "second/m" |
| 36 | got = c.ResolveWebSearch(¤t) |
| 37 | if got.Entry == nil || got.Entry.Name != "second" { |
| 38 | t.Fatal("main disable incorrectly overrides explicit account") |
| 39 | } |
| 40 | got.Entry.BaseURL = "changed" |
| 41 | if c.Providers[1].BaseURL == "changed" { |
| 42 | t.Fatal("route not detached") |
| 43 | } |
| 44 | c.Providers[1].WebSearch = boolPointer(false) |
| 45 | if got = c.ResolveWebSearch(nil); got.Status != "invalid" || got.Entry != nil { |
| 46 | t.Fatal("disabled assignment fell back") |
| 47 | } |
| 48 | c.Providers = c.Providers[:1] |
| 49 | if got = c.ResolveWebSearch(nil); got.Status != "invalid" { |
| 50 | t.Fatal("removed assignment fell back") |
| 51 | } |
| 52 | if c.Agent.WebSearchModel != "second/m" { |
| 53 | t.Fatal("lost invalid reference") |
| 54 | } |
| 55 | for _, ref := range []string{"first/missing", "missing/m", "first"} { |
| 56 | if err := c.SetWebSearchModel(ref); err == nil { |
| 57 | t.Fatalf("accepted %q", ref) |
| 58 | } |
| 59 | } |
| 60 | c.Agent.WebSearchModel = "first/m" |
| 61 | c.Desktop.ProviderAccess = []string{} |
| 62 | if c.ResolveWebSearch(nil).Status != "invalid" { |
| 63 | t.Fatal("access restriction bypassed") |
| 64 | } |
| 65 | c.Desktop.ProviderAccess = nil |
| 66 | c.Environment.Offline = true |
| 67 | if c.ResolveWebSearch(nil).Status != "disabled" { |
| 68 | t.Fatal("offline bypassed") |
| 69 | } |
| 70 | c.Environment.Offline = false |
| 71 | c.Tools.Enabled = []string{"read_file"} |
| 72 | if c.ResolveWebSearch(nil).Status != "disabled" { |
| 73 | t.Fatal("allowlist bypassed") |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestWebSearchModelCredentialsAndProtocol(t *testing.T) { |
| 78 | c := searchAssignmentConfig() |
| 79 | c.Agent.WebSearchModel = "first/m" |
| 80 | c.Providers[0].BaseURL = "https://search.example" |
| 81 | c.Providers[0].APIKeyEnv = "REASONIX_SEARCH_TEST_MISSING_KEY" |
| 82 | t.Setenv("REASONIX_SEARCH_TEST_MISSING_KEY", "") |
| 83 | if c.ResolveWebSearch(nil).Status != "invalid" { |
| 84 | t.Fatal("missing credentials accepted") |
| 85 | } |
| 86 | c.Providers[0].resolvedAPIKey = "test" |
| 87 | if c.ResolveWebSearch(nil).Status != "ready" { |
| 88 | t.Fatal("configured provider rejected") |
| 89 | } |
| 90 | c.Providers[0].Kind = "openai" |
| 91 | if c.ResolveWebSearch(nil).Status != "invalid" { |
| 92 | t.Fatal("unsupported protocol accepted") |
| 93 | } |
| 94 | c.Providers[0].BaseURL = "https://api.deepseek.com" |
| 95 | got := c.ResolveWebSearch(nil) |
| 96 | if got.Entry == nil || got.Entry.Kind != "anthropic" || got.Entry.APIKey() != "test" { |
| 97 | t.Fatal("official conversion lost credentials") |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestWebSearchModelRoundTripAndPreservation(t *testing.T) { |
| 102 | c := Default() |
| 103 | c.Agent.WebSearchModel = "search/org/fast" |
| 104 | var decoded Config |
| 105 | if _, err := toml.Decode(RenderTOML(c), &decoded); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if decoded.Agent.WebSearchModel != c.Agent.WebSearchModel { |
| 109 | t.Fatal("full render lost assignment") |
| 110 | } |
| 111 | if !strings.Contains(RenderTOMLProjectDelta(c), `web_search_model = "search/org/fast"`) { |
| 112 | t.Fatal("project delta lost assignment") |
| 113 | } |
| 114 | path := filepath.Join(t.TempDir(), "config.toml") |
| 115 | original := "# user comment\nfuture_option = true\n[agent]\n# preserved\nweb_search_model = \"auto\"\nfuture_agent_option = 42\n" |
| 116 | if err := os.WriteFile(path, []byte(original), 0600); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | unlock := LockUserConfigEdits() |
| 120 | defer unlock() |
| 121 | if err := c.SaveWebSearchModelTo(path); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | raw, err := os.ReadFile(path) |
| 125 | if err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | for _, want := range []string{"# user comment", "# preserved", "future_option = true", "future_agent_option = 42", `web_search_model = "search/org/fast"`} { |
| 129 | if !strings.Contains(string(raw), want) { |
| 130 | t.Fatalf("lost %s: %s", want, raw) |
| 131 | } |
| 132 | } |
| 133 | // Previous schema ignores the added optional field, rather than rejecting TOML. |
| 134 | var previous struct { |
| 135 | Agent struct { |
| 136 | VisionModel string `toml:"vision_model"` |
| 137 | } |
| 138 | } |
| 139 | meta, err := toml.Decode(string(raw), &previous) |
| 140 | if err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | found := false |
| 144 | for _, key := range meta.Undecoded() { |
| 145 | if key.String() == "agent.web_search_model" { |
| 146 | found = true |
| 147 | } |
| 148 | } |
| 149 | if !found { |
| 150 | t.Fatal("expected previous reader to ignore assignment") |
| 151 | } |
| 152 | } |
| 153 |