mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 17:04:21 +00:00
e39e97ee1b
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
230 lines
7.0 KiB
Go
230 lines
7.0 KiB
Go
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")
|
|
}
|
|
}
|