1460 lines
47 KiB
Go
1460 lines
47 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
|
||
"reasonix/internal/config"
|
||
"reasonix/internal/control"
|
||
fileencoding "reasonix/internal/fileutil/encoding"
|
||
"reasonix/internal/hook"
|
||
"reasonix/internal/provider"
|
||
"reasonix/internal/sandbox"
|
||
)
|
||
|
||
type captureTurnRunner struct {
|
||
inputs []string
|
||
}
|
||
|
||
func (r *captureTurnRunner) Run(_ context.Context, input string) error {
|
||
r.inputs = append(r.inputs, input)
|
||
return nil
|
||
}
|
||
|
||
func TestWithFreshSystemPromptReplacesExistingSystemMessage(t *testing.T) {
|
||
msgs := []provider.Message{
|
||
{Role: provider.RoleSystem, Content: "old", ReasoningContent: "stale", ReasoningSignature: "sig", ToolCalls: []provider.ToolCall{{ID: "call", Name: "noop"}}, ToolCallID: "tool", Name: "name"},
|
||
{Role: provider.RoleUser, Content: "hello"},
|
||
}
|
||
|
||
got := withFreshSystemPrompt(msgs, "new")
|
||
if got[0].Content != "new" {
|
||
t.Fatalf("system prompt = %q, want new", got[0].Content)
|
||
}
|
||
if got[0].ReasoningContent != "" || got[0].ReasoningSignature != "" || len(got[0].ToolCalls) != 0 || got[0].ToolCallID != "" || got[0].Name != "" {
|
||
t.Fatalf("system metadata should be cleared, got %+v", got[0])
|
||
}
|
||
if got[1].Content != "hello" {
|
||
t.Fatalf("non-system message changed: %+v", got[1])
|
||
}
|
||
if msgs[0].Content != "old" {
|
||
t.Fatalf("input slice was mutated: %+v", msgs[0])
|
||
}
|
||
}
|
||
|
||
func TestWithFreshSystemPromptPrependsMissingSystemMessage(t *testing.T) {
|
||
msgs := []provider.Message{{Role: provider.RoleUser, Content: "hello"}}
|
||
|
||
got := withFreshSystemPrompt(msgs, "new")
|
||
if len(got) != 2 || got[0].Role != provider.RoleSystem || got[0].Content != "new" {
|
||
t.Fatalf("expected prepended system prompt, got %+v", got)
|
||
}
|
||
if got[1].Content != "hello" {
|
||
t.Fatalf("existing user message changed: %+v", got[1])
|
||
}
|
||
}
|
||
|
||
func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) {
|
||
p := config.ProviderEntry{
|
||
Name: "mimo-api",
|
||
Models: []string{
|
||
"mimo-v2", "mimo-v2-pro",
|
||
"mimo-v2-asr", "mimo-v2-tts",
|
||
"mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
|
||
},
|
||
VisionModels: []string{"mimo-v2", "mimo-v2-asr", "mimo-v2-omni"},
|
||
}
|
||
view := providerViewFromEntry(p, true, false)
|
||
want := []string{"mimo-v2", "mimo-v2-pro"}
|
||
if !reflect.DeepEqual(view.Models, want) {
|
||
t.Errorf("ProviderView.Models = %v, want %v", view.Models, want)
|
||
}
|
||
if got, want := view.VisionModels, []string{"mimo-v2"}; !reflect.DeepEqual(got, want) {
|
||
t.Errorf("ProviderView.VisionModels = %v, want %v", got, want)
|
||
}
|
||
if !view.VisionModelsSet {
|
||
t.Fatal("ProviderView.VisionModelsSet = false, want true for configured vision_models")
|
||
}
|
||
}
|
||
|
||
func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
|
||
p := config.ProviderEntry{
|
||
Name: "custom",
|
||
Models: []string{"text-only", "qwen-vl-plus"},
|
||
Vision: true,
|
||
}
|
||
view := providerViewFromEntry(p, false, true)
|
||
if got, want := view.VisionModels, []string{"text-only", "qwen-vl-plus"}; !reflect.DeepEqual(got, want) {
|
||
t.Errorf("ProviderView.VisionModels = %v, want %v", got, want)
|
||
}
|
||
if !view.VisionModelsSet {
|
||
t.Fatal("ProviderView.VisionModelsSet = false, want true for provider-wide vision")
|
||
}
|
||
}
|
||
|
||
func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
|
||
view := providerViewFromEntry(config.ProviderEntry{
|
||
Name: "anthropic",
|
||
Thinking: "ADAPTIVE",
|
||
}, false, true)
|
||
if view.Thinking != "adaptive" {
|
||
t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
|
||
}
|
||
}
|
||
|
||
func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
|
||
os.Unsetenv("TEST_PROVIDER_KEY_SOURCE")
|
||
if _, err := config.SetCredential("TEST_PROVIDER_KEY_SOURCE", "sk-test"); err != nil {
|
||
t.Fatalf("SetCredential: %v", err)
|
||
}
|
||
|
||
view := providerViewFromEntry(config.ProviderEntry{
|
||
Name: "custom",
|
||
APIKeyEnv: "TEST_PROVIDER_KEY_SOURCE",
|
||
}, false, true)
|
||
if !view.KeySet {
|
||
t.Fatal("KeySet = false, want true")
|
||
}
|
||
if !view.Configured {
|
||
t.Fatal("Configured = false, want true from resolved credentials")
|
||
}
|
||
if view.KeySource == "" || !strings.Contains(view.KeySource, "credentials") {
|
||
t.Fatalf("KeySource = %q, want credentials source", view.KeySource)
|
||
}
|
||
}
|
||
|
||
func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
|
||
home := isolateDesktopUserDirs(t)
|
||
project := robustTempDir(t)
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
cfg.Sandbox.AllowWrite = []string{
|
||
"${HOME}/.m2",
|
||
"${HOME}/.m2/repository",
|
||
}
|
||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||
t.Fatalf("save config: %v", err)
|
||
}
|
||
|
||
app := NewApp()
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
|
||
}
|
||
app.activeTabID = "project"
|
||
|
||
got := app.Settings().Sandbox
|
||
if got.EffectiveWorkspaceRoot != project {
|
||
t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
|
||
}
|
||
// Settings expose expanded configured roots; the writer confiner normalizes
|
||
// separators later when enforcing them.
|
||
want := []string{
|
||
project,
|
||
home + "/.m2",
|
||
home + "/.m2/repository",
|
||
}
|
||
if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
|
||
t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
|
||
}
|
||
if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
|
||
t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
|
||
}
|
||
if got.EffectiveShell == "" {
|
||
t.Fatal("EffectiveShell is empty")
|
||
}
|
||
}
|
||
|
||
func TestSandboxEffectiveShellViewLabels(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
shell sandbox.Shell
|
||
want string
|
||
}{
|
||
{"bash", sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}, "bash"},
|
||
{"git bash", sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`}, "git-bash"},
|
||
{"windows powershell", sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}, "powershell"},
|
||
{"pwsh", sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}, "pwsh"},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := sandboxEffectiveShellView(tc.shell); got != tc.want {
|
||
t.Errorf("%s: sandboxEffectiveShellView() = %q, want %q", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
t.Setenv("LOCAL_API_KEY", "")
|
||
os.Unsetenv("LOCAL_API_KEY")
|
||
|
||
noAuth := providerViewFromEntry(config.ProviderEntry{
|
||
Name: "local",
|
||
Kind: "openai",
|
||
BaseURL: "http://127.0.0.1:23333/v1",
|
||
Models: []string{"model-a"},
|
||
}, false, true)
|
||
if noAuth.RequiresKey {
|
||
t.Fatal("no-auth provider RequiresKey = true, want false")
|
||
}
|
||
if !noAuth.Configured {
|
||
t.Fatal("no-auth provider Configured = false, want true")
|
||
}
|
||
if noAuth.KeySet {
|
||
t.Fatal("no-auth provider KeySet = true, want false")
|
||
}
|
||
|
||
legacyLoopback := providerViewFromEntry(config.ProviderEntry{
|
||
Name: "local",
|
||
Kind: "openai",
|
||
BaseURL: "http://127.0.0.1:23333/v1",
|
||
Models: []string{"model-a"},
|
||
APIKeyEnv: "LOCAL_API_KEY",
|
||
}, false, true)
|
||
if legacyLoopback.RequiresKey {
|
||
t.Fatal("loopback provider with missing legacy key env RequiresKey = true, want false")
|
||
}
|
||
if !legacyLoopback.Configured {
|
||
t.Fatal("loopback provider with missing legacy key env Configured = false, want true")
|
||
}
|
||
|
||
official := providerViewFromEntry(config.ProviderEntry{
|
||
Name: "deepseek",
|
||
Kind: "openai",
|
||
BaseURL: "https://api.deepseek.com",
|
||
Models: []string{"deepseek-v4-flash"},
|
||
}, true, true)
|
||
if !official.RequiresKey {
|
||
t.Fatal("official provider RequiresKey = false, want true")
|
||
}
|
||
if official.Configured {
|
||
t.Fatal("official provider without key Configured = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestSetProviderKeyDoesNotWarnWhenProjectEnvAlsoDefinesSavedKey(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
project := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_SHADOW=old-key\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Setenv("TEST_PROVIDER_SHADOW", "")
|
||
os.Unsetenv("TEST_PROVIDER_SHADOW")
|
||
|
||
app := &App{
|
||
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
|
||
activeTabID: "project",
|
||
}
|
||
warning, err := app.SetProviderKey("TEST_PROVIDER_SHADOW", "new-key")
|
||
if err != nil {
|
||
t.Fatalf("SetProviderKey: %v", err)
|
||
}
|
||
if warning != "" {
|
||
t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
|
||
}
|
||
data, readErr := os.ReadFile(config.UserCredentialsPath())
|
||
if readErr != nil {
|
||
t.Fatalf("read credentials: %v", readErr)
|
||
}
|
||
if !strings.Contains(string(data), "TEST_PROVIDER_SHADOW=new-key") {
|
||
t.Fatalf("saved credentials missing new key:\n%s", data)
|
||
}
|
||
}
|
||
|
||
func TestSetProviderKeyDoesNotWarnWhenEnvironmentAlsoDefinesSavedKey(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
t.Setenv("TEST_PROVIDER_EMPTY_ENV", "")
|
||
|
||
app := &App{}
|
||
warning, err := app.SetProviderKey("TEST_PROVIDER_EMPTY_ENV", "new-key")
|
||
if err != nil {
|
||
t.Fatalf("SetProviderKey: %v", err)
|
||
}
|
||
if warning != "" {
|
||
t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
|
||
}
|
||
data, readErr := os.ReadFile(config.UserCredentialsPath())
|
||
if readErr != nil {
|
||
t.Fatalf("read credentials: %v", readErr)
|
||
}
|
||
if !strings.Contains(string(data), "TEST_PROVIDER_EMPTY_ENV=new-key") {
|
||
t.Fatalf("saved credentials missing new key:\n%s", data)
|
||
}
|
||
}
|
||
|
||
func TestSetProviderKeyDoesNotWarnWhenEmptyProjectEnvAlsoDefinesSavedKey(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
project := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_EMPTY_PROJECT=\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Setenv("TEST_PROVIDER_EMPTY_PROJECT", "")
|
||
os.Unsetenv("TEST_PROVIDER_EMPTY_PROJECT")
|
||
|
||
app := &App{
|
||
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
|
||
activeTabID: "project",
|
||
}
|
||
warning, err := app.SetProviderKey("TEST_PROVIDER_EMPTY_PROJECT", "new-key")
|
||
if err != nil {
|
||
t.Fatalf("SetProviderKey: %v", err)
|
||
}
|
||
if warning != "" {
|
||
t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
|
||
}
|
||
}
|
||
|
||
func TestFetchProviderModelsFiltersNonChatModels(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
if _, err := config.SetCredential("TEST_PROVIDER_KEY", "test-key"); err != nil {
|
||
t.Fatalf("SetCredential: %v", err)
|
||
}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/models" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||
return
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"object": "list",
|
||
"data": []map[string]string{
|
||
{"id": "mimo-v2.5-pro", "object": "model"},
|
||
{"id": "mimo-v2.5-asr", "object": "model"},
|
||
{"id": "mimo-v2.5-tts", "object": "model"},
|
||
},
|
||
})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
got, err := NewApp().FetchProviderModels(ProviderView{
|
||
Name: "mimo-api",
|
||
BaseURL: srv.URL,
|
||
APIKeyEnv: "TEST_PROVIDER_KEY",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("FetchProviderModels: %v", err)
|
||
}
|
||
want := []string{"mimo-v2.5-pro"}
|
||
if !reflect.DeepEqual(got, want) {
|
||
t.Errorf("FetchProviderModels = %v, want %v", got, want)
|
||
}
|
||
}
|
||
|
||
func TestFetchProviderModelsUsesSavedCredentialBeforeEnvironment(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
const keyEnv = "TEST_PROVIDER_FETCH_KEY"
|
||
if _, err := config.SetCredential(keyEnv, "saved-key"); err != nil {
|
||
t.Fatalf("SetCredential: %v", err)
|
||
}
|
||
t.Setenv(keyEnv, "stale-env-key")
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/models" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if r.Header.Get("Authorization") != "Bearer saved-key" {
|
||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||
return
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"object": "list",
|
||
"data": []map[string]string{
|
||
{"id": "model-a", "object": "model"},
|
||
},
|
||
})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
got, err := NewApp().FetchProviderModels(ProviderView{
|
||
Name: "custom",
|
||
BaseURL: srv.URL,
|
||
APIKeyEnv: keyEnv,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("FetchProviderModels: %v", err)
|
||
}
|
||
if want := []string{"model-a"}; !reflect.DeepEqual(got, want) {
|
||
t.Errorf("FetchProviderModels = %v, want %v", got, want)
|
||
}
|
||
if got := os.Getenv(keyEnv); got != "stale-env-key" {
|
||
t.Fatalf("process env = %q, want stale env left untouched", got)
|
||
}
|
||
}
|
||
|
||
func TestSaveProviderFiltersNonChatModels(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if err := app.SaveProvider(ProviderView{
|
||
Name: "mimo-api",
|
||
Kind: "openai",
|
||
BaseURL: "https://api.xiaomimimo.com/v1",
|
||
Models: []string{"mimo-v2.5-asr", "mimo-v2.5-pro", "mimo-v2.5-tts"},
|
||
VisionModels: []string{
|
||
"mimo-v2.5-asr",
|
||
"mimo-v2.5-pro",
|
||
"mimo-v2.5-tts",
|
||
},
|
||
VisionModelsSet: true,
|
||
Default: "mimo-v2.5-asr",
|
||
APIKeyEnv: "MIMO_API_KEY",
|
||
}); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := cfg.Provider("mimo-api")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
want := []string{"mimo-v2.5-pro"}
|
||
if !reflect.DeepEqual(got.ModelList(), want) {
|
||
t.Errorf("saved provider models = %v, want %v", got.ModelList(), want)
|
||
}
|
||
if got.DefaultModel() != "mimo-v2.5-pro" {
|
||
t.Errorf("saved provider default = %q, want mimo-v2.5-pro", got.DefaultModel())
|
||
}
|
||
if got, want := got.VisionModels, []string{"mimo-v2.5-pro"}; !reflect.DeepEqual(got, want) {
|
||
t.Errorf("saved provider vision_models = %v, want %v", got, want)
|
||
}
|
||
raw, err := os.ReadFile(config.UserConfigPath())
|
||
if err != nil {
|
||
t.Fatalf("read saved config: %v", err)
|
||
}
|
||
saved := string(raw)
|
||
blockStart := strings.Index(saved, "\n[[providers]]\nname = \"mimo-api\"")
|
||
if blockStart < 0 {
|
||
t.Fatalf("saved config missing mimo-api provider block:\n%s", raw)
|
||
}
|
||
block := saved[blockStart:]
|
||
if next := strings.Index(block[len("\n[[providers]]"):], "\n[[providers]]"); next >= 0 {
|
||
block = block[:len("\n[[providers]]")+next]
|
||
}
|
||
if !strings.Contains(block, `models = ["mimo-v2.5-pro"]`) {
|
||
t.Fatalf("saved provider block did not persist single selection as models array:\n%s", block)
|
||
}
|
||
if strings.Contains(block, `model = "mimo-v2.5-pro"`) {
|
||
t.Fatalf("saved provider block should not persist explicit single selection as legacy model:\n%s", block)
|
||
}
|
||
if !strings.Contains(block, `vision_models = ["mimo-v2.5-pro"]`) {
|
||
t.Fatalf("saved provider block did not persist filtered vision_models:\n%s", block)
|
||
}
|
||
}
|
||
|
||
func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if err := app.SaveProvider(ProviderView{
|
||
Name: "glm-proxy",
|
||
Kind: "openai",
|
||
BaseURL: "https://proxy.example.com/v1",
|
||
Models: []string{"glm-4.5-air"},
|
||
APIKeyEnv: "GLM_PROXY_API_KEY",
|
||
Thinking: "DISABLED",
|
||
}); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := cfg.Provider("glm-proxy")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
if got.Thinking != "disabled" {
|
||
t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
|
||
}
|
||
}
|
||
|
||
func TestSaveProviderPersistsAuthHeader(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if err := app.SaveProvider(ProviderView{
|
||
Name: "minimax-global-anthropic",
|
||
Kind: "anthropic",
|
||
BaseURL: "https://api.minimax.io/anthropic",
|
||
Models: []string{"MiniMax-M3"},
|
||
APIKeyEnv: "MINIMAX_API_KEY",
|
||
AuthHeader: true,
|
||
}); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := cfg.Provider("minimax-global-anthropic")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
if !got.AuthHeader {
|
||
t.Fatal("saved provider auth_header = false, want true")
|
||
}
|
||
view := providerViewFromEntry(*got, false, true)
|
||
if !view.AuthHeader {
|
||
t.Fatal("provider view authHeader = false, want true")
|
||
}
|
||
}
|
||
|
||
func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if err := app.SaveProvider(ProviderView{
|
||
Name: "sub2api",
|
||
Kind: "openai",
|
||
BaseURL: "https://proxy.example.com/v1",
|
||
ChatURL: " https://proxy.example.com/custom/chat/completions ",
|
||
ModelsURL: " https://proxy.example.com/v1/models ",
|
||
Models: []string{"model-a"},
|
||
Default: "model-a",
|
||
APIKeyEnv: "SUB2API_KEY",
|
||
}); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := cfg.Provider("sub2api")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
if got.ChatURL != "https://proxy.example.com/custom/chat/completions" {
|
||
t.Fatalf("saved chat_url = %q", got.ChatURL)
|
||
}
|
||
if got.ModelsURL != "https://proxy.example.com/v1/models" {
|
||
t.Fatalf("saved models_url = %q", got.ModelsURL)
|
||
}
|
||
|
||
view := app.Settings()
|
||
for _, provider := range view.Providers {
|
||
if provider.Name != "sub2api" {
|
||
continue
|
||
}
|
||
if provider.ChatURL != "https://proxy.example.com/custom/chat/completions" {
|
||
t.Fatalf("Settings chatUrl = %q", provider.ChatURL)
|
||
}
|
||
if provider.ModelsURL != "https://proxy.example.com/v1/models" {
|
||
t.Fatalf("Settings modelsUrl = %q", provider.ModelsURL)
|
||
}
|
||
return
|
||
}
|
||
t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
|
||
}
|
||
|
||
func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
cfg.Providers = []config.ProviderEntry{{
|
||
Name: "custom",
|
||
Kind: "openai",
|
||
BaseURL: "https://proxy.example.com/v1",
|
||
Models: []string{"model-a", "model-b"},
|
||
Default: "model-a",
|
||
APIKeyEnv: "CUSTOM_API_KEY",
|
||
Price: &provider.Pricing{Input: 1, Output: 2, Currency: "$"},
|
||
Prices: map[string]*provider.Pricing{"model-b": {Input: 3, Output: 4, Currency: "$"}},
|
||
Thinking: "adaptive",
|
||
Effort: "high",
|
||
VisionDetail: "low",
|
||
ExtraBody: map[string]any{"enable_thinking": true},
|
||
NoProxy: true,
|
||
}}
|
||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||
t.Fatalf("SaveTo: %v", err)
|
||
}
|
||
|
||
app := NewApp()
|
||
settings := app.Settings()
|
||
var view ProviderView
|
||
found := false
|
||
for _, p := range settings.Providers {
|
||
if p.Name == "custom" {
|
||
view = p
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
|
||
}
|
||
if view.ExtraBody["enable_thinking"] != true {
|
||
t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
|
||
}
|
||
|
||
if err := app.SaveProvider(view); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
gotCfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := gotCfg.Provider("custom")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
if got.Price == nil || got.Price.Input != 1 || got.Price.Output != 2 || got.Price.Currency != "$" {
|
||
t.Fatalf("provider-wide price = %+v, want preserved", got.Price)
|
||
}
|
||
if got.Prices["model-b"] == nil || got.Prices["model-b"].Input != 3 || got.Prices["model-b"].Output != 4 || got.Prices["model-b"].Currency != "$" {
|
||
t.Fatalf("per-model prices = %+v, want model-b price preserved", got.Prices)
|
||
}
|
||
if got.Thinking != "adaptive" || got.Effort != "high" {
|
||
t.Fatalf("thinking/effort = %q/%q, want adaptive/high", got.Thinking, got.Effort)
|
||
}
|
||
if got.VisionDetail != "low" {
|
||
t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
|
||
}
|
||
if got.ExtraBody["enable_thinking"] != true {
|
||
t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
|
||
}
|
||
if !got.NoProxy {
|
||
t.Fatal("no_proxy = false, want preserved true")
|
||
}
|
||
}
|
||
|
||
func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
cfg.Providers = []config.ProviderEntry{{
|
||
Name: "custom",
|
||
Kind: "openai",
|
||
BaseURL: "https://proxy.example.com/v1",
|
||
Models: []string{"text-only", "qwen-vl-plus"},
|
||
Default: "text-only",
|
||
Vision: true,
|
||
}}
|
||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||
t.Fatalf("SaveTo: %v", err)
|
||
}
|
||
|
||
if err := NewApp().SaveProvider(ProviderView{
|
||
Name: "custom",
|
||
Kind: "openai",
|
||
BaseURL: "https://proxy.example.com/v1",
|
||
Models: []string{"text-only", "qwen-vl-plus"},
|
||
VisionModels: []string{"qwen-vl-plus"},
|
||
VisionModelsSet: true,
|
||
Default: "text-only",
|
||
}); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
gotCfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := gotCfg.Provider("custom")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
if got.Vision {
|
||
t.Fatal("saved provider kept provider-wide vision=true")
|
||
}
|
||
if got, want := got.VisionModels, []string{"qwen-vl-plus"}; !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("saved provider vision_models = %v, want %v", got, want)
|
||
}
|
||
textOnly := *got
|
||
textOnly.Model = "text-only"
|
||
if config.EffectiveVision(&textOnly) {
|
||
t.Fatal("unchecked text-only model should not inherit image input")
|
||
}
|
||
vision := *got
|
||
vision.Model = "qwen-vl-plus"
|
||
if !config.EffectiveVision(&vision) {
|
||
t.Fatal("checked vision model should keep image input")
|
||
}
|
||
}
|
||
|
||
func TestSaveProviderPreservesExplicitEmptyVisionModels(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
if err := NewApp().SaveProvider(ProviderView{
|
||
Name: "custom",
|
||
Kind: "openai",
|
||
BaseURL: "https://proxy.example.com/v1",
|
||
Models: []string{"text-only", "qwen-vl-plus"},
|
||
VisionModels: []string{},
|
||
VisionModelsSet: true,
|
||
Default: "text-only",
|
||
}); err != nil {
|
||
t.Fatalf("SaveProvider: %v", err)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
got, ok := cfg.Provider("custom")
|
||
if !ok {
|
||
t.Fatal("saved provider not found")
|
||
}
|
||
if got.VisionModels == nil || len(got.VisionModels) != 0 {
|
||
t.Fatalf("saved provider vision_models = %#v, want explicit empty list", got.VisionModels)
|
||
}
|
||
raw, err := os.ReadFile(config.UserConfigPath())
|
||
if err != nil {
|
||
t.Fatalf("read saved config: %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), `vision_models = []`) {
|
||
t.Fatalf("saved config did not persist explicit empty vision_models:\n%s", raw)
|
||
}
|
||
}
|
||
|
||
func TestOfficialMimoAPITemplateRemoved(t *testing.T) {
|
||
if entries, keyEnv, err := officialProviderTemplate("mimo-api", "en"); err == nil {
|
||
t.Fatalf("officialProviderTemplate(mimo-api) = entries=%v key=%q nil error, want unknown template", entries, keyEnv)
|
||
}
|
||
}
|
||
|
||
func TestOfficialDeepSeekTemplateDefaultsToRMBPricing(t *testing.T) {
|
||
entries, keyEnv, err := officialProviderTemplate("deepseek", "en")
|
||
if err != nil {
|
||
t.Fatalf("officialProviderTemplate: %v", err)
|
||
}
|
||
if keyEnv != "DEEPSEEK_API_KEY" || len(entries) != 1 {
|
||
t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
|
||
}
|
||
got := entries[0]
|
||
if got.Prices["deepseek-v4-flash"] == nil || got.Prices["deepseek-v4-flash"].Currency != "¥" || got.Prices["deepseek-v4-flash"].Output != 2 {
|
||
t.Fatalf("deepseek-v4-flash price = %+v, want RMB pricing", got.Prices["deepseek-v4-flash"])
|
||
}
|
||
if got.Prices["deepseek-v4-pro"] == nil || got.Prices["deepseek-v4-pro"].Currency != "¥" || got.Prices["deepseek-v4-pro"].Output != 6 {
|
||
t.Fatalf("deepseek-v4-pro price = %+v, want RMB pricing", got.Prices["deepseek-v4-pro"])
|
||
}
|
||
}
|
||
|
||
func TestSetAgentParamsPersistsStepLimitsToUserConfig(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if err := app.SetAgentParams(0.35, 37, 9, "custom system"); err != nil {
|
||
t.Fatalf("SetAgentParams: %v", err)
|
||
}
|
||
|
||
view := app.Settings()
|
||
if view.Agent.MaxSteps != 37 || view.Agent.PlannerMaxSteps != 9 {
|
||
t.Fatalf("Settings().Agent = %+v, want maxSteps=37 plannerMaxSteps=9", view.Agent)
|
||
}
|
||
if view.Agent.Temperature != 0.35 || view.Agent.SystemPrompt != "custom system" {
|
||
t.Fatalf("Settings().Agent did not preserve other agent params: %+v", view.Agent)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.Agent.MaxSteps != 37 || cfg.Agent.PlannerMaxSteps != 9 {
|
||
t.Fatalf("saved config agent steps = max:%d planner:%d, want 37/9", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
|
||
}
|
||
if cfg.Agent.Temperature != 0.35 || cfg.Agent.SystemPrompt != "custom system" {
|
||
t.Fatalf("saved config did not preserve other agent params: %+v", cfg.Agent)
|
||
}
|
||
}
|
||
|
||
func TestSetReasoningLanguagePersistsToUserConfig(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if err := app.SetReasoningLanguage("zh"); err != nil {
|
||
t.Fatalf("SetReasoningLanguage: %v", err)
|
||
}
|
||
|
||
view := app.Settings()
|
||
if view.Agent.ReasoningLanguage != "zh" {
|
||
t.Fatalf("Settings().Agent.ReasoningLanguage = %q, want zh", view.Agent.ReasoningLanguage)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.Agent.ReasoningLanguage != "zh" || cfg.ReasoningLanguage() != "zh" {
|
||
t.Fatalf("saved reasoning language = %q/%q, want zh", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())
|
||
}
|
||
}
|
||
|
||
func TestSetDesktopLanguagePersistsResponseLanguageAndUpdatesLiveTabs(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
projectRoot := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("language = \"zh\"\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
userCtrl := control.New(control.Options{})
|
||
projectCtrl := control.New(control.Options{})
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"user": {
|
||
ID: "user",
|
||
Scope: "global",
|
||
Ctrl: userCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
"project": {
|
||
ID: "project",
|
||
Scope: "project",
|
||
WorkspaceRoot: projectRoot,
|
||
Ctrl: projectCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
}
|
||
app.activeTabID = "user"
|
||
|
||
if err := app.SetDesktopLanguage("en"); err != nil {
|
||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||
}
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.DesktopLanguage() != "en" || cfg.Language != "en" {
|
||
t.Fatalf("saved language prefs = desktop:%q response:%q, want en/en", cfg.DesktopLanguage(), cfg.Language)
|
||
}
|
||
got := userCtrl.Compose("解释这个函数")
|
||
if !strings.Contains(got, "<response-language>") || !strings.Contains(got, "use English") {
|
||
t.Fatalf("live controller Compose = %q, want English response language", got)
|
||
}
|
||
projectComposed := projectCtrl.Compose("explain this function")
|
||
if !strings.Contains(projectComposed, "use Simplified Chinese") {
|
||
t.Fatalf("project controller Compose = %q, want project zh response language", projectComposed)
|
||
}
|
||
}
|
||
|
||
func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
projectRoot := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("[agent]\nreasoning_language = \"en\"\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
userCtrl := control.New(control.Options{ReasoningLanguage: "auto"})
|
||
projectCtrl := control.New(control.Options{ReasoningLanguage: "auto"})
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"user": {
|
||
ID: "user",
|
||
Scope: "global",
|
||
Ctrl: userCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
"project": {
|
||
ID: "project",
|
||
Scope: "project",
|
||
WorkspaceRoot: projectRoot,
|
||
Ctrl: projectCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
}
|
||
app.activeTabID = "user"
|
||
|
||
if err := app.SetReasoningLanguage("zh"); err != nil {
|
||
t.Fatalf("SetReasoningLanguage: %v", err)
|
||
}
|
||
|
||
userComposed := userCtrl.Compose("hi")
|
||
if !strings.Contains(userComposed, "简体中文") {
|
||
t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
|
||
}
|
||
projectComposed := projectCtrl.Compose("hi")
|
||
if !strings.Contains(projectComposed, "use English") {
|
||
t.Fatalf("project override tab Compose = %q, want en reasoning language", projectComposed)
|
||
}
|
||
}
|
||
|
||
func TestSetAutoPlanUpdatesLiveTabControllers(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
userRunner := &captureTurnRunner{}
|
||
projectRunner := &captureTurnRunner{}
|
||
userCtrl := control.New(control.Options{AutoPlan: "on", Runner: userRunner})
|
||
projectCtrl := control.New(control.Options{AutoPlan: "on", Runner: projectRunner})
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"user": {
|
||
ID: "user",
|
||
Scope: "global",
|
||
Ctrl: userCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
"project": {
|
||
ID: "project",
|
||
Scope: "project",
|
||
WorkspaceRoot: t.TempDir(),
|
||
Ctrl: projectCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
}
|
||
app.activeTabID = "user"
|
||
|
||
if err := app.SetAutoPlan("off"); err != nil {
|
||
t.Fatalf("SetAutoPlan: %v", err)
|
||
}
|
||
|
||
input := "实现 GitHub issue #2395:\n- 新增配置项\n- 自动判断复杂任务\n- 补测试和文档"
|
||
if err := userCtrl.RunTurn(context.Background(), input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := projectCtrl.RunTurn(context.Background(), input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(userRunner.inputs) != 1 || strings.HasPrefix(userRunner.inputs[0], control.PlanModeMarker) {
|
||
t.Fatalf("user tab should use updated auto_plan=off, inputs=%q", userRunner.inputs)
|
||
}
|
||
if len(projectRunner.inputs) != 1 || strings.HasPrefix(projectRunner.inputs[0], control.PlanModeMarker) {
|
||
t.Fatalf("project tab without override should use updated auto_plan=off, inputs=%q", projectRunner.inputs)
|
||
}
|
||
}
|
||
|
||
func TestSetAutoPlanIgnoresProjectOverrideForLiveTab(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
projectRoot := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("[agent]\nauto_plan = \"on\"\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
userRunner := &captureTurnRunner{}
|
||
projectRunner := &captureTurnRunner{}
|
||
userCtrl := control.New(control.Options{AutoPlan: "on", Runner: userRunner})
|
||
projectCtrl := control.New(control.Options{AutoPlan: "on", Runner: projectRunner})
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"user": {
|
||
ID: "user",
|
||
Scope: "global",
|
||
Ctrl: userCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
"project": {
|
||
ID: "project",
|
||
Scope: "project",
|
||
WorkspaceRoot: projectRoot,
|
||
Ctrl: projectCtrl,
|
||
Ready: true,
|
||
disabledMCP: map[string]ServerView{},
|
||
},
|
||
}
|
||
app.activeTabID = "user"
|
||
|
||
if err := app.SetAutoPlan("off"); err != nil {
|
||
t.Fatalf("SetAutoPlan: %v", err)
|
||
}
|
||
|
||
input := "实现 GitHub issue #2395:\n- 新增配置项\n- 自动判断复杂任务\n- 补测试和文档"
|
||
if err := userCtrl.RunTurn(context.Background(), input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := projectCtrl.RunTurn(context.Background(), input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(userRunner.inputs) != 1 || strings.HasPrefix(userRunner.inputs[0], control.PlanModeMarker) {
|
||
t.Fatalf("user tab should use updated auto_plan=off, inputs=%q", userRunner.inputs)
|
||
}
|
||
if len(projectRunner.inputs) != 1 || strings.HasPrefix(projectRunner.inputs[0], control.PlanModeMarker) {
|
||
t.Fatalf("project auto_plan should be ignored, inputs=%q", projectRunner.inputs)
|
||
}
|
||
}
|
||
|
||
func TestSetAutoPlanEnablingClassifierRebuildsActiveController(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
cfg.Agent.AutoPlan = "off"
|
||
cfg.Agent.AutoPlanClassifier = "deepseek-flash"
|
||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||
t.Fatalf("save config: %v", err)
|
||
}
|
||
|
||
app := NewApp()
|
||
app.ctx = context.Background()
|
||
app.readyHook = func() {}
|
||
old := control.New(control.Options{AutoPlan: "off", Label: "old-controller"})
|
||
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
|
||
defer func() {
|
||
if c := app.activeCtrl(); c != nil {
|
||
c.Close()
|
||
}
|
||
}()
|
||
|
||
if err := app.SetAutoPlan("on"); err != nil {
|
||
t.Fatalf("SetAutoPlan(on): %v", err)
|
||
}
|
||
if c := app.activeCtrl(); c == nil {
|
||
t.Fatal("SetAutoPlan should leave a rebuilt controller")
|
||
}
|
||
if c := app.activeCtrl(); c == old {
|
||
t.Fatal("SetAutoPlan should rebuild when enabling a configured classifier")
|
||
}
|
||
|
||
got := config.LoadForEdit(config.UserConfigPath())
|
||
if got.Agent.AutoPlan != "on" {
|
||
t.Fatalf("saved auto_plan = %q, want on", got.Agent.AutoPlan)
|
||
}
|
||
}
|
||
|
||
func TestSetReasoningLanguageRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
app.setTestCtrl(newBackgroundJobController(t, "reasoning-language-job"), "")
|
||
|
||
err := app.SetReasoningLanguage("zh")
|
||
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
|
||
t.Fatalf("SetReasoningLanguage with background job error = %v, want active-work guard", err)
|
||
}
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.ReasoningLanguage() != "auto" {
|
||
t.Fatalf("reasoning language changed after rejected update: %q", cfg.ReasoningLanguage())
|
||
}
|
||
}
|
||
|
||
func TestSetDesktopCheckUpdatesPersistsToUserConfig(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if !app.Settings().CheckUpdates {
|
||
t.Fatal("Settings().CheckUpdates default = false, want true")
|
||
}
|
||
if err := app.SetDesktopCheckUpdates(false); err != nil {
|
||
t.Fatalf("SetDesktopCheckUpdates: %v", err)
|
||
}
|
||
view := app.Settings()
|
||
if view.CheckUpdates {
|
||
t.Fatal("Settings().CheckUpdates = true, want false")
|
||
}
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.Desktop.CheckUpdates == nil || *cfg.Desktop.CheckUpdates {
|
||
t.Fatalf("desktop.check_updates = %+v, want false", cfg.Desktop.CheckUpdates)
|
||
}
|
||
if cfg.DesktopCheckUpdates() {
|
||
t.Fatal("DesktopCheckUpdates() = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestSetDefaultToolApprovalModePersistsToUserConfig(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if app.Settings().DefaultToolApprovalMode != control.ToolApprovalAuto {
|
||
t.Fatalf("Settings().DefaultToolApprovalMode = %q, want auto", app.Settings().DefaultToolApprovalMode)
|
||
}
|
||
if err := app.SetDefaultToolApprovalMode(control.ToolApprovalAuto); err != nil {
|
||
t.Fatalf("SetDefaultToolApprovalMode: %v", err)
|
||
}
|
||
view := app.Settings()
|
||
if view.DefaultToolApprovalMode != control.ToolApprovalAuto {
|
||
t.Fatalf("Settings().DefaultToolApprovalMode = %q, want auto", view.DefaultToolApprovalMode)
|
||
}
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.Desktop.DefaultToolApprovalMode != control.ToolApprovalAuto {
|
||
t.Fatalf("desktop.default_tool_approval_mode = %q, want auto", cfg.Desktop.DefaultToolApprovalMode)
|
||
}
|
||
if cfg.DesktopDefaultToolApprovalMode() != control.ToolApprovalAuto {
|
||
t.Fatalf("DesktopDefaultToolApprovalMode() = %q, want auto", cfg.DesktopDefaultToolApprovalMode())
|
||
}
|
||
}
|
||
|
||
func TestSetDesktopMetricsDefaultsOnAndPersistsOff(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if !app.Settings().Metrics {
|
||
t.Fatal("Settings().Metrics default = false, want true")
|
||
}
|
||
if err := app.SetDesktopMetrics(false); err != nil {
|
||
t.Fatalf("SetDesktopMetrics: %v", err)
|
||
}
|
||
view := app.Settings()
|
||
if view.Metrics {
|
||
t.Fatal("Settings().Metrics = true, want false")
|
||
}
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.Desktop.Metrics == nil || *cfg.Desktop.Metrics {
|
||
t.Fatalf("desktop.metrics = %+v, want false", cfg.Desktop.Metrics)
|
||
}
|
||
if cfg.DesktopMetrics() {
|
||
t.Fatal("DesktopMetrics() = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestSetMemoryCompilerDefaultsOnAndPersistsOff(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
|
||
app := NewApp()
|
||
if !app.Settings().MemoryCompiler {
|
||
t.Fatal("Settings().MemoryCompiler default = false, want true")
|
||
}
|
||
if err := app.SetMemoryCompilerEnabled(false); err != nil {
|
||
t.Fatalf("SetMemoryCompilerEnabled: %v", err)
|
||
}
|
||
view := app.Settings()
|
||
if view.MemoryCompiler {
|
||
t.Fatal("Settings().MemoryCompiler = true, want false")
|
||
}
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if cfg.Agent.MemoryCompiler.Enabled == nil || *cfg.Agent.MemoryCompiler.Enabled {
|
||
t.Fatalf("agent.memory_compiler.enabled = %+v, want false", cfg.Agent.MemoryCompiler.Enabled)
|
||
}
|
||
if cfg.MemoryCompilerEnabled() {
|
||
t.Fatal("MemoryCompilerEnabled() = true, want false")
|
||
}
|
||
}
|
||
|
||
type memoryCompilerTargetFake struct {
|
||
calls []bool
|
||
}
|
||
|
||
func (f *memoryCompilerTargetFake) SetMemoryCompilerEnabled(enabled bool) {
|
||
f.calls = append(f.calls, enabled)
|
||
}
|
||
|
||
func TestApplyMemoryCompilerToControllersBroadcastsToAllTargets(t *testing.T) {
|
||
first := &memoryCompilerTargetFake{}
|
||
second := &memoryCompilerTargetFake{}
|
||
|
||
applyMemoryCompilerToControllers(false, []memoryCompilerTarget{first, nil, second})
|
||
|
||
if !reflect.DeepEqual(first.calls, []bool{false}) {
|
||
t.Fatalf("first calls = %v, want [false]", first.calls)
|
||
}
|
||
if !reflect.DeepEqual(second.calls, []bool{false}) {
|
||
t.Fatalf("second calls = %v, want [false]", second.calls)
|
||
}
|
||
}
|
||
|
||
func TestSaveHooksSettingsPreservesUnknownSettingsKeys(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
path := hook.GlobalSettingsPath("")
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(`{"theme":"dark","hooks":{"Stop":[{"command":"old"}]}}`), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
if err := app.SaveHooksSettings("global", []HookConfigView{{
|
||
Event: string(hook.PreToolUse),
|
||
Match: "bash",
|
||
Command: "echo guard",
|
||
}}); err != nil {
|
||
t.Fatalf("SaveHooksSettings: %v", err)
|
||
}
|
||
|
||
var raw map[string]json.RawMessage
|
||
body, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := json.Unmarshal(body, &raw); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(raw["theme"]) != `"dark"` {
|
||
t.Fatalf("theme key was not preserved: %s", raw["theme"])
|
||
}
|
||
view := app.HooksSettings("global")
|
||
if len(view.Hooks) != 1 || view.Hooks[0].Event != string(hook.PreToolUse) || view.Hooks[0].Command != "echo guard" {
|
||
t.Fatalf("HooksSettings = %+v, want saved PreToolUse hook", view)
|
||
}
|
||
}
|
||
|
||
func TestSaveHooksSettingsDecodesLegacyEncodedGlobalSettings(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
path := hook.GlobalSettingsPath("")
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
legacy := `{"label":"中文","hooks":{"Stop":[{"command":"echo 旧"}]}}`
|
||
if err := os.WriteFile(path, fileencoding.Encode(legacy, fileencoding.GB18030), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
before := app.HooksSettings("global")
|
||
if len(before.Hooks) != 1 || before.Hooks[0].Command != "echo 旧" {
|
||
t.Fatalf("HooksSettings before save = %+v, want decoded legacy hook", before.Hooks)
|
||
}
|
||
if err := app.SaveHooksSettings("global", []HookConfigView{{
|
||
Event: string(hook.PreToolUse),
|
||
Command: "echo 新",
|
||
}}); err != nil {
|
||
t.Fatalf("SaveHooksSettings: %v", err)
|
||
}
|
||
|
||
body, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var raw map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &raw); err != nil {
|
||
t.Fatalf("saved settings should be valid UTF-8 JSON: %v", err)
|
||
}
|
||
if string(raw["label"]) != `"中文"` {
|
||
t.Fatalf("label key was not preserved after decoding legacy settings: %s", raw["label"])
|
||
}
|
||
view := app.HooksSettings("global")
|
||
if len(view.Hooks) != 1 || view.Hooks[0].Command != "echo 新" {
|
||
t.Fatalf("HooksSettings after save = %+v, want new decoded hook", view.Hooks)
|
||
}
|
||
}
|
||
|
||
func TestSaveHooksSettingsNormalizesQuotedNodeEvalHookCommand(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
|
||
bad := `node -e "\"` + script + `\""`
|
||
want := hook.NormalizeCommand(bad)
|
||
if want == bad {
|
||
t.Fatal("test command did not normalize")
|
||
}
|
||
|
||
app := NewApp()
|
||
if err := app.SaveHooksSettings("global", []HookConfigView{{
|
||
Event: string(hook.PreToolUse),
|
||
Match: "bash",
|
||
Command: bad,
|
||
}}); err != nil {
|
||
t.Fatalf("SaveHooksSettings: %v", err)
|
||
}
|
||
|
||
view := app.HooksSettings("global")
|
||
if len(view.Hooks) != 1 || view.Hooks[0].Command != want {
|
||
t.Fatalf("HooksSettings = %+v, want normalized command %q", view.Hooks, want)
|
||
}
|
||
}
|
||
|
||
func TestProjectHooksSettingsUseActiveWorkspaceRootAndTrust(t *testing.T) {
|
||
home := isolateDesktopUserDirs(t)
|
||
project := t.TempDir()
|
||
app := NewApp()
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
|
||
}
|
||
app.activeTabID = "project"
|
||
|
||
if err := app.SaveHooksSettings("project", []HookConfigView{{
|
||
Event: string(hook.Stop),
|
||
Command: "echo done",
|
||
Description: "Turn done",
|
||
}}); err != nil {
|
||
t.Fatalf("SaveHooksSettings(project): %v", err)
|
||
}
|
||
if err := app.TrustProjectHooks(); err != nil {
|
||
t.Fatalf("TrustProjectHooks: %v", err)
|
||
}
|
||
if !hook.IsTrusted(project, home) {
|
||
t.Fatal("project hooks were not trusted")
|
||
}
|
||
view := app.HooksSettings("project")
|
||
if view.Scope != "project" || view.ProjectRoot != project || !view.Trusted {
|
||
t.Fatalf("project hook view metadata = %+v", view)
|
||
}
|
||
if len(view.Hooks) != 1 || view.Hooks[0].Event != string(hook.Stop) || view.Hooks[0].Description != "Turn done" {
|
||
t.Fatalf("project hooks = %+v", view.Hooks)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(project, ".reasonix", "settings.json")); err != nil {
|
||
t.Fatalf("project hooks settings file missing: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestTrustProjectHooksForRootUsesDisplayedProjectRoot(t *testing.T) {
|
||
home := isolateDesktopUserDirs(t)
|
||
projectA := t.TempDir()
|
||
projectB := t.TempDir()
|
||
app := NewApp()
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"a": {ID: "a", Scope: "project", WorkspaceRoot: projectA, Ready: true},
|
||
"b": {ID: "b", Scope: "project", WorkspaceRoot: projectB, Ready: true},
|
||
}
|
||
app.activeTabID = "b"
|
||
|
||
if err := app.TrustProjectHooksForRoot(projectA); err != nil {
|
||
t.Fatalf("TrustProjectHooksForRoot: %v", err)
|
||
}
|
||
if !hook.IsTrusted(projectA, home) {
|
||
t.Fatal("displayed project root was not trusted")
|
||
}
|
||
if hook.IsTrusted(projectB, home) {
|
||
t.Fatal("active project root was trusted instead of displayed project root")
|
||
}
|
||
}
|
||
|
||
func TestSaveHooksSettingsForRootUsesDisplayedProjectRoot(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
projectA := t.TempDir()
|
||
projectB := t.TempDir()
|
||
app := NewApp()
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"a": {ID: "a", Scope: "project", WorkspaceRoot: projectA, Ready: true},
|
||
"b": {ID: "b", Scope: "project", WorkspaceRoot: projectB, Ready: true},
|
||
}
|
||
app.activeTabID = "b"
|
||
|
||
if err := app.SaveHooksSettingsForRoot("project", projectA, []HookConfigView{{
|
||
Event: string(hook.Stop),
|
||
Command: "echo done",
|
||
}}); err != nil {
|
||
t.Fatalf("SaveHooksSettingsForRoot: %v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(projectA, ".reasonix", "settings.json")); err != nil {
|
||
t.Fatalf("displayed project root settings missing: %v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(projectB, ".reasonix", "settings.json")); err == nil {
|
||
t.Fatal("active project root was written instead of displayed project root")
|
||
}
|
||
}
|
||
|
||
// TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess locks the
|
||
// read-path contract: loading a legacy-form config (configured providers but
|
||
// no declared desktop.provider_access) through the View helpers returns a
|
||
// normalized in-memory view while leaving the file bytes untouched. The
|
||
// on-disk migration only happens once a locked write path runs.
|
||
func TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
userPath := config.UserConfigPath()
|
||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
legacy := "default_model = \"local/m1\"\n\n[[providers]]\nname = \"local\"\nbase_url = \"http://127.0.0.1:9999/v1\"\nmodels = [\"m1\"]\n"
|
||
if err := os.WriteFile(userPath, []byte(legacy), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
for name, load := range map[string]func() (*config.Config, string, error){
|
||
"view": app.loadDesktopUserConfigForView,
|
||
"view-with-credentials": app.loadDesktopUserConfigForViewWithCredentials,
|
||
} {
|
||
cfg, _, err := load()
|
||
if err != nil {
|
||
t.Fatalf("%s load: %v", name, err)
|
||
}
|
||
if len(cfg.Desktop.ProviderAccess) == 0 {
|
||
t.Fatalf("%s load should normalize legacy provider access in memory", name)
|
||
}
|
||
raw, err := os.ReadFile(userPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(raw) != legacy {
|
||
t.Fatalf("%s load must not rewrite the user config, got:\n%s", name, raw)
|
||
}
|
||
}
|
||
|
||
// The first locked write path persists the pending migration.
|
||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||
t.Fatalf("applyConfigOnly: %v", err)
|
||
}
|
||
if !configDeclaresProviderAccess(userPath) {
|
||
t.Fatal("locked write path should persist the provider access migration to disk")
|
||
}
|
||
migrated := config.LoadForEditWithoutCredentials(userPath)
|
||
if len(migrated.Desktop.ProviderAccess) == 0 {
|
||
t.Fatalf("migrated config lost provider access: %v", migrated.Desktop.ProviderAccess)
|
||
}
|
||
}
|
||
|
||
// TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory locks the
|
||
// same contract for the legacy bot-config migration: read paths (including the
|
||
// bot runtime's credential-loading view) see the merged bot config in memory
|
||
// without any file being written; the locked write path performs the on-disk
|
||
// migration.
|
||
func TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
userPath := config.UserConfigPath()
|
||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
userBody := "default_model = \"local/m1\"\n"
|
||
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
legacyRoot := t.TempDir()
|
||
legacyPath := filepath.Join(legacyRoot, "reasonix.toml")
|
||
legacyBody := "[bot]\nenabled = true\nmodel = \"local/m1\"\n"
|
||
if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
app := NewApp()
|
||
app.tabs = map[string]*WorkspaceTab{
|
||
"t": {ID: "t", Scope: "project", WorkspaceRoot: legacyRoot, Ready: true},
|
||
}
|
||
app.activeTabID = "t"
|
||
|
||
assertFilesUntouched := func(step string) {
|
||
t.Helper()
|
||
rawUser, err := os.ReadFile(userPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(rawUser) != userBody {
|
||
t.Fatalf("%s must not rewrite the user config, got:\n%s", step, rawUser)
|
||
}
|
||
rawLegacy, err := os.ReadFile(legacyPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(rawLegacy) != legacyBody {
|
||
t.Fatalf("%s must not rewrite the legacy config, got:\n%s", step, rawLegacy)
|
||
}
|
||
}
|
||
|
||
cfg, _, err := app.loadDesktopUserConfigForView()
|
||
if err != nil {
|
||
t.Fatalf("loadDesktopUserConfigForView: %v", err)
|
||
}
|
||
if !cfg.Bot.Enabled {
|
||
t.Fatal("view load should merge the legacy bot config in memory")
|
||
}
|
||
assertFilesUntouched("loadDesktopUserConfigForView")
|
||
|
||
botCfg, err := app.loadDesktopBotConfig()
|
||
if err != nil {
|
||
t.Fatalf("loadDesktopBotConfig: %v", err)
|
||
}
|
||
if !botCfg.Bot.Enabled {
|
||
t.Fatal("bot runtime load should see the merged legacy bot config")
|
||
}
|
||
assertFilesUntouched("loadDesktopBotConfig")
|
||
|
||
// The first locked write path migrates the bot config into the user file.
|
||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||
t.Fatalf("applyConfigOnly: %v", err)
|
||
}
|
||
migrated := config.LoadForEditWithoutCredentials(userPath)
|
||
if !migrated.Bot.Enabled {
|
||
t.Fatal("locked write path should persist the legacy bot config migration")
|
||
}
|
||
rawLegacy, err := os.ReadFile(legacyPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(rawLegacy) != legacyBody {
|
||
t.Fatalf("migration must not rewrite the legacy config, got:\n%s", rawLegacy)
|
||
}
|
||
}
|
||
|
||
func TestSetBotSettingsPreservesFeishuOutboundMediaRoots(t *testing.T) {
|
||
isolateDesktopUserDirs(t)
|
||
root := t.TempDir()
|
||
cfg := config.Default()
|
||
cfg.Bot.Feishu.OutboundMediaRoots = []string{root}
|
||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||
t.Fatalf("save initial config: %v", err)
|
||
}
|
||
|
||
app := NewApp()
|
||
view := botSettingsView(cfg.Bot)
|
||
view.QueueCap++
|
||
if err := app.SetBotSettings(view); err != nil {
|
||
t.Fatalf("SetBotSettings: %v", err)
|
||
}
|
||
|
||
got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
|
||
if !reflect.DeepEqual(got.Bot.Feishu.OutboundMediaRoots, []string{root}) {
|
||
t.Fatalf("outbound media roots = %v, want preserved %q", got.Bot.Feishu.OutboundMediaRoots, root)
|
||
}
|
||
}
|