1package modes
2
3import (
4 "context"
5 "slices"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/patriceckhart/zot/packages/core"
11 "github.com/patriceckhart/zot/packages/tui"
12)
13
14func TestReasoningSelectorUsesCurrentModelLevels(t *testing.T) {
15 i := &Interactive{cfg: InteractiveConfig{
16 Provider: "openai",
17 Model: "gpt-5.6-sol",
18 Reasoning: "minimum",
19 }}
20
21 item := i.reasoningSettingItem()
22 var levels []string
23 for _, option := range item.options {
24 levels = append(levels, option.value)
25 }
26 want := []string{"", "low", "medium", "high", "xhigh", "max"}
27 if !slices.Equal(levels, want) {
28 t.Fatalf("reasoning levels = %q, want %q", levels, want)
29 }
30 if item.options[item.choice].value != "low" {
31 t.Fatalf("clamped current level = %q, want low", item.options[item.choice].value)
32 }
33}
34
35func TestReasoningSelectorOnlyOffersOffForUnsupportedModel(t *testing.T) {
36 i := &Interactive{cfg: InteractiveConfig{
37 Provider: "google",
38 Model: "gemini-2.0-flash",
39 Reasoning: "high",
40 }}
41
42 item := i.reasoningSettingItem()
43 if len(item.options) != 1 || item.options[0].value != "" {
44 t.Fatalf("reasoning levels = %#v, want off only", item.options)
45 }
46 if item.hint != "current model does not support reasoning" {
47 t.Fatalf("hint = %q", item.hint)
48 }
49}
50
51func TestReasoningCommandOpensDirectSelector(t *testing.T) {
52 i := &Interactive{
53 cfg: InteractiveConfig{
54 Reasoning: "high",
55 },
56 settingsDialog: newSettingsDialog(),
57 }
58
59 i.runSlash(context.Background(), "/reasoning")
60
61 if !i.settingsDialog.Active() || !i.settingsDialog.selecting || !i.settingsDialog.direct {
62 t.Fatal("reasoning command did not open the direct option selector")
63 }
64 lines := i.settingsDialog.Render(tui.Dark, 100)
65 text := strings.Join(lines, "\n")
66 if !strings.Contains(text, "reasoning level") || !strings.Contains(text, "high") {
67 t.Fatalf("reasoning selector missing current level: %q", text)
68 }
69 i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyDown})
70 act := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
71 i.applySettingChange(act)
72 if i.cfg.Reasoning != "xhigh" || i.settingsDialog.Active() {
73 t.Fatalf("reasoning selection was not applied and closed: level=%q active=%v", i.cfg.Reasoning, i.settingsDialog.Active())
74 }
75 if i.statusOK != "reasoning level xhigh" {
76 t.Fatalf("reasoning status = %q", i.statusOK)
77 }
78
79 i.runSlash(context.Background(), "/reasoning")
80 act = i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEsc})
81 if !act.Close || i.settingsDialog.Active() {
82 t.Fatal("escape did not close the direct reasoning selector")
83 }
84}
85
86func TestReasoningSettingNotifiesRuntime(t *testing.T) {
87 i := &Interactive{agent: &core.Agent{Reasoning: "high"}}
88 var levels []string
89 i.cfg.OnReasoningChanged = func(level string) {
90 if i.agent.Reasoning != level {
91 t.Fatalf("callback ran before agent update: got %q, want %q", i.agent.Reasoning, level)
92 }
93 levels = append(levels, level)
94 }
95 i.applyReasoningSetting("medium")
96 i.applyReasoningSetting("off")
97 if !slices.Equal(levels, []string{"medium", ""}) {
98 t.Fatalf("reasoning notifications = %q", levels)
99 }
100}
101
102func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) {
103 if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" {
104 t.Fatalf("id = %q; want @presetflash", got)
105 }
106 if got := normalizeModelQuery("preset"); got != "preset" {
107 t.Fatalf("query = %q; want preset", got)
108 }
109 haystack := normalizeModelQuery("openrouter @preset/flash Flash (preset)")
110 if !strings.Contains(haystack, normalizeModelQuery("preset")) {
111 t.Fatalf("%q does not contain preset", haystack)
112 }
113}
114
115func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {
116 d := newModelDialog()
117 d.Open("", nil, "high")
118
119 text := strings.Join(d.Render(tui.Dark, 100), "\n")
120 if !strings.Contains(text, "current reasoning: high") || !strings.Contains(text, "/reasoning") {
121 t.Fatalf("model dialog missing reasoning guidance: %q", text)
122 }
123}
124
125func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
126 i := &Interactive{
127 cfg: InteractiveConfig{
128 RefreshLlamaCPPModels: func(context.Context) error { return nil },
129 },
130 modelRefresh: make(chan modelRefreshResult, 1),
131 modelDialog: newModelDialog(),
132 }
133
134 i.runSlash(context.Background(), "/model")
135
136 if !i.modelDialog.Active() {
137 t.Fatal("model picker did not open immediately")
138 }
139 if i.statusOK == "refreshing models" {
140 t.Fatal("refresh status shown without llama.cpp router configuration")
141 }
142}
143
144func TestModelPickerRefreshesDiscoverableCustomProviders(t *testing.T) {
145 refreshed := make(chan struct{}, 1)
146 i := &Interactive{
147 cfg: InteractiveConfig{
148 CustomDiscoveryConfigured: func() bool { return true },
149 RefreshCustomProviderModels: func(context.Context) error {
150 refreshed <- struct{}{}
151 return nil
152 },
153 },
154 modelRefresh: make(chan modelRefreshResult, 1),
155 modelDialog: newModelDialog(),
156 }
157
158 i.runSlash(context.Background(), "/model")
159
160 if i.modelDialog.Active() {
161 t.Fatal("model picker opened before the custom provider refresh finished")
162 }
163 if i.statusOK != "refreshing models" {
164 t.Fatalf("status = %q", i.statusOK)
165 }
166 select {
167 case <-refreshed:
168 case <-time.After(5 * time.Second):
169 t.Fatal("custom provider refresh was not invoked")
170 }
171 select {
172 case result := <-i.modelRefresh:
173 i.openModelPickerAfterRefresh(result.err)
174 case <-time.After(5 * time.Second):
175 t.Fatal("refresh result never delivered")
176 }
177 if !i.modelDialog.Active() {
178 t.Fatal("model picker did not open after refresh")
179 }
180
181 // Without opt-in, the picker opens immediately even when the hook exists.
182 i = &Interactive{
183 cfg: InteractiveConfig{
184 CustomDiscoveryConfigured: func() bool { return false },
185 RefreshCustomProviderModels: func(context.Context) error { t.Error("unexpected refresh"); return nil },
186 },
187 modelRefresh: make(chan modelRefreshResult, 1),
188 modelDialog: newModelDialog(),
189 }
190 i.runSlash(context.Background(), "/model")
191 if !i.modelDialog.Active() {
192 t.Fatal("model picker did not open immediately")
193 }
194}