返回 DeepSeek-Reasonix
memory_test.go
根目录 / internal / memory / memory_test.go
1 package memory
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 fileencoding "reasonix/internal/fileutil/encoding"
10 )
11
12 // TestComposeEmptyIsIdentity is the cache-first invariant: with no memory at
13 // all, Compose must return the base prompt byte-for-byte, so the cached system
14 // prefix is exactly what it was before memory existed.
15 func TestComposeEmptyIsIdentity(t *testing.T) {
16 base := "You are a helpful coding agent.\nBe concise."
17 got := Compose(base, &Set{})
18 if got != base {
19 t.Fatalf("empty memory changed the prompt:\n base=%q\n got =%q", base, got)
20 }
21 // A nil-ish set (no docs, blank index) must also be identity.
22 if got := Compose(base, &Set{Index: " \n"}); got != base {
23 t.Fatalf("blank index changed the prompt: got %q", got)
24 }
25 }
26
27 // TestComposeAppendsAfterBase verifies memory folds in *after* the base prompt,
28 // so the base stays a valid cache prefix even as memory changes between sessions.
29 func TestComposeAppendsAfterBase(t *testing.T) {
30 base := "BASE PROMPT"
31 set := &Set{Docs: []Source{{Path: "/p/REASONIX.md", Scope: ScopeProject, Body: "Use tabs."}}}
32 got := Compose(base, set)
33 if !strings.HasPrefix(got, base) {
34 t.Fatalf("base is not the prefix of the composed prompt:\n%q", got)
35 }
36 if !strings.Contains(got, "Use tabs.") {
37 t.Fatalf("doc body missing from composed prompt:\n%q", got)
38 }
39 }
40
41 func TestBlockSeparatesStandingInstructionsFromBackgroundMemory(t *testing.T) {
42 set := &Set{
43 Docs: []Source{{Path: "/p/AGENTS.md", Scope: ScopeProject, Directory: "/p", Body: "Always run tests.", Depth: 0}},
44 Index: "- [API decision](api-decision.md) — [project/project] Chosen in an earlier session",
45 Store: Store{Dir: "/memory/project"},
46 }
47 block := set.Block()
48 for _, want := range []string{"# Instructions", "## workspace/AGENTS.md (project", "## Background memory index", "background, not standing instructions"} {
49 if !strings.Contains(block, want) {
50 t.Fatalf("Block() missing %q:\n%s", want, block)
51 }
52 }
53 for _, privatePath := range []string{"/p/AGENTS.md", "/memory/project"} {
54 if strings.Contains(block, privatePath) {
55 t.Fatalf("Block() exposed machine-local path %q:\n%s", privatePath, block)
56 }
57 }
58 }
59
60 func TestLoadIncludesStableGlobalPreferencesAndFeedback(t *testing.T) {
61 root := t.TempDir()
62 user := filepath.Join(root, "user")
63 proj := filepath.Join(root, "project")
64 mustMkdir(t, filepath.Join(proj, ".git"))
65 mustWrite(t, filepath.Join(proj, "AGENTS.md"), "STANDING INSTRUCTION BODY")
66 store := StoreFor(user, proj)
67 if _, err := store.Save(Memory{Name: "alpha-user", Description: "global preference", Type: TypeUser, Scope: FactScopeGlobal, Body: "GLOBAL USER BODY"}); err != nil {
68 t.Fatal(err)
69 }
70 legacyFeedback := "---\nname: zeta-feedback\ndescription: legacy global feedback\nmetadata:\n type: feedback\n---\n\nGLOBAL FEEDBACK BODY\n"
71 mustWrite(t, filepath.Join(store.GlobalDir, "zeta-feedback.md"), legacyFeedback)
72 if err := reindexIn(store.GlobalDir, "zeta-feedback", Memory{Name: "zeta-feedback", Description: "legacy global feedback", Type: TypeFeedback, Scope: FactScopeGlobal}); err != nil {
73 t.Fatal(err)
74 }
75 if _, err := store.Save(Memory{Name: "global-reference", Description: "global reference", Type: TypeReference, Scope: FactScopeGlobal, Body: "GLOBAL REFERENCE BODY"}); err != nil {
76 t.Fatal(err)
77 }
78 if _, err := store.Save(Memory{Name: "project-feedback", Description: "project feedback", Type: TypeFeedback, Scope: FactScopeProject, Body: "PROJECT FEEDBACK BODY"}); err != nil {
79 t.Fatal(err)
80 }
81
82 set := Load(Options{CWD: proj, UserDir: user})
83 if len(set.GlobalGuidance) != 2 {
84 t.Fatalf("global guidance = %+v, want user + feedback only", set.GlobalGuidance)
85 }
86 block := set.Block()
87 for _, want := range []string{"## Global preferences and feedback", "GLOBAL USER BODY", "GLOBAL FEEDBACK BODY"} {
88 if !strings.Contains(block, want) {
89 t.Fatalf("Block() missing %q:\n%s", want, block)
90 }
91 }
92 for _, excluded := range []string{"GLOBAL REFERENCE BODY", "PROJECT FEEDBACK BODY"} {
93 if strings.Contains(block, excluded) {
94 t.Fatalf("Block() promoted non-guidance body %q:\n%s", excluded, block)
95 }
96 }
97 if strings.Index(block, "GLOBAL USER BODY") > strings.Index(block, "GLOBAL FEEDBACK BODY") {
98 t.Fatalf("global guidance is not deterministically sorted by name:\n%s", block)
99 }
100 if strings.Index(block, "## Global preferences and feedback") > strings.Index(block, "# Instructions") && strings.Contains(block, "# Instructions") {
101 t.Fatalf("lower-priority global guidance must precede standing instructions:\n%s", block)
102 }
103 if again := set.Block(); again != block {
104 t.Fatal("unchanged memory snapshot produced unstable prompt bytes")
105 }
106 }
107
108 func TestLoadProjectFactSuppressesEquivalentGlobalGuidance(t *testing.T) {
109 root := t.TempDir()
110 user := filepath.Join(root, "user")
111 proj := filepath.Join(root, "project")
112 mustMkdir(t, filepath.Join(proj, ".git"))
113 store := StoreFor(user, proj)
114 if _, err := store.Save(Memory{
115 Name: "response-style", Type: TypeFeedback, Scope: FactScopeGlobal,
116 Description: "global style", Body: "Always be verbose.",
117 }); err != nil {
118 t.Fatal(err)
119 }
120 if _, err := store.Save(Memory{
121 Name: "response-style", Type: TypeFeedback, Scope: FactScopeProject,
122 Description: "project style", Body: "Be concise in this project.",
123 }); err != nil {
124 t.Fatal(err)
125 }
126 if _, err := store.Save(Memory{
127 Name: "language", Type: TypeUser, Scope: FactScopeGlobal,
128 Description: "global language", Body: "Answer in Chinese.",
129 }); err != nil {
130 t.Fatal(err)
131 }
132
133 set := Load(Options{CWD: proj, UserDir: user})
134 if len(set.GlobalGuidance) != 1 || set.GlobalGuidance[0].Name != "language" {
135 t.Fatalf("global guidance = %+v, want only unshadowed language preference", set.GlobalGuidance)
136 }
137 block := set.Block()
138 if strings.Contains(block, "Always be verbose.") {
139 t.Fatalf("shadowed global guidance leaked into stable prefix:\n%s", block)
140 }
141 if !strings.Contains(block, "Answer in Chinese.") {
142 t.Fatalf("unshadowed global guidance missing from stable prefix:\n%s", block)
143 }
144 }
145
146 // TestDiscoverPrecedenceOrder checks user → ancestor → project → local ordering,
147 // which puts the most specific guidance last.
148 func TestDiscoverPrecedenceOrder(t *testing.T) {
149 root := t.TempDir()
150 user := filepath.Join(root, "userconfig")
151 proj := filepath.Join(root, "proj")
152 mustMkdir(t, user)
153 mustMkdir(t, proj)
154 // Make proj a git root so discovery stops there.
155 mustMkdir(t, filepath.Join(proj, ".git"))
156
157 mustWrite(t, filepath.Join(user, "REASONIX.md"), "USER LEVEL")
158 mustWrite(t, filepath.Join(proj, "REASONIX.md"), "PROJECT LEVEL")
159 mustWrite(t, filepath.Join(proj, "REASONIX.local.md"), "LOCAL LEVEL")
160
161 set := Load(Options{CWD: proj, UserDir: user})
162 if len(set.Docs) != 3 {
163 t.Fatalf("want 3 docs, got %d: %+v", len(set.Docs), set.Docs)
164 }
165 wantScopes := []Scope{ScopeUser, ScopeProject, ScopeLocal}
166 for i, s := range wantScopes {
167 if set.Docs[i].Scope != s {
168 t.Fatalf("doc %d: want scope %q, got %q", i, s, set.Docs[i].Scope)
169 }
170 }
171 // In the composed block, local must appear after project must appear after user.
172 block := set.Block()
173 iu, ip, il := strings.Index(block, "USER LEVEL"), strings.Index(block, "PROJECT LEVEL"), strings.Index(block, "LOCAL LEVEL")
174 if !(iu >= 0 && iu < ip && ip < il) {
175 t.Fatalf("precedence order wrong in block: user=%d project=%d local=%d\n%s", iu, ip, il, block)
176 }
177 }
178
179 func TestDiscoverDecodesGB18030PrimaryDoc(t *testing.T) {
180 proj := t.TempDir()
181 mustMkdir(t, filepath.Join(proj, ".git"))
182 body := "# 项目约定\n\n始终使用中文回答。"
183 if err := os.WriteFile(filepath.Join(proj, "AGENTS.md"), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
184 t.Fatal(err)
185 }
186
187 set := Load(Options{CWD: proj})
188 if len(set.Docs) != 1 || !strings.Contains(set.Docs[0].Body, "始终使用中文回答") {
189 t.Fatalf("decoded docs = %+v", set.Docs)
190 }
191 }
192
193 // TestImportResolution checks "@path" inlining, including a relative import.
194 func TestImportResolution(t *testing.T) {
195 proj := t.TempDir()
196 mustMkdir(t, filepath.Join(proj, ".git"))
197 mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT")
198 mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top line\n@shared.md\nBottom line")
199
200 set := Load(Options{CWD: proj})
201 if len(set.Docs) != 1 {
202 t.Fatalf("want 1 doc, got %d", len(set.Docs))
203 }
204 body := set.Docs[0].Body
205 if !strings.Contains(body, "SHARED CONTENT") {
206 t.Fatalf("import not inlined: %q", body)
207 }
208 if strings.Contains(body, "@shared.md") {
209 t.Fatalf("import directive left in body: %q", body)
210 }
211 }
212
213 func TestImportResolutionRejectsEscapes(t *testing.T) {
214 proj := t.TempDir()
215 mustMkdir(t, filepath.Join(proj, ".git"))
216 outside := t.TempDir()
217 mustWrite(t, filepath.Join(outside, "secret.md"), "SECRET")
218 mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top\n@/abs/path.md\n@~/secret.md\n@../secret.md\nBottom")
219
220 set := Load(Options{CWD: proj})
221 if len(set.Docs) != 1 {
222 t.Fatalf("want 1 doc, got %d", len(set.Docs))
223 }
224 body := set.Docs[0].Body
225 if strings.Contains(body, "SECRET") {
226 t.Fatalf("unsafe import was inlined: %q", body)
227 }
228 for _, directive := range []string{"@/abs/path.md", "@~/secret.md", "@../secret.md"} {
229 if !strings.Contains(body, directive) {
230 t.Fatalf("unsafe directive %q should be left visible, body: %q", directive, body)
231 }
232 }
233 }
234
235 func TestImportResolutionRejectsSymlinkEscape(t *testing.T) {
236 proj := t.TempDir()
237 mustMkdir(t, filepath.Join(proj, ".git"))
238 outside := t.TempDir()
239 mustWrite(t, filepath.Join(outside, "secret.md"), "SECRET")
240 if err := os.Symlink(filepath.Join(outside, "secret.md"), filepath.Join(proj, "linked.md")); err != nil {
241 t.Skipf("symlink unavailable: %v", err)
242 }
243 mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top\n@linked.md\nBottom")
244
245 set := Load(Options{CWD: proj})
246 if len(set.Docs) != 1 {
247 t.Fatalf("want 1 doc, got %d", len(set.Docs))
248 }
249 body := set.Docs[0].Body
250 if strings.Contains(body, "SECRET") || !strings.Contains(body, "@linked.md") {
251 t.Fatalf("symlink escape should not be inlined, body: %q", body)
252 }
253 }
254
255 // TestImportCycleDoesNotHang verifies cycle detection terminates.
256 func TestImportCycleDoesNotHang(t *testing.T) {
257 proj := t.TempDir()
258 mustMkdir(t, filepath.Join(proj, ".git"))
259 mustWrite(t, filepath.Join(proj, "a.md"), "A\n@b.md")
260 mustWrite(t, filepath.Join(proj, "b.md"), "B\n@a.md")
261 mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md")
262
263 set := Load(Options{CWD: proj}) // must return, not loop forever
264 body := set.Docs[0].Body
265 if !strings.Contains(body, "A") || !strings.Contains(body, "B") {
266 t.Fatalf("cycle import dropped content: %q", body)
267 }
268 }
269
270 func mustMkdir(t *testing.T, dir string) {
271 t.Helper()
272 if err := os.MkdirAll(dir, 0o755); err != nil {
273 t.Fatal(err)
274 }
275 }
276
277 func mustWrite(t *testing.T, path, body string) {
278 t.Helper()
279 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
280 t.Fatal(err)
281 }
282 }
283
284 func TestImportDiamondAndCycle(t *testing.T) {
285 proj := t.TempDir()
286 mustMkdir(t, filepath.Join(proj, ".git"))
287
288 mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT")
289 mustWrite(t, filepath.Join(proj, "a.md"), "A\n@shared.md")
290 mustWrite(t, filepath.Join(proj, "b.md"), "B\n@shared.md")
291 mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md\n@b.md")
292
293 set := Load(Options{CWD: proj})
294 if len(set.Docs) != 1 {
295 t.Fatalf("want 1 doc, got %d", len(set.Docs))
296 }
297 body := set.Docs[0].Body
298
299 count := strings.Count(body, "SHARED CONTENT")
300 if count != 1 {
301 t.Errorf("expected exact imported content to appear once, got %d times. Body:\n%s", count, body)
302 }
303 if strings.Contains(body, "skipped: import cycle") {
304 t.Errorf("body contains incorrect import cycle message:\n%s", body)
305 }
306
307 projCycle := t.TempDir()
308 mustMkdir(t, filepath.Join(projCycle, ".git"))
309 mustWrite(t, filepath.Join(projCycle, "cycle1.md"), "CYCLE1\n@cycle2.md")
310 mustWrite(t, filepath.Join(projCycle, "cycle2.md"), "CYCLE2\n@cycle1.md")
311 mustWrite(t, filepath.Join(projCycle, "REASONIX.md"), "@cycle1.md")
312
313 setCycle := Load(Options{CWD: projCycle})
314 if len(setCycle.Docs) != 1 {
315 t.Fatalf("want 1 doc, got %d", len(setCycle.Docs))
316 }
317 bodyCycle := setCycle.Docs[0].Body
318 if !strings.Contains(bodyCycle, "skipped: import cycle") {
319 t.Errorf("expected import cycle to be detected and reported. Body:\n%s", bodyCycle)
320 }
321 }
322
322 lines GO