test(tasks,agent): cover TaskTicker and agent helpers

Add lifecycle and utility function tests:
- tasks/task_ticker_test.go: TaskTicker lifecycle, recoverAll, followup
- agent/pruning_test.go: resolvePruningSettings, findAssistantCutoff, takeHead/Tail
- agent/extractive_memory_test.go: ExtractiveMemoryFallback, dedup
- agent/intent_classify_test.go: quickClassify, containsWholeWord, ClassifyIntent
- agent/loop_utils_test.go: uniquifyToolCallIDs, shouldShareKG, InvalidateUserWorkspace
- agent/inject_and_misc_test.go: truncateForLog, processInjectedMessage, drainInjectChannel
This commit is contained in:
viettranx committed 2026-04-11 21:22:23 +07:00
1 parent 0494721af2
commit e39e97ee1b
6 files changed
+1787

No files matched your search

+167
View File
@@ -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)
}
}
+234
View File
@@ -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)
}
}
+229
View File
@@ -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")
}
}
+373
View File
@@ -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")
}
}
+322
View File
@@ -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")
}
}
+462
View File
@@ -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())
}
}