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