mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
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
235 lines
7.5 KiB
Go
235 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|