1package modes
2
3import (
4 "slices"
5 "testing"
6
7 "github.com/bnema/zut/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 TestSlashSuggesterHasSubagents(t *testing.T) {
41 s := newSlashSuggester()
42 if got := commandNames(s.matches("/su")); !contains(got, "/subagents") {
43 t.Fatalf("/subagents missing from suggestions, got %v", got)
44 }
45}
46
47func TestSlashSuggesterHasFastModeToggle(t *testing.T) {
48 s := newSlashSuggester()
49 if got := commandNames(s.matches("/fa")); !contains(got, "/fast") {
50 t.Fatalf("/fast missing from suggestions, got %v", got)
51 }
52 if !isKnownSlashCommand("/FAST") {
53 t.Fatal("/FAST was not recognized as a built-in command")
54 }
55}
56
57func TestSlashSuggesterHasOrchestratorToggle(t *testing.T) {
58 s := newSlashSuggester()
59 if got := commandNames(s.matches("/orc")); !contains(got, "/orchestrator") {
60 t.Fatalf("/orchestrator missing from suggestions, got %v", got)
61 }
62 if !isKnownSlashCommand("/ORCHESTRATOR") {
63 t.Fatal("/ORCHESTRATOR was not recognized as a built-in command")
64 }
65}
66
67func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) {
68 s := newSlashSuggester()
69 if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) {
70 t.Fatalf("matches = %v, want exact /session", got)
71 }
72 if got := s.Selection("/session"); got != "/session" {
73 t.Fatalf("selection = %q, want /session", got)
74 }
75}
76
77func TestSlashSuggesterCompletesSessionActions(t *testing.T) {
78 s := newSlashSuggester()
79 if got, want := commandNames(s.matches("/session ")), []string{
80 "/session timeline",
81 "/session export",
82 "/session import",
83 "/session fork",
84 "/session tree",
85 }; !slices.Equal(got, want) {
86 t.Fatalf("matches = %v, want %v", got, want)
87 }
88 if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) {
89 t.Fatalf("filtered matches = %v", got)
90 }
91 if got := s.Selection("/session t"); got != "/session timeline" {
92 t.Fatalf("selection = %q, want /session timeline", got)
93 }
94 if got := s.matches("/model x"); len(got) != 0 {
95 t.Fatalf("arguments of other commands must not suggest: %v", got)
96 }
97}
98
99func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
100 s := newSlashSuggester()
101 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
102 t.Fatalf("/EX did not suggest /exit: %v", got)
103 }
104 if !isKnownSlashCommand("/Exit") {
105 t.Fatal("/Exit was not recognized as a built-in command")
106 }
107 if !isKnownSlashCommand("/Fork") {
108 t.Fatal("/Fork was not recognized as a built-in command")
109 }
110 if !slashCancelsTurn("/CLEAR") {
111 t.Fatal("/CLEAR did not retain /clear cancellation semantics")
112 }
113 if !slashCommandCancelsTurn("/fork") || !slashCommandCancelsTurn("/session fork") {
114 t.Fatal("fork commands did not retain idle-transition cancellation semantics")
115 }
116 if slashCommandCancelsTurn("/session tree") {
117 t.Fatal("/session tree should use its fail-closed gate instead of cancelling a turn")
118 }
119}
120
121func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
122 s := newSlashSuggester()
123 s.SetExtra([]slashCommand{{Name: "/EXIT", Desc: "extension exit"}})
124 matches := commandNames(s.matches("/exit"))
125 if len(matches) != 1 || matches[0] != "/exit" {
126 t.Fatalf("matches = %v, want only built-in /exit", matches)
127 }
128}
129
130func TestSlashSuggesterCompletesVisibleSkills(t *testing.T) {
131 s := newSlashSuggester()
132 s.SetSkills([]*skills.Skill{
133 {Name: "test-fix", Description: "Fix failing tests."},
134 {Name: "ai-development-workflow", Description: "Guide AI development."},
135 {Name: "manual-only", Description: "Explicit invocation only.", DisableModelInvocation: true},
136 {Name: "builtin", Description: "Internal.", Builtin: true},
137 })
138
139 if got, want := commandNames(s.matches("/skill:")), []string{
140 "/skill:ai-development-workflow",
141 "/skill:manual-only",
142 "/skill:test-fix",
143 }; !slices.Equal(got, want) {
144 t.Fatalf("matches = %v, want %v", got, want)
145 }
146 s.lastMatches = s.matches("/skill:")
147 if got := s.Selection("/skill:"); got != "/skill:ai-development-workflow" {
148 t.Fatalf("initial selection = %q", got)
149 }
150 s.Down()
151 if got := s.Selection("/skill:"); got != "/skill:manual-only" {
152 t.Fatalf("selection after down = %q", got)
153 }
154 if got := commandNames(s.matches("/SKILL:ai-dev")); !slices.Equal(got, []string{"/skill:ai-development-workflow"}) {
155 t.Fatalf("filtered matches = %v", got)
156 }
157 if got := s.Selection("/skill:ai-dev"); got != "/skill:ai-development-workflow" {
158 t.Fatalf("selection = %q", got)
159 }
160}
161
162func TestSlashSuggesterRefreshesSkillsOncePerInputSession(t *testing.T) {
163 s := newSlashSuggester()
164 if s.SkillInputStarted("/skill") {
165 t.Fatal("refresh started before colon")
166 }
167 if !s.SkillInputStarted("/skill:") {
168 t.Fatal("first /skill: did not request refresh")
169 }
170 if s.SkillInputStarted("/skill:review") {
171 t.Fatal("same input session requested another refresh")
172 }
173 if s.SkillInputStarted("") {
174 t.Fatal("leaving skill input requested refresh")
175 }
176 if !s.SkillInputStarted("/SKILL:") {
177 t.Fatal("new case-insensitive input session did not request refresh")
178 }
179}
180
181func commandNames(cmds []slashCommand) []string {
182 out := make([]string, 0, len(cmds))
183 for _, c := range cmds {
184 if !c.Header {
185 out = append(out, c.Name)
186 }
187 }
188 return out
189}
190
191func contains(xs []string, want string) bool {
192 for _, x := range xs {
193 if x == want {
194 return true
195 }
196 }
197 return false
198}