返回 DeepSeek-Reasonix
memory_suggestions_test.go
根目录 / desktop / memory_suggestions_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 "reasonix/internal/memory"
13 "reasonix/internal/provider"
14 )
15
16 func TestMemorySuggestionsReturnsNonNilArraysBeforeStartup(t *testing.T) {
17 isolateDesktopUserDirs(t)
18
19 view := NewApp().MemorySuggestions()
20 if view.Memories == nil || view.Skills == nil {
21 t.Fatalf("MemorySuggestions() arrays must be non-nil before startup: %+v", view)
22 }
23 raw, err := json.Marshal(view)
24 if err != nil {
25 t.Fatalf("marshal MemorySuggestions(): %v", err)
26 }
27 for _, bad := range []string{`"memories":null`, `"skills":null`} {
28 if strings.Contains(string(raw), bad) {
29 t.Fatalf("MemorySuggestions() JSON contains %s; frontend expects []: %s", bad, raw)
30 }
31 }
32 }
33
34 func TestMemorySuggestionsAcceptMemoryCandidate(t *testing.T) {
35 isolateDesktopUserDirs(t)
36 userDir := t.TempDir()
37 cwd := t.TempDir()
38 sessionDir := t.TempDir()
39 store := memory.StoreFor(userDir, cwd)
40 writeSuggestionSession(t, sessionDir, "pref.jsonl",
41 provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
42 provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
43 )
44
45 app := NewApp()
46 app.setTestCtrl(control.New(control.Options{
47 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
48 SessionDir: sessionDir,
49 }), "test-model")
50 app.tabs["test"].WorkspaceRoot = cwd
51
52 view := app.MemorySuggestions()
53 if len(view.Memories) == 0 {
54 t.Fatalf("MemorySuggestions() memories = %+v, want at least one candidate", view.Memories)
55 }
56 if view.Memories[0].Scope != string(memory.FactScopeProject) {
57 t.Fatalf("candidate scope = %q, want project", view.Memories[0].Scope)
58 }
59 path, err := app.AcceptMemorySuggestion(view.Memories[0])
60 if err != nil {
61 t.Fatalf("AcceptMemorySuggestion: %v", err)
62 }
63 if path == "" {
64 t.Fatal("AcceptMemorySuggestion returned empty path")
65 }
66 got := store.List()
67 if len(got) != 1 || got[0].Scope != memory.FactScopeProject || !strings.Contains(got[0].Body, "中文回复") {
68 t.Fatalf("saved memories = %+v, want confirmed candidate body", got)
69 }
70 }
71
72 func TestMemorySuggestionsForTabUsesSelectedTab(t *testing.T) {
73 isolateDesktopUserDirs(t)
74 activeUserDir := t.TempDir()
75 selectedUserDir := t.TempDir()
76 activeCwd := t.TempDir()
77 selectedCwd := t.TempDir()
78 activeSessionDir := t.TempDir()
79 selectedSessionDir := t.TempDir()
80 activeStore := memory.StoreFor(activeUserDir, activeCwd)
81 selectedStore := memory.StoreFor(selectedUserDir, selectedCwd)
82 writeSuggestionSession(t, selectedSessionDir, "selected.jsonl",
83 provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
84 provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
85 )
86
87 app := NewApp()
88 app.setTestCtrl(control.New(control.Options{
89 Memory: &memory.Set{Store: activeStore, CWD: activeCwd, UserDir: activeUserDir},
90 SessionDir: activeSessionDir,
91 }), "test-model")
92 app.tabs["test"].WorkspaceRoot = activeCwd
93 app.tabs["selected"] = &WorkspaceTab{
94 ID: "selected",
95 Scope: "project",
96 WorkspaceRoot: selectedCwd,
97 Ctrl: control.New(control.Options{
98 Memory: &memory.Set{Store: selectedStore, CWD: selectedCwd, UserDir: selectedUserDir},
99 SessionDir: selectedSessionDir,
100 }),
101 Ready: true,
102 disabledMCP: map[string]ServerView{},
103 }
104
105 if view := app.MemorySuggestions(); len(view.Memories) != 0 {
106 t.Fatalf("active tab suggestions = %+v, want none", view.Memories)
107 }
108 view := app.MemorySuggestionsForTab("selected")
109 if len(view.Memories) == 0 {
110 t.Fatalf("MemorySuggestionsForTab(selected) memories = %+v, want at least one candidate", view.Memories)
111 }
112 path, err := app.AcceptMemorySuggestionForTab("selected", view.Memories[0])
113 if err != nil {
114 t.Fatalf("AcceptMemorySuggestionForTab: %v", err)
115 }
116 if !strings.HasPrefix(path, selectedStore.Dir) && !strings.HasPrefix(path, selectedStore.GlobalDir) {
117 t.Fatalf("memory path = %q, want selected store under %q or %q", path, selectedStore.Dir, selectedStore.GlobalDir)
118 }
119 if got := activeStore.List(); len(got) != 0 {
120 t.Fatalf("active store should remain untouched, got %+v", got)
121 }
122 got := selectedStore.List()
123 if len(got) != 1 || !strings.Contains(got[0].Body, "中文回复") {
124 t.Fatalf("selected store = %+v, want confirmed candidate body", got)
125 }
126
127 skillPath, err := app.AcceptSkillSuggestionForTab("selected", SkillSuggestion{
128 ID: "selected-skill",
129 Name: "selected-workflow",
130 Description: "Selected workspace workflow",
131 Scope: "project",
132 Body: "Use the selected workspace context before changing files.",
133 })
134 if err != nil {
135 t.Fatalf("AcceptSkillSuggestionForTab: %v", err)
136 }
137 wantSkillPath := filepath.Join(selectedCwd, ".reasonix", "skills", "selected-workflow", "SKILL.md")
138 if skillPath != wantSkillPath {
139 t.Fatalf("skill path = %q, want %q", skillPath, wantSkillPath)
140 }
141 if _, err := os.Stat(filepath.Join(activeCwd, ".reasonix", "skills", "selected-workflow", "SKILL.md")); !os.IsNotExist(err) {
142 t.Fatalf("active workspace should not receive selected skill, stat err = %v", err)
143 }
144 body, err := os.ReadFile(skillPath)
145 if err != nil {
146 t.Fatalf("read selected skill: %v", err)
147 }
148 if !strings.Contains(string(body), "selected workspace context") {
149 t.Fatalf("selected skill body missing candidate content:\n%s", body)
150 }
151 }
152
153 func TestMemorySuggestionsAcceptSkillCandidate(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 userDir := t.TempDir()
156 cwd := t.TempDir()
157 sessionDir := t.TempDir()
158 store := memory.StoreFor(userDir, cwd)
159 writeSuggestionSession(t, sessionDir, "pr-a.jsonl",
160 provider.Message{Role: provider.RoleUser, Content: "把这个 PR 合并到本地并说明主要做了什么。"},
161 provider.Message{Role: provider.RoleAssistant, Content: "已检查。"},
162 )
163 writeSuggestionSession(t, sessionDir, "pr-b.jsonl",
164 provider.Message{Role: provider.RoleUser, Content: "解决该 pr 下机器人提出来的问题,合理的问题进行修复。"},
165 provider.Message{Role: provider.RoleAssistant, Content: "已处理。"},
166 )
167
168 app := NewApp()
169 app.setTestCtrl(control.New(control.Options{
170 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
171 SessionDir: sessionDir,
172 }), "test-model")
173 app.tabs["test"].WorkspaceRoot = cwd
174
175 view := app.MemorySuggestions()
176 var candidate SkillSuggestion
177 for _, item := range view.Skills {
178 if item.Name == "reasonix-pr-followup" {
179 candidate = item
180 break
181 }
182 }
183 if candidate.Name == "" {
184 t.Fatalf("MemorySuggestions() skills = %+v, want reasonix-pr-followup", view.Skills)
185 }
186 path, err := app.AcceptSkillSuggestion(candidate)
187 if err != nil {
188 t.Fatalf("AcceptSkillSuggestion: %v", err)
189 }
190 wantSuffix := filepath.Join(".reasonix", "skills", "reasonix-pr-followup", "SKILL.md")
191 if !strings.HasSuffix(path, wantSuffix) {
192 t.Fatalf("skill path = %q, want suffix %q", path, wantSuffix)
193 }
194 body, err := os.ReadFile(path)
195 if err != nil {
196 t.Fatalf("read skill: %v", err)
197 }
198 if !strings.Contains(string(body), "Review or update a Reasonix GitHub PR") {
199 t.Fatalf("skill body missing description: %s", body)
200 }
201 }
202
203 func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider.Message) {
204 t.Helper()
205 if err := os.MkdirAll(dir, 0o755); err != nil {
206 t.Fatal(err)
207 }
208 sess := agent.NewSession("")
209 for _, msg := range messages {
210 sess.Add(msg)
211 }
212 if err := sess.Save(filepath.Join(dir, name)); err != nil {
213 t.Fatalf("save session %s: %v", name, err)
214 }
215 }
216
217 // TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
218 // asciiSlug is short (<56 chars) must produce the same Name as old code
219 // (plain slug, no hash suffix), so an already-accepted memory under the old
220 // Name is not duplicated after upgrade.
221 func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
222 isolateDesktopUserDirs(t)
223 userDir := t.TempDir()
224 cwd := t.TempDir()
225 sessionDir := t.TempDir()
226 store := memory.StoreFor(userDir, cwd)
227 writeSuggestionSession(t, sessionDir, "en.jsonl",
228 provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
229 provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
230 )
231
232 app := NewApp()
233 app.setTestCtrl(control.New(control.Options{
234 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
235 SessionDir: sessionDir,
236 }), "test-model")
237 app.tabs["test"].WorkspaceRoot = cwd
238
239 view := app.MemorySuggestions()
240 if len(view.Memories) == 0 {
241 t.Fatalf("no candidates")
242 }
243 // Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
244 oldName := asciiSlug("Always prefer English for code comments.")
245 if view.Memories[0].Name != oldName {
246 t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
247 }
248 }
249
250 // TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
251 // differ in content but produce the same empty asciiSlug must still get
252 // distinct Name/ID. Without the hash suffix they would both fall back to
253 // "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
254 // wouldn't survive refresh, and Store.Save overwrites by name.
255 func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
256 isolateDesktopUserDirs(t)
257 userDir := t.TempDir()
258 cwd := t.TempDir()
259 sessionDir := t.TempDir()
260 store := memory.StoreFor(userDir, cwd)
261 // Two pure-CJK "always" statements that pass extractMemoryStatement but
262 // share the exact same empty asciiSlug.
263 writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
264 provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
265 provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
266 )
267 writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
268 provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
269 provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
270 )
271
272 app := NewApp()
273 app.setTestCtrl(control.New(control.Options{
274 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
275 SessionDir: sessionDir,
276 }), "test-model")
277 app.tabs["test"].WorkspaceRoot = cwd
278
279 view := app.MemorySuggestions()
280 if len(view.Memories) < 2 {
281 t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
282 }
283 names := map[string]bool{}
284 ids := map[string]bool{}
285 for _, m := range view.Memories {
286 if names[m.Name] {
287 t.Fatalf("duplicate Name %q among history candidates", m.Name)
288 }
289 if ids[m.ID] {
290 t.Fatalf("duplicate ID %q among history candidates", m.ID)
291 }
292 names[m.Name] = true
293 ids[m.ID] = true
294 }
295
296 // Accept both → two distinct persisted memories.
297 for _, c := range view.Memories {
298 if _, err := app.AcceptMemorySuggestion(c); err != nil {
299 t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
300 }
301 }
302 saved := store.List()
303 if len(saved) != len(view.Memories) {
304 t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
305 }
306 }
307
308 // TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
309 // be derived from the statement, not from iteration order or random state, so
310 // a refresh keeps the same ID and the frontend's accepted-state map stays valid.
311 func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
312 isolateDesktopUserDirs(t)
313 userDir := t.TempDir()
314 cwd := t.TempDir()
315 sessionDir := t.TempDir()
316 store := memory.StoreFor(userDir, cwd)
317 writeSuggestionSession(t, sessionDir, "pref.jsonl",
318 provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
319 provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
320 )
321
322 app := NewApp()
323 app.setTestCtrl(control.New(control.Options{
324 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
325 SessionDir: sessionDir,
326 }), "test-model")
327 app.tabs["test"].WorkspaceRoot = cwd
328
329 first := app.MemorySuggestions()
330 second := app.MemorySuggestions()
331 if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
332 t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
333 }
334 for i := range first.Memories {
335 if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
336 t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
337 i, first.Memories[i].Name, first.Memories[i].ID,
338 second.Memories[i].Name, second.Memories[i].ID)
339 }
340 }
341 }
342
343 func TestMemorySuggestionsDeduplicateAllScopedFactsAndInstructionBodies(t *testing.T) {
344 isolateDesktopUserDirs(t)
345 userDir := t.TempDir()
346 cwd := t.TempDir()
347 sessionDir := t.TempDir()
348 store := memory.StoreFor(userDir, cwd)
349 if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
350 Name: "response-language", Description: "Global response language", Scope: memory.FactScopeGlobal, Type: memory.TypeUser,
351 Body: "Always answer in Chinese unless the user explicitly asks for English.",
352 }); err != nil {
353 t.Fatal(err)
354 }
355 if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
356 Name: "response-language-project", Description: "Project response language", Scope: memory.FactScopeProject, Type: memory.TypeProject,
357 Body: "Always answer in Chinese unless the user explicitly asks for English.",
358 }); err != nil {
359 t.Fatal(err)
360 }
361 set := &memory.Set{Store: store, CWD: cwd, UserDir: userDir, Docs: []memory.Source{{Body: "Always use tabs for indentation."}}}
362 writeSuggestionSession(t, sessionDir, "dedupe.jsonl",
363 provider.Message{Role: provider.RoleUser, Content: "Always answer in Chinese unless the user explicitly asks for English."},
364 provider.Message{Role: provider.RoleUser, Content: "Always use tabs for indentation."},
365 )
366 got := suggestMemories(set, loadSuggestionSessions(sessionDir, suggestionSessionLimit))
367 if len(got) != 0 {
368 t.Fatalf("suggestions = %+v, want all candidates covered by scoped facts/docs to be omitted", got)
369 }
370 }
371
371 lines GO