diff --git a/internal/agent/extractive_memory_test.go b/internal/agent/extractive_memory_test.go new file mode 100644 index 00000000..8dab0817 --- /dev/null +++ b/internal/agent/extractive_memory_test.go @@ -0,0 +1,167 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// ─── ExtractiveMemoryFallback ───────────────────────────────────────────── + +func TestExtractiveMemoryFallback_EmptyHistory(t *testing.T) { + result := ExtractiveMemoryFallback(nil) + if result != "" { + t.Errorf("empty history should return empty, got %q", result) + } +} + +func TestExtractiveMemoryFallback_OnlySystemAndToolMessages(t *testing.T) { + msgs := []providers.Message{ + {Role: "system", Content: "system prompt"}, + {Role: "tool", Content: "tool result"}, + } + result := ExtractiveMemoryFallback(msgs) + // No user/assistant content → nothing to extract + if result != "" { + t.Errorf("only system/tool roles should return empty, got %q", result) + } +} + +func TestExtractiveMemoryFallback_ExtractsDecision(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "what should we use?"}, + {Role: "assistant", Content: "We decided to use PostgreSQL for the main database"}, + } + result := ExtractiveMemoryFallback(msgs) + if result == "" { + t.Fatal("expected non-empty result") + } + if !strings.Contains(result, "Decisions") { + t.Error("expected Decisions section") + } + if !strings.Contains(result, "PostgreSQL") { + t.Error("expected PostgreSQL in decisions") + } +} + +func TestExtractiveMemoryFallback_ExtractsURL(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "use this endpoint: https://api.example.com/v1/data for all requests"}, + {Role: "assistant", Content: "Got it"}, + } + result := ExtractiveMemoryFallback(msgs) + if !strings.Contains(result, "https://api.example.com/v1/data") { + t.Error("expected URL in key facts") + } +} + +func TestExtractiveMemoryFallback_ExtractsDate(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "the deadline is 2025-12-31"}, + {Role: "assistant", Content: "noted"}, + } + result := ExtractiveMemoryFallback(msgs) + if !strings.Contains(result, "2025-12-31") { + t.Error("expected date in key facts") + } +} + +func TestExtractiveMemoryFallback_ExtractsUserPreference(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "I prefer using tabs over spaces always"}, + {Role: "assistant", Content: "understood"}, + } + result := ExtractiveMemoryFallback(msgs) + if !strings.Contains(result, "Preferences") { + t.Error("expected User Preferences section") + } +} + +func TestExtractiveMemoryFallback_HeaderPresent(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "decided to use Go for backend"}, + {Role: "assistant", Content: "sounds good"}, + } + result := ExtractiveMemoryFallback(msgs) + if !strings.Contains(result, "Extracted Context") { + t.Error("expected header in result") + } +} + +func TestExtractiveMemoryFallback_NothingExtractable(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "hello there"}, + {Role: "assistant", Content: "hi"}, + } + // Might return empty if no patterns match. + // This is valid behavior — we just verify no panic. + _ = ExtractiveMemoryFallback(msgs) +} + +func TestExtractiveMemoryFallback_DeduplicatesMatches(t *testing.T) { + // Same decision repeated twice → should appear once. + msgs := []providers.Message{ + {Role: "user", Content: "decided to use Redis for caching"}, + {Role: "assistant", Content: "decided to use Redis for caching"}, + } + result := ExtractiveMemoryFallback(msgs) + // Count occurrences of "Redis" + count := strings.Count(result, "decided to use Redis") + if count > 1 { + t.Errorf("expected deduplicated output, got %d occurrences", count) + } +} + +// ─── dedup ──────────────────────────────────────────────────────────────── + +func TestDedup_RemovesDuplicates(t *testing.T) { + input := []string{"a", "b", "a", "c", "b"} + got := dedup(input) + if len(got) != 3 { + t.Errorf("dedup = %v, want 3 unique items", got) + } +} + +func TestDedup_EmptyInput(t *testing.T) { + got := dedup(nil) + if got != nil { + t.Errorf("dedup(nil) = %v, want nil", got) + } +} + +func TestDedup_SkipsBlankStrings(t *testing.T) { + input := []string{"a", " ", "", "b"} + got := dedup(input) + for _, s := range got { + if s == "" || s == " " { + t.Errorf("dedup returned blank entry: %q", s) + } + } +} + +func TestDedup_PreservesOrder(t *testing.T) { + input := []string{"c", "a", "b", "a"} + got := dedup(input) + if len(got) != 3 || got[0] != "c" || got[1] != "a" || got[2] != "b" { + t.Errorf("dedup = %v, want [c a b]", got) + } +} + +// ─── appendIfAbsent ─────────────────────────────────────────────────────── + +func TestAppendIfAbsent_AppendsWhenMissing(t *testing.T) { + s := []string{"a", "b"} + got := appendIfAbsent(s, "c") + if len(got) != 3 || got[2] != "c" { + t.Errorf("appendIfAbsent = %v, want [a b c]", got) + } +} + +func TestAppendIfAbsent_SkipsWhenPresent(t *testing.T) { + s := []string{"a", "b", "c"} + got := appendIfAbsent(s, "b") + if len(got) != 3 { + t.Errorf("appendIfAbsent should not append duplicate, got %v", got) + } +} diff --git a/internal/agent/inject_and_misc_test.go b/internal/agent/inject_and_misc_test.go new file mode 100644 index 00000000..c02fc638 --- /dev/null +++ b/internal/agent/inject_and_misc_test.go @@ -0,0 +1,234 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +// ─── truncateForLog ─────────────────────────────────────────────────────── + +func TestTruncateForLog_ShortString(t *testing.T) { + got := truncateForLog("hello", 100) + if got != "hello" { + t.Errorf("short string should be unchanged, got %q", got) + } +} + +func TestTruncateForLog_ExactLimit(t *testing.T) { + s := strings.Repeat("x", 50) + got := truncateForLog(s, 50) + if got != s { + t.Error("exact-limit string should be unchanged") + } +} + +func TestTruncateForLog_TruncatesLongString(t *testing.T) { + s := strings.Repeat("a", 200) + got := truncateForLog(s, 100) + if !strings.HasSuffix(got, "...") { + t.Error("truncated string should end with '...'") + } + if len(got) != 103 { // 100 + len("...") + t.Errorf("truncated length = %d, want 103", len(got)) + } +} + +// ─── processInjectedMessage ─────────────────────────────────────────────── + +func TestProcessInjectedMessage_PlainMessage(t *testing.T) { + l := &Loop{} + result, ok := l.processInjectedMessage(InjectedMessage{Content: "hello", UserID: "u1"}, nil) + if !ok { + t.Fatal("expected ok=true") + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.forSession.Content != "hello" { + t.Errorf("forSession.Content = %q, want 'hello'", result.forSession.Content) + } + if !strings.Contains(result.forLLM.Content, "hello") { + t.Error("forLLM should contain original message") + } + if !strings.Contains(result.forLLM.Content, "follow-up") { + t.Error("forLLM should contain context wrapper") + } + if result.forLLM.Role != "user" { + t.Errorf("forLLM.Role = %q, want 'user'", result.forLLM.Role) + } +} + +func TestProcessInjectedMessage_TruncatesOversizedContent(t *testing.T) { + l := &Loop{maxMessageChars: 10} + longMsg := strings.Repeat("X", 100) + result, ok := l.processInjectedMessage(InjectedMessage{Content: longMsg, UserID: "u1"}, nil) + if !ok { + t.Fatal("expected ok=true") + } + if !strings.Contains(result.forSession.Content, "[Message truncated]") { + t.Error("expected truncation marker in forSession") + } +} + +func TestProcessInjectedMessage_UsesDefaultMaxCharsWhenZero(t *testing.T) { + l := &Loop{maxMessageChars: 0} + // A short message should not be truncated even with 0 (uses default). + result, ok := l.processInjectedMessage(InjectedMessage{Content: "short", UserID: "u1"}, nil) + if !ok { + t.Fatal("expected ok=true") + } + if strings.Contains(result.forSession.Content, "[Message truncated]") { + t.Error("short message should not be truncated") + } +} + +func TestProcessInjectedMessage_EmitRunCalledWhenProvided(t *testing.T) { + l := &Loop{} + emitted := false + emit := func(e AgentEvent) { emitted = true } + _, ok := l.processInjectedMessage(InjectedMessage{Content: "msg", UserID: "u1"}, emit) + if !ok { + t.Fatal("expected ok=true") + } + if !emitted { + t.Error("expected emitRun to be called") + } +} + +// ─── drainInjectChannel ─────────────────────────────────────────────────── + +func TestDrainInjectChannel_NilChannel(t *testing.T) { + l := &Loop{} + forLLM, forSession := l.drainInjectChannel(nil, nil) + if forLLM != nil || forSession != nil { + t.Error("nil channel should return nil slices") + } +} + +func TestDrainInjectChannel_EmptyChannel(t *testing.T) { + l := &Loop{} + ch := make(chan InjectedMessage, 5) + forLLM, forSession := l.drainInjectChannel(ch, nil) + if len(forLLM) != 0 || len(forSession) != 0 { + t.Error("empty channel should return empty slices") + } +} + +func TestDrainInjectChannel_SingleMessage(t *testing.T) { + l := &Loop{} + ch := make(chan InjectedMessage, 5) + ch <- InjectedMessage{Content: "hello", UserID: "u1"} + + forLLM, forSession := l.drainInjectChannel(ch, nil) + if len(forLLM) != 1 || len(forSession) != 1 { + t.Errorf("expected 1 message each, got %d/%d", len(forLLM), len(forSession)) + } + if forSession[0].Content != "hello" { + t.Errorf("forSession[0].Content = %q, want 'hello'", forSession[0].Content) + } +} + +func TestDrainInjectChannel_MultipleMessages(t *testing.T) { + l := &Loop{} + ch := make(chan InjectedMessage, 5) + ch <- InjectedMessage{Content: "msg1", UserID: "u1"} + ch <- InjectedMessage{Content: "msg2", UserID: "u1"} + ch <- InjectedMessage{Content: "msg3", UserID: "u1"} + + forLLM, forSession := l.drainInjectChannel(ch, nil) + if len(forLLM) != 3 || len(forSession) != 3 { + t.Errorf("expected 3 messages each, got %d/%d", len(forLLM), len(forSession)) + } +} + +// ─── filterBootstrapTools ───────────────────────────────────────────────── + +func TestFilterBootstrapTools_AllowedNames(t *testing.T) { + input := []string{"write_file", "read_file", "exec", "Write"} + got := filterBootstrapTools(input) + // Only write_file and Write are in allowlist + for _, name := range got { + if !bootstrapToolAllowlist[name] { + t.Errorf("unexpected tool in filtered list: %q", name) + } + } + found := map[string]bool{} + for _, n := range got { + found[n] = true + } + if !found["write_file"] { + t.Error("write_file should be in bootstrap tools") + } +} + +func TestFilterBootstrapTools_EmptyInput(t *testing.T) { + got := filterBootstrapTools(nil) + if got != nil { + t.Errorf("nil input should return nil, got %v", got) + } +} + +func TestFilterBootstrapTools_NoAllowedTools(t *testing.T) { + got := filterBootstrapTools([]string{"exec", "read_file", "spawn"}) + if len(got) != 0 { + t.Errorf("no allowed tools should return empty, got %v", got) + } +} + +// ─── agentToolPolicyForTeam ─────────────────────────────────────────────── + +func TestAgentToolPolicyForTeam_ReturnsUnchanged(t *testing.T) { + p := &config.ToolPolicySpec{AlsoAllow: []string{"read_file"}} + got := agentToolPolicyForTeam(p, true) + if got != p { + t.Error("agentToolPolicyForTeam should return the same pointer") + } +} + +func TestAgentToolPolicyForTeam_NilPolicyReturnNil(t *testing.T) { + got := agentToolPolicyForTeam(nil, false) + if got != nil { + t.Errorf("nil policy should return nil, got %+v", got) + } +} + +// ─── extractUniqueSubmatch ──────────────────────────────────────────────── + +func TestExtractUniqueSubmatch_FilePaths(t *testing.T) { + text := "See /home/user/project/main.go and /etc/config.yaml for details" + // reFilePath captures group 1 (the path itself) + matches := extractUniqueSubmatch(reFilePath, text, 1) + found := false + for _, m := range matches { + if strings.Contains(m, "main.go") || strings.Contains(m, "config.yaml") { + found = true + } + } + if !found { + t.Errorf("expected file paths in submatch results, got %v", matches) + } +} + +func TestExtractUniqueSubmatch_Deduplicates(t *testing.T) { + // Same path twice → should deduplicate + text := "/home/user/file.go and /home/user/file.go again" + matches := extractUniqueSubmatch(reFilePath, text, 1) + count := 0 + for _, m := range matches { + if strings.Contains(m, "file.go") { + count++ + } + } + if count > 1 { + t.Errorf("expected deduplication, got %d occurrences of file.go", count) + } +} + +func TestExtractUniqueSubmatch_EmptyText(t *testing.T) { + matches := extractUniqueSubmatch(reFilePath, "", 1) + if len(matches) != 0 { + t.Errorf("empty text should return empty, got %v", matches) + } +} diff --git a/internal/agent/intent_classify_test.go b/internal/agent/intent_classify_test.go new file mode 100644 index 00000000..7676827e --- /dev/null +++ b/internal/agent/intent_classify_test.go @@ -0,0 +1,229 @@ +package agent + +import ( + "context" + "errors" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// ─── quickClassify ──────────────────────────────────────────────────────── + +func TestQuickClassify_QuestionMark(t *testing.T) { + intent, ok := quickClassify("?") + if !ok || intent != IntentStatusQuery { + t.Errorf("'?' → got (%v, %v), want (status_query, true)", intent, ok) + } +} + +func TestQuickClassify_CancelKeywords(t *testing.T) { + cases := []string{"stop", "cancel", "abort", "thôi", "dừng", "hủy", "nevermind"} + for _, kw := range cases { + t.Run(kw, func(t *testing.T) { + intent, ok := quickClassify(kw) + if !ok || intent != IntentCancel { + t.Errorf("%q → got (%v, %v), want (cancel, true)", kw, intent, ok) + } + }) + } +} + +func TestQuickClassify_LongMessage_FallsThrough(t *testing.T) { + // > 15 runes → should not fast-path + msg := "this is a much longer message that exceeds fifteen runes" + _, ok := quickClassify(msg) + if ok { + t.Error("long message should not be fast-pathed") + } +} + +func TestQuickClassify_CancelEmbeddedInLongerWord_NoMatch(t *testing.T) { + // "nonstop" contains "stop" but not as a whole word; length is also short. + _, ok := quickClassify("nonstop") + if ok { + t.Error("'nonstop' should not match cancel (stop is not a whole word)") + } +} + +func TestQuickClassify_MixedCase(t *testing.T) { + intent, ok := quickClassify("STOP") + if !ok || intent != IntentCancel { + t.Errorf("'STOP' → got (%v, %v), want (cancel, true)", intent, ok) + } +} + +func TestQuickClassify_EmptyString_FallsThrough(t *testing.T) { + _, ok := quickClassify("") + if ok { + t.Error("empty string should fall through to LLM") + } +} + +// ─── containsWholeWord ─────────────────────────────────────────────────── + +func TestContainsWholeWord(t *testing.T) { + cases := []struct { + s, kw string + want bool + }{ + {"stop", "stop", true}, + {"please stop now", "stop", true}, + {"nonstop", "stop", false}, + {"stopping", "stop", false}, + {"stop!", "stop", true}, + {"hủy bỏ", "hủy", true}, + {"no match here", "cancel", false}, + {"cancel me", "cancel", true}, + } + for _, c := range cases { + got := containsWholeWord(c.s, c.kw) + if got != c.want { + t.Errorf("containsWholeWord(%q, %q) = %v, want %v", c.s, c.kw, got, c.want) + } + } +} + +// ─── ClassifyIntent via mock provider ──────────────────────────────────── + +// stubProvider implements providers.Provider for intent classification tests. +type stubProvider struct { + response string + err error +} + +func (s *stubProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) { + if s.err != nil { + return nil, s.err + } + return &providers.ChatResponse{Content: s.response}, nil +} + +func (s *stubProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) { + return &providers.ChatResponse{Content: s.response}, nil +} + +func (s *stubProvider) DefaultModel() string { return "stub-model" } +func (s *stubProvider) Name() string { return "stub" } + +func TestClassifyIntent_FastPath_Cancel(t *testing.T) { + p := &stubProvider{response: "new_task"} // LLM would say new_task, but fast-path wins + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "stop") + if intent != IntentCancel { + t.Errorf("got %q, want cancel", intent) + } +} + +func TestClassifyIntent_FastPath_StatusQuery(t *testing.T) { + p := &stubProvider{response: "new_task"} + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "?") + if intent != IntentStatusQuery { + t.Errorf("got %q, want status_query", intent) + } +} + +func TestClassifyIntent_LLM_StatusQuery(t *testing.T) { + p := &stubProvider{response: "status_query"} + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "what are you currently working on?") + if intent != IntentStatusQuery { + t.Errorf("got %q, want status_query", intent) + } +} + +func TestClassifyIntent_LLM_Cancel(t *testing.T) { + p := &stubProvider{response: "cancel"} + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "please stop what you are doing right now") + if intent != IntentCancel { + t.Errorf("got %q, want cancel", intent) + } +} + +func TestClassifyIntent_LLM_Steer(t *testing.T) { + p := &stubProvider{response: "steer"} + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "also check the error logs while you are at it") + if intent != IntentSteer { + t.Errorf("got %q, want steer", intent) + } +} + +func TestClassifyIntent_LLM_NewTask(t *testing.T) { + p := &stubProvider{response: "new_task"} + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "what is the capital of France?") + if intent != IntentNewTask { + t.Errorf("got %q, want new_task", intent) + } +} + +func TestClassifyIntent_LLM_Error_FallsBackToNewTask(t *testing.T) { + p := &stubProvider{err: errors.New("provider unavailable")} + intent := ClassifyIntent(context.Background(), p, "gpt-4o", "some long message that exceeds 15 runes") + if intent != IntentNewTask { + t.Errorf("on error should return new_task, got %q", intent) + } +} + +// ─── formatPhase / formatToolLabel ─────────────────────────────────────── + +func TestFormatPhase_KnownPhases(t *testing.T) { + cases := []struct { + phase, tool, locale string + wantEmpty bool + }{ + {"thinking", "", "en", false}, + {"tool_exec", "exec", "en", false}, + {"tool_exec", "", "en", false}, + {"compacting", "", "en", false}, + {"unknown_phase", "", "en", false}, + } + for _, c := range cases { + got := formatPhase(c.phase, c.tool, c.locale) + if c.wantEmpty && got != "" { + t.Errorf("formatPhase(%q,%q) = %q, want empty", c.phase, c.tool, got) + } + if !c.wantEmpty && got == "" { + t.Errorf("formatPhase(%q,%q) returned empty", c.phase, c.tool) + } + } +} + +func TestFormatToolLabel(t *testing.T) { + cases := []struct { + tool string + want string + }{ + {"web_search", "web search"}, + {"web_fetch", "web search"}, + {"exec", "code execution"}, + {"browser", "browser"}, + {"spawn", "delegation"}, + {"memory_read", "memory"}, + {"file_write", "file operations"}, + {"custom_tool", "custom_tool"}, + } + for _, c := range cases { + got := formatToolLabel(c.tool) + if got != c.want { + t.Errorf("formatToolLabel(%q) = %q, want %q", c.tool, got, c.want) + } + } +} + +// ─── FormatStatusReply ──────────────────────────────────────────────────── + +func TestFormatStatusReply_NilStatus(t *testing.T) { + got := FormatStatusReply(nil, "en") + if got == "" { + t.Error("expected non-empty reply for nil status") + } +} + +func TestFormatStatusReply_WithStatus(t *testing.T) { + status := &AgentActivityStatus{ + Phase: "thinking", + Iteration: 2, + } + got := FormatStatusReply(status, "en") + if got == "" { + t.Error("expected non-empty reply with status") + } +} diff --git a/internal/agent/loop_utils_test.go b/internal/agent/loop_utils_test.go new file mode 100644 index 00000000..4366de39 --- /dev/null +++ b/internal/agent/loop_utils_test.go @@ -0,0 +1,373 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ─── uniquifyToolCallIDs ────────────────────────────────────────────────── + +func TestUniquifyToolCallIDs_Empty(t *testing.T) { + got := uniquifyToolCallIDs(nil, "run-1", 1) + if got != nil { + t.Errorf("empty input should return nil, got %v", got) + } +} + +func TestUniquifyToolCallIDs_SingleCall(t *testing.T) { + calls := []providers.ToolCall{{ID: "orig-1", Name: "tool"}} + got := uniquifyToolCallIDs(calls, "run-abc", 1) + if len(got) != 1 { + t.Fatalf("expected 1 call, got %d", len(got)) + } + // ID should be rewritten. + if got[0].ID == "orig-1" { + t.Error("ID should have been rewritten") + } + // Must start with "call_" and be 40 chars. + if !strings.HasPrefix(got[0].ID, "call_") { + t.Errorf("ID should start with 'call_', got %q", got[0].ID) + } + if len(got[0].ID) != 40 { + t.Errorf("ID length = %d, want 40", len(got[0].ID)) + } +} + +func TestUniquifyToolCallIDs_Deterministic(t *testing.T) { + calls := []providers.ToolCall{{ID: "orig-1", Name: "tool"}} + got1 := uniquifyToolCallIDs(calls, "run-abc", 1) + got2 := uniquifyToolCallIDs(calls, "run-abc", 1) + if got1[0].ID != got2[0].ID { + t.Errorf("same inputs should produce same ID: %q vs %q", got1[0].ID, got2[0].ID) + } +} + +func TestUniquifyToolCallIDs_DifferentRunIDsDifferentOutput(t *testing.T) { + calls := []providers.ToolCall{{ID: "orig-1", Name: "tool"}} + got1 := uniquifyToolCallIDs(calls, "run-aaa", 1) + got2 := uniquifyToolCallIDs(calls, "run-bbb", 1) + if got1[0].ID == got2[0].ID { + t.Error("different run IDs should produce different call IDs") + } +} + +func TestUniquifyToolCallIDs_DifferentIterationsDifferentOutput(t *testing.T) { + calls := []providers.ToolCall{{ID: "orig-1", Name: "tool"}} + got1 := uniquifyToolCallIDs(calls, "run-same", 1) + got2 := uniquifyToolCallIDs(calls, "run-same", 2) + if got1[0].ID == got2[0].ID { + t.Error("different iterations should produce different call IDs") + } +} + +func TestUniquifyToolCallIDs_MultipleCalls_UniqueIDs(t *testing.T) { + calls := []providers.ToolCall{ + {ID: "dup-id", Name: "tool1"}, + {ID: "dup-id", Name: "tool2"}, // same original ID + } + got := uniquifyToolCallIDs(calls, "run-x", 1) + if got[0].ID == got[1].ID { + t.Errorf("duplicate original IDs should produce unique outputs: both = %q", got[0].ID) + } +} + +func TestUniquifyToolCallIDs_DoesNotMutateInput(t *testing.T) { + orig := "original-id" + calls := []providers.ToolCall{{ID: orig, Name: "tool"}} + _ = uniquifyToolCallIDs(calls, "run-x", 1) + if calls[0].ID != orig { + t.Error("input slice should not be mutated") + } +} + +// ─── shouldShareKnowledgeGraph ─────────────────────────────────────────── + +func TestShouldShareKnowledgeGraph_NilConfig(t *testing.T) { + l := &Loop{workspaceSharing: nil} + if l.shouldShareKnowledgeGraph() { + t.Error("nil config should return false") + } +} + +func TestShouldShareKnowledgeGraph_EnabledConfig(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ShareKnowledgeGraph: true}} + if !l.shouldShareKnowledgeGraph() { + t.Error("ShareKnowledgeGraph=true should return true") + } +} + +func TestShouldShareKnowledgeGraph_DisabledByDefault(t *testing.T) { + l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ShareMemory: true}} + if l.shouldShareKnowledgeGraph() { + t.Error("ShareMemory alone should not enable KG sharing") + } +} + +// ─── InvalidateUserWorkspace ────────────────────────────────────────────── + +func TestInvalidateUserWorkspace_RemovesCachedSetup(t *testing.T) { + l := &Loop{} + userID := "user-abc" + // Store a value. + l.userSetups.Store(userID, &userSetup{workspace: "/tmp/ws"}) + + l.InvalidateUserWorkspace(userID) + + if _, ok := l.userSetups.Load(userID); ok { + t.Error("userSetup should be removed after invalidate") + } +} + +func TestInvalidateUserWorkspace_NonExistentKeyIsNoop(t *testing.T) { + l := &Loop{} + l.InvalidateUserWorkspace("ghost-user") // must not panic +} + +// ─── ProviderName ───────────────────────────────────────────────────────── + +func TestProviderName_NilProvider(t *testing.T) { + l := &Loop{} + if got := l.ProviderName(); got != "" { + t.Errorf("nil provider should return empty, got %q", got) + } +} + +func TestProviderName_WithProvider(t *testing.T) { + l := &Loop{provider: &stubProvider{}} + if got := l.ProviderName(); got != "stub" { + t.Errorf("ProviderName = %q, want 'stub'", got) + } +} + +// ─── expandWorkspace ────────────────────────────────────────────────────── + +func TestExpandWorkspace_AbsolutePathUnchanged(t *testing.T) { + got := expandWorkspace("/absolute/path") + if got != "/absolute/path" { + t.Errorf("expandWorkspace = %q, want /absolute/path", got) + } +} + +func TestExpandWorkspace_HomeExpanded(t *testing.T) { + got := expandWorkspace("~/projects") + if strings.HasPrefix(got, "~") { + t.Errorf("tilde not expanded: %q", got) + } + if !strings.HasPrefix(got, "/") { + t.Errorf("expected absolute path after ~ expansion, got %q", got) + } +} + +func TestExpandWorkspace_RelativePathBecomesAbsolute(t *testing.T) { + got := expandWorkspace("relative/path") + if !strings.HasPrefix(got, "/") { + t.Errorf("relative path should become absolute, got %q", got) + } +} + +// ─── buildChannelMeta ───────────────────────────────────────────────────── + +func TestBuildChannelMeta_NilRequest(t *testing.T) { + l := &Loop{} + if got := l.buildChannelMeta(nil); got != nil { + t.Errorf("nil request should return nil, got %+v", got) + } +} + +func TestBuildChannelMeta_EmptyChannelType(t *testing.T) { + l := &Loop{} + req := &RunRequest{ChannelType: ""} + if got := l.buildChannelMeta(req); got != nil { + t.Errorf("empty ChannelType should return nil, got %+v", got) + } +} + +func TestBuildChannelMeta_WithChannelType(t *testing.T) { + l := &Loop{defaultTimezone: "Asia/Ho_Chi_Minh"} + req := &RunRequest{ + ChannelType: "telegram", + SenderName: "Alice", + } + meta := l.buildChannelMeta(req) + if meta == nil { + t.Fatal("expected non-nil meta") + } + if meta.ChannelType != "telegram" { + t.Errorf("ChannelType = %q, want 'telegram'", meta.ChannelType) + } + if meta.DisplayName != "Alice" { + t.Errorf("DisplayName = %q, want 'Alice'", meta.DisplayName) + } + if meta.DefaultTimezone != "Asia/Ho_Chi_Minh" { + t.Errorf("DefaultTimezone = %q", meta.DefaultTimezone) + } +} + +// ─── agentToolPolicyWithMCP ─────────────────────────────────────────────── + +func TestAgentToolPolicyWithMCP_NilPolicyNoMCP(t *testing.T) { + got := agentToolPolicyWithMCP(nil, false) + if got != nil { + t.Errorf("no MCP with nil policy should return nil, got %+v", got) + } +} + +func TestAgentToolPolicyWithMCP_NilPolicyWithMCP(t *testing.T) { + got := agentToolPolicyWithMCP(nil, true) + if got == nil { + t.Fatal("nil policy with MCP should return non-nil policy") + } + found := false + for _, a := range got.AlsoAllow { + if a == "group:mcp" { + found = true + } + } + if !found { + t.Error("expected group:mcp in AlsoAllow") + } +} + +func TestAgentToolPolicyWithMCP_NoDuplicateMCPGroup(t *testing.T) { + p := &config.ToolPolicySpec{AlsoAllow: []string{"group:mcp", "other"}} + got := agentToolPolicyWithMCP(p, true) + count := 0 + for _, a := range got.AlsoAllow { + if a == "group:mcp" { + count++ + } + } + if count != 1 { + t.Errorf("group:mcp should appear exactly once, got %d", count) + } +} + +// ─── agentToolPolicyWithWorkspace ───────────────────────────────────────── + +func TestAgentToolPolicyWithWorkspace_NoTeam(t *testing.T) { + got := agentToolPolicyWithWorkspace(nil, false) + if got != nil { + t.Errorf("no team should return nil, got %+v", got) + } +} + +func TestAgentToolPolicyWithWorkspace_WithTeam_InjectsFileTools(t *testing.T) { + got := agentToolPolicyWithWorkspace(nil, true) + if got == nil { + t.Fatal("with team should return non-nil policy") + } + required := []string{"read_file", "write_file", "list_files"} + for _, tool := range required { + found := false + for _, a := range got.AlsoAllow { + if a == tool { + found = true + } + } + if !found { + t.Errorf("expected %q in AlsoAllow", tool) + } + } +} + +func TestAgentToolPolicyWithWorkspace_NoDuplicates(t *testing.T) { + p := &config.ToolPolicySpec{AlsoAllow: []string{"read_file"}} + got := agentToolPolicyWithWorkspace(p, true) + count := 0 + for _, a := range got.AlsoAllow { + if a == "read_file" { + count++ + } + } + if count != 1 { + t.Errorf("read_file should appear exactly once, got %d", count) + } +} + +// ─── buildTeamMD ───────────────────────────────────────────────────────── + +func TestBuildTeamMD_LeadRole_ContainsWorkflow(t *testing.T) { + selfID := uuid.New() + team := &store.TeamData{ + Name: "test-team", + Description: "desc", + } + members := []store.TeamMemberData{ + {AgentID: selfID, Role: store.TeamRoleLead, AgentKey: "lead-agent"}, + {AgentID: uuid.New(), Role: store.TeamRoleMember, AgentKey: "member-1", DisplayName: "Member One"}, + } + md := buildTeamMD(team, members, selfID) + if !strings.Contains(md, "# Team: test-team") { + t.Error("expected team name in header") + } + if !strings.Contains(md, "Role: lead") { + t.Error("expected role in output") + } + if !strings.Contains(md, "team_tasks") { + t.Error("expected team_tasks in lead workflow section") + } + if !strings.Contains(md, "Member One") { + t.Error("expected member display name") + } +} + +func TestBuildTeamMD_MemberRole_NoTaskCreation(t *testing.T) { + selfID := uuid.New() + team := &store.TeamData{Name: "proj"} + members := []store.TeamMemberData{ + {AgentID: selfID, Role: store.TeamRoleMember, AgentKey: "member-agent"}, + } + md := buildTeamMD(team, members, selfID) + if !strings.Contains(md, "Role: member") { + t.Error("expected member role in output") + } + // Member section should not contain lead-only task graph instructions + if strings.Contains(md, "Task Planning") { + t.Error("Task Planning section should only appear for lead") + } +} + +func TestBuildTeamMD_ReviewerRole_ContainsApproveInstructions(t *testing.T) { + selfID := uuid.New() + team := &store.TeamData{Name: "review-team"} + members := []store.TeamMemberData{ + {AgentID: selfID, Role: store.TeamRoleReviewer, AgentKey: "reviewer-1"}, + } + md := buildTeamMD(team, members, selfID) + if !strings.Contains(md, "APPROVED") { + t.Error("expected APPROVED/REJECTED guidance for reviewer") + } +} + +func TestBuildTeamMD_EmptyMembers(t *testing.T) { + selfID := uuid.New() + team := &store.TeamData{Name: "solo"} + md := buildTeamMD(team, nil, selfID) + // selfID not in members → defaults to member role + if !strings.Contains(md, "# Team: solo") { + t.Error("expected team name") + } +} + +func TestBuildTeamMD_LeadSeesReviewersSection(t *testing.T) { + selfID := uuid.New() + reviewerID := uuid.New() + team := &store.TeamData{Name: "full-team"} + members := []store.TeamMemberData{ + {AgentID: selfID, Role: store.TeamRoleLead, AgentKey: "lead"}, + {AgentID: reviewerID, Role: store.TeamRoleReviewer, AgentKey: "rev-1", DisplayName: "RevBot"}, + } + md := buildTeamMD(team, members, selfID) + if !strings.Contains(md, "Reviewers") { + t.Error("lead should see Reviewers section") + } + if !strings.Contains(md, "RevBot") { + t.Error("expected reviewer display name") + } +} diff --git a/internal/agent/pruning_test.go b/internal/agent/pruning_test.go new file mode 100644 index 00000000..98c54d2e --- /dev/null +++ b/internal/agent/pruning_test.go @@ -0,0 +1,322 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// ─── resolvePruningSettings ─────────────────────────────────────────────── + +func TestResolvePruningSettings_Defaults(t *testing.T) { + s := resolvePruningSettings(nil) + if s.keepLastAssistants != defaultKeepLastAssistants { + t.Errorf("keepLastAssistants = %d, want %d", s.keepLastAssistants, defaultKeepLastAssistants) + } + if s.softTrimRatio != defaultSoftTrimRatio { + t.Errorf("softTrimRatio = %v, want %v", s.softTrimRatio, defaultSoftTrimRatio) + } + if s.hardClearRatio != defaultHardClearRatio { + t.Errorf("hardClearRatio = %v, want %v", s.hardClearRatio, defaultHardClearRatio) + } + if !s.hardClearEnabled { + t.Error("hardClearEnabled should be true by default") + } + if s.hardClearPlaceholder != defaultHardClearPlaceholder { + t.Errorf("placeholder = %q, want %q", s.hardClearPlaceholder, defaultHardClearPlaceholder) + } +} + +func TestResolvePruningSettings_CustomValues(t *testing.T) { + enabled := false + cfg := &config.ContextPruningConfig{ + KeepLastAssistants: 5, + SoftTrimRatio: 0.4, + HardClearRatio: 0.6, + MinPrunableToolChars: 10000, + SoftTrim: &config.ContextPruningSoftTrim{ + MaxChars: 2000, + HeadChars: 800, + TailChars: 800, + }, + HardClear: &config.ContextPruningHardClear{ + Enabled: &enabled, + Placeholder: "[gone]", + }, + } + s := resolvePruningSettings(cfg) + if s.keepLastAssistants != 5 { + t.Errorf("keepLastAssistants = %d, want 5", s.keepLastAssistants) + } + if s.softTrimRatio != 0.4 { + t.Errorf("softTrimRatio = %v, want 0.4", s.softTrimRatio) + } + if s.hardClearEnabled { + t.Error("hardClearEnabled should be false") + } + if s.hardClearPlaceholder != "[gone]" { + t.Errorf("placeholder = %q, want [gone]", s.hardClearPlaceholder) + } + if s.softTrimMaxChars != 2000 { + t.Errorf("softTrimMaxChars = %d, want 2000", s.softTrimMaxChars) + } +} + +func TestResolvePruningSettings_InvalidRatiosIgnored(t *testing.T) { + // Ratios out of (0,1] range should be ignored, defaults kept. + cfg := &config.ContextPruningConfig{ + SoftTrimRatio: 1.5, // invalid + HardClearRatio: 0, // invalid (0 not > 0) + } + s := resolvePruningSettings(cfg) + if s.softTrimRatio != defaultSoftTrimRatio { + t.Errorf("invalid SoftTrimRatio should keep default, got %v", s.softTrimRatio) + } + if s.hardClearRatio != defaultHardClearRatio { + t.Errorf("invalid HardClearRatio should keep default, got %v", s.hardClearRatio) + } +} + +// ─── findAssistantCutoff ────────────────────────────────────────────────── + +func TestFindAssistantCutoff_FewerThanKeepLast(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + } + // keepLast=3 but only 1 assistant → returns -1 (not enough to protect) + idx := findAssistantCutoff(msgs, 3) + if idx != -1 { + t.Errorf("cutoff = %d, want -1", idx) + } +} + +func TestFindAssistantCutoff_ExactlyKeepLast(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "next"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "end"}, + {Role: "assistant", Content: "a3"}, + } + // keepLast=3: protect all 3 assistant messages → cutoff at index of first assistant + idx := findAssistantCutoff(msgs, 3) + if idx != 1 { + t.Errorf("cutoff = %d, want 1", idx) + } +} + +func TestFindAssistantCutoff_MoreThanKeepLast(t *testing.T) { + // 5 assistants, keepLast=2 → cutoff at 3rd-from-last assistant + msgs := []providers.Message{ + {Role: "assistant", Content: "a0"}, // idx 0 + {Role: "assistant", Content: "a1"}, // idx 1 + {Role: "assistant", Content: "a2"}, // idx 2 + {Role: "assistant", Content: "a3"}, // idx 3 + {Role: "assistant", Content: "a4"}, // idx 4 + } + idx := findAssistantCutoff(msgs, 2) + // 2nd from last = idx 3 + if idx != 3 { + t.Errorf("cutoff = %d, want 3", idx) + } +} + +func TestFindAssistantCutoff_ZeroKeepLast(t *testing.T) { + msgs := []providers.Message{{Role: "assistant", Content: "x"}} + // keepLast=0 → return len(msgs) (protect nothing) + idx := findAssistantCutoff(msgs, 0) + if idx != len(msgs) { + t.Errorf("cutoff = %d, want %d", idx, len(msgs)) + } +} + +// ─── takeHead / takeTail ────────────────────────────────────────────────── + +func TestTakeHead(t *testing.T) { + cases := []struct { + input string + n int + want string + }{ + {"hello", 3, "hel"}, + {"hello", 10, "hello"}, + {"hello", 0, ""}, + {"αβγδε", 3, "αβγ"}, // unicode + } + for _, c := range cases { + got := takeHead(c.input, c.n) + if got != c.want { + t.Errorf("takeHead(%q, %d) = %q, want %q", c.input, c.n, got, c.want) + } + } +} + +func TestTakeTail(t *testing.T) { + cases := []struct { + input string + n int + want string + }{ + {"hello", 3, "llo"}, + {"hello", 10, "hello"}, + {"hello", 0, ""}, + {"αβγδε", 2, "δε"}, // unicode + } + for _, c := range cases { + got := takeTail(c.input, c.n) + if got != c.want { + t.Errorf("takeTail(%q, %d) = %q, want %q", c.input, c.n, got, c.want) + } + } +} + +// ─── estimateMessageChars ───────────────────────────────────────────────── + +func TestEstimateMessageChars(t *testing.T) { + m := providers.Message{Content: "αβγ"} // 3 runes, 6 bytes + if got := estimateMessageChars(m); got != 3 { + t.Errorf("estimateMessageChars = %d, want 3", got) + } +} + +// ─── pruneContextMessages — mode off ──────────────────────────────────── + +func TestPruneContextMessages_ModeOff_ReturnsOriginal(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + } + cfg := &config.ContextPruningConfig{Mode: "off"} + got := pruneContextMessages(msgs, 100000, cfg) + if len(got) != len(msgs) { + t.Error("mode=off should return original slice") + } +} + +func TestPruneContextMessages_ZeroWindow_ReturnsOriginal(t *testing.T) { + msgs := []providers.Message{{Role: "user", Content: "hi"}} + got := pruneContextMessages(msgs, 0, nil) + if len(got) != len(msgs) { + t.Error("zero context window should return original") + } +} + +func TestPruneContextMessages_SmallContext_NoChange(t *testing.T) { + // Total content << softTrimRatio → no pruning. + msgs := []providers.Message{ + {Role: "user", Content: "short"}, + {Role: "assistant", Content: "reply", ToolCalls: nil}, + {Role: "user", Content: "another"}, + {Role: "assistant", Content: "done"}, + } + // Very large window → ratio tiny → nothing pruned. + got := pruneContextMessages(msgs, 1_000_000, nil) + if len(got) != len(msgs) { + t.Errorf("small context: len=%d, want %d", len(got), len(msgs)) + } +} + +func TestPruneContextMessages_SoftTrim_LongToolResult(t *testing.T) { + // Build scenario: 4 assistant messages (so keepLast=3 protects last 3), + // and an old tool result that is very long. + toolContent := strings.Repeat("X", 10000) // long + + msgs := []providers.Message{ + {Role: "user", Content: "step1"}, + {Role: "assistant", Content: "a0", ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "t"}}}, + {Role: "tool", Content: toolContent, ToolCallID: "tc1"}, + {Role: "user", Content: "step2"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "step3"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "step4"}, + {Role: "assistant", Content: "a3"}, + } + + cfg := &config.ContextPruningConfig{ + SoftTrimRatio: 0.01, // threshold very low → prune immediately + HardClearRatio: 0.99, // don't hard clear + SoftTrim: &config.ContextPruningSoftTrim{ + MaxChars: 100, + HeadChars: 50, + TailChars: 50, + }, + } + // Use a context window that makes ratio exceed threshold. + got := pruneContextMessages(msgs, 100, cfg) + + // Tool result should be trimmed — shorter than original. + for _, m := range got { + if m.Role == "tool" { + if len(m.Content) >= len(toolContent) { + t.Error("expected tool result to be trimmed") + } + return + } + } + // If tool message not found, that's also acceptable (hard-cleared). +} + +func TestPruneContextMessages_HardClear_WhenRatioVeryHigh(t *testing.T) { + // Set up context that forces hard clear. + toolContent := strings.Repeat("Y", 5000) + + msgs := []providers.Message{ + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "a0", ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "t"}}}, + {Role: "tool", Content: toolContent, ToolCallID: "tc1"}, + {Role: "user", Content: "q2"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q3"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "q4"}, + {Role: "assistant", Content: "a3"}, + } + + enabled := true + cfg := &config.ContextPruningConfig{ + SoftTrimRatio: 0.001, + HardClearRatio: 0.001, + MinPrunableToolChars: 1, // very low threshold + SoftTrim: &config.ContextPruningSoftTrim{ + MaxChars: 100, + HeadChars: 50, + TailChars: 50, + }, + HardClear: &config.ContextPruningHardClear{ + Enabled: &enabled, + Placeholder: "[cleared]", + }, + } + + got := pruneContextMessages(msgs, 10, cfg) + + // Tool result should be replaced by placeholder or heavily trimmed. + for _, m := range got { + if m.Role == "tool" { + if len(m.Content) >= len(toolContent) { + t.Error("tool content should have been reduced") + } + } + } +} + +// ─── hasImportantTail ──────────────────────────────────────────────────── + +func TestHasImportantTail_MatchesErrorKeyword(t *testing.T) { + content := strings.Repeat("x", 1000) + " error occurred" + if !hasImportantTail(content) { + t.Error("expected important tail (error keyword)") + } +} + +func TestHasImportantTail_ShortContentNoMatch(t *testing.T) { + // No keywords → false + if hasImportantTail("no important content here at all") { + t.Error("expected no important tail") + } +} diff --git a/internal/tasks/task_ticker_test.go b/internal/tasks/task_ticker_test.go new file mode 100644 index 00000000..03a052c2 --- /dev/null +++ b/internal/tasks/task_ticker_test.go @@ -0,0 +1,462 @@ +package tasks + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ─── minimal stub stores ─────────────────────────────────────────────────── + +// stubTeamStore satisfies store.TeamStore by embedding the interface and only +// overriding the methods actually exercised by TaskTicker. +type stubTeamStore struct { + store.TeamStore + + mu sync.Mutex + + forceRecoverCalls atomic.Int32 + recoverCalls atomic.Int32 + staleCalls atomic.Int32 + inReviewCalls atomic.Int32 + orphanCalls atomic.Int32 + followupListCalls atomic.Int32 + incrementCalls atomic.Int32 + + forceRecoverErr error + recoverErr error + staleErr error + + recovered []store.RecoveredTaskInfo + stale []store.RecoveredTaskInfo + inReview []store.RecoveredTaskInfo + orphans []store.RecoveredTaskInfo + followupTasks []store.TeamTaskData +} + +func (s *stubTeamStore) ForceRecoverAllTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) { + s.forceRecoverCalls.Add(1) + return s.recovered, s.forceRecoverErr +} + +func (s *stubTeamStore) RecoverAllStaleTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) { + s.recoverCalls.Add(1) + return s.recovered, s.recoverErr +} + +func (s *stubTeamStore) MarkAllStaleTasks(_ context.Context, _ time.Time) ([]store.RecoveredTaskInfo, error) { + s.staleCalls.Add(1) + return s.stale, s.staleErr +} + +func (s *stubTeamStore) MarkInReviewStaleTasks(_ context.Context, _ time.Time) ([]store.RecoveredTaskInfo, error) { + s.inReviewCalls.Add(1) + return s.inReview, nil +} + +func (s *stubTeamStore) FixOrphanedBlockedTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) { + s.orphanCalls.Add(1) + return s.orphans, nil +} + +func (s *stubTeamStore) ListAllFollowupDueTasks(_ context.Context) ([]store.TeamTaskData, error) { + s.followupListCalls.Add(1) + return s.followupTasks, nil +} + +func (s *stubTeamStore) IncrementFollowupCount(_ context.Context, _ uuid.UUID, _ *time.Time) error { + s.incrementCalls.Add(1) + return nil +} + +func (s *stubTeamStore) GetTeam(_ context.Context, _ uuid.UUID) (*store.TeamData, error) { + return nil, errors.New("not found") +} + +func (s *stubTeamStore) GetTask(_ context.Context, _ uuid.UUID) (*store.TeamTaskData, error) { + return nil, errors.New("not found") +} + +// stubAgentStore satisfies store.AgentStore — unused by ticker logic. +type stubAgentStore struct { + store.AgentStore +} + +// ─── NewTaskTicker ───────────────────────────────────────────────────────── + +func TestNewTaskTicker_DefaultInterval(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 0) + if tt.interval != defaultRecoveryInterval { + t.Errorf("interval = %v, want %v", tt.interval, defaultRecoveryInterval) + } +} + +func TestNewTaskTicker_CustomInterval(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 7) + if tt.interval != 7*time.Second { + t.Errorf("interval = %v, want 7s", tt.interval) + } +} + +func TestNewTaskTicker_NegativeIntervalUsesDefault(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, -1) + if tt.interval != defaultRecoveryInterval { + t.Errorf("interval = %v, want default", tt.interval) + } +} + +// ─── Start / Stop ────────────────────────────────────────────────────────── + +func TestTaskTicker_StartStop(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + // Use a large interval so periodic ticks don't fire. + tt := NewTaskTicker(ts, as, nil, 3600) + tt.Start() + tt.Stop() // must not block + + // ForceRecover must have been called exactly once (startup recovery). + if n := ts.forceRecoverCalls.Load(); n != 1 { + t.Errorf("ForceRecoverAllTasks called %d times, want 1", n) + } +} + +func TestTaskTicker_StopIsIdempotentAndClean(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + tt.Start() + + done := make(chan struct{}) + go func() { + tt.Stop() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Stop() did not return within 2s") + } +} + +// ─── Periodic tick ───────────────────────────────────────────────────────── + +func TestTaskTicker_PeriodicTick_CallsRecoverOnTick(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + // Very short interval so we get at least one tick. + tt := NewTaskTicker(ts, as, nil, 0) + // Override interval to something tiny. + tt.interval = 10 * time.Millisecond + + tt.Start() + + // Wait for at least one periodic tick (recoverCalls increments on tick). + deadline := time.After(500 * time.Millisecond) + for { + if ts.recoverCalls.Load() >= 1 { + break + } + select { + case <-deadline: + t.Fatalf("RecoverAllStaleTasks never called after 500ms (periodic tick missed)") + default: + time.Sleep(5 * time.Millisecond) + } + } + + tt.Stop() +} + +// ─── recoverAll steps (unit-level, no goroutine) ────────────────────────── + +func TestTaskTicker_RecoverAll_ForceRecoverCalled(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + + tt.recoverAll(true) + + if ts.forceRecoverCalls.Load() != 1 { + t.Errorf("ForceRecoverAllTasks calls = %d, want 1", ts.forceRecoverCalls.Load()) + } + if ts.recoverCalls.Load() != 0 { + t.Errorf("RecoverAllStaleTasks unexpectedly called") + } +} + +func TestTaskTicker_RecoverAll_StaleCalled(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + + tt.recoverAll(false) + + if ts.staleCalls.Load() != 1 { + t.Errorf("MarkAllStaleTasks calls = %d, want 1", ts.staleCalls.Load()) + } + if ts.inReviewCalls.Load() != 1 { + t.Errorf("MarkInReviewStaleTasks calls = %d, want 1", ts.inReviewCalls.Load()) + } + if ts.orphanCalls.Load() != 1 { + t.Errorf("FixOrphanedBlockedTasks calls = %d, want 1", ts.orphanCalls.Load()) + } +} + +func TestTaskTicker_RecoverAll_ErrorsAreToleratedGracefully(t *testing.T) { + ts := &stubTeamStore{ + forceRecoverErr: errors.New("db offline"), + recoverErr: errors.New("db offline"), + staleErr: errors.New("db offline"), + } + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + + // Must not panic + tt.recoverAll(true) + tt.recoverAll(false) +} + +// ─── followupInterval ────────────────────────────────────────────────────── + +func TestFollowupInterval_DefaultWhenNoSettings(t *testing.T) { + d := followupInterval(store.TeamData{}) + if d != defaultFollowupInterval { + t.Errorf("got %v, want %v", d, defaultFollowupInterval) + } +} + +func TestFollowupInterval_CustomFromSettings(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"followup_interval_minutes": float64(10)}) + d := followupInterval(store.TeamData{Settings: raw}) + if d != 10*time.Minute { + t.Errorf("got %v, want 10m", d) + } +} + +func TestFollowupInterval_ZeroValueUsesDefault(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"followup_interval_minutes": float64(0)}) + d := followupInterval(store.TeamData{Settings: raw}) + if d != defaultFollowupInterval { + t.Errorf("got %v, want default", d) + } +} + +func TestFollowupInterval_InvalidJSONUsesDefault(t *testing.T) { + d := followupInterval(store.TeamData{Settings: []byte("not-json")}) + if d != defaultFollowupInterval { + t.Errorf("got %v, want default", d) + } +} + +// ─── pruneCooldowns ─────────────────────────────────────────────────────── + +func TestPruneCooldowns_RemovesExpiredEntries(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + + id1 := uuid.New() + id2 := uuid.New() + + // id1 is old enough to be pruned (> 2*followupCooldown) + tt.lastFollowupSent[id1] = time.Now().Add(-(2*followupCooldown + time.Second)) + // id2 is recent — should be kept + tt.lastFollowupSent[id2] = time.Now() + + tt.pruneCooldowns() + + if _, ok := tt.lastFollowupSent[id1]; ok { + t.Error("expected id1 to be pruned") + } + if _, ok := tt.lastFollowupSent[id2]; !ok { + t.Error("expected id2 to be kept") + } +} + +func TestPruneCooldowns_EmptyMapIsNoop(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + tt.pruneCooldowns() // must not panic +} + +// ─── processTeamFollowups ───────────────────────────────────────────────── + +func TestProcessTeamFollowups_CooldownPreventsRepeat(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + mb := bus.New() + tt := NewTaskTicker(ts, as, mb, 3600) + + taskID := uuid.New() + // Mark as sent recently. + tt.lastFollowupSent[taskID] = time.Now() + + tasks := []store.TeamTaskData{ + { + BaseModel: store.BaseModel{ID: taskID}, + FollowupChannel: "telegram", + FollowupChatID: "chat-1", + FollowupMessage: "any reminder", + }, + } + + tt.processTeamFollowups(context.Background(), tasks, defaultFollowupInterval) + + // Bus should not receive an outbound message because of cooldown. + select { + case <-time.After(20 * time.Millisecond): + // Good: nothing sent. + } + if ts.incrementCalls.Load() != 0 { + t.Error("IncrementFollowupCount should not be called when on cooldown") + } +} + +func TestProcessTeamFollowups_SkipsEmptyChannel(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + mb := bus.New() + tt := NewTaskTicker(ts, as, mb, 3600) + + taskID := uuid.New() + tasks := []store.TeamTaskData{ + { + BaseModel: store.BaseModel{ID: taskID}, + FollowupChannel: "", // empty — should skip + FollowupChatID: "chat-1", + FollowupMessage: "any reminder", + }, + } + + tt.processTeamFollowups(context.Background(), tasks, defaultFollowupInterval) + + if ts.incrementCalls.Load() != 0 { + t.Error("IncrementFollowupCount should not be called when channel is empty") + } +} + +func TestProcessTeamFollowups_SendsReminderWhenEligible(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + mb := bus.New() + tt := NewTaskTicker(ts, as, mb, 3600) + + taskID := uuid.New() + tasks := []store.TeamTaskData{ + { + BaseModel: store.BaseModel{ID: taskID}, + FollowupChannel: "telegram", + FollowupChatID: "chat-99", + FollowupMessage: "please respond", + FollowupCount: 0, + FollowupMax: 3, + }, + } + + tt.processTeamFollowups(context.Background(), tasks, defaultFollowupInterval) + + // Should have incremented followup count. + if ts.incrementCalls.Load() != 1 { + t.Errorf("IncrementFollowupCount calls = %d, want 1", ts.incrementCalls.Load()) + } + + // lastFollowupSent should be updated. + tt.mu.Lock() + _, exists := tt.lastFollowupSent[taskID] + tt.mu.Unlock() + if !exists { + t.Error("lastFollowupSent not updated for task") + } +} + +func TestProcessTeamFollowups_MaxReached_NoNextAt(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + mb := bus.New() + tt := NewTaskTicker(ts, as, mb, 3600) + + taskID := uuid.New() + tasks := []store.TeamTaskData{ + { + BaseModel: store.BaseModel{ID: taskID}, + FollowupChannel: "telegram", + FollowupChatID: "chat-99", + FollowupMessage: "last reminder", + FollowupCount: 2, + FollowupMax: 3, // newCount = 3 = max → nextAt should be nil + }, + } + + tt.processTeamFollowups(context.Background(), tasks, defaultFollowupInterval) + + // Increment should still be called (it receives nil nextAt when max reached). + if ts.incrementCalls.Load() != 1 { + t.Errorf("IncrementFollowupCount calls = %d, want 1", ts.incrementCalls.Load()) + } +} + +// ─── notifyLeaders (nil msgBus is a no-op) ──────────────────────────────── + +func TestNotifyLeaders_NilMsgBusIsNoop(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + + // Must not panic even with tasks. + tt.notifyLeaders(context.Background(), []store.RecoveredTaskInfo{ + {ID: uuid.New(), TeamID: uuid.New(), TenantID: uuid.New()}, + }, "recovered", "hint") +} + +// ─── Race detector: concurrent Start/Stop ──────────────────────────────── + +func TestTaskTicker_ConcurrentStartStop_Race(t *testing.T) { + ts := &stubTeamStore{} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + tt.Start() + + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Access cooldown map concurrently via pruneCooldowns (uses lock). + tt.pruneCooldowns() + }() + } + wg.Wait() + tt.Stop() +} + +// ─── processFollowups with no tasks ────────────────────────────────────── + +func TestProcessFollowups_NoTasksIsNoop(t *testing.T) { + ts := &stubTeamStore{followupTasks: nil} + as := &stubAgentStore{} + tt := NewTaskTicker(ts, as, nil, 3600) + + tt.processFollowups(context.Background()) + + if ts.incrementCalls.Load() != 0 { + t.Errorf("IncrementFollowupCount calls = %d, want 0", ts.incrementCalls.Load()) + } +}