Files
DemoCoder/main_test.go
2026-04-21 13:19:17 +03:00

615 lines
20 KiB
Go

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 <think>inner</think> 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)
}
}