1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/mattn/go-runewidth"
11 "github.com/patriceckhart/zot/packages/agent/skills"
12 "github.com/patriceckhart/zot/packages/core"
13 "github.com/patriceckhart/zot/packages/provider"
14 "github.com/patriceckhart/zot/packages/tui"
15)
16
17func pinnedTestConfig() InteractiveConfig {
18 return InteractiveConfig{
19 PreloadPinnedSkills: true,
20 LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
21 SkillSnapshot: func() []*skills.Skill { return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}} },
22 }
23}
24
25func TestPinnedSkillsLifecycle(t *testing.T) {
26 i := NewInteractive(pinnedTestConfig())
27 i.ed.SetValue("typed request")
28 if got := strings.Join(i.pinnedSkillsNotice(80), ""); !strings.Contains(got, "review") {
29 t.Fatal(got)
30 }
31 if got := i.ed.Value(); got != "typed request" {
32 t.Fatal("notice changed editor")
33 }
34 first := i.preloadPinnedPromptLocked("first")
35 if !strings.Contains(first, "Inspect carefully.") {
36 t.Fatal(first)
37 }
38 if got := i.preloadPinnedPromptLocked("second"); got != "second" {
39 t.Fatal(got)
40 }
41 if len(i.pinnedSkillsNotice(80)) != 0 {
42 t.Fatal("notice survived consumption")
43 }
44 i.runSlash(context.Background(), "/clear")
45 if !strings.Contains(i.preloadPinnedPromptLocked("after clear"), "Inspect carefully.") {
46 t.Fatal("clear did not rearm pins")
47 }
48 i.SetPinnedSkillsPending(true)
49 i.SetPinnedSkillsPending(false)
50 if got := i.preloadPinnedPromptLocked("resumed"); got != "resumed" {
51 t.Fatal("resume reinjected pins")
52 }
53 cfg := pinnedTestConfig()
54 cfg.PreloadPinnedSkills = false
55 resumed := NewInteractive(cfg)
56 if got := resumed.preloadPinnedPromptLocked("existing"); got != "existing" {
57 t.Fatal("resumed startup injected pins")
58 }
59}
60
61func TestSkillPinPickerTogglesAndSaveFailure(t *testing.T) {
62 cfg := pinnedTestConfig()
63 pins := skills.Pins{}
64 fail := false
65 cfg.LoadSkillPins = func(string) (skills.Pins, error) { return pins, nil }
66 cfg.ToggleSkillPin = func(cwd, name string, global bool) error {
67 if fail {
68 return errors.New("read-only preferences")
69 }
70 if name != "review" {
71 t.Fatalf("name = %q", name)
72 }
73 if global {
74 if len(pins.Global) == 0 {
75 pins.Global = []string{name}
76 } else {
77 pins.Global = nil
78 }
79 } else {
80 if len(pins.Project) == 0 {
81 pins.Project = []string{name}
82 } else {
83 pins.Project = nil
84 }
85 }
86 return nil
87 }
88 i := NewInteractive(cfg)
89 i.openSkillsDialog()
90 for _, key := range []rune{'p', 'g'} {
91 if !i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: key}) {
92 t.Fatal("key ignored")
93 }
94 }
95 rendered := strings.Join(i.skillsDialog.Render(tui.Theme{}, 100), "\n")
96 if !strings.Contains(rendered, "[pg]") {
97 t.Fatal(rendered)
98 }
99 if len(i.pinnedSkills) != 1 {
100 t.Fatal("scope union duplicated skill")
101 }
102 i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
103 if len(i.pinnedSkills) != 1 || len(i.skillsDialog.pins.Project) != 0 {
104 t.Fatal("project unpin removed global pin")
105 }
106 fail = true
107 i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: 'g'})
108 if !strings.Contains(i.statusErr, "read-only") || len(i.skillsDialog.pins.Global) != 1 {
109 t.Fatal("save failure was not preserved")
110 }
111 i.skillsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
112 if i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: 'g'}) {
113 t.Fatal("body view toggled a pin")
114 }
115}
116
117func TestPinnedSkillsNoticeShowsScopes(t *testing.T) {
118 cfg := pinnedTestConfig()
119 cfg.LoadSkillPins = func(string) (skills.Pins, error) {
120 return skills.Pins{
121 Project: []string{"local", "shared"},
122 Global: []string{"everywhere", "shared"},
123 }, nil
124 }
125 cfg.SkillSnapshot = func() []*skills.Skill {
126 return []*skills.Skill{{Name: "local"}, {Name: "everywhere"}, {Name: "shared"}}
127 }
128 i := NewInteractive(cfg)
129 text := stripANSIBytes(strings.Join(i.pinnedSkillsNotice(120), "\n"))
130 for _, want := range []string{"local (project)", "everywhere (global)", "shared (project + global)"} {
131 if !strings.Contains(text, want) {
132 t.Errorf("notice missing %q: %q", want, text)
133 }
134 }
135 if strings.Count(text, "shared") != 1 {
136 t.Fatalf("both scopes duplicated the skill: %q", text)
137 }
138}
139
140func TestPinnedSkillsWarningsAndNoticeWidth(t *testing.T) {
141 cfg := pinnedTestConfig()
142 cfg.LoadSkillPins = func(string) (skills.Pins, error) {
143 return skills.Pins{Global: []string{"missing", "review"}}, nil
144 }
145 i := NewInteractive(cfg)
146 i.refreshSkillPins()
147 if len(i.reloadErrors) != 1 || !strings.Contains(i.reloadErrors[0], "missing") {
148 t.Fatalf("warnings = %v", i.reloadErrors)
149 }
150 i.pinnedSkills = []*skills.Skill{{Name: strings.Repeat("界", 30)}}
151 for _, width := range []int{1, 4, 12, 30, 80} {
152 for _, row := range i.pinnedSkillsNotice(width) {
153 if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
154 t.Fatalf("width %d exceeds %d", got, width)
155 }
156 }
157 }
158}
159
160func TestPinnedSkillsEnterProviderAndPersistedTranscript(t *testing.T) {
161 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162 defer cancel()
163 client := &preparationCaptureClient{requests: make(chan provider.Request, 1)}
164 ag := core.NewAgent(client, "test", "base", nil)
165 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "test", "test", "test")
166 if err != nil {
167 t.Fatal(err)
168 }
169 defer sess.Close()
170 persisted := make(chan error, 4)
171 ag.OnMessageAppended = func(m provider.Message) { persisted <- sess.AppendMessage(m) }
172 done := make(chan struct{}, 1)
173 cfg := pinnedTestConfig()
174 cfg.Agent = ag
175 cfg.FlushSession = func() { done <- struct{}{} }
176 i := NewInteractive(cfg)
177 // Construction only prepares the notice. It must not contact a provider.
178 select {
179 case <-client.requests:
180 t.Fatal("startup invoked model")
181 default:
182 }
183 image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image")}
184 i.startTurnWithImages(ctx, "question", []provider.ImageBlock{image})
185 select {
186 case req := <-client.requests:
187 content := req.Messages[0].Content
188 if len(content) != 2 {
189 t.Fatalf("content = %#v", content)
190 }
191 text := content[0].(provider.TextBlock).Text
192 if !strings.Contains(text, "Inspect carefully.") || !strings.HasSuffix(text, "question") {
193 t.Fatal(text)
194 }
195 if _, ok := content[1].(provider.ImageBlock); !ok {
196 t.Fatal("image lost")
197 }
198 case <-ctx.Done():
199 t.Fatal("provider not called")
200 }
201 select {
202 case <-done:
203 case <-ctx.Done():
204 t.Fatal("turn did not finish")
205 }
206 for range 2 {
207 select {
208 case err := <-persisted:
209 if err != nil {
210 t.Fatal(err)
211 }
212 case <-ctx.Done():
213 t.Fatal("message not persisted")
214 }
215 }
216 reopened, messages, err := core.OpenSession(sess.Path)
217 if err != nil {
218 t.Fatal(err)
219 }
220 defer reopened.Close()
221 if len(messages) != 2 || !strings.Contains(messages[0].Content[0].(provider.TextBlock).Text, "Inspect carefully.") {
222 t.Fatalf("messages = %#v", messages)
223 }
224}