summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBx <bx@boxstep.io>2026-08-25 12:48:12 -0700
committerBx <bx@boxstep.io>2026-08-25 12:48:12 -0700
commit50ca864530f5672528cd90032cdc3089f347a9bb (patch)
treed74100954e3d952f17f55c9a1ad1b526255708b2
downloadpico-50ca864530f5672528cd90032cdc3089f347a9bb.tar.gz
pico-50ca864530f5672528cd90032cdc3089f347a9bb.zip
initial commit
Diffstat (limited to '')
-rw-r--r--.gitignore2
-rw-r--r--Makefile4
-rw-r--r--go.mod3
-rw-r--r--main.go611
-rw-r--r--main_test.go357
5 files changed, 977 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..51a41ee
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
1.env
2bin/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0ac3cdd
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,4 @@
1.PHONY: all
2all: build
3build:
4 go build -o bin/pico .
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f90ffbb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
1module pico
2
3go 1.26
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..df82ac9
--- /dev/null
+++ b/main.go
@@ -0,0 +1,611 @@
1// pico — a minimal single-binary CLI coding agent.
2package main
3
4import (
5 "bufio"
6 "bytes"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strconv"
17 "strings"
18 "syscall"
19 "time"
20 "unicode/utf8"
21)
22
23// ------------------------------------------------------------------- palette
24
25const (
26 reset = "\x1b[0m"
27 bold = "\x1b[1m"
28 dim = "\x1b[2m"
29 blue = "\x1b[34m"
30 cyan = "\x1b[36m"
31 green = "\x1b[32m"
32 red = "\x1b[31m"
33)
34
35// -------------------------------------------------------------------- config
36
37var (
38 apiURL = firstEnv("API_URL", "ANTHROPIC_BASE_URL", "http://localhost:8000")
39 apiKey = firstEnv("API_KEY", "ANTHROPIC_API_KEY", "")
40 model = firstEnv("MODEL", "ANTHROPIC_MODEL", "gpt-oss-120b")
41)
42
43func firstEnv(keys ...string) string {
44 def := keys[len(keys)-1]
45 for _, k := range keys[:len(keys)-1] {
46 if k == "" {
47 continue
48 }
49 if v := os.Getenv(k); v != "" {
50 return strings.TrimRight(v, "/")
51 }
52 }
53 return def
54}
55
56// --------------------------------------------------------------------- tools
57
58type param struct {
59 name string
60 typ string // trailing "?" marks optional
61}
62
63type tool struct {
64 name string
65 desc string
66 params []param
67 run func(in map[string]any) (string, error)
68}
69
70var tools = []tool{
71 {"read", "Read file with line numbers (file path, not directory)",
72 []param{{"path", "string"}, {"offset", "integer?"}, {"limit", "integer?"}}, toolRead},
73 {"write", "Write content to file",
74 []param{{"path", "string"}, {"content", "string"}}, toolWrite},
75 {"edit", "Replace old with new in file (old must be unique unless all=true)",
76 []param{{"path", "string"}, {"old", "string"}, {"new", "string"}, {"all", "boolean?"}}, toolEdit},
77 {"glob", "Find files by pattern, sorted by mtime",
78 []param{{"pat", "string"}, {"path", "string?"}}, toolGlob},
79 {"grep", "Search files for regex pattern",
80 []param{{"pat", "string"}, {"path", "string?"}}, toolGrep},
81 {"bash", "Run shell command",
82 []param{{"cmd", "string"}}, toolBash},
83}
84
85var byName = func() map[string]*tool {
86 m := map[string]*tool{}
87 for i := range tools {
88 m[tools[i].name] = &tools[i]
89 }
90 return m
91}()
92
93// schemas is built once at startup and reused verbatim on every request, so
94// the serialized prefix stays byte-identical and KV cache holds.
95// Properties are emitted in declaration order, which map marshaling would not
96// preserve. Each property carries "type" and nothing else.
97var schemas = func() []json.RawMessage {
98 out := make([]json.RawMessage, len(tools))
99 for i, t := range tools {
100 var props, required []string
101 for _, p := range t.params {
102 props = append(props, fmt.Sprintf(`%s:{"type":%s}`, jstr(p.name), jstr(strings.TrimSuffix(p.typ, "?"))))
103 if !strings.HasSuffix(p.typ, "?") {
104 required = append(required, jstr(p.name))
105 }
106 }
107 out[i] = json.RawMessage(fmt.Sprintf(
108 `{"name":%s,"description":%s,"input_schema":{"type":"object","properties":{%s},"required":[%s]}}`,
109 jstr(t.name), jstr(t.desc), strings.Join(props, ","), strings.Join(required, ",")))
110 }
111 return out
112}()
113
114func jstr(s string) string {
115 enc, _ := json.Marshal(s)
116 return string(enc)
117}
118
119// ------------------------------------------------------------- arg coercion
120
121func argStr(in map[string]any, k, def string) string {
122 if s, ok := in[k].(string); ok {
123 return s
124 }
125 return def
126}
127
128func argInt(in map[string]any, k string, def int) int {
129 switch v := in[k].(type) {
130 case float64:
131 return int(v)
132 case string:
133 if n, err := strconv.Atoi(v); err == nil {
134 return n
135 }
136 }
137 return def
138}
139
140func argBool(in map[string]any, k string, def bool) bool {
141 if b, ok := in[k].(bool); ok {
142 return b
143 }
144 return def
145}
146
147// ---------------------------------------------------------------------- read
148
149func toolRead(in map[string]any) (string, error) {
150 data, err := os.ReadFile(argStr(in, "path", ""))
151 if err != nil {
152 return "", err
153 }
154 // SplitAfter keeps the newline on each line; drop the empty tail element.
155 lines := strings.SplitAfter(string(data), "\n")
156 if lines[len(lines)-1] == "" {
157 lines = lines[:len(lines)-1]
158 }
159 offset := argInt(in, "offset", 0)
160 limit := argInt(in, "limit", len(lines))
161 start := min(max(offset, 0), len(lines))
162 end := min(max(offset+limit, start), len(lines))
163
164 var b strings.Builder
165 for i, ln := range lines[start:end] {
166 fmt.Fprintf(&b, "%4d| %s", start+i+1, ln)
167 }
168 return b.String(), nil
169}
170
171// ---------------------------------------------------------------- write, edit
172
173func toolWrite(in map[string]any) (string, error) {
174 if err := os.WriteFile(argStr(in, "path", ""), []byte(argStr(in, "content", "")), 0644); err != nil {
175 return "", err
176 }
177 return "ok", nil
178}
179
180func toolEdit(in map[string]any) (string, error) {
181 path := argStr(in, "path", "")
182 data, err := os.ReadFile(path)
183 if err != nil {
184 return "", err
185 }
186 s, old, new := string(data), argStr(in, "old", ""), argStr(in, "new", "")
187
188 // Both of these are ordinary results, not errors: the model reads them
189 // and retries with a longer `old`.
190 switch n := strings.Count(s, old); {
191 case n == 0:
192 return "error: old_string not found", nil
193 case n > 1 && !argBool(in, "all", false):
194 return fmt.Sprintf("error: old_string appears %d times, must be unique (use all=true)", n), nil
195 }
196
197 if argBool(in, "all", false) {
198 s = strings.ReplaceAll(s, old, new)
199 } else {
200 s = strings.Replace(s, old, new, 1)
201 }
202 if err := os.WriteFile(path, []byte(s), 0644); err != nil {
203 return "", err
204 }
205 return "ok", nil
206}
207
208// ---------------------------------------------------------------- glob, grep
209
210func argPath(in map[string]any) string {
211 if p := argStr(in, "path", "."); p != "" {
212 return p
213 }
214 return "."
215}
216
217func toolGlob(in map[string]any) (string, error) {
218 matches := globExpand(strings.ReplaceAll(argPath(in)+"/"+argStr(in, "pat", ""), "//", "/"))
219 if len(matches) == 0 {
220 return "none", nil
221 }
222 mtime := make(map[string]int64, len(matches))
223 for _, m := range matches {
224 if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
225 mtime[m] = fi.ModTime().UnixNano()
226 }
227 }
228 sort.SliceStable(matches, func(i, j int) bool { return mtime[matches[i]] > mtime[matches[j]] })
229 return strings.Join(matches, "\n"), nil
230}
231
232func toolGrep(in map[string]any) (string, error) {
233 re, err := regexp.Compile(argStr(in, "pat", ""))
234 if err != nil {
235 return "", err
236 }
237 var hits []string
238 for _, p := range globExpand(argPath(in) + "/**") {
239 f, err := os.Open(p)
240 if err != nil {
241 continue
242 }
243 // Directories appear in the ** expansion; scanning one yields nothing.
244 sc := bufio.NewScanner(f)
245 sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
246 for n := 1; sc.Scan(); n++ {
247 if line := sc.Text(); re.MatchString(line) {
248 hits = append(hits, fmt.Sprintf("%s:%d:%s", p, n, strings.TrimRight(line, " \t\r")))
249 }
250 }
251 f.Close()
252 if len(hits) >= 50 {
253 break
254 }
255 }
256 if len(hits) == 0 {
257 return "none", nil
258 }
259 if len(hits) > 50 {
260 hits = hits[:50]
261 }
262 return strings.Join(hits, "\n"), nil
263}
264
265// ---------------------------------------------------------------------- bash
266
267func toolBash(in map[string]any) (string, error) {
268 pr, pw, err := os.Pipe()
269 if err != nil {
270 return "", err
271 }
272 cmd := exec.Command("sh", "-c", argStr(in, "cmd", ""))
273 cmd.Stdout, cmd.Stderr = pw, pw // one pipe: interleaved in real order
274 // Own process group, so the timeout below can kill the whole tree.
275 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
276 if err := cmd.Start(); err != nil {
277 pw.Close()
278 pr.Close()
279 return "", err
280 }
281 // The parent MUST drop its own copy of the write end, or the pipe never
282 // reaches EOF and the reader below blocks forever -- even for `true`.
283 pw.Close()
284
285 done := make(chan string, 1)
286 go func() {
287 defer pr.Close()
288 var acc strings.Builder
289 sc := bufio.NewScanner(pr)
290 sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
291 for sc.Scan() {
292 line := sc.Text()
293 fmt.Printf(" %s│ %s%s\n", dim, strings.TrimRight(line, " \t\r"), reset)
294 acc.WriteString(line + "\n")
295 }
296 done <- acc.String()
297 }()
298
299 var out string
300 select {
301 case out = <-done:
302 case <-time.After(30 * time.Second):
303 // Kill the GROUP, not just sh: `sh -c "sleep 60"` forks, and the
304 // grandchild inherits the pipe's write end. Killing sh alone leaves
305 // the pipe open and the drain below blocks until the grandchild
306 // exits on its own -- a 30s timeout that takes 60s.
307 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
308 cmd.Process.Kill()
309 }
310 out = <-done // let the reader drain before reporting
311 out += "\n(timed out after 30s)"
312 }
313 cmd.Wait() // always reap
314
315 if out = strings.TrimSpace(out); out == "" {
316 return "(empty)", nil
317 }
318 return out, nil
319}
320
321// ------------------------------------------------------------------ dispatch
322
323func runTool(name string, input json.RawMessage) string {
324 t, ok := byName[name]
325 if !ok {
326 return fmt.Sprintf("error: unknown tool %q", name)
327 }
328 var in map[string]any
329 json.Unmarshal(input, &in)
330 out, err := t.run(in)
331 if err != nil {
332 return "error: " + err.Error()
333 }
334 return out
335}
336
337// --------------------------------------------------------------- glob engine
338
339func globExpand(pattern string) []string {
340 root, rest := ".", pattern
341 if strings.HasPrefix(pattern, "/") {
342 root, rest = "/", pattern[1:]
343 }
344 var out []string
345 globWalk(root, strings.Split(rest, "/"), &out)
346 if root == "." {
347 for i := range out {
348 out[i] = strings.TrimPrefix(out[i], "./")
349 }
350 }
351 return out
352}
353
354func globWalk(dir string, segs []string, out *[]string) {
355 if len(segs) == 0 {
356 *out = append(*out, dir)
357 return
358 }
359 seg, rest := segs[0], segs[1:]
360
361 if seg == "" || seg == "." {
362 globWalk(dir, rest, out)
363 return
364 }
365
366 if seg == "**" {
367 globWalk(dir, rest, out) // ** matches zero path segments
368 for _, e := range readDir(dir) {
369 if strings.HasPrefix(e.Name(), ".") {
370 continue
371 }
372 switch p := join(dir, e.Name()); {
373 case e.IsDir():
374 globWalk(p, segs, out) // keep ** at the head
375 case len(rest) == 0:
376 *out = append(*out, p)
377 }
378 }
379 return
380 }
381
382 for _, e := range readDir(dir) {
383 name := e.Name()
384 if strings.HasPrefix(name, ".") && !strings.HasPrefix(seg, ".") {
385 continue
386 }
387 if ok, _ := filepath.Match(seg, name); !ok {
388 continue
389 }
390 p := join(dir, name)
391 if len(rest) == 0 {
392 *out = append(*out, p) // file or directory
393 } else if e.IsDir() {
394 globWalk(p, rest, out)
395 }
396 }
397}
398
399// readDir returns entries sorted by name; unreadable directories yield none.
400func readDir(dir string) []os.DirEntry {
401 es, err := os.ReadDir(dir)
402 if err != nil {
403 return nil
404 }
405 return es
406}
407
408func join(dir, name string) string {
409 if strings.HasSuffix(dir, "/") {
410 return dir + name
411 }
412 return dir + "/" + name
413}
414
415// ------------------------------------------------------------------ API call
416
417type message struct {
418 Role string `json:"role"`
419 Content any `json:"content"`
420}
421
422type toolResult struct {
423 Type string `json:"type"`
424 ToolUseID string `json:"tool_use_id"`
425 Content string `json:"content"`
426}
427
428type block struct {
429 Type string `json:"type"`
430 Text string `json:"text"`
431 ID string `json:"id"`
432 Name string `json:"name"`
433 Input json.RawMessage `json:"input"`
434}
435
436var systemPrompt = func() string {
437 cwd, _ := os.Getwd()
438 return "Concise coding assistant. cwd: " + cwd
439}()
440
441func callAPI(messages []message) ([]json.RawMessage, error) {
442 body, err := json.Marshal(map[string]any{
443 "model": model,
444 "max_tokens": 8192,
445 "system": systemPrompt,
446 "messages": messages,
447 "tools": schemas,
448 })
449 if err != nil {
450 return nil, err
451 }
452 req, err := http.NewRequest("POST", apiURL+"/v1/messages", bytes.NewReader(body))
453 if err != nil {
454 return nil, err
455 }
456 // Every header is byte-identical across turns: a varying one would
457 // invalidate llama-swap's KV cache on every request.
458 req.Header.Set("Content-Type", "application/json")
459 req.Header.Set("anthropic-version", "2023-06-01")
460 req.Header.Set("x-api-key", apiKey)
461
462 // No timeout: a cold llama-swap model load costs ~10-25s before prefill
463 // even begins, and llama-swap's own responseHeader timeout is 1440s.
464 resp, err := http.DefaultClient.Do(req)
465 if err != nil {
466 return nil, err
467 }
468 defer resp.Body.Close()
469 raw, err := io.ReadAll(resp.Body)
470 if err != nil {
471 return nil, err
472 }
473 if resp.StatusCode >= 400 {
474 return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500))
475 }
476 // Content blocks stay raw: thinking/text/tool_use and anything unknown
477 // all round-trip verbatim into the next request.
478 var parsed struct {
479 Content []json.RawMessage `json:"content"`
480 }
481 if err := json.Unmarshal(raw, &parsed); err != nil {
482 return nil, err
483 }
484 return parsed.Content, nil
485}
486
487// ---------------------------------------------------------------- agent loop
488
489func agentLoop(messages *[]message) error {
490 for {
491 blocks, err := callAPI(*messages)
492 if err != nil {
493 return err
494 }
495 var results []toolResult
496 for _, raw := range blocks {
497 var b block
498 if err := json.Unmarshal(raw, &b); err != nil {
499 return err
500 }
501 switch b.Type {
502 case "text":
503 fmt.Printf("\n%s⏺%s %s\n", cyan, reset, renderMarkdown(b.Text))
504 case "tool_use":
505 fmt.Printf("\n%s⏺ %s%s(%s%s%s)\n",
506 green, capitalize(b.Name), reset, dim, argPreview(b.Name, b.Input), reset)
507 out := runTool(b.Name, b.Input)
508 printResult(out)
509 results = append(results, toolResult{"tool_result", b.ID, out})
510 }
511 // Other block types (thinking) are not displayed but still echoed.
512 }
513 *messages = append(*messages, message{"assistant", blocks})
514 if len(results) == 0 {
515 return nil
516 }
517 *messages = append(*messages, message{"user", results})
518 }
519}
520
521// ------------------------------------------------------------------- display
522
523var boldRe = regexp.MustCompile(`(?s)\*\*(.*?)\*\*`)
524
525func renderMarkdown(s string) string { return boldRe.ReplaceAllString(s, bold+"$1"+reset) }
526
527func capitalize(s string) string {
528 if s == "" {
529 return s
530 }
531 return strings.ToUpper(s[:1]) + s[1:]
532}
533
534// truncate cuts to n code points, never bytes, and never adds an ellipsis.
535func truncate(s string, n int) string {
536 if utf8.RuneCountInString(s) <= n {
537 return s
538 }
539 return string([]rune(s)[:n])
540}
541
542func argPreview(name string, input json.RawMessage) string {
543 t, ok := byName[name]
544 if !ok {
545 return ""
546 }
547 var in map[string]any
548 json.Unmarshal(input, &in)
549 for _, p := range t.params {
550 v, present := in[p.name]
551 if !present {
552 continue
553 }
554 if s, isStr := v.(string); isStr {
555 return truncate(s, 50)
556 }
557 enc, _ := json.Marshal(v)
558 return truncate(string(enc), 50)
559 }
560 return ""
561}
562
563func printResult(out string) {
564 lines := strings.Split(out, "\n")
565 preview := truncate(lines[0], 60)
566 if len(lines) > 1 {
567 preview += fmt.Sprintf(" ... +%d lines", len(lines)-1)
568 } else if utf8.RuneCountInString(lines[0]) > 60 {
569 preview += "..."
570 }
571 fmt.Printf(" %s⎿ %s%s\n", dim, preview, reset)
572}
573
574// ---------------------------------------------------------------------- REPL
575
576func main() {
577 cwd, _ := os.Getwd()
578 fmt.Printf("%spico%s | %s%s | %s%s\n\n", bold, reset, dim, model, cwd, reset)
579
580 sep := dim + strings.Repeat("─", 80) + reset
581 stdin := bufio.NewScanner(os.Stdin)
582 stdin.Buffer(make([]byte, 0, 64*1024), 8<<20)
583
584 var messages []message
585 for {
586 fmt.Println(sep)
587 fmt.Printf("%s%s❯%s ", bold, blue, reset)
588 if !stdin.Scan() {
589 fmt.Println()
590 return
591 }
592 fmt.Println(sep)
593
594 switch line := strings.TrimSpace(stdin.Text()); line {
595 case "":
596 continue
597 case "/q", "exit":
598 return
599 case "/c":
600 messages = nil
601 fmt.Printf("%s⏺ Cleared conversation%s\n", green, reset)
602 default:
603 messages = append(messages, message{"user", line})
604 if err := agentLoop(&messages); err != nil {
605 fmt.Printf("%s⏺ Error: %v%s\n", red, err, reset)
606 continue
607 }
608 fmt.Println()
609 }
610 }
611}
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}