1package modes
2
3import (
4 "slices"
5 "testing"
6
7 "github.com/patriceckhart/zot/packages/agent/skills"
8)
9
10func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) {
11 s := newSlashSuggester()
12
13 if got := commandNames(s.matches("/unj")); contains(got, "/unjail") {
14 t.Fatalf("/unjail should be hidden while not jailed, got %v", got)
15 }
16 if got := commandNames(s.matches("/ja")); !contains(got, "/jail") {
17 t.Fatalf("/jail should be visible while not jailed, got %v", got)
18 }
19
20 s.SetJailed(true)
21 if got := commandNames(s.matches("/unj")); !contains(got, "/unjail") {
22 t.Fatalf("/unjail should be visible while jailed, got %v", got)
23 }
24 if got := commandNames(s.matches("/ja")); contains(got, "/jail") {
25 t.Fatalf("/jail should be hidden while jailed, got %v", got)
26 }
27}
28
29func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) {
30 s := newSlashSuggester()
31 if got := commandNames(s.matches("/llama")); contains(got, "/llama") {
32 t.Fatalf("/llama visible without login: %v", got)
33 }
34 s.SetLlamaConfigured(true)
35 if got := commandNames(s.matches("/llama")); !contains(got, "/llama") {
36 t.Fatalf("/llama missing with login: %v", got)
37 }
38}
39
40func TestSlashSuggesterShowsNewOnlyWithSessions(t *testing.T) {
41 s := newSlashSuggester()
42 if got := commandNames(s.matches("/new")); contains(got, "/new") {
43 t.Fatalf("/new visible with sessions disabled: %v", got)
44 }
45 s.SetSessionsEnabled(true)
46 if got := commandNames(s.matches("/new")); !contains(got, "/new") {
47 t.Fatalf("/new missing with sessions enabled: %v", got)
48 }
49}
50
51func TestSlashSuggesterHasSwarm(t *testing.T) {
52 s := newSlashSuggester()
53 if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {
54 t.Fatalf("/swarm missing from suggestions, got %v", got)
55 }
56}
57
58func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) {
59 s := newSlashSuggester()
60 if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) {
61 t.Fatalf("matches = %v, want exact /session", got)
62 }
63 if got := s.Selection("/session"); got != "/session" {
64 t.Fatalf("selection = %q, want /session", got)
65 }
66}
67
68func TestSlashSuggesterCompletesSessionActions(t *testing.T) {
69 s := newSlashSuggester()
70 if got, want := commandNames(s.matches("/session ")), []string{
71 "/session timeline",
72 "/session export",
73 "/session import",
74 "/session fork",
75 "/session tree",
76 }; !slices.Equal(got, want) {
77 t.Fatalf("matches = %v, want %v", got, want)
78 }
79 if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) {
80 t.Fatalf("filtered matches = %v", got)
81 }
82 if got := s.Selection("/session t"); got != "/session timeline" {
83 t.Fatalf("selection = %q, want /session timeline", got)
84 }
85}
86
87func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
88 s := newSlashSuggester()
89 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
90 t.Fatalf("/EX did not suggest /exit: %v", got)
91 }
92 if !isKnownSlashCommand("/Exit") {
93 t.Fatal("/Exit was not recognized as a built-in command")
94 }
95 if !slashCancelsTurn("/CLEAR") {
96 t.Fatal("/CLEAR did not retain /clear cancellation semantics")
97 }
98 if !slashCancelsTurn("/NEW") {
99 t.Fatal("/NEW did not retain /new cancellation semantics")
100 }
101}
102
103func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
104 s := newSlashSuggester()
105 s.SetExtra([]slashCommand{{Name: "/EXIT", Desc: "extension exit"}})
106 matches := commandNames(s.matches("/exit"))
107 if len(matches) != 1 || matches[0] != "/exit" {
108 t.Fatalf("matches = %v, want only built-in /exit", matches)
109 }
110}
111
112func TestSlashSuggesterCompletesVisibleSkills(t *testing.T) {
113 s := newSlashSuggester()
114 s.SetSkills([]*skills.Skill{
115 {Name: "test-fix", Description: "Fix failing tests."},
116 {Name: "ai-development-workflow", Description: "Guide AI development."},
117 {Name: "manual-only", Description: "Explicit invocation only.", DisableModelInvocation: true},
118 {Name: "builtin", Description: "Internal.", Builtin: true},
119 })
120
121 if got, want := commandNames(s.matches("/skill:")), []string{
122 "/skill:ai-development-workflow",
123 "/skill:manual-only",
124 "/skill:test-fix",
125 }; !slices.Equal(got, want) {
126 t.Fatalf("matches = %v, want %v", got, want)
127 }
128 s.lastMatches = s.matches("/skill:")
129 if got := s.Selection("/skill:"); got != "/skill:ai-development-workflow" {
130 t.Fatalf("initial selection = %q", got)
131 }
132 s.Down()
133 if got := s.Selection("/skill:"); got != "/skill:manual-only" {
134 t.Fatalf("selection after down = %q", got)
135 }
136 if got := commandNames(s.matches("/SKILL:ai-dev")); !slices.Equal(got, []string{"/skill:ai-development-workflow"}) {
137 t.Fatalf("filtered matches = %v", got)
138 }
139 if got := s.Selection("/skill:ai-dev"); got != "/skill:ai-development-workflow" {
140 t.Fatalf("selection = %q", got)
141 }
142}
143
144func TestSlashSuggesterRefreshesSkillsOncePerInputSession(t *testing.T) {
145 s := newSlashSuggester()
146 if s.SkillInputStarted("/skill") {
147 t.Fatal("refresh started before colon")
148 }
149 if !s.SkillInputStarted("/skill:") {
150 t.Fatal("first /skill: did not request refresh")
151 }
152 if s.SkillInputStarted("/skill:review") {
153 t.Fatal("same input session requested another refresh")
154 }
155 if s.SkillInputStarted("") {
156 t.Fatal("leaving skill input requested refresh")
157 }
158 if !s.SkillInputStarted("/SKILL:") {
159 t.Fatal("new case-insensitive input session did not request refresh")
160 }
161}
162
163func commandNames(cmds []slashCommand) []string {
164 out := make([]string, 0, len(cmds))
165 for _, c := range cmds {
166 if !c.Header {
167 out = append(out, c.Name)
168 }
169 }
170 return out
171}
172
173func contains(xs []string, want string) bool {
174 for _, x := range xs {
175 if x == want {
176 return true
177 }
178 }
179 return false
180}