package main import ( "encoding/json" "os" "path/filepath" "reflect" "testing" "gocoder/agents" "gocoder/db" ) func TestResolveWorkspaceGroupIDPrefersConversationGroup(t *testing.T) { convGroupID := "conversation-group" conv := &db.Conversation{GroupID: &convGroupID} got := resolveWorkspaceGroupID(conv, "request-group") if got != convGroupID { t.Fatalf("resolveWorkspaceGroupID returned %q, want %q", got, convGroupID) } } func TestResolveWorkspaceGroupIDFallsBackToRequestGroup(t *testing.T) { got := resolveWorkspaceGroupID(nil, "request-group") if got != "request-group" { t.Fatalf("resolveWorkspaceGroupID returned %q, want %q", got, "request-group") } } func TestResolveWorkspaceGroupIDReturnsEmptyWhenUnset(t *testing.T) { conv := &db.Conversation{} got := resolveWorkspaceGroupID(conv, "") if got != "" { t.Fatalf("resolveWorkspaceGroupID returned %q, want empty string", got) } } func TestFriendlyToolDisplayName(t *testing.T) { cases := map[string]string{ "planner_agent": "Planner", "programmer_agent": "Programmer", "qa_agent": "QA", "write_file": "write_file", } for input, want := range cases { if got := friendlyToolDisplayName(input); got != want { t.Fatalf("friendlyToolDisplayName(%q) = %q, want %q", input, got, want) } } } func TestStageStatusKind(t *testing.T) { cases := map[string]string{ "Planner": "planner", "Programmer": "programmer", "QA": "qa", "Manager": "manager", "Other": "thinking", } for input, want := range cases { if got := stageStatusKind(input); got != want { t.Fatalf("stageStatusKind(%q) = %q, want %q", input, got, want) } } } func TestSubagentNoResponseError(t *testing.T) { got := subagentNoResponseError("QA", "it kept calling tools but never produced a final answer before hitting the 20-loop limit") want := "QA returned no response: it kept calling tools but never produced a final answer before hitting the 20-loop limit" if got != want { t.Fatalf("subagentNoResponseError returned %q, want %q", got, want) } } func TestSubagentNoResponseErrorDefaultsStageName(t *testing.T) { got := subagentNoResponseError(" ", " ") want := "Subagent returned no response" if got != want { t.Fatalf("subagentNoResponseError returned %q, want %q", got, want) } } func TestCollectThinkingAndContentPrefersExplicitReasoning(t *testing.T) { thinking, content := collectThinkingAndContent(" plan ", "", "final answer") if got, want := thinking, "plan"; got != want { t.Fatalf("thinking = %q, want %q", got, want) } if got, want := content, "final answer"; got != want { t.Fatalf("content = %q, want %q", got, want) } } func TestSplitThinkTagsFromContentSeparatesThinkingFromAnswer(t *testing.T) { thinking, content := splitThinkTagsFromContent("before inner after") if got, want := thinking, "inner"; got != want { t.Fatalf("thinking = %q, want %q", got, want) } if got, want := content, "before after"; got != want { t.Fatalf("content = %q, want %q", got, want) } } func TestFinalizeStreamedToolCallsOrdersAndConcatenatesArguments(t *testing.T) { toolCalls := map[int]*streamedToolCall{} appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{ Index: 1, ID: "call_2", Type: "function", Function: LMStudioStreamToolCallFunctionDelta{ Name: "qa_agent", Arguments: `{"task":"Check`, }, }) appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{ Index: 0, ID: "call_1", Type: "function", Function: LMStudioStreamToolCallFunctionDelta{ Name: "planner_agent", Arguments: `{"task":"Plan"}`, }, }) appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{ Index: 1, Function: LMStudioStreamToolCallFunctionDelta{ Arguments: ` the result"}`, }, }) got := finalizeStreamedToolCalls(toolCalls) if len(got) != 2 { t.Fatalf("finalizeStreamedToolCalls returned %d calls, want 2", len(got)) } if got[0].ID != "call_1" || got[0].Function.Name != "planner_agent" { t.Fatalf("first tool call = %+v, want planner_agent call_1", got[0]) } if string(got[0].Function.Arguments) != `{"task":"Plan"}` { t.Fatalf("first tool call args = %s, want %s", string(got[0].Function.Arguments), `{"task":"Plan"}`) } if got[1].ID != "call_2" || got[1].Function.Name != "qa_agent" { t.Fatalf("second tool call = %+v, want qa_agent call_2", got[1]) } if string(got[1].Function.Arguments) != `{"task":"Check the result"}` { t.Fatalf("second tool call args = %s, want %s", string(got[1].Function.Arguments), `{"task":"Check the result"}`) } } func TestAPIMessagesKeepEmptyContentFields(t *testing.T) { req := apiRequest{ Model: "test-model", Messages: []apiMessagePart{ { Role: "assistant", Content: "", ToolCalls: []ToolCallAI{ { ID: "call_1", Type: "function", Function: ToolCallFunction{ Name: "planner_agent", Arguments: ToolCallArguments(`{"task":"Plan"}`), }, }, }, }, { Role: "tool", Content: "", ToolCallID: "call_1", Name: "planner_agent", }, }, } data, err := json.Marshal(req) if err != nil { t.Fatalf("json.Marshal returned error: %v", err) } var decoded struct { Messages []struct { Content string `json:"content"` ToolCalls []struct { Function struct { Arguments string `json:"arguments"` } `json:"function"` } `json:"tool_calls"` } `json:"messages"` } if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("json.Unmarshal returned error: %v", err) } if len(decoded.Messages) != 2 { t.Fatalf("decoded %d messages, want 2", len(decoded.Messages)) } if got, want := decoded.Messages[0].Content, ""; got != want { t.Fatalf("assistant message content = %q, want %q", got, want) } if len(decoded.Messages[0].ToolCalls) != 1 { t.Fatalf("assistant message tool call count = %d, want 1", len(decoded.Messages[0].ToolCalls)) } if got, want := decoded.Messages[0].ToolCalls[0].Function.Arguments, `{"task":"Plan"}`; got != want { t.Fatalf("assistant tool call arguments = %q, want %q", got, want) } if got, want := decoded.Messages[1].Content, ""; got != want { t.Fatalf("tool message content = %q, want %q", got, want) } } func TestBuildPromptHistoryInjectsSummaryAndSkipsEarlierMessages(t *testing.T) { history := []db.Message{ {Order: 1, Role: "user", Kind: "message", Content: "old user"}, {Order: 2, Role: "assistant", Kind: "message", Content: "old assistant"}, {Order: 3, Role: "user", Kind: "message", Content: "new user"}, } got := buildPromptHistory(history, "compressed memory", 2) if len(got) != 2 { t.Fatalf("buildPromptHistory returned %d messages, want 2", len(got)) } if got[0].Role != "system" || got[0].Content == "" { t.Fatalf("buildPromptHistory did not inject a system summary message: %+v", got[0]) } if got[1].Content != "new user" { t.Fatalf("buildPromptHistory kept wrong history tail: %+v", got[1]) } } func TestAgentRegistryIncludesCompactor(t *testing.T) { registry := agents.NewAgentRegistry() compactor := registry.GetByType("compactor") if compactor == nil { t.Fatal("expected compactor agent to be registered") } if got, want := compactor.DisplayName(), "Compactor"; got != want { t.Fatalf("compactor display name = %q, want %q", got, want) } } func TestConfiguredCompactionRatiosDefaultToSafeValues(t *testing.T) { originalTrigger := config.LLM.ContextCompactionTriggerRatio originalTarget := config.LLM.ContextCompactionTargetRatio t.Cleanup(func() { config.LLM.ContextCompactionTriggerRatio = originalTrigger config.LLM.ContextCompactionTargetRatio = originalTarget }) config.LLM.ContextCompactionTriggerRatio = 0 config.LLM.ContextCompactionTargetRatio = 0 if got, want := configuredCompactionTriggerRatio(), 0.80; got != want { t.Fatalf("configuredCompactionTriggerRatio() = %v, want %v", got, want) } if got, want := configuredCompactionTargetRatio(), 0.70; got != want { t.Fatalf("configuredCompactionTargetRatio() = %v, want %v", got, want) } } func TestDefaultConfigUsesBuiltInServerDefaults(t *testing.T) { cfg := defaultConfig() if got, want := cfg.Server.Addr, defaultServerAddr; got != want { t.Fatalf("default server addr = %q, want %q", got, want) } if got, want := cfg.Server.Host, defaultServerHost; got != want { t.Fatalf("default server host = %q, want %q", got, want) } if cfg.LLM.AutoOpenReasoning == nil || !*cfg.LLM.AutoOpenReasoning { t.Fatalf("default auto_open_reasoning = %+v, want true", cfg.LLM.AutoOpenReasoning) } } func TestNormalizeConfigDefaultsAutoOpenReasoning(t *testing.T) { cfg := Config{} normalizeConfig(&cfg) if cfg.LLM.AutoOpenReasoning == nil || !*cfg.LLM.AutoOpenReasoning { t.Fatalf("normalizeConfig did not default auto_open_reasoning to true: %+v", cfg.LLM.AutoOpenReasoning) } } func TestValidateContextCompactionSettingsRejectsInvalidValues(t *testing.T) { cases := []contextCompactionSettings{ {ContextTokens: 0, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.7}, {ContextTokens: 100, ContextCompactionTriggerRatio: 1, ContextCompactionTargetRatio: 0.7}, {ContextTokens: 100, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.8}, } for _, tc := range cases { if err := validateContextCompactionSettings(tc); err == nil { t.Fatalf("validateContextCompactionSettings(%+v) = nil, want error", tc) } } } func TestValidateAppSettingsRejectsInvalidValues(t *testing.T) { base := defaultConfig() base.LLM.Provider = "openai" base.LLM.BaseURL = "http://localhost:1234/v1" base.LLM.Model = "qwen/qwen3.6-35b-a3b" cases := []struct { name string mutate func(*AppSettings) }{ {name: "missing server addr", mutate: func(s *AppSettings) { s.ServerAddr = "" }}, {name: "missing server host", mutate: func(s *AppSettings) { s.ServerHost = "" }}, {name: "missing provider", mutate: func(s *AppSettings) { s.Provider = "" }}, } for _, tc := range cases { settings := AppSettings{ LLMConfig: base.LLM, ServerAddr: base.Server.Addr, ServerHost: base.Server.Host, } tc.mutate(&settings) if err := validateAppSettings(settings); err == nil { t.Fatalf("validateAppSettings(%s) = nil, want error", tc.name) } } } func TestValidateLLMSettingsRejectsInvalidValues(t *testing.T) { base := LLMConfig{ Provider: "openai", BaseURL: "http://localhost:1234/v1", APIKey: "lm-studio", Model: "qwen/qwen3.6-35b-a3b", ContextTokens: 1024, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.7, Temperature: 0.3, MaxTokens: 1024, TopP: 1.0, FrequencyPenalty: 0, PresencePenalty: 0, } cases := []struct { name string mutate func(*LLMConfig) }{ {name: "missing provider", mutate: func(cfg *LLMConfig) { cfg.Provider = "" }}, {name: "missing base url", mutate: func(cfg *LLMConfig) { cfg.BaseURL = "" }}, {name: "missing model", mutate: func(cfg *LLMConfig) { cfg.Model = "" }}, {name: "invalid max tokens", mutate: func(cfg *LLMConfig) { cfg.MaxTokens = 0 }}, {name: "invalid top p", mutate: func(cfg *LLMConfig) { cfg.TopP = 0 }}, {name: "invalid context tokens", mutate: func(cfg *LLMConfig) { cfg.ContextTokens = 0 }}, {name: "invalid trigger ratio", mutate: func(cfg *LLMConfig) { cfg.ContextCompactionTriggerRatio = 1 }}, {name: "invalid target ratio", mutate: func(cfg *LLMConfig) { cfg.ContextCompactionTargetRatio = 0.8 }}, {name: "invalid reasoning effort", mutate: func(cfg *LLMConfig) { cfg.ReasoningEffort = "ultra" }}, } for _, tc := range cases { cfg := base tc.mutate(&cfg) if err := validateLLMSettings(cfg); err == nil { t.Fatalf("validateLLMSettings(%s) = nil, want error", tc.name) } } } func TestValidateLLMSettingsAcceptsReasoningEffortValues(t *testing.T) { base := LLMConfig{ Provider: "openai", BaseURL: "http://localhost:1234/v1", APIKey: "lm-studio", Model: "qwen/qwen3.6-35b-a3b", ContextTokens: 1024, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.7, Temperature: 0.3, MaxTokens: 1024, TopP: 1.0, FrequencyPenalty: 0, PresencePenalty: 0, } for _, effort := range []string{"", "low", "medium", "high", " HIGH "} { cfg := base cfg.ReasoningEffort = effort if err := validateLLMSettings(cfg); err != nil { t.Fatalf("validateLLMSettings(%q) returned error: %v", effort, err) } } } func TestUpdateContextCompactionSettingsPersistsToDisk(t *testing.T) { original := currentConfig() t.Cleanup(func() { setConfig(original) }) tempDir := t.TempDir() originalWD, err := os.Getwd() if err != nil { t.Fatalf("os.Getwd returned error: %v", err) } t.Cleanup(func() { _ = os.Chdir(originalWD) }) if err := os.Chdir(tempDir); err != nil { t.Fatalf("os.Chdir returned error: %v", err) } configCopy := defaultConfig() configCopy.Server.Addr = ":9999" configCopy.Server.Host = "localhost" configCopy.LLM.ContextTokens = 512 configCopy.LLM.ContextCompactionTriggerRatio = 0.8 configCopy.LLM.ContextCompactionTargetRatio = 0.7 setConfig(configCopy) settings := contextCompactionSettings{ ContextTokens: 2048, ContextCompactionTriggerRatio: 0.85, ContextCompactionTargetRatio: 0.65, } if err := updateContextCompactionSettings(settings); err != nil { t.Fatalf("updateContextCompactionSettings returned error: %v", err) } updated := currentConfig() if got, want := updated.LLM.ContextTokens, settings.ContextTokens; got != want { t.Fatalf("updated context tokens = %d, want %d", got, want) } if got, want := updated.LLM.ContextCompactionTriggerRatio, settings.ContextCompactionTriggerRatio; got != want { t.Fatalf("updated trigger ratio = %v, want %v", got, want) } if got, want := updated.LLM.ContextCompactionTargetRatio, settings.ContextCompactionTargetRatio; got != want { t.Fatalf("updated target ratio = %v, want %v", got, want) } if got, want := updated.Server.Addr, configCopy.Server.Addr; got != want { t.Fatalf("updated server addr = %q, want %q", got, want) } if got, want := updated.Server.Host, configCopy.Server.Host; got != want { t.Fatalf("updated server host = %q, want %q", got, want) } data, err := os.ReadFile(filepath.Join(tempDir, "config.json")) if err != nil { t.Fatalf("os.ReadFile returned error: %v", err) } if len(data) == 0 { t.Fatal("config.json was not written") } } func TestUpdateLLMSettingsPersistsFullConfig(t *testing.T) { original := currentConfig() t.Cleanup(func() { setConfig(original) }) tempDir := t.TempDir() originalWD, err := os.Getwd() if err != nil { t.Fatalf("os.Getwd returned error: %v", err) } t.Cleanup(func() { _ = os.Chdir(originalWD) }) if err := os.Chdir(tempDir); err != nil { t.Fatalf("os.Chdir returned error: %v", err) } configCopy := defaultConfig() configCopy.Server.Addr = ":9999" configCopy.Server.Host = "localhost" configCopy.LLM = LLMConfig{ Provider: "openai", BaseURL: "http://localhost:4321/v1", APIKey: "new-key", Model: "test-model", ContextTokens: 4096, ContextCompactionTriggerRatio: 0.85, ContextCompactionTargetRatio: 0.6, Temperature: 0.2, MaxTokens: 2048, TopP: 0.95, FrequencyPenalty: 0.25, PresencePenalty: 0.1, } setConfig(configCopy) settings := LLMConfig{ Provider: "anthropic", BaseURL: "https://example.com/v1", APIKey: "updated-key", Model: "claude", ReasoningEffort: "high", AutoOpenReasoning: boolPtr(false), ContextTokens: 8192, ContextCompactionTriggerRatio: 0.9, ContextCompactionTargetRatio: 0.7, Temperature: 0.15, MaxTokens: 4096, TopP: 0.9, FrequencyPenalty: 0.4, PresencePenalty: 0.2, } if err := updateLLMSettings(settings); err != nil { t.Fatalf("updateLLMSettings returned error: %v", err) } updated := currentConfig() if got, want := updated.LLM, settings; !reflect.DeepEqual(got, want) { t.Fatalf("updated LLM config = %+v, want %+v", got, want) } data, err := os.ReadFile(filepath.Join(tempDir, "config.json")) if err != nil { t.Fatalf("os.ReadFile returned error: %v", err) } var persisted Config if err := json.Unmarshal(data, &persisted); err != nil { t.Fatalf("json.Unmarshal returned error: %v", err) } if got, want := persisted.LLM, settings; !reflect.DeepEqual(got, want) { t.Fatalf("persisted LLM config = %+v, want %+v", got, want) } if got, want := persisted.Server.Addr, configCopy.Server.Addr; got != want { t.Fatalf("persisted server addr = %q, want %q", got, want) } if got, want := persisted.Server.Host, configCopy.Server.Host; got != want { t.Fatalf("persisted server host = %q, want %q", got, want) } } func TestUpdateAppSettingsPersistsFullConfig(t *testing.T) { original := currentConfig() t.Cleanup(func() { setConfig(original) }) tempDir := t.TempDir() originalWD, err := os.Getwd() if err != nil { t.Fatalf("os.Getwd returned error: %v", err) } t.Cleanup(func() { _ = os.Chdir(originalWD) }) if err := os.Chdir(tempDir); err != nil { t.Fatalf("os.Chdir returned error: %v", err) } configCopy := defaultConfig() configCopy.Server.Addr = ":9999" configCopy.Server.Host = "localhost" setConfig(configCopy) settings := AppSettings{ LLMConfig: LLMConfig{ Provider: "anthropic", BaseURL: "https://example.com/v1", APIKey: "updated-key", Model: "claude", ReasoningEffort: "low", AutoOpenReasoning: boolPtr(false), ContextTokens: 8192, ContextCompactionTriggerRatio: 0.9, ContextCompactionTargetRatio: 0.7, Temperature: 0.15, MaxTokens: 4096, TopP: 0.9, FrequencyPenalty: 0.4, PresencePenalty: 0.2, }, ServerAddr: ":9090", ServerHost: "127.0.0.1", } if err := updateAppSettings(settings); err != nil { t.Fatalf("updateAppSettings returned error: %v", err) } updated := currentConfig() if got, want := updated.LLM, settings.LLMConfig; !reflect.DeepEqual(got, want) { t.Fatalf("updated LLM config = %+v, want %+v", got, want) } if got, want := updated.Server.Addr, settings.ServerAddr; got != want { t.Fatalf("updated server addr = %q, want %q", got, want) } if got, want := updated.Server.Host, settings.ServerHost; got != want { t.Fatalf("updated server host = %q, want %q", got, want) } data, err := os.ReadFile(filepath.Join(tempDir, "config.json")) if err != nil { t.Fatalf("os.ReadFile returned error: %v", err) } var persisted Config if err := json.Unmarshal(data, &persisted); err != nil { t.Fatalf("json.Unmarshal returned error: %v", err) } if got, want := persisted.LLM, settings.LLMConfig; !reflect.DeepEqual(got, want) { t.Fatalf("persisted LLM config = %+v, want %+v", got, want) } if got, want := persisted.Server.Addr, settings.ServerAddr; got != want { t.Fatalf("persisted server addr = %q, want %q", got, want) } if got, want := persisted.Server.Host, settings.ServerHost; got != want { t.Fatalf("persisted server host = %q, want %q", got, want) } }