| 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 |