summaryrefslogtreecommitdiffstats
path: root/main_test.go
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--main_test.go357
1 files changed, 357 insertions, 0 deletions
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..04c2566
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,357 @@
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14 "unicode/utf8"
15)
16
17func inDir(t *testing.T) string {
18 t.Helper()
19 d := t.TempDir()
20 wd, _ := os.Getwd()
21 if err := os.Chdir(d); err != nil {
22 t.Fatal(err)
23 }
24 t.Cleanup(func() { os.Chdir(wd) })
25 return d
26}
27
28func TestRead(t *testing.T) {
29 inDir(t)
30 os.WriteFile("f", []byte("a\nb\nc\n"), 0644)
31
32 got, _ := toolRead(map[string]any{"path": "f"})
33 if want := " 1| a\n 2| b\n 3| c\n"; got != want {
34 t.Errorf("full: got %q want %q", got, want)
35 }
36 got, _ = toolRead(map[string]any{"path": "f", "offset": 1.0, "limit": 1.0})
37 if want := " 2| b\n"; got != want {
38 t.Errorf("window: got %q want %q", got, want)
39 }
40 // stringly-typed args, as small local models emit them
41 got, _ = toolRead(map[string]any{"path": "f", "offset": "1", "limit": "1"})
42 if want := " 2| b\n"; got != want {
43 t.Errorf("string args: got %q want %q", got, want)
44 }
45 // no trailing newline on the final line
46 os.WriteFile("g", []byte("a\nb"), 0644)
47 got, _ = toolRead(map[string]any{"path": "g"})
48 if want := " 1| a\n 2| b"; got != want {
49 t.Errorf("no-trailing-nl: got %q want %q", got, want)
50 }
51 // empty file -> no lines
52 os.WriteFile("e", nil, 0644)
53 if got, _ = toolRead(map[string]any{"path": "e"}); got != "" {
54 t.Errorf("empty: got %q", got)
55 }
56 if _, err := toolRead(map[string]any{"path": "nope"}); err == nil {
57 t.Error("missing file should error")
58 }
59}
60
61func TestEdit(t *testing.T) {
62 inDir(t)
63 os.WriteFile("f", []byte("x y x"), 0644)
64
65 got, err := toolEdit(map[string]any{"path": "f", "old": "x", "new": "z"})
66 if err != nil {
67 t.Fatalf("must be a result, not an error: %v", err)
68 }
69 if want := "error: old_string appears 2 times, must be unique (use all=true)"; got != want {
70 t.Errorf("got %q want %q", got, want)
71 }
72 if b, _ := os.ReadFile("f"); string(b) != "x y x" {
73 t.Errorf("file was modified: %q", b)
74 }
75 if got, _ = toolEdit(map[string]any{"path": "f", "old": "x", "new": "z", "all": true}); got != "ok" {
76 t.Errorf("all=true: got %q", got)
77 }
78 if b, _ := os.ReadFile("f"); string(b) != "z y z" {
79 t.Errorf("all=true result: %q", b)
80 }
81 if got, _ = toolEdit(map[string]any{"path": "f", "old": "q", "new": "r"}); got != "error: old_string not found" {
82 t.Errorf("not found: got %q", got)
83 }
84}
85
86func TestGlob(t *testing.T) {
87 inDir(t)
88 os.MkdirAll("sub", 0755)
89 os.MkdirAll(".hidden", 0755)
90 os.WriteFile("old.go", []byte("package p"), 0644)
91 os.WriteFile("sub/new.go", []byte("package p"), 0644)
92 os.WriteFile(".hidden/x.go", []byte("package p"), 0644)
93 os.WriteFile("notgo.txt", nil, 0644)
94 now := time.Now()
95 os.Chtimes("old.go", now.Add(-time.Hour), now.Add(-time.Hour))
96 os.Chtimes("sub/new.go", now, now)
97
98 got, _ := toolGlob(map[string]any{"pat": "**/*.go"})
99 if want := "sub/new.go\nold.go"; got != want {
100 t.Errorf("newest-first: got %q want %q", got, want)
101 }
102 if strings.Contains(got, ".hidden") {
103 t.Errorf("dot-directory not skipped: %q", got)
104 }
105 if got, _ = toolGlob(map[string]any{"pat": "**/*.rs"}); got != "none" {
106 t.Errorf("no matches: got %q", got)
107 }
108}
109
110func TestGrepCapsAt50(t *testing.T) {
111 inDir(t)
112 for i := 0; i < 8; i++ {
113 var b strings.Builder
114 for j := 0; j < 10; j++ {
115 fmt.Fprintf(&b, "needle %d\n", j)
116 }
117 os.WriteFile(fmt.Sprintf("f%d.txt", i), []byte(b.String()), 0644)
118 }
119 got, _ := toolGrep(map[string]any{"pat": "needle"})
120 if n := len(strings.Split(got, "\n")); n != 50 {
121 t.Errorf("got %d lines, want 50", n)
122 }
123 if got, _ = toolGrep(map[string]any{"pat": "haystack"}); got != "none" {
124 t.Errorf("no hits: got %q", got)
125 }
126 if _, err := toolGrep(map[string]any{"pat": "("}); err == nil {
127 t.Error("bad regex should error")
128 }
129 // RE2: backreferences do not compile
130 if _, err := toolGrep(map[string]any{"pat": `(a)\1`}); err == nil {
131 t.Error("backreference should error")
132 }
133}
134
135func TestBash(t *testing.T) {
136 if got, _ := toolBash(map[string]any{"cmd": "true"}); got != "(empty)" {
137 t.Errorf("true: got %q want (empty)", got)
138 }
139 got, _ := toolBash(map[string]any{"cmd": "echo out; echo err 1>&2"})
140 if !strings.Contains(got, "out") || !strings.Contains(got, "err") {
141 t.Errorf("stdout+stderr: got %q", got)
142 }
143 if got, _ = toolBash(map[string]any{"cmd": "cd /tmp"}); got != "(empty)" {
144 t.Errorf("cd: got %q", got)
145 }
146}
147
148func TestBashTimeout(t *testing.T) {
149 if testing.Short() {
150 t.Skip("30s")
151 }
152 start := time.Now()
153 got, _ := toolBash(map[string]any{"cmd": "echo before; sleep 60"})
154 elapsed := time.Since(start)
155 if !strings.HasSuffix(got, "(timed out after 30s)") {
156 t.Errorf("got %q", got)
157 }
158 if !strings.HasPrefix(got, "before") {
159 t.Errorf("drained output lost: %q", got)
160 }
161 if elapsed < 29*time.Second || elapsed > 35*time.Second {
162 t.Errorf("elapsed %v, want ~30s", elapsed)
163 }
164}
165
166func TestUnknownTool(t *testing.T) {
167 if got := runTool("nope", json.RawMessage(`{}`)); got != `error: unknown tool "nope"` {
168 t.Errorf("got %q", got)
169 }
170}
171
172// Schemas carry "type" and nothing else -- a maxLength >~2000
173// makes gpt-oss-120b fail every tool call with "failed to parse grammar".
174func TestSchemas(t *testing.T) {
175 for i, s := range schemas {
176 var parsed struct {
177 Name string `json:"name"`
178 Schema struct {
179 Properties map[string]map[string]any `json:"properties"`
180 Required []string `json:"required"`
181 } `json:"input_schema"`
182 }
183 if err := json.Unmarshal(s, &parsed); err != nil {
184 t.Fatalf("tool %s: %v", tools[i].name, err)
185 }
186 // The invariant: every property carries "type" and nothing else.
187 for prop, keys := range parsed.Schema.Properties {
188 if len(keys) != 1 {
189 t.Errorf("tool %s prop %s: %d schema keys, want only \"type\"", parsed.Name, prop, len(keys))
190 }
191 if _, ok := keys["type"]; !ok {
192 t.Errorf("tool %s prop %s: missing \"type\"", parsed.Name, prop)
193 }
194 }
195 if parsed.Schema.Required == nil {
196 t.Errorf("tool %s: required is null, must be an array", parsed.Name)
197 }
198 if parsed.Name != tools[i].name {
199 t.Errorf("schema %d: name %q want %q", i, parsed.Name, tools[i].name)
200 }
201 }
202 // declaration order, not map order
203 if want := `"properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"},"all":{"type":"boolean"}}`; !strings.Contains(string(schemas[2]), want) {
204 t.Errorf("edit schema property order wrong:\n%s", schemas[2])
205 }
206 if want := `"required":["path","old","new"]`; !strings.Contains(string(schemas[2]), want) {
207 t.Errorf("edit required wrong:\n%s", schemas[2])
208 }
209 if want := `"offset":{"type":"integer"}`; !strings.Contains(string(schemas[0]), want) {
210 t.Errorf("optional marker should be stripped from the type:\n%s", schemas[0])
211 }
212}
213
214func TestDisplay(t *testing.T) {
215 if got := renderMarkdown("a **b** c"); got != "a "+bold+"b"+reset+" c" {
216 t.Errorf("bold: got %q", got)
217 }
218 if got := renderMarkdown("**a\nb**"); got != bold+"a\nb"+reset {
219 t.Errorf("dotall: got %q", got)
220 }
221 if got := capitalize("read"); got != "Read" {
222 t.Errorf("got %q", got)
223 }
224 // truncation by code points, never bytes
225 if got := truncate(strings.Repeat("é", 60), 50); len([]rune(got)) != 50 {
226 t.Errorf("got %d runes", len([]rune(got)))
227 }
228 // first declared param present in the input
229 in := json.RawMessage(`{"limit":5,"path":"/tmp/x"}`)
230 if got := argPreview("read", in); got != "/tmp/x" {
231 t.Errorf("preview: got %q", got)
232 }
233 if got := argPreview("read", json.RawMessage(`{"limit":5}`)); got != "5" {
234 t.Errorf("non-string preview: got %q", got)
235 }
236 if got := argPreview("nope", in); got != "" {
237 t.Errorf("unknown tool preview: got %q", got)
238 }
239}
240
241func TestGlobEngine(t *testing.T) {
242 d := inDir(t)
243 os.MkdirAll("a/b", 0755)
244 os.WriteFile("a/b/c.txt", nil, 0644)
245 os.WriteFile("a/top.txt", nil, 0644)
246
247 // ** matches zero segments
248 if got := globExpand("a/**/top.txt"); len(got) != 1 || got[0] != "a/top.txt" {
249 t.Errorf("zero-segment **: got %v", got)
250 }
251 // final segment emits directories too
252 got := globExpand("a/*")
253 if len(got) != 2 {
254 t.Errorf("final segment should emit dir and file: got %v", got)
255 }
256 // absolute patterns stay absolute
257 if got := globExpand(filepath.Join(d, "a", "*.txt")); len(got) != 1 || !strings.HasPrefix(got[0], "/") {
258 t.Errorf("absolute: got %v", got)
259 }
260 // unreadable directory is skipped silently
261 os.MkdirAll("locked", 0000)
262 defer os.Chmod("locked", 0755)
263 if got := globExpand("locked/*"); len(got) != 0 {
264 t.Errorf("unreadable dir: got %v", got)
265 }
266}
267
268// Two tool_use blocks in one assistant turn produce two
269// result lines and exactly ONE follow-up request whose last message is a user
270// message carrying both tool_result entries.
271func TestAgentLoopBatchesToolResults(t *testing.T) {
272 inDir(t)
273 os.WriteFile("a.txt", []byte("A\n"), 0644)
274 os.WriteFile("b.txt", []byte("B\n"), 0644)
275
276 var bodies []map[string]any
277 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
278 var body map[string]any
279 json.NewDecoder(r.Body).Decode(&body)
280 bodies = append(bodies, body)
281 if len(bodies) == 1 {
282 io.WriteString(w, `{"content":[
283 {"type":"thinking","thinking":"secret","signature":""},
284 {"type":"tool_use","id":"t1","name":"read","input":{"path":"a.txt"}},
285 {"type":"tool_use","id":"t2","name":"read","input":{"path":"b.txt"}}]}`)
286 return
287 }
288 io.WriteString(w, `{"content":[{"type":"text","text":"done"}]}`)
289 }))
290 defer srv.Close()
291
292 old := apiURL
293 apiURL = srv.URL
294 defer func() { apiURL = old }()
295
296 var messages []message
297 messages = append(messages, message{"user", "read both"})
298 if err := agentLoop(&messages); err != nil {
299 t.Fatal(err)
300 }
301
302 if len(bodies) != 2 {
303 t.Fatalf("made %d requests, want exactly 2", len(bodies))
304 }
305 // user, assistant(3 blocks), user(2 tool_results), assistant(text)
306 if len(messages) != 4 {
307 t.Fatalf("history has %d messages, want 4: %+v", len(messages), messages)
308 }
309 last := bodies[1]["messages"].([]any)
310 tail := last[len(last)-1].(map[string]any)
311 if tail["role"] != "user" {
312 t.Errorf("follow-up tail role = %v, want user", tail["role"])
313 }
314 results, ok := tail["content"].([]any)
315 if !ok || len(results) != 2 {
316 t.Fatalf("follow-up tail content = %v, want 2 tool_results", tail["content"])
317 }
318 for i, want := range []string{" 1| A\n", " 1| B\n"} {
319 r := results[i].(map[string]any)
320 if r["type"] != "tool_result" || r["content"] != want {
321 t.Errorf("result %d = %+v, want tool_result %q", i, r, want)
322 }
323 }
324 if got := results[0].(map[string]any)["tool_use_id"]; got != "t1" {
325 t.Errorf("tool_use_id = %v, want t1", got)
326 }
327
328 // The thinking block is never displayed, but IS replayed verbatim.
329 asst := last[1].(map[string]any)
330 blocks := asst["content"].([]any)
331 if len(blocks) != 3 {
332 t.Fatalf("assistant replayed %d blocks, want 3", len(blocks))
333 }
334 if b0 := blocks[0].(map[string]any); b0["type"] != "thinking" || b0["thinking"] != "secret" {
335 t.Errorf("thinking block not round-tripped verbatim: %+v", b0)
336 }
337}
338
339func TestAPIErrorTruncatesTo500(t *testing.T) {
340 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
341 w.WriteHeader(500)
342 io.WriteString(w, strings.Repeat("é", 900))
343 }))
344 defer srv.Close()
345 old := apiURL
346 apiURL = srv.URL
347 defer func() { apiURL = old }()
348
349 _, err := callAPI(nil)
350 if err == nil {
351 t.Fatal("want error")
352 }
353 body := strings.TrimPrefix(err.Error(), "HTTP 500: ")
354 if n := utf8.RuneCountInString(body); n != 500 {
355 t.Errorf("body is %d code points, want 500", n)
356 }
357}