194 lines
7.1 KiB
Go
194 lines
7.1 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRecallToolSearchesSavedMemories(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
saveMemory(t, store, Memory{
|
|
Name: "cache-first-history",
|
|
Title: "Cache first history",
|
|
Description: "History retrieval must preserve prompt cache stability",
|
|
Type: TypeProject,
|
|
Body: "Use a read-only BM25 retrieval tool instead of injecting dynamic history into the system prompt.",
|
|
})
|
|
saveMemory(t, store, Memory{
|
|
Name: "frontend-colors",
|
|
Description: "Dashboard color preference",
|
|
Type: TypeUser,
|
|
Body: "Avoid one-note palettes.",
|
|
})
|
|
|
|
tl := NewRecallTool(store)
|
|
if tl.Name() != "memory" || !tl.ReadOnly() {
|
|
t.Fatalf("unexpected tool identity: name=%q readonly=%v", tl.Name(), tl.ReadOnly())
|
|
}
|
|
if !json.Valid(tl.Schema()) {
|
|
t.Fatal("memory schema is not valid JSON")
|
|
}
|
|
|
|
out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"BM25 prompt cache","limit":5}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute search: %v", err)
|
|
}
|
|
if !strings.Contains(out, "cache-first-history") {
|
|
t.Fatalf("search output missing expected memory:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "frontend-colors") {
|
|
t.Fatalf("unrelated memory should not match strongly enough:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRecallToolSchemaIsCacheStable(t *testing.T) {
|
|
tl := NewRecallTool(Store{Dir: t.TempDir()})
|
|
if got, want := tl.Description(), "Search, list, and read saved project memories. Use this before saving a new memory to avoid duplicates, and when a saved memory from the index looks relevant but needs its full body. This tool is read-only; use remember to save or update a memory, and forget to delete one."; got != want {
|
|
t.Fatalf("memory description changed; this is provider-visible and affects prompt-cache shape.\nwant: %q\n got: %q", want, got)
|
|
}
|
|
const wantSchema = `{
|
|
"type": "object",
|
|
"properties": {
|
|
"operation": {"type": "string", "enum": ["search", "read", "list"], "description": "search ranks saved memories; read returns one full memory by name; list returns the saved-memory index."},
|
|
"query": {"type": "string", "description": "Search query for operation=search."},
|
|
"name": {"type": "string", "description": "Memory slug for operation=read, e.g. the name in [Label](name.md)."},
|
|
"type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Optional memory type filter for search or list."},
|
|
"limit": {"type": "integer", "description": "Maximum search/list results to return, default 8, max 20."}
|
|
},
|
|
"required": ["operation"]
|
|
}`
|
|
if got := string(tl.Schema()); got != wantSchema {
|
|
t.Fatalf("memory schema changed; this is provider-visible and affects prompt-cache shape.\nwant:\n%s\n got:\n%s", wantSchema, got)
|
|
}
|
|
}
|
|
|
|
func TestRecallToolDropsCommonWordNoise(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
saveMemory(t, store, Memory{
|
|
Name: "rare-cache-rule",
|
|
Description: "Rare synthesis-cache rule",
|
|
Type: TypeProject,
|
|
Body: "rareterm common common common",
|
|
})
|
|
for i := 0; i < 12; i++ {
|
|
saveMemory(t, store, Memory{
|
|
Name: "common-note-" + string(rune('a'+i)),
|
|
Description: "Common note",
|
|
Type: TypeProject,
|
|
Body: "common",
|
|
})
|
|
}
|
|
|
|
out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"rareterm common","limit":20}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute search: %v", err)
|
|
}
|
|
if !strings.Contains(out, "rare-cache-rule") {
|
|
t.Fatalf("top rare hit missing:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "common-note-") {
|
|
t.Fatalf("common-word-only noise should be dropped:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRecallToolNoResultsGuidesFallbackSearches(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"postgres://host:5433"}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute search: %v", err)
|
|
}
|
|
for _, want := range []string{"0 results does not prove", "Retry with 1-3 distinctive terms", "use the history tool"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("no-result output missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecallToolExcludesArchivedMemories(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
saveMemory(t, store, Memory{
|
|
Name: "stale-synthesis-cache",
|
|
Description: "Stale synthesis-cache conclusion",
|
|
Type: TypeProject,
|
|
Body: "This archived conclusion should no longer affect agent recall.",
|
|
})
|
|
if _, err := store.Archive("stale-synthesis-cache"); err != nil {
|
|
t.Fatalf("Archive: %v", err)
|
|
}
|
|
|
|
tl := NewRecallTool(store)
|
|
for _, args := range []string{
|
|
`{"operation":"search","query":"stale synthesis cache","limit":5}`,
|
|
`{"operation":"list"}`,
|
|
} {
|
|
out, err := tl.Execute(context.Background(), []byte(args))
|
|
if err != nil {
|
|
t.Fatalf("Execute(%s): %v", args, err)
|
|
}
|
|
if strings.Contains(out, "stale-synthesis-cache") {
|
|
t.Fatalf("archived memory leaked into active recall for %s:\n%s", args, out)
|
|
}
|
|
}
|
|
if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read","name":"stale-synthesis-cache"}`)); err == nil {
|
|
t.Fatal("read should not find archived memory as active memory")
|
|
}
|
|
}
|
|
|
|
func TestRecallToolReadsMemoryByName(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
saveMemory(t, store, Memory{
|
|
Name: "user-prefers-tabs",
|
|
Title: "Prefers tabs",
|
|
Description: "User prefers tabs for indentation",
|
|
Type: TypeUser,
|
|
Body: "Use tabs unless the repository style clearly says otherwise.",
|
|
})
|
|
|
|
out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"user-prefers-tabs"}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute read: %v", err)
|
|
}
|
|
for _, want := range []string{"Memory user-prefers-tabs", "type: user", "Use tabs"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("read output missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecallToolListsAndFiltersByType(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
saveMemory(t, store, Memory{Name: "one", Description: "project fact", Type: TypeProject, Body: "body"})
|
|
saveMemory(t, store, Memory{Name: "two", Description: "user fact", Type: TypeUser, Body: "body"})
|
|
|
|
out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"list","type":"user"}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute list: %v", err)
|
|
}
|
|
if !strings.Contains(out, "two") || strings.Contains(out, "one") {
|
|
t.Fatalf("type filter did not apply:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRecallToolValidatesInputs(t *testing.T) {
|
|
store := Store{Dir: t.TempDir()}
|
|
tl := NewRecallTool(store)
|
|
if _, err := tl.Execute(context.Background(), []byte(`{"operation":"search"}`)); err == nil {
|
|
t.Fatal("search without query should fail")
|
|
}
|
|
if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read"}`)); err == nil {
|
|
t.Fatal("read without name should fail")
|
|
}
|
|
if _, err := tl.Execute(context.Background(), []byte(`{"operation":"list","type":"unknown"}`)); err == nil {
|
|
t.Fatal("unknown type should fail")
|
|
}
|
|
}
|
|
|
|
func saveMemory(t *testing.T, store Store, m Memory) {
|
|
t.Helper()
|
|
if _, err := store.Save(m); err != nil {
|
|
t.Fatalf("Save(%s): %v", m.Name, err)
|
|
}
|
|
}
|