provider/openai: - Fix doubled tool call args (argsComplete flag): Ollama sends complete args in the first streaming chunk then repeats them as delta, causing doubled JSON and 400 errors in elfs - Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep) - Add Reasoning field support for Ollama thinking output cmd/gnoma: - Early TTY detection so logger is created with correct destination before any component gets a reference to it (fixes slog WARN bleed into TUI textarea) permission: - Exempt spawn_elfs and agent tools from safety scanner: elf prompt text may legitimately mention .env/.ssh/credentials patterns and should not be blocked tui/app: - /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge (ask for plain text output) → TUI fallback write from streamBuf - looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback content before writing (reject refusals, strip narrative preambles) - Collapse thinking output to 3 lines; ctrl+o to expand (live stream and committed messages) - Stream-level filter for model pseudo-tool-call blocks: suppresses <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|> from entering streamBuf across chunk boundaries - sanitizeAssistantText regex covers both block formats - Reset streamFilterClose at every turn start
227 lines
5.7 KiB
Go
227 lines
5.7 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
cfg := Defaults()
|
|
if cfg.Provider.Default != "mistral" {
|
|
t.Errorf("Provider.Default = %q, want mistral", cfg.Provider.Default)
|
|
}
|
|
if cfg.Provider.MaxTokens != 8192 {
|
|
t.Errorf("Provider.MaxTokens = %d", cfg.Provider.MaxTokens)
|
|
}
|
|
if cfg.Tools.BashTimeout.Duration() != 30*time.Second {
|
|
t.Errorf("Tools.BashTimeout = %v", cfg.Tools.BashTimeout)
|
|
}
|
|
}
|
|
|
|
func TestLoadTOML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.toml")
|
|
|
|
content := `
|
|
[provider]
|
|
default = "anthropic"
|
|
model = "claude-sonnet-4"
|
|
max_tokens = 16384
|
|
|
|
[provider.api_keys]
|
|
anthropic = "sk-test-123"
|
|
|
|
[provider.endpoints]
|
|
ollama = "http://myhost:11434/v1"
|
|
|
|
[tools]
|
|
bash_timeout = "60s"
|
|
max_file_size = 2097152
|
|
`
|
|
os.WriteFile(path, []byte(content), 0o644)
|
|
|
|
cfg := Defaults()
|
|
if err := loadTOML(&cfg, path); err != nil {
|
|
t.Fatalf("loadTOML: %v", err)
|
|
}
|
|
|
|
if cfg.Provider.Default != "anthropic" {
|
|
t.Errorf("Provider.Default = %q", cfg.Provider.Default)
|
|
}
|
|
if cfg.Provider.Model != "claude-sonnet-4" {
|
|
t.Errorf("Provider.Model = %q", cfg.Provider.Model)
|
|
}
|
|
if cfg.Provider.MaxTokens != 16384 {
|
|
t.Errorf("Provider.MaxTokens = %d", cfg.Provider.MaxTokens)
|
|
}
|
|
if cfg.Provider.APIKeys["anthropic"] != "sk-test-123" {
|
|
t.Errorf("APIKeys[anthropic] = %q", cfg.Provider.APIKeys["anthropic"])
|
|
}
|
|
if cfg.Provider.Endpoints["ollama"] != "http://myhost:11434/v1" {
|
|
t.Errorf("Endpoints[ollama] = %q", cfg.Provider.Endpoints["ollama"])
|
|
}
|
|
if cfg.Tools.BashTimeout.Duration() != 60*time.Second {
|
|
t.Errorf("Tools.BashTimeout = %v", cfg.Tools.BashTimeout)
|
|
}
|
|
if cfg.Tools.MaxFileSize != 2097152 {
|
|
t.Errorf("Tools.MaxFileSize = %d", cfg.Tools.MaxFileSize)
|
|
}
|
|
}
|
|
|
|
func TestLoadTOML_FileNotFound(t *testing.T) {
|
|
cfg := Defaults()
|
|
err := loadTOML(&cfg, "/nonexistent/config.toml")
|
|
if !os.IsNotExist(err) {
|
|
t.Errorf("expected os.IsNotExist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyEnv(t *testing.T) {
|
|
cfg := Defaults()
|
|
|
|
t.Setenv("MISTRAL_API_KEY", "sk-mistral-test")
|
|
t.Setenv("ANTHROPICS_API_KEY", "sk-anthro-test")
|
|
t.Setenv("GOOGLE_API_KEY", "goog-test")
|
|
t.Setenv("GNOMA_PROVIDER", "openai")
|
|
t.Setenv("GNOMA_MODEL", "gpt-4o-mini")
|
|
|
|
applyEnv(&cfg)
|
|
|
|
if cfg.Provider.APIKeys["mistral"] != "sk-mistral-test" {
|
|
t.Errorf("APIKeys[mistral] = %q", cfg.Provider.APIKeys["mistral"])
|
|
}
|
|
if cfg.Provider.APIKeys["anthropic"] != "sk-anthro-test" {
|
|
t.Errorf("APIKeys[anthropic] = %q (should pick ANTHROPICS_API_KEY)", cfg.Provider.APIKeys["anthropic"])
|
|
}
|
|
if cfg.Provider.APIKeys["google"] != "goog-test" {
|
|
t.Errorf("APIKeys[google] = %q (should pick GOOGLE_API_KEY)", cfg.Provider.APIKeys["google"])
|
|
}
|
|
if cfg.Provider.Default != "openai" {
|
|
t.Errorf("Provider.Default = %q, want openai (from GNOMA_PROVIDER)", cfg.Provider.Default)
|
|
}
|
|
if cfg.Provider.Model != "gpt-4o-mini" {
|
|
t.Errorf("Provider.Model = %q, want gpt-4o-mini (from GNOMA_MODEL)", cfg.Provider.Model)
|
|
}
|
|
}
|
|
|
|
func TestApplyEnv_EnvVarReference(t *testing.T) {
|
|
cfg := Defaults()
|
|
cfg.Provider.APIKeys["custom"] = "${MY_CUSTOM_KEY}"
|
|
|
|
t.Setenv("MY_CUSTOM_KEY", "resolved-value")
|
|
|
|
applyEnv(&cfg)
|
|
|
|
if cfg.Provider.APIKeys["custom"] != "resolved-value" {
|
|
t.Errorf("APIKeys[custom] = %q, want resolved-value", cfg.Provider.APIKeys["custom"])
|
|
}
|
|
}
|
|
|
|
func TestProjectRoot_GoMod(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "pkg", "util")
|
|
os.MkdirAll(sub, 0o755)
|
|
os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/foo\n"), 0o644)
|
|
|
|
origDir, _ := os.Getwd()
|
|
os.Chdir(sub)
|
|
defer os.Chdir(origDir)
|
|
|
|
got := ProjectRoot()
|
|
if got != root {
|
|
t.Errorf("ProjectRoot() = %q, want %q", got, root)
|
|
}
|
|
}
|
|
|
|
func TestProjectRoot_Git(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "src")
|
|
os.MkdirAll(sub, 0o755)
|
|
os.MkdirAll(filepath.Join(root, ".git"), 0o755)
|
|
|
|
origDir, _ := os.Getwd()
|
|
os.Chdir(sub)
|
|
defer os.Chdir(origDir)
|
|
|
|
got := ProjectRoot()
|
|
if got != root {
|
|
t.Errorf("ProjectRoot() = %q, want %q", got, root)
|
|
}
|
|
}
|
|
|
|
func TestProjectRoot_GnomaDir(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "internal")
|
|
os.MkdirAll(sub, 0o755)
|
|
os.MkdirAll(filepath.Join(root, ".gnoma"), 0o755)
|
|
|
|
origDir, _ := os.Getwd()
|
|
os.Chdir(sub)
|
|
defer os.Chdir(origDir)
|
|
|
|
got := ProjectRoot()
|
|
if got != root {
|
|
t.Errorf("ProjectRoot() = %q, want %q", got, root)
|
|
}
|
|
}
|
|
|
|
func TestProjectRoot_Fallback(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
origDir, _ := os.Getwd()
|
|
os.Chdir(dir)
|
|
defer os.Chdir(origDir)
|
|
|
|
got := ProjectRoot()
|
|
if got != dir {
|
|
t.Errorf("ProjectRoot() = %q, want %q (cwd fallback)", got, dir)
|
|
}
|
|
}
|
|
|
|
func TestLayeredLoad(t *testing.T) {
|
|
// Set up global config
|
|
globalDir := t.TempDir()
|
|
gnomaDir := filepath.Join(globalDir, "gnoma")
|
|
os.MkdirAll(gnomaDir, 0o755)
|
|
os.WriteFile(filepath.Join(gnomaDir, "config.toml"), []byte(`
|
|
[provider]
|
|
default = "anthropic"
|
|
max_tokens = 4096
|
|
`), 0o644)
|
|
|
|
// Set up project config that overrides
|
|
projectDir := t.TempDir()
|
|
pGnomaDir := filepath.Join(projectDir, ".gnoma")
|
|
os.MkdirAll(pGnomaDir, 0o755)
|
|
os.WriteFile(filepath.Join(pGnomaDir, "config.toml"), []byte(`
|
|
[provider]
|
|
model = "claude-haiku"
|
|
`), 0o644)
|
|
|
|
// Override XDG_CONFIG_HOME and working directory
|
|
t.Setenv("XDG_CONFIG_HOME", globalDir)
|
|
origDir, _ := os.Getwd()
|
|
os.Chdir(projectDir)
|
|
defer os.Chdir(origDir)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
// Global: default = anthropic
|
|
if cfg.Provider.Default != "anthropic" {
|
|
t.Errorf("Default = %q, want anthropic (from global)", cfg.Provider.Default)
|
|
}
|
|
// Project: model = claude-haiku
|
|
if cfg.Provider.Model != "claude-haiku" {
|
|
t.Errorf("Model = %q, want claude-haiku (from project)", cfg.Provider.Model)
|
|
}
|
|
// Global: max_tokens = 4096
|
|
if cfg.Provider.MaxTokens != 4096 {
|
|
t.Errorf("MaxTokens = %d, want 4096 (from global)", cfg.Provider.MaxTokens)
|
|
}
|
|
}
|