1package agent
2
3import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "runtime"
8 "slices"
9 "strings"
10 "testing"
11
12 "github.com/patriceckhart/zot/packages/core"
13 "github.com/patriceckhart/zot/packages/provider"
14)
15
16func TestSkillPinsPersistIndependentScopes(t *testing.T) {
17 t.Setenv("ZOT_HOME", t.TempDir())
18 a, b := t.TempDir(), t.TempDir()
19 for _, pin := range []struct {
20 cwd, name string
21 global bool
22 }{
23 {a, "review", false}, {a, "review", true}, {b, "other", false},
24 } {
25 if err := toggleSkillPin(pin.cwd, pin.name, pin.global); err != nil {
26 t.Fatal(err)
27 }
28 }
29 if err := toggleSkillPin(a, "review", false); err != nil {
30 t.Fatal(err)
31 }
32 pins, err := loadSkillPins(a)
33 if err != nil || len(pins.Project) != 0 || !slices.Equal(pins.Global, []string{"review"}) {
34 t.Fatalf("pins = %+v, %v", pins, err)
35 }
36 pins, err = loadSkillPins(b)
37 if err != nil || !slices.Equal(pins.Project, []string{"other"}) || !slices.Equal(pins.Global, []string{"review"}) {
38 t.Fatalf("pins = %+v, %v", pins, err)
39 }
40 info, err := os.Stat(filepath.Join(ZotHome(), "skill-pins.json"))
41 if err != nil {
42 t.Fatal(err)
43 }
44 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
45 t.Fatalf("mode = %o", info.Mode().Perm())
46 }
47 entries, err := os.ReadDir(a)
48 if err != nil || len(entries) != 0 {
49 t.Fatalf("project was modified: %v, %v", entries, err)
50 }
51}
52
53func TestSkillPinsDirectoryAliases(t *testing.T) {
54 t.Setenv("ZOT_HOME", t.TempDir())
55 root := t.TempDir()
56 target, alias := filepath.Join(root, "target"), filepath.Join(root, "alias")
57 if err := os.Mkdir(target, 0o700); err != nil {
58 t.Fatal(err)
59 }
60 if err := os.Symlink(target, alias); err != nil {
61 t.Skipf("symlink unavailable: %v", err)
62 }
63 if err := toggleSkillPin(target, "review", false); err != nil {
64 t.Fatal(err)
65 }
66 pins, err := loadSkillPins(alias)
67 if err != nil || !slices.Equal(pins.Project, []string{"review"}) {
68 t.Fatalf("alias pins = %+v, %v", pins, err)
69 }
70}
71
72func TestSkillPinsRejectCorruptFileWithoutOverwriting(t *testing.T) {
73 t.Setenv("ZOT_HOME", t.TempDir())
74 path := filepath.Join(ZotHome(), "skill-pins.json")
75 original := []byte(`{"global":["review"],"projects":42}`)
76 if err := os.WriteFile(path, original, 0o600); err != nil {
77 t.Fatal(err)
78 }
79 pins, err := loadSkillPins(t.TempDir())
80 if err == nil || len(pins.Global) != 0 {
81 t.Fatalf("pins = %+v, err = %v", pins, err)
82 }
83 if err := toggleSkillPin(t.TempDir(), "new", true); err == nil {
84 t.Fatal("corrupt file overwritten")
85 }
86 got, err := os.ReadFile(path)
87 if err != nil || !bytes.Equal(got, original) {
88 t.Fatalf("file changed: %q, %v", got, err)
89 }
90}
91
92func TestPreloadSkillPinsFreshOnlyAndNoSkill(t *testing.T) {
93 t.Setenv("ZOT_HOME", t.TempDir())
94 t.Setenv("HOME", t.TempDir())
95 cwd := t.TempDir()
96 dir := filepath.Join(cwd, ".zot", "skills", "review")
97 if err := os.MkdirAll(dir, 0o700); err != nil {
98 t.Fatal(err)
99 }
100 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\ndisable-model-invocation: true\n---\nInspect carefully.\n"), 0o600); err != nil {
101 t.Fatal(err)
102 }
103 if err := toggleSkillPin(cwd, "review", true); err != nil {
104 t.Fatal(err)
105 }
106 if err := toggleSkillPin(cwd, "review", false); err != nil {
107 t.Fatal(err)
108 }
109 if err := toggleSkillPin(cwd, "missing", false); err != nil {
110 t.Fatal(err)
111 }
112 for _, tt := range []struct {
113 name string
114 fresh, disabled bool
115 }{
116 {"fresh", true, false}, {"resumed", false, false}, {"disabled", true, true},
117 } {
118 t.Run(tt.name, func(t *testing.T) {
119 var diagnostics bytes.Buffer
120 args := Args{CWD: cwd, WithSkills: true, NoExt: true, NoSkill: tt.disabled}
121 got := preloadSkillPins(args, tt.fresh, "question", &diagnostics)
122 if tt.fresh && !tt.disabled {
123 if strings.Count(got, "# Skill: review") != 1 || !strings.Contains(got, "Inspect carefully.") {
124 t.Fatal(got)
125 }
126 if !strings.Contains(diagnostics.String(), "missing") {
127 t.Fatal("missing warning")
128 }
129 } else if got != "question" || diagnostics.Len() != 0 {
130 t.Fatalf("prompt = %q, warnings = %q", got, diagnostics.String())
131 }
132 })
133 }
134}
135
136func TestOpenSessionFreshness(t *testing.T) {
137 t.Setenv("ZOT_HOME", t.TempDir())
138 isolateSessionEnvironment(t)
139 cwd := t.TempDir()
140 r := Resolved{Provider: "test", Model: "test"}
141 ag := core.NewAgent(nil, "test", "", nil)
142 args := Args{CWD: cwd, Continue: true}
143 sess, fresh, err := openOrCreateSessionState(args, r, ag, "test")
144 if err != nil || !fresh {
145 t.Fatalf("continue without history: fresh=%v, err=%v", fresh, err)
146 }
147 if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "previous"}}}); err != nil {
148 t.Fatal(err)
149 }
150 sess.Close()
151 sess, fresh, err = openOrCreateSessionState(args, r, ag, "test")
152 if err != nil || fresh {
153 t.Fatalf("continue with history: fresh=%v, err=%v", fresh, err)
154 }
155 sess.Close()
156 // A persisted session compacted to no messages is still a resumed session.
157 path := filepath.Join(t.TempDir(), "empty.jsonl")
158 empty, err := core.NewSessionAtPath(path, cwd, "test", "test", "test")
159 if err != nil {
160 t.Fatal(err)
161 }
162 if err := empty.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
163 t.Fatal(err)
164 }
165 if err := empty.AppendCompaction(nil); err != nil {
166 t.Fatal(err)
167 }
168 defer empty.Close()
169 sess, fresh, err = openOrCreateSessionState(Args{CWD: cwd, Session: path}, r, ag, "test")
170 if err != nil || fresh || len(ag.Messages()) != 0 {
171 t.Fatalf("empty resume: fresh=%v, err=%v", fresh, err)
172 }
173 sess.Close()
174 sess, fresh, err = openOrCreateSessionState(Args{CWD: cwd, Session: filepath.Join(t.TempDir(), "new.jsonl")}, r, ag, "test")
175 if err != nil || !fresh {
176 t.Fatalf("new explicit path: fresh=%v, err=%v", fresh, err)
177 }
178 sess.Close()
179 _, fresh, err = openOrCreateSessionState(Args{NoSess: true}, r, ag, "test")
180 if err != nil || !fresh {
181 t.Fatalf("no persistence: fresh=%v, err=%v", fresh, err)
182 }
183}