Files
HomeAgent/internal/agent/core/context_test.go
root 3fc2151588 refactor: pluginize text cleaning and tool NoMemory control
- SDK: ToolDef.NoMemory field, PluginSDK.RegisterTextCleaner/TextCleaners
- Registry: aggregate text cleaners from plugins, expose CleanText()
- Memory: replace hardcoded QQ regex CleanTemplateText with dynamic CleanText/SetTextCleaner
- StageHost: add ToolDef(name) lookup
- eventloop: check ToolDef.NoMemory before emitMemoryCandidate
- context/Prune: replace hardcoded agentcli/terminal source filter with ToolsUsed NoMemory check
- agentcli/cmd: mark tools with NoMemory: true
- main.go: wire memory.SetTextCleaner(pluginReg.CleanText)
2026-07-24 14:49:08 +08:00

289 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"os"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func newTestCtx() *RelevanceContext {
return NewRelevanceContext("", memory.NewStaticEmbedder(""))
}
func TestContextAppendAndLen(t *testing.T) {
ctx := newTestCtx()
if ctx.Len() != 0 {
t.Errorf("new context should be empty, got %d", ctx.Len())
}
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "hello"})
if ctx.Len() != 1 {
t.Errorf("expected len 1, got %d", ctx.Len())
}
}
func TestContextRecent(t *testing.T) {
ctx := newTestCtx()
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "a"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "b"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "c"})
recent := ctx.Recent(2)
if len(recent) != 2 {
t.Errorf("expected 2 recent, got %d", len(recent))
}
if recent[0].Input != "b" || recent[1].Input != "c" {
t.Errorf("expected [b, c], got %v", recent)
}
}
func TestContextFormat(t *testing.T) {
ctx := newTestCtx()
f := ctx.Format()
if f != "" {
t.Errorf("empty context should format to empty string, got %q", f)
}
now := time.Now()
ctx.Append(ContextEvent{Timestamp: now, Source: "user", Input: "hello"})
f = ctx.Format()
if f == "" {
t.Fatal("non-empty context should produce non-empty format")
}
if !contains(f, "hello") {
t.Errorf("format should contain input 'hello', got: %s", f)
}
if !contains(f, "user") {
t.Errorf("format should contain source 'user'")
}
}
func TestContextPruneKeepsTopK(t *testing.T) {
ctx := newTestCtx()
for i := 0; i < 20; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
Source: "user",
Input: "今天天气很好",
Response: "是的天气不错",
})
}
ctx.Append(ContextEvent{
Timestamp: time.Now(),
Source: "user",
Input: "帮我算一下微积分题目",
Response: "好的我来算",
})
archived := ctx.Prune("微积分", 5, nil)
_ = archived
if ctx.Len() > 15 {
t.Errorf("after prune to 5, len should be ≤15, got %d", ctx.Len())
}
}
func TestContextPruneWithDocStore(t *testing.T) {
ctx := newTestCtx()
for i := 0; i < 15; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
Source: "user",
Input: "今天天气很好",
Response: "是的",
})
}
archived := ctx.Prune("天气", 10, nil)
if archived != 0 {
t.Errorf("with nil docStore, archived should be 0, got %d", archived)
}
}
func TestContextAppendAfterPrune(t *testing.T) {
ctx := newTestCtx()
for i := 0; i < 20; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
Source: "user",
Input: "hello world",
})
}
ctx.Prune("hello", 3, nil)
if ctx.Len() > 13 {
t.Errorf("expected ≤13 after prune, got %d", ctx.Len())
}
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "new message"})
if ctx.Len() > 14 {
t.Errorf("expected ≤14 after append, got %d", ctx.Len())
}
}
func TestContextPruneWithStaticEmbedder(t *testing.T) {
tmpFile, err := os.CreateTemp("", "test_embeddings_*.txt")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpFile.Name())
content := `8 4
天气 0.1 0.2 0.3 0.4
下雨 0.15 0.25 0.35 0.45
台风 0.12 0.22 0.32 0.42
股票 0.9 0.1 0.1 0.1
基金 0.85 0.15 0.1 0.1
微积分 0.1 0.1 0.9 0.1
导数 0.15 0.1 0.85 0.15
数学 0.1 0.1 0.8 0.2
`
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpFile.Close()
embedder := memory.NewStaticEmbedder(tmpFile.Name())
if !embedder.Loaded() {
t.Fatal("embedder should be loaded")
}
ctx := NewRelevanceContext("", embedder)
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "今天天气很好", Response: "是的"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "会不会下雨", Response: "会"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "台风来了", Response: "注意"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "帮我算微积分", Response: "好的"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "导数怎么求", Response: "公式"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "数学题", Response: "解答"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "股票涨了", Response: "恭喜"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "基金定投", Response: "可以"})
if ctx.Len() != 8 {
t.Fatalf("expected 8 events, got %d", ctx.Len())
}
archived := ctx.Prune("最近基金怎么样", 3, nil)
if ctx.Len() > 13 {
t.Errorf("prune should limit total events, got %d", ctx.Len())
}
remaining := ctx.Format()
t.Logf("query: 最近基金怎么样\nremaining events:\n%s", remaining)
t.Logf("archived: %d", archived)
needsFund := contains(remaining, "基金定投") || contains(remaining, "股票涨了")
needsWeather := contains(remaining, "今天天气很好") || contains(remaining, "台风来了")
t.Logf("has financial events: %v, has weather events: %v", needsFund, needsWeather)
}
func TestContextPruneRecent10Protected(t *testing.T) {
ctx := newTestCtx()
for i := 0; i < 15; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
Source: "user",
Input: "今天天气很好",
})
}
for i := 0; i < 5; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
Source: "user",
Input: "股票行情",
})
}
ctx.Prune("天气", 3, nil)
// 最近 10 条全部是"股票行情"第6-15条是天气第16-20条是股票
// protectCount=10 保护最近 10 条 → 5 条天气最多保留 5+3=8 条
// 至少最近 10 条全部保留 → 至少包含 5 条股票
remaining := ctx.Format()
t.Logf("after weather query:\n%s", remaining)
weatherCount := 0
stockCount := 0
for _, line := range splitLines(remaining) {
if contains(line, "天气") {
weatherCount++
}
if contains(line, "股票") {
stockCount++
}
}
t.Logf("weather events: %d, stock events: %d", weatherCount, stockCount)
if stockCount < 5 {
t.Errorf("recent 10 should all be protected, expected at least 5 stock events, got %d", stockCount)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && containsStr(s, substr)
}
func containsStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestHasNoMemoryTool(t *testing.T) {
host := NewStageHost()
host.RegisterTool("no_mem_tool", sdk.ToolDef{Name: "no_mem_tool", NoMemory: true}, nil)
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool"}, nil)
lookup := host.ToolDef
gotNil := hasNoMemoryTool([]string{"no_mem_tool"}, nil)
if gotNil {
t.Error("hasNoMemoryTool with nil lookup should return false")
}
tests := []struct {
name string
toolsUsed []string
want bool
}{
{"empty tools", nil, false},
{"no matching tool", []string{"unknown"}, false},
{"tool without NoMemory", []string{"mem_tool"}, false},
{"tool with NoMemory", []string{"no_mem_tool"}, true},
{"mixed tools, first is no_memory", []string{"no_mem_tool", "mem_tool"}, true},
{"mixed tools, last is no_memory", []string{"mem_tool", "no_mem_tool"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasNoMemoryTool(tt.toolsUsed, lookup)
if got != tt.want {
t.Errorf("hasNoMemoryTool(%v) = %v, want %v", tt.toolsUsed, got, tt.want)
}
})
}
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}