mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
1 parent
0494721af2
commit
e39e97ee1b
6 files changed
+1787
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user