返回 DeepSeek-Reasonix
recall_test.go
根目录 / internal / memory / recall_test.go
1 package memory
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8 )
9
10 func TestRecallToolSearchesSavedMemories(t *testing.T) {
11 store := Store{Dir: t.TempDir()}
12 saveMemory(t, store, Memory{
13 Name: "cache-first-history",
14 Title: "Cache first history",
15 Description: "History retrieval must preserve prompt cache stability",
16 Type: TypeProject,
17 Body: "Use a read-only BM25 retrieval tool instead of injecting dynamic history into the system prompt.",
18 })
19 saveMemory(t, store, Memory{
20 Name: "frontend-colors",
21 Description: "Dashboard color preference",
22 Type: TypeUser,
23 Body: "Avoid one-note palettes.",
24 })
25
26 tl := NewRecallTool(store)
27 if tl.Name() != "memory" || !tl.ReadOnly() {
28 t.Fatalf("unexpected tool identity: name=%q readonly=%v", tl.Name(), tl.ReadOnly())
29 }
30 if !json.Valid(tl.Schema()) {
31 t.Fatal("memory schema is not valid JSON")
32 }
33
34 out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"BM25 prompt cache","limit":5}`))
35 if err != nil {
36 t.Fatalf("Execute search: %v", err)
37 }
38 if !strings.Contains(out, "cache-first-history") {
39 t.Fatalf("search output missing expected memory:\n%s", out)
40 }
41 if strings.Contains(out, "frontend-colors") {
42 t.Fatalf("unrelated memory should not match strongly enough:\n%s", out)
43 }
44 }
45
46 func TestRecallToolSchemaIsCacheStable(t *testing.T) {
47 tl := NewRecallTool(Store{Dir: t.TempDir()})
48 if got, want := tl.Description(), "Search, list, and read saved background memories for this project, including explicitly global facts. 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 archive one."; got != want {
49 t.Fatalf("memory description changed; this is provider-visible and affects prompt-cache shape.\nwant: %q\n got: %q", want, got)
50 }
51 const wantSchema = `{
52 "type": "object",
53 "properties": {
54 "operation": {"type": "string", "enum": ["search", "read", "list"], "description": "search ranks saved memories; read returns one full memory by stable id or legacy name; list returns the saved-memory index."},
55 "query": {"type": "string", "description": "Search query for operation=search."},
56 "name": {"type": "string", "description": "Stable memory id, project/<name>.md or global/<name>.md reference, or legacy slug for operation=read."},
57 "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Optional memory type filter for search or list."},
58 "scope": {"type": "string", "enum": ["project", "global"], "description": "Optional scope filter for search or list."},
59 "limit": {"type": "integer", "description": "Maximum search/list results to return, default 8, max 20."}
60 },
61 "required": ["operation"]
62 }`
63 if got := string(tl.Schema()); got != wantSchema {
64 t.Fatalf("memory schema changed; this is provider-visible and affects prompt-cache shape.\nwant:\n%s\n got:\n%s", wantSchema, got)
65 }
66 }
67
68 func TestRecallToolDropsCommonWordNoise(t *testing.T) {
69 store := Store{Dir: t.TempDir()}
70 saveMemory(t, store, Memory{
71 Name: "rare-cache-rule",
72 Description: "Rare synthesis-cache rule",
73 Type: TypeProject,
74 Body: "rareterm common common common",
75 })
76 for i := 0; i < 12; i++ {
77 saveMemory(t, store, Memory{
78 Name: "common-note-" + string(rune('a'+i)),
79 Description: "Common note",
80 Type: TypeProject,
81 Body: "common",
82 })
83 }
84
85 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"rareterm common","limit":20}`))
86 if err != nil {
87 t.Fatalf("Execute search: %v", err)
88 }
89 if !strings.Contains(out, "rare-cache-rule") {
90 t.Fatalf("top rare hit missing:\n%s", out)
91 }
92 if strings.Contains(out, "common-note-") {
93 t.Fatalf("common-word-only noise should be dropped:\n%s", out)
94 }
95 }
96
97 func TestRecallToolNoResultsGuidesFallbackSearches(t *testing.T) {
98 store := Store{Dir: t.TempDir()}
99 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"postgres://host:5433"}`))
100 if err != nil {
101 t.Fatalf("Execute search: %v", err)
102 }
103 for _, want := range []string{"0 results does not prove", "Retry with 1-3 distinctive terms", "use the history tool"} {
104 if !strings.Contains(out, want) {
105 t.Fatalf("no-result output missing %q:\n%s", want, out)
106 }
107 }
108 }
109
110 func TestRecallToolExcludesArchivedMemories(t *testing.T) {
111 store := Store{Dir: t.TempDir()}
112 saveMemory(t, store, Memory{
113 Name: "stale-synthesis-cache",
114 Description: "Stale synthesis-cache conclusion",
115 Type: TypeProject,
116 Body: "This archived conclusion should no longer affect agent recall.",
117 })
118 if _, err := store.Archive("stale-synthesis-cache"); err != nil {
119 t.Fatalf("Archive: %v", err)
120 }
121
122 tl := NewRecallTool(store)
123 for _, args := range []string{
124 `{"operation":"search","query":"stale synthesis cache","limit":5}`,
125 `{"operation":"list"}`,
126 } {
127 out, err := tl.Execute(context.Background(), []byte(args))
128 if err != nil {
129 t.Fatalf("Execute(%s): %v", args, err)
130 }
131 if strings.Contains(out, "stale-synthesis-cache") {
132 t.Fatalf("archived memory leaked into active recall for %s:\n%s", args, out)
133 }
134 }
135 if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read","name":"stale-synthesis-cache"}`)); err == nil {
136 t.Fatal("read should not find archived memory as active memory")
137 }
138 }
139
140 func TestRecallToolReadsMemoryByName(t *testing.T) {
141 store := Store{Dir: t.TempDir()}
142 saveMemory(t, store, Memory{
143 Name: "user-prefers-tabs",
144 Title: "Prefers tabs",
145 Description: "User prefers tabs for indentation",
146 Type: TypeUser,
147 Body: "Use tabs unless the repository style clearly says otherwise.",
148 })
149
150 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"user-prefers-tabs"}`))
151 if err != nil {
152 t.Fatalf("Execute read: %v", err)
153 }
154 for _, want := range []string{"Memory user-prefers-tabs", "id: mem-", "revision: 1", "type: user", "Use tabs"} {
155 if !strings.Contains(out, want) {
156 t.Fatalf("read output missing %q:\n%s", want, out)
157 }
158 }
159 }
160
161 func TestRecallToolReadsMemoryByListedMarkdownName(t *testing.T) {
162 store := Store{Dir: t.TempDir()}
163 saveMemory(t, store, Memory{
164 Name: "listed-memory",
165 Description: "Listed memory reference",
166 Type: TypeProject,
167 Body: "The listed Markdown target is a valid read reference.",
168 })
169
170 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"listed-memory.md"}`))
171 if err != nil {
172 t.Fatalf("Execute read with listed Markdown name: %v", err)
173 }
174 if !strings.Contains(out, "Memory listed-memory") || !strings.Contains(out, "valid read reference") {
175 t.Fatalf("read by listed Markdown name = %s", out)
176 }
177 }
178
179 func TestRecallToolOutputsUseStableReferences(t *testing.T) {
180 root := t.TempDir()
181 store := Store{Dir: root + "/project", GlobalDir: root + "/global"}
182 saveMemory(t, store, Memory{
183 Name: "private-store-path",
184 Description: "Distinctive privacy sentinel",
185 Type: TypeProject,
186 Scope: FactScopeProject,
187 Body: "Provider-visible results use stable memory references.",
188 })
189
190 tool := NewRecallTool(store)
191 for _, args := range []string{
192 `{"operation":"search","query":"distinctive privacy sentinel"}`,
193 `{"operation":"read","name":"project/private-store-path.md"}`,
194 `{"operation":"list"}`,
195 } {
196 out, err := tool.Execute(context.Background(), []byte(args))
197 if err != nil {
198 t.Fatalf("Execute(%s): %v", args, err)
199 }
200 if strings.Contains(out, root) || strings.Contains(out, store.Dir) {
201 t.Fatalf("provider-visible memory output exposed store path for %s:\n%s", args, out)
202 }
203 if !strings.Contains(out, "project/private-store-path.md") {
204 t.Fatalf("provider-visible memory output missing stable reference for %s:\n%s", args, out)
205 }
206 }
207 }
208
209 func TestRecallToolStableReferencesDisambiguateSameNameAcrossScopes(t *testing.T) {
210 root := t.TempDir()
211 store := Store{Dir: root + "/project", GlobalDir: root + "/global"}
212 for _, fixture := range []struct {
213 ref string
214 desc string
215 body string
216 }{
217 {ref: "project/shared.md", desc: "shared round trip project", body: "project body"},
218 {ref: "global/shared.md", desc: "shared round trip global", body: "global body"},
219 } {
220 if _, err := store.SaveWithOptions(Memory{Name: fixture.ref, Description: fixture.desc, Body: fixture.body}, SaveOptions{}); err != nil {
221 t.Fatal(err)
222 }
223 }
224
225 tool := NewRecallTool(store)
226 list, err := tool.Execute(context.Background(), []byte(`{"operation":"list"}`))
227 if err != nil {
228 t.Fatal(err)
229 }
230 for _, ref := range []string{"project/shared.md", "global/shared.md"} {
231 if !strings.Contains(list, "reference="+ref) {
232 t.Fatalf("list output missing %q:\n%s", ref, list)
233 }
234 }
235 search, err := tool.Execute(context.Background(), []byte(`{"operation":"search","query":"shared round trip","limit":5}`))
236 if err != nil {
237 t.Fatal(err)
238 }
239 for _, ref := range []string{"project/shared.md", "global/shared.md"} {
240 if !strings.Contains(search, "reference: "+ref) {
241 t.Fatalf("search output missing %q:\n%s", ref, search)
242 }
243 }
244 for _, fixture := range []struct {
245 ref string
246 body string
247 }{
248 {ref: "project/shared.md", body: "project body"},
249 {ref: "global/shared.md", body: "global body"},
250 } {
251 out, err := tool.Execute(context.Background(), []byte(`{"operation":"read","name":"`+fixture.ref+`"}`))
252 if err != nil || !strings.Contains(out, fixture.body) || !strings.Contains(out, "reference: "+fixture.ref) {
253 t.Fatalf("read %s = %q, err=%v", fixture.ref, out, err)
254 }
255 }
256 }
257
258 func TestRecallToolListsAndFiltersByType(t *testing.T) {
259 store := Store{Dir: t.TempDir()}
260 saveMemory(t, store, Memory{Name: "one", Description: "project fact", Type: TypeProject, Body: "body"})
261 saveMemory(t, store, Memory{Name: "two", Description: "user fact", Type: TypeUser, Body: "body"})
262
263 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"list","type":"user"}`))
264 if err != nil {
265 t.Fatalf("Execute list: %v", err)
266 }
267 if !strings.Contains(out, "two") || !strings.Contains(out, "id=mem-") || !strings.Contains(out, "revision=1") || strings.Contains(out, "one") {
268 t.Fatalf("type filter did not apply:\n%s", out)
269 }
270 }
271
272 func TestRecallToolReadsMemoryByStableID(t *testing.T) {
273 store := Store{Dir: t.TempDir()}
274 result, err := store.SaveWithOptions(Memory{Name: "rename-safe", Description: "stable identity", Body: "body"}, SaveOptions{})
275 if err != nil {
276 t.Fatal(err)
277 }
278 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"`+result.Memory.ID+`"}`))
279 if err != nil {
280 t.Fatal(err)
281 }
282 if !strings.Contains(out, "Memory rename-safe") || !strings.Contains(out, "id: "+result.Memory.ID) {
283 t.Fatalf("read by stable ID = %s", out)
284 }
285 }
286
287 func TestRecallToolListsAndFiltersByScope(t *testing.T) {
288 root := t.TempDir()
289 store := Store{Dir: root + "/project", GlobalDir: root + "/global"}
290 saveMemory(t, store, Memory{Name: "local-user", Description: "project user fact", Type: TypeUser, Scope: FactScopeProject, Body: "body"})
291 saveMemory(t, store, Memory{Name: "global-user", Description: "global user fact", Type: TypeUser, Scope: FactScopeGlobal, Body: "body"})
292
293 out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"list","type":"user","scope":"project"}`))
294 if err != nil {
295 t.Fatalf("Execute list: %v", err)
296 }
297 if !strings.Contains(out, "local-user") || strings.Contains(out, "global-user") {
298 t.Fatalf("scope filter did not apply:\n%s", out)
299 }
300 }
301
302 func TestRecallToolValidatesInputs(t *testing.T) {
303 store := Store{Dir: t.TempDir()}
304 tl := NewRecallTool(store)
305 if _, err := tl.Execute(context.Background(), []byte(`{"operation":"search"}`)); err == nil {
306 t.Fatal("search without query should fail")
307 }
308 if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read"}`)); err == nil {
309 t.Fatal("read without name should fail")
310 }
311 if _, err := tl.Execute(context.Background(), []byte(`{"operation":"list","type":"unknown"}`)); err == nil {
312 t.Fatal("unknown type should fail")
313 }
314 if _, err := tl.Execute(context.Background(), []byte(`{"operation":"list","scope":"unknown"}`)); err == nil {
315 t.Fatal("unknown scope should fail")
316 }
317 }
318
319 func saveMemory(t *testing.T, store Store, m Memory) {
320 t.Helper()
321 if _, err := store.Save(m); err != nil {
322 t.Fatalf("Save(%s): %v", m.Name, err)
323 }
324 }
325
325 lines GO