返回 DeepSeek-Reasonix
memory_test.go
根目录 / internal / control / memory_test.go
1 package control
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10
11 "reasonix/internal/memory"
12 )
13
14 // TestMemoryWriteReflectsInSnapshot verifies that a memory write lands on disk
15 // and that Memory() returns a freshly reloaded snapshot afterwards — the behavior
16 // the memoryManager (off-c.mu) extraction must preserve.
17 func TestMemoryWriteReflectsInSnapshot(t *testing.T) {
18 dir := t.TempDir()
19 c := New(Options{Memory: memory.Load(memory.Options{CWD: dir})})
20
21 before := c.Memory()
22 if before == nil {
23 t.Fatal("memory should be enabled")
24 }
25
26 path, err := c.QuickAdd(memory.ScopeProject, "prefer tabs over spaces")
27 if err != nil {
28 t.Fatalf("QuickAdd: %v", err)
29 }
30
31 body, err := os.ReadFile(path)
32 if err != nil {
33 t.Fatalf("read doc: %v", err)
34 }
35 if !strings.Contains(string(body), "prefer tabs over spaces") {
36 t.Fatalf("note not written to disk:\n%s", body)
37 }
38
39 after := c.Memory()
40 if after == nil {
41 t.Fatal("memory snapshot is nil after QuickAdd")
42 }
43 if after == before {
44 t.Fatal("Memory() returned the stale snapshot; the manager did not swap in a reload")
45 }
46 }
47
48 func TestSaveMemoryQueuesFullBodyForCurrentSession(t *testing.T) {
49 root := t.TempDir()
50 userDir := filepath.Join(root, "user")
51 cwd := filepath.Join(root, "project")
52 if err := os.MkdirAll(cwd, 0o755); err != nil {
53 t.Fatal(err)
54 }
55 c := New(Options{Memory: memory.Load(memory.Options{CWD: cwd, UserDir: userDir})})
56
57 body := "Always answer in Chinese unless the user explicitly asks for English.\nKeep technical terms precise."
58 if _, err := c.SaveMemory(memory.Memory{
59 Name: "response-language",
60 Description: "preferred response language",
61 Type: memory.TypeUser,
62 Scope: memory.FactScopeGlobal,
63 Body: body,
64 }); err != nil {
65 t.Fatalf("SaveMemory: %v", err)
66 }
67
68 composed := c.Compose("hello")
69 if !strings.Contains(composed, "Saved memory \"response-language\"") || !strings.Contains(composed, body) {
70 t.Fatalf("saved memory name and body should ride the next turn:\n%s", composed)
71 }
72 if again := c.Compose("again"); strings.Contains(again, body) || strings.Contains(again, "<memory-update>") {
73 t.Fatalf("saved memory update should drain after one turn: %q", again)
74 }
75 }
76
77 func TestForgetMemoryRevokesLoadedGlobalGuidanceForCurrentSession(t *testing.T) {
78 root := t.TempDir()
79 userDir := filepath.Join(root, "user")
80 cwd := filepath.Join(root, "project")
81 if err := os.MkdirAll(cwd, 0o755); err != nil {
82 t.Fatal(err)
83 }
84 store := memory.StoreFor(userDir, cwd)
85 const body = "Never use emoji in responses."
86 if _, err := store.Save(memory.Memory{
87 Name: "no-emoji",
88 Description: "avoid emoji",
89 Type: memory.TypeFeedback,
90 Scope: memory.FactScopeGlobal,
91 Body: body,
92 }); err != nil {
93 t.Fatal(err)
94 }
95 c := New(Options{Memory: memory.Load(memory.Options{CWD: cwd, UserDir: userDir})})
96 if before := c.Memory().Block(); !strings.Contains(before, body) {
97 t.Fatalf("test setup did not load global guidance:\n%s", before)
98 }
99
100 if err := c.ForgetMemory("no-emoji"); err != nil {
101 t.Fatalf("ForgetMemory: %v", err)
102 }
103 if after := c.Memory().Block(); strings.Contains(after, body) {
104 t.Fatalf("reloaded snapshot retained forgotten global guidance:\n%s", after)
105 }
106 composed := c.Compose("hello")
107 for _, want := range []string{"Forgot memory \"no-emoji\"", "disregard its loaded guidance", "background-index entry"} {
108 if !strings.Contains(composed, want) {
109 t.Fatalf("forget update missing %q:\n%s", want, composed)
110 }
111 }
112 }
113
114 func TestRestoreArchivedMemoryQueuesFullBodyForCurrentSession(t *testing.T) {
115 root := t.TempDir()
116 userDir := filepath.Join(root, "user")
117 cwd := filepath.Join(root, "project")
118 if err := os.MkdirAll(cwd, 0o755); err != nil {
119 t.Fatal(err)
120 }
121 store := memory.StoreFor(userDir, cwd)
122 first, err := store.SaveWithOptions(memory.Memory{
123 Name: "build-contract", Description: "project build contract", Body: "Run the focused package tests before the full suite.",
124 }, memory.SaveOptions{})
125 if err != nil {
126 t.Fatal(err)
127 }
128 archivePath, err := store.Archive(first.Memory.ID)
129 if err != nil {
130 t.Fatal(err)
131 }
132 c := New(Options{Memory: memory.Load(memory.Options{CWD: cwd, UserDir: userDir})})
133
134 restored, err := c.RestoreArchivedMemory(archivePath)
135 if err != nil {
136 t.Fatal(err)
137 }
138 if restored.ID != first.Memory.ID || restored.Revision != 2 {
139 t.Fatalf("restored memory = %+v", restored)
140 }
141 composed := c.Compose("continue")
142 for _, want := range []string{"Recovered archived memory", "build-contract", "Run the focused package tests"} {
143 if !strings.Contains(composed, want) {
144 t.Fatalf("recovery update missing %q:\n%s", want, composed)
145 }
146 }
147 }
148
149 // TestMemoryWritesConcurrencySafe hammers memory writes from many goroutines
150 // while c.mu-guarded reads run concurrently. Under -race this proves the
151 // memoryManager's writeMu/mu split has no data race and no deadlock — and that
152 // holding writeMu (off c.mu) across the disk I/O still serializes writes so every
153 // note lands.
154 func TestMemoryWritesConcurrencySafe(t *testing.T) {
155 dir := t.TempDir()
156 c := New(Options{Memory: memory.Load(memory.Options{CWD: dir})})
157
158 const writers = 8
159 const each = 5
160
161 stop := make(chan struct{})
162 var readers sync.WaitGroup
163 readers.Add(1)
164 go func() {
165 defer readers.Done()
166 for {
167 select {
168 case <-stop:
169 return
170 default:
171 _ = c.Running() // takes c.mu
172 _ = c.RuntimeStatus() // takes c.mu
173 _ = c.Memory() // takes c.mu, returns the snapshot pointer
174 }
175 }
176 }()
177
178 var writersWG sync.WaitGroup
179 for w := range writers {
180 writersWG.Add(1)
181 go func(w int) {
182 defer writersWG.Done()
183 for i := range each {
184 if _, err := c.QuickAdd(memory.ScopeProject, fmt.Sprintf("note w%d-%d", w, i)); err != nil {
185 t.Errorf("QuickAdd: %v", err)
186 }
187 }
188 }(w)
189 }
190 writersWG.Wait()
191 close(stop)
192 readers.Wait()
193
194 body, err := os.ReadFile(c.Memory().DocPath(memory.ScopeProject))
195 if err != nil {
196 t.Fatalf("read doc: %v", err)
197 }
198 for w := range writers {
199 for i := range each {
200 want := fmt.Sprintf("note w%d-%d", w, i)
201 if !strings.Contains(string(body), want) {
202 t.Fatalf("memory doc missing %q after concurrent writes:\n%s", want, body)
203 }
204 }
205 }
206 }
207
208 func TestRestoreMemoryQueuesAuditedRevisionForNextTurn(t *testing.T) {
209 dir := t.TempDir()
210 c := New(Options{Memory: memory.Load(memory.Options{CWD: dir, UserDir: t.TempDir()})})
211 store := c.Memory().Store
212 first, err := store.SaveWithOptions(memory.Memory{Name: "release-target", Description: "v1", Body: "main-v2"}, memory.SaveOptions{})
213 if err != nil {
214 t.Fatal(err)
215 }
216 if _, err := store.SaveWithOptions(memory.Memory{ID: first.Memory.ID, Name: "release-target", Description: "v2", Body: "release-v2"}, memory.SaveOptions{}); err != nil {
217 t.Fatal(err)
218 }
219 c.memory.applyWrite(c.Memory(), "")
220
221 restored, err := c.RestoreMemory(first.Memory.ID, 1)
222 if err != nil {
223 t.Fatal(err)
224 }
225 if restored.Revision != 3 || restored.Body != "main-v2" {
226 t.Fatalf("restored = %+v", restored)
227 }
228 if revisions := c.MemoryRevisions(first.Memory.ID); len(revisions) < 2 {
229 t.Fatalf("revision history = %+v", revisions)
230 }
231 composed := c.Compose("continue")
232 if !strings.Contains(composed, "Restored memory") || !strings.Contains(composed, "revision 3") {
233 t.Fatalf("restore note did not ride next turn: %q", composed)
234 }
235 }
236
236 lines GO