返回 DeepSeek-Reasonix
slash_test.go
根目录 / internal / control / slash_test.go
1 package control
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9
10 "reasonix/internal/event"
11 "reasonix/internal/instruction"
12 "reasonix/internal/memory"
13 "reasonix/internal/skill"
14 )
15
16 func labelsOf(items []SlashItem) []string {
17 out := make([]string, len(items))
18 for i, it := range items {
19 out[i] = it.Label
20 }
21 return out
22 }
23
24 func has(items []SlashItem, label string) bool {
25 for _, it := range items {
26 if it.Label == label {
27 return true
28 }
29 }
30 return false
31 }
32
33 func TestSlashArgItems(t *testing.T) {
34 data := ArgData{
35 Skills: []skill.Skill{{Name: "explore", Scope: skill.ScopeBuiltin}, {Name: "review", Scope: skill.ScopeBuiltin}},
36 DisabledSkills: []skill.Skill{{Name: "security-review", Scope: skill.ScopeBuiltin}},
37 ServerNames: []string{"fs", "git"},
38 ConfiguredMCP: []string{"fs", "linear"},
39 DisconnectedMCP: []string{"optional"},
40 ModelRefs: []string{"deepseek-flash/deepseek-v4-flash", "deepseek-pro/deepseek-v4-pro"},
41 CurrentModel: "deepseek-flash/deepseek-v4-flash",
42 EffortLevels: []string{"auto", "disabled", "high", "max"},
43 ProviderNames: []string{"deepseek-flash", "deepseek-pro", "custom"},
44 CurrentProvider: "deepseek-flash",
45 PluginNames: []string{"superpowers", "workflow-kit"},
46 MemoryRefs: []string{"mem-cache", "cache-first"},
47 MemoryArchives: []string{"/tmp/memory archive/cache-first.md"},
48 }
49
50 // /skills subcommands
51 items, from := SlashArgItems("/skills ", data)
52 if from != len("/skills ") {
53 t.Errorf("from = %d, want %d", from, len("/skills "))
54 }
55 for _, w := range []string{"show", "enable", "disable", "new", "paths"} {
56 if !has(items, w) {
57 t.Errorf("/skills missing subcommand %q; got %v", w, labelsOf(items))
58 }
59 }
60 if has(items, "manage") {
61 t.Errorf("/skills should hide redundant manage subcommand; got %v", labelsOf(items))
62 }
63 if has(items, "list") {
64 t.Errorf("/skills should hide redundant list subcommand; got %v", labelsOf(items))
65 }
66 // /skills show → skill names
67 items, _ = SlashArgItems("/skills show ", data)
68 if !has(items, "explore") || !has(items, "review") {
69 t.Errorf("/skills show should list skill names; got %v", labelsOf(items))
70 }
71 // Legacy /skill still works as an alias.
72 items, _ = SlashArgItems("/skill show ", data)
73 if !has(items, "explore") || !has(items, "review") {
74 t.Errorf("/skill show alias should list skill names; got %v", labelsOf(items))
75 }
76 items, _ = SlashArgItems("/skill disable ", data)
77 if !has(items, "explore") || has(items, "security-review") {
78 t.Errorf("/skill disable should list enabled skills only; got %v", labelsOf(items))
79 }
80 items, _ = SlashArgItems("/skill enable ", data)
81 if !has(items, "security-review") || has(items, "review") {
82 t.Errorf("/skill enable should list disabled skills only; got %v", labelsOf(items))
83 }
84 // /mcp subcommands + filtering
85 items, _ = SlashArgItems("/mcp ", data)
86 if has(items, "list") {
87 t.Errorf("/mcp should hide redundant list subcommand; got %v", labelsOf(items))
88 }
89 items, _ = SlashArgItems("/mcp re", data)
90 if len(items) != 1 || items[0].Label != "remove" {
91 t.Errorf("/mcp re should filter to remove; got %v", labelsOf(items))
92 }
93 // /mcp remove → server names
94 items, _ = SlashArgItems("/mcp remove ", data)
95 if !has(items, "fs") || !has(items, "git") {
96 t.Errorf("/mcp remove should list servers; got %v", labelsOf(items))
97 }
98 // /mcp connect -> disconnected configured server names
99 items, _ = SlashArgItems("/mcp connect ", data)
100 if !has(items, "optional") {
101 t.Errorf("/mcp connect should list disconnected configured servers; got %v", labelsOf(items))
102 }
103 // /mcp show/tools -> connected + configured server names
104 items, _ = SlashArgItems("/mcp show ", data)
105 if !has(items, "fs") || !has(items, "linear") || !has(items, "optional") {
106 t.Errorf("/mcp show should list known servers; got %v", labelsOf(items))
107 }
108 items, _ = SlashArgItems("/mcp tools ", data)
109 if !has(items, "git") || !has(items, "linear") {
110 t.Errorf("/mcp tools should list known servers; got %v", labelsOf(items))
111 }
112 // /model → refs, current marked
113 items, _ = SlashArgItems("/model ", data)
114 if !has(items, "deepseek-pro/deepseek-v4-pro") {
115 t.Errorf("/model should list refs; got %v", labelsOf(items))
116 }
117 for _, it := range items {
118 if it.Label == data.CurrentModel && it.Hint != "current" {
119 t.Errorf("active model should be hinted 'current', got %q", it.Hint)
120 }
121 }
122 // /provider → provider names, current marked
123 items, _ = SlashArgItems("/provider ", data)
124 if !has(items, "deepseek-pro") || !has(items, "custom") {
125 t.Errorf("/provider should list provider names; got %v", labelsOf(items))
126 }
127 for _, it := range items {
128 if it.Label == data.CurrentProvider && it.Hint != "current" {
129 t.Errorf("active provider should be hinted 'current', got %q", it.Hint)
130 }
131 }
132 // /provider de → filter to deepseek-*
133 items, _ = SlashArgItems("/provider de", data)
134 if len(items) != 2 {
135 t.Errorf("/provider de should filter to 2 deepseek providers; got %v", labelsOf(items))
136 }
137 // /hooks
138 items, _ = SlashArgItems("/hooks ", data)
139 if !has(items, "list") || has(items, "trust") {
140 t.Errorf("/hooks should offer list without a trust step; got %v", labelsOf(items))
141 }
142 // /effort
143 items, _ = SlashArgItems("/effort ", data)
144 if !has(items, "auto") || !has(items, "disabled") || !has(items, "high") || !has(items, "max") || has(items, "off") {
145 t.Errorf("/effort should offer auto/disabled/high/max; got %v", labelsOf(items))
146 }
147 // /goal
148 items, _ = SlashArgItems("/goal ", data)
149 if has(items, "--research") || has(items, "--simple") || !has(items, "status") || !has(items, "clear") {
150 t.Errorf("/goal should hide legacy budget flags and offer management commands; got %v", labelsOf(items))
151 }
152 if items, _ := SlashArgItems("/goal --research ", data); len(items) != 0 {
153 t.Errorf("/goal after a research flag should accept free-form objectives; got %v", labelsOf(items))
154 }
155 // /reasoning-language
156 items, _ = SlashArgItems("/reasoning-language ", data)
157 if !has(items, "auto") || !has(items, "zh") || !has(items, "en") || has(items, "中文") {
158 t.Errorf("/reasoning-language should offer only auto/zh/en; got %v", labelsOf(items))
159 }
160 // /currency
161 items, _ = SlashArgItems("/currency ", data)
162 if !has(items, "auto") || !has(items, "CNY") || !has(items, "USD") {
163 t.Errorf("/currency should offer only auto/CNY/USD; got %v", labelsOf(items))
164 }
165 // /theme
166 items, _ = SlashArgItems("/theme ", data)
167 if !has(items, "auto") || !has(items, "light") || !has(items, "graphite") || !has(items, "glacier") {
168 t.Errorf("/theme should offer modes and styles; got %v", labelsOf(items))
169 }
170 // a non-structured command yields nothing
171 if items, _ := SlashArgItems("/help ", data); len(items) != 0 {
172 t.Errorf("/help should have no arg items; got %v", labelsOf(items))
173 }
174 // a fully-typed terminal subcommand offers nothing (no lingering no-op) so the
175 // caller can submit instead of "accepting" a no-op — the /skills list bug.
176 if items, _ := SlashArgItems("/skills list", data); len(items) != 0 {
177 t.Errorf("/skills list (token complete) should offer no suggestion; got %v", labelsOf(items))
178 }
179 // and hidden menu commands stay hidden while direct typed execution remains
180 // handled by runSkillSubcommand.
181 if items, _ := SlashArgItems("/skills li", data); len(items) != 0 {
182 t.Errorf("/skills li should not offer hidden list suggestion; got %v", labelsOf(items))
183 }
184 // /plugins mirrors the session-facing plugin inventory command.
185 items, _ = SlashArgItems("/plugins ", data)
186 if !has(items, "show") {
187 t.Errorf("/plugins should offer show; got %v", labelsOf(items))
188 }
189 items, _ = SlashArgItems("/plugins show ", data)
190 if !has(items, "superpowers") || !has(items, "workflow-kit") {
191 t.Errorf("/plugins show should list plugin names; got %v", labelsOf(items))
192 }
193 // /memory diagnostics and recovery commands.
194 items, _ = SlashArgItems("/memory ", data)
195 for _, want := range []string{"recall", "revisions", "restore", "archived", "recover", "instructions"} {
196 if !has(items, want) {
197 t.Errorf("/memory missing subcommand %q; got %v", want, labelsOf(items))
198 }
199 }
200 items, _ = SlashArgItems("/memory revisions ", data)
201 if !has(items, "mem-cache") || !has(items, "cache-first") {
202 t.Errorf("/memory revisions should offer active memory refs; got %v", labelsOf(items))
203 }
204 items, _ = SlashArgItems("/memory recover ", data)
205 if !has(items, "/tmp/memory archive/cache-first.md") {
206 t.Errorf("/memory recover should offer archive paths; got %v", labelsOf(items))
207 }
208 }
209
210 func TestSlashArgItemsEffortUsesProvidedSnapshot(t *testing.T) {
211 data := ArgData{EffortLevels: []string{"auto", "snapshot-level"}}
212 items, _ := SlashArgItems("/effort ", data)
213 if got := labelsOf(items); len(got) != 2 || got[0] != "auto" || got[1] != "snapshot-level" {
214 t.Fatalf("/effort labels = %v, want provided snapshot levels", got)
215 }
216 }
217
218 func TestMemoryListTextIncludesSavedMemories(t *testing.T) {
219 store := memory.Store{Dir: t.TempDir()}
220 if _, err := store.Save(memory.Memory{
221 Name: "cache-first",
222 Title: "Cache first",
223 Description: "Preserve prompt cache stability",
224 Type: memory.TypeProject,
225 Body: "Use retrieval tools instead of dynamic prefix injection.",
226 }); err != nil {
227 t.Fatal(err)
228 }
229 c := newOwnedTestController(t, Options{Memory: &memory.Set{Store: store}})
230 out := MemoryCommandText(c, "")
231 for _, want := range []string{"saved memories", "[Cache first](cache-first.md)", "Preserve prompt cache stability"} {
232 if !strings.Contains(out, want) {
233 t.Fatalf("/memory output missing %q:\n%s", want, out)
234 }
235 }
236 }
237
238 func TestMemoryListTextIncludesArchivedMemories(t *testing.T) {
239 store := memory.Store{Dir: t.TempDir()}
240 if _, err := store.Save(memory.Memory{
241 Name: "stale-plan",
242 Title: "Stale plan",
243 Description: "Superseded by the new retrieval design",
244 Type: memory.TypeProject,
245 Body: "Old plan body.",
246 }); err != nil {
247 t.Fatal(err)
248 }
249 archive, err := store.Archive("stale-plan")
250 if err != nil {
251 t.Fatal(err)
252 }
253 c := newOwnedTestController(t, Options{Memory: &memory.Set{Store: store}})
254 out := MemoryCommandText(c, "")
255 for _, want := range []string{"archived memories", "[Stale plan](" + archive + ")", "Superseded by the new retrieval design"} {
256 if !strings.Contains(out, want) {
257 t.Fatalf("/memory output missing %q:\n%s", want, out)
258 }
259 }
260 if strings.Contains(out, "saved memories\n [Stale plan]") {
261 t.Fatalf("archived memory should not appear as active saved memory:\n%s", out)
262 }
263 }
264
265 func TestMemoryListTextIncludesEveryScopeAndObservableMetadata(t *testing.T) {
266 root := t.TempDir()
267 projectDir := filepath.Join(root, "project")
268 globalDir := filepath.Join(root, "global")
269 globalStore := memory.Store{Dir: globalDir}
270 globalSaved, err := globalStore.SaveWithOptions(memory.Memory{
271 Name: "shared-policy", Title: "Global policy", Description: "global fallback",
272 Type: memory.TypeReference, Scope: memory.FactScopeGlobal, Body: "Use the global endpoint.",
273 }, memory.SaveOptions{})
274 if err != nil {
275 t.Fatal(err)
276 }
277 projectStore := memory.Store{Dir: projectDir}
278 projectSaved, err := projectStore.SaveWithOptions(memory.Memory{
279 Name: "shared-policy", Title: "Project policy", Description: "project override",
280 Type: memory.TypeProject, Scope: memory.FactScopeProject, Body: "Use the project endpoint.",
281 }, memory.SaveOptions{})
282 if err != nil {
283 t.Fatal(err)
284 }
285
286 store := memory.Store{Dir: projectDir, GlobalDir: globalDir}
287 c := newOwnedTestController(t, Options{Memory: &memory.Set{Store: store}})
288 out := MemoryCommandText(c, "")
289 for _, want := range []string{
290 globalSaved.Memory.ID,
291 projectSaved.Memory.ID,
292 "revision=1",
293 "scope=global",
294 "scope=project",
295 "type=reference",
296 "type=project",
297 "freshness=fresh",
298 } {
299 if !strings.Contains(out, want) {
300 t.Fatalf("/memory output missing %q:\n%s", want, out)
301 }
302 }
303 if got := strings.Count(out, "shared-policy.md"); got != 2 {
304 t.Fatalf("/memory should show both same-name scoped facts, got %d:\n%s", got, out)
305 }
306 }
307
308 func TestManagementMemoryRecallAndInstructionDiagnostics(t *testing.T) {
309 now := time.Now().UTC()
310 set := &memory.Set{
311 Docs: []memory.Source{{
312 Path: "/workspace/AGENTS.md", Scope: memory.ScopeProject,
313 Directory: "/workspace", Imports: []instruction.Import{{Path: "/workspace/shared.md", SourcePath: "/workspace/AGENTS.md"}},
314 Order: 2,
315 }},
316 InstructionDiagnostics: []instruction.Diagnostic{{
317 Code: "import_cycle", Path: "/workspace/shared.md", SourcePath: "/workspace/AGENTS.md", Line: 4, Message: "cycle detected",
318 }},
319 }
320 var notices []string
321 c := newOwnedTestController(t, Options{
322 Memory: set,
323 Sink: event.FuncSink(func(e event.Event) {
324 if e.Kind == event.Notice {
325 notices = append(notices, e.Text)
326 }
327 }),
328 })
329 c.memory.recordRecall(memory.RecallResult{
330 Query: "Which cache policy applies?",
331 Hits: []memory.RecallHit{{
332 Memory: memory.Memory{
333 ID: "mem-cache", Revision: 3, Name: "cache-policy", Scope: memory.FactScopeProject,
334 Type: memory.TypeProject, UpdatedAt: now,
335 },
336 Score: 4.25, Freshness: memory.FreshnessFresh, Reason: "matched cache, policy; project scope",
337 }},
338 CharBudget: 2400, UsedChars: 280, Omitted: 1,
339 })
340
341 if !c.managementNotice("/memory recall") {
342 t.Fatal("/memory recall was not handled")
343 }
344 if !c.managementNotice("/memory instructions") {
345 t.Fatal("/memory instructions was not handled")
346 }
347 joined := strings.Join(notices, "\n")
348 for _, want := range []string{
349 "Which cache policy applies?",
350 "mem-cache",
351 "score=4.250",
352 "reason=matched cache, policy; project scope",
353 "budget=280/2400",
354 "omitted=1",
355 "precedence=1",
356 "directory=/workspace",
357 "import=/workspace/shared.md",
358 "import_cycle",
359 "/workspace/AGENTS.md:4",
360 } {
361 if !strings.Contains(joined, want) {
362 t.Fatalf("/memory diagnostics missing %q:\n%s", want, joined)
363 }
364 }
365 }
366
367 func TestManagementMemoryRevisionRestore(t *testing.T) {
368 userDir := t.TempDir()
369 cwd := filepath.Join(t.TempDir(), "project")
370 store := memory.StoreFor(userDir, cwd)
371 first, err := store.SaveWithOptions(memory.Memory{
372 Name: "provider-policy", Title: "Provider policy", Description: "first version",
373 Type: memory.TypeProject, Body: "Use provider A.",
374 }, memory.SaveOptions{})
375 if err != nil {
376 t.Fatal(err)
377 }
378 updated := first.Memory
379 updated.Description = "second version"
380 updated.Body = "Use provider B."
381 second, err := store.SaveWithOptions(updated, memory.SaveOptions{
382 ExpectedRevision: first.Memory.Revision, RequireExpectedRevision: true,
383 })
384 if err != nil {
385 t.Fatal(err)
386 }
387 var notices []string
388 c := newOwnedTestController(t, Options{
389 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
390 Sink: event.FuncSink(func(e event.Event) {
391 if e.Kind == event.Notice {
392 notices = append(notices, e.Text)
393 }
394 }),
395 })
396
397 if !c.managementNotice("/memory revisions " + second.Memory.ID) {
398 t.Fatal("/memory revisions was not handled")
399 }
400 if !c.managementNotice("/memory restore " + second.Memory.ID + " 1") {
401 t.Fatal("/memory restore was not handled")
402 }
403 active, ok := c.Memory().Store.Read(second.Memory.ID)
404 if !ok {
405 t.Fatal("restored memory is not active")
406 }
407 if active.Revision != 3 || active.Body != "Use provider A." {
408 t.Fatalf("restored memory = revision %d body %q", active.Revision, active.Body)
409 }
410 joined := strings.Join(notices, "\n")
411 for _, want := range []string{"revision=2", "active", "revision=1", "restored provider-policy", "revision=3"} {
412 if !strings.Contains(joined, want) {
413 t.Fatalf("/memory revision flow missing %q:\n%s", want, joined)
414 }
415 }
416 }
417
418 func TestManagementMemoryArchiveRecoveryAcceptsQuotedPathWithSpaces(t *testing.T) {
419 userDir := filepath.Join(t.TempDir(), "reasonix home with spaces")
420 cwd := filepath.Join(t.TempDir(), "project")
421 store := memory.StoreFor(userDir, cwd)
422 saved, err := store.SaveWithOptions(memory.Memory{
423 Name: "archived-policy", Title: "Archived policy", Description: "recover me",
424 Type: memory.TypeProject, Body: "Archived body.",
425 }, memory.SaveOptions{})
426 if err != nil {
427 t.Fatal(err)
428 }
429 archivePath, err := store.Archive(saved.Memory.ID)
430 if err != nil {
431 t.Fatal(err)
432 }
433 var notices []string
434 c := newOwnedTestController(t, Options{
435 Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
436 Sink: event.FuncSink(func(e event.Event) {
437 if e.Kind == event.Notice {
438 notices = append(notices, e.Text)
439 }
440 }),
441 })
442
443 if !c.managementNotice("/memory archived") {
444 t.Fatal("/memory archived was not handled")
445 }
446 if !c.managementNotice(`/memory recover "` + archivePath + `"`) {
447 t.Fatal("/memory recover was not handled")
448 }
449 active, ok := c.Memory().Store.Read(saved.Memory.ID)
450 if !ok {
451 t.Fatal("recovered memory is not active")
452 }
453 if active.Revision != 2 {
454 t.Fatalf("recovered revision = %d, want 2", active.Revision)
455 }
456 joined := strings.Join(notices, "\n")
457 for _, want := range []string{archivePath, "recovered archived-policy", "revision=2"} {
458 if !strings.Contains(joined, want) {
459 t.Fatalf("/memory archive flow missing %q:\n%s", want, joined)
460 }
461 }
462 }
463
464 func TestManagementHooksTrustCompatibilityNotice(t *testing.T) {
465 isolateControlConfigHome(t)
466 var notices []string
467 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
468 if e.Kind == event.Notice {
469 notices = append(notices, e.Text)
470 }
471 })})
472 if !c.managementNotice("/hooks trust") {
473 t.Fatal("legacy /hooks trust was not handled")
474 }
475 if len(notices) != 1 || !strings.Contains(notices[0], "enabled automatically") {
476 t.Fatalf("legacy /hooks trust notice = %v", notices)
477 }
478 }
479
480 func TestManagementMigrateEmitsProgress(t *testing.T) {
481 isolateControlConfigHome(t)
482 var notices []string
483 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
484 if e.Kind == event.Notice {
485 notices = append(notices, e.Text)
486 }
487 })})
488
489 if !c.managementNotice("/migrate") {
490 t.Fatal("/migrate was not handled")
491 }
492 joined := strings.Join(notices, "\n")
493 for _, want := range []string{
494 "migration rescue: checking legacy config and credentials",
495 "migration rescue: scanning legacy memory",
496 "migration rescue: scanning legacy sessions",
497 "migration rescue complete:",
498 } {
499 if !strings.Contains(joined, want) {
500 t.Fatalf("missing notice %q in:\n%s", want, joined)
501 }
502 }
503 }
504
505 func TestManagementMigrateFromImportsExplicitSessions(t *testing.T) {
506 home := isolateControlConfigHome(t)
507 legacySessions := filepath.Join(home, "Old Reasonix", "sessions")
508 if err := os.MkdirAll(legacySessions, 0o755); err != nil {
509 t.Fatal(err)
510 }
511 if err := os.WriteFile(filepath.Join(legacySessions, "old-chat.jsonl"), []byte(`{"role":"user","content":"hello from old install"}`+"\n"), 0o644); err != nil {
512 t.Fatal(err)
513 }
514 var notices []string
515 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
516 if e.Kind == event.Notice {
517 notices = append(notices, e.Text)
518 }
519 })})
520
521 if !c.managementNotice(`/migrate --from "` + filepath.Dir(legacySessions) + `"`) {
522 t.Fatal("/migrate --from was not handled")
523 }
524 joined := strings.Join(notices, "\n")
525 for _, want := range []string{
526 "migration rescue: scanning explicit legacy sessions from " + filepath.Dir(legacySessions),
527 "imported 1 past session(s) from " + legacySessions,
528 } {
529 if !strings.Contains(joined, want) {
530 t.Fatalf("missing notice %q in:\n%s", want, joined)
531 }
532 }
533 }
534
534 lines GO