From 50ca864530f5672528cd90032cdc3089f347a9bb Mon Sep 17 00:00:00 2001 From: Bx Date: Tue, 25 Aug 2026 12:48:12 -0700 Subject: initial commit --- .gitignore | 2 + Makefile | 4 + go.mod | 3 + main.go | 611 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 357 ++++++++++++++++++++++++++++++++++ 5 files changed, 977 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51a41ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +bin/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ac3cdd --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: all +all: build +build: + 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 @@ +module pico + +go 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 @@ +// pico — a minimal single-binary CLI coding agent. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf8" +) + +// ------------------------------------------------------------------- palette + +const ( + reset = "\x1b[0m" + bold = "\x1b[1m" + dim = "\x1b[2m" + blue = "\x1b[34m" + cyan = "\x1b[36m" + green = "\x1b[32m" + red = "\x1b[31m" +) + +// -------------------------------------------------------------------- config + +var ( + apiURL = firstEnv("API_URL", "ANTHROPIC_BASE_URL", "http://localhost:8000") + apiKey = firstEnv("API_KEY", "ANTHROPIC_API_KEY", "") + model = firstEnv("MODEL", "ANTHROPIC_MODEL", "gpt-oss-120b") +) + +func firstEnv(keys ...string) string { + def := keys[len(keys)-1] + for _, k := range keys[:len(keys)-1] { + if k == "" { + continue + } + if v := os.Getenv(k); v != "" { + return strings.TrimRight(v, "/") + } + } + return def +} + +// --------------------------------------------------------------------- tools + +type param struct { + name string + typ string // trailing "?" marks optional +} + +type tool struct { + name string + desc string + params []param + run func(in map[string]any) (string, error) +} + +var tools = []tool{ + {"read", "Read file with line numbers (file path, not directory)", + []param{{"path", "string"}, {"offset", "integer?"}, {"limit", "integer?"}}, toolRead}, + {"write", "Write content to file", + []param{{"path", "string"}, {"content", "string"}}, toolWrite}, + {"edit", "Replace old with new in file (old must be unique unless all=true)", + []param{{"path", "string"}, {"old", "string"}, {"new", "string"}, {"all", "boolean?"}}, toolEdit}, + {"glob", "Find files by pattern, sorted by mtime", + []param{{"pat", "string"}, {"path", "string?"}}, toolGlob}, + {"grep", "Search files for regex pattern", + []param{{"pat", "string"}, {"path", "string?"}}, toolGrep}, + {"bash", "Run shell command", + []param{{"cmd", "string"}}, toolBash}, +} + +var byName = func() map[string]*tool { + m := map[string]*tool{} + for i := range tools { + m[tools[i].name] = &tools[i] + } + return m +}() + +// schemas is built once at startup and reused verbatim on every request, so +// the serialized prefix stays byte-identical and KV cache holds. +// Properties are emitted in declaration order, which map marshaling would not +// preserve. Each property carries "type" and nothing else. +var schemas = func() []json.RawMessage { + out := make([]json.RawMessage, len(tools)) + for i, t := range tools { + var props, required []string + for _, p := range t.params { + props = append(props, fmt.Sprintf(`%s:{"type":%s}`, jstr(p.name), jstr(strings.TrimSuffix(p.typ, "?")))) + if !strings.HasSuffix(p.typ, "?") { + required = append(required, jstr(p.name)) + } + } + out[i] = json.RawMessage(fmt.Sprintf( + `{"name":%s,"description":%s,"input_schema":{"type":"object","properties":{%s},"required":[%s]}}`, + jstr(t.name), jstr(t.desc), strings.Join(props, ","), strings.Join(required, ","))) + } + return out +}() + +func jstr(s string) string { + enc, _ := json.Marshal(s) + return string(enc) +} + +// ------------------------------------------------------------- arg coercion + +func argStr(in map[string]any, k, def string) string { + if s, ok := in[k].(string); ok { + return s + } + return def +} + +func argInt(in map[string]any, k string, def int) int { + switch v := in[k].(type) { + case float64: + return int(v) + case string: + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func argBool(in map[string]any, k string, def bool) bool { + if b, ok := in[k].(bool); ok { + return b + } + return def +} + +// ---------------------------------------------------------------------- read + +func toolRead(in map[string]any) (string, error) { + data, err := os.ReadFile(argStr(in, "path", "")) + if err != nil { + return "", err + } + // SplitAfter keeps the newline on each line; drop the empty tail element. + lines := strings.SplitAfter(string(data), "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + offset := argInt(in, "offset", 0) + limit := argInt(in, "limit", len(lines)) + start := min(max(offset, 0), len(lines)) + end := min(max(offset+limit, start), len(lines)) + + var b strings.Builder + for i, ln := range lines[start:end] { + fmt.Fprintf(&b, "%4d| %s", start+i+1, ln) + } + return b.String(), nil +} + +// ---------------------------------------------------------------- write, edit + +func toolWrite(in map[string]any) (string, error) { + if err := os.WriteFile(argStr(in, "path", ""), []byte(argStr(in, "content", "")), 0644); err != nil { + return "", err + } + return "ok", nil +} + +func toolEdit(in map[string]any) (string, error) { + path := argStr(in, "path", "") + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + s, old, new := string(data), argStr(in, "old", ""), argStr(in, "new", "") + + // Both of these are ordinary results, not errors: the model reads them + // and retries with a longer `old`. + switch n := strings.Count(s, old); { + case n == 0: + return "error: old_string not found", nil + case n > 1 && !argBool(in, "all", false): + return fmt.Sprintf("error: old_string appears %d times, must be unique (use all=true)", n), nil + } + + if argBool(in, "all", false) { + s = strings.ReplaceAll(s, old, new) + } else { + s = strings.Replace(s, old, new, 1) + } + if err := os.WriteFile(path, []byte(s), 0644); err != nil { + return "", err + } + return "ok", nil +} + +// ---------------------------------------------------------------- glob, grep + +func argPath(in map[string]any) string { + if p := argStr(in, "path", "."); p != "" { + return p + } + return "." +} + +func toolGlob(in map[string]any) (string, error) { + matches := globExpand(strings.ReplaceAll(argPath(in)+"/"+argStr(in, "pat", ""), "//", "/")) + if len(matches) == 0 { + return "none", nil + } + mtime := make(map[string]int64, len(matches)) + for _, m := range matches { + if fi, err := os.Stat(m); err == nil && !fi.IsDir() { + mtime[m] = fi.ModTime().UnixNano() + } + } + sort.SliceStable(matches, func(i, j int) bool { return mtime[matches[i]] > mtime[matches[j]] }) + return strings.Join(matches, "\n"), nil +} + +func toolGrep(in map[string]any) (string, error) { + re, err := regexp.Compile(argStr(in, "pat", "")) + if err != nil { + return "", err + } + var hits []string + for _, p := range globExpand(argPath(in) + "/**") { + f, err := os.Open(p) + if err != nil { + continue + } + // Directories appear in the ** expansion; scanning one yields nothing. + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 8<<20) + for n := 1; sc.Scan(); n++ { + if line := sc.Text(); re.MatchString(line) { + hits = append(hits, fmt.Sprintf("%s:%d:%s", p, n, strings.TrimRight(line, " \t\r"))) + } + } + f.Close() + if len(hits) >= 50 { + break + } + } + if len(hits) == 0 { + return "none", nil + } + if len(hits) > 50 { + hits = hits[:50] + } + return strings.Join(hits, "\n"), nil +} + +// ---------------------------------------------------------------------- bash + +func toolBash(in map[string]any) (string, error) { + pr, pw, err := os.Pipe() + if err != nil { + return "", err + } + cmd := exec.Command("sh", "-c", argStr(in, "cmd", "")) + cmd.Stdout, cmd.Stderr = pw, pw // one pipe: interleaved in real order + // Own process group, so the timeout below can kill the whole tree. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + pw.Close() + pr.Close() + return "", err + } + // The parent MUST drop its own copy of the write end, or the pipe never + // reaches EOF and the reader below blocks forever -- even for `true`. + pw.Close() + + done := make(chan string, 1) + go func() { + defer pr.Close() + var acc strings.Builder + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 0, 64*1024), 8<<20) + for sc.Scan() { + line := sc.Text() + fmt.Printf(" %s│ %s%s\n", dim, strings.TrimRight(line, " \t\r"), reset) + acc.WriteString(line + "\n") + } + done <- acc.String() + }() + + var out string + select { + case out = <-done: + case <-time.After(30 * time.Second): + // Kill the GROUP, not just sh: `sh -c "sleep 60"` forks, and the + // grandchild inherits the pipe's write end. Killing sh alone leaves + // the pipe open and the drain below blocks until the grandchild + // exits on its own -- a 30s timeout that takes 60s. + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + cmd.Process.Kill() + } + out = <-done // let the reader drain before reporting + out += "\n(timed out after 30s)" + } + cmd.Wait() // always reap + + if out = strings.TrimSpace(out); out == "" { + return "(empty)", nil + } + return out, nil +} + +// ------------------------------------------------------------------ dispatch + +func runTool(name string, input json.RawMessage) string { + t, ok := byName[name] + if !ok { + return fmt.Sprintf("error: unknown tool %q", name) + } + var in map[string]any + json.Unmarshal(input, &in) + out, err := t.run(in) + if err != nil { + return "error: " + err.Error() + } + return out +} + +// --------------------------------------------------------------- glob engine + +func globExpand(pattern string) []string { + root, rest := ".", pattern + if strings.HasPrefix(pattern, "/") { + root, rest = "/", pattern[1:] + } + var out []string + globWalk(root, strings.Split(rest, "/"), &out) + if root == "." { + for i := range out { + out[i] = strings.TrimPrefix(out[i], "./") + } + } + return out +} + +func globWalk(dir string, segs []string, out *[]string) { + if len(segs) == 0 { + *out = append(*out, dir) + return + } + seg, rest := segs[0], segs[1:] + + if seg == "" || seg == "." { + globWalk(dir, rest, out) + return + } + + if seg == "**" { + globWalk(dir, rest, out) // ** matches zero path segments + for _, e := range readDir(dir) { + if strings.HasPrefix(e.Name(), ".") { + continue + } + switch p := join(dir, e.Name()); { + case e.IsDir(): + globWalk(p, segs, out) // keep ** at the head + case len(rest) == 0: + *out = append(*out, p) + } + } + return + } + + for _, e := range readDir(dir) { + name := e.Name() + if strings.HasPrefix(name, ".") && !strings.HasPrefix(seg, ".") { + continue + } + if ok, _ := filepath.Match(seg, name); !ok { + continue + } + p := join(dir, name) + if len(rest) == 0 { + *out = append(*out, p) // file or directory + } else if e.IsDir() { + globWalk(p, rest, out) + } + } +} + +// readDir returns entries sorted by name; unreadable directories yield none. +func readDir(dir string) []os.DirEntry { + es, err := os.ReadDir(dir) + if err != nil { + return nil + } + return es +} + +func join(dir, name string) string { + if strings.HasSuffix(dir, "/") { + return dir + name + } + return dir + "/" + name +} + +// ------------------------------------------------------------------ API call + +type message struct { + Role string `json:"role"` + Content any `json:"content"` +} + +type toolResult struct { + Type string `json:"type"` + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` +} + +type block struct { + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +var systemPrompt = func() string { + cwd, _ := os.Getwd() + return "Concise coding assistant. cwd: " + cwd +}() + +func callAPI(messages []message) ([]json.RawMessage, error) { + body, err := json.Marshal(map[string]any{ + "model": model, + "max_tokens": 8192, + "system": systemPrompt, + "messages": messages, + "tools": schemas, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", apiURL+"/v1/messages", bytes.NewReader(body)) + if err != nil { + return nil, err + } + // Every header is byte-identical across turns: a varying one would + // invalidate llama-swap's KV cache on every request. + req.Header.Set("Content-Type", "application/json") + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("x-api-key", apiKey) + + // No timeout: a cold llama-swap model load costs ~10-25s before prefill + // even begins, and llama-swap's own responseHeader timeout is 1440s. + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500)) + } + // Content blocks stay raw: thinking/text/tool_use and anything unknown + // all round-trip verbatim into the next request. + var parsed struct { + Content []json.RawMessage `json:"content"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, err + } + return parsed.Content, nil +} + +// ---------------------------------------------------------------- agent loop + +func agentLoop(messages *[]message) error { + for { + blocks, err := callAPI(*messages) + if err != nil { + return err + } + var results []toolResult + for _, raw := range blocks { + var b block + if err := json.Unmarshal(raw, &b); err != nil { + return err + } + switch b.Type { + case "text": + fmt.Printf("\n%s⏺%s %s\n", cyan, reset, renderMarkdown(b.Text)) + case "tool_use": + fmt.Printf("\n%s⏺ %s%s(%s%s%s)\n", + green, capitalize(b.Name), reset, dim, argPreview(b.Name, b.Input), reset) + out := runTool(b.Name, b.Input) + printResult(out) + results = append(results, toolResult{"tool_result", b.ID, out}) + } + // Other block types (thinking) are not displayed but still echoed. + } + *messages = append(*messages, message{"assistant", blocks}) + if len(results) == 0 { + return nil + } + *messages = append(*messages, message{"user", results}) + } +} + +// ------------------------------------------------------------------- display + +var boldRe = regexp.MustCompile(`(?s)\*\*(.*?)\*\*`) + +func renderMarkdown(s string) string { return boldRe.ReplaceAllString(s, bold+"$1"+reset) } + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// truncate cuts to n code points, never bytes, and never adds an ellipsis. +func truncate(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + return string([]rune(s)[:n]) +} + +func argPreview(name string, input json.RawMessage) string { + t, ok := byName[name] + if !ok { + return "" + } + var in map[string]any + json.Unmarshal(input, &in) + for _, p := range t.params { + v, present := in[p.name] + if !present { + continue + } + if s, isStr := v.(string); isStr { + return truncate(s, 50) + } + enc, _ := json.Marshal(v) + return truncate(string(enc), 50) + } + return "" +} + +func printResult(out string) { + lines := strings.Split(out, "\n") + preview := truncate(lines[0], 60) + if len(lines) > 1 { + preview += fmt.Sprintf(" ... +%d lines", len(lines)-1) + } else if utf8.RuneCountInString(lines[0]) > 60 { + preview += "..." + } + fmt.Printf(" %s⎿ %s%s\n", dim, preview, reset) +} + +// ---------------------------------------------------------------------- REPL + +func main() { + cwd, _ := os.Getwd() + fmt.Printf("%spico%s | %s%s | %s%s\n\n", bold, reset, dim, model, cwd, reset) + + sep := dim + strings.Repeat("─", 80) + reset + stdin := bufio.NewScanner(os.Stdin) + stdin.Buffer(make([]byte, 0, 64*1024), 8<<20) + + var messages []message + for { + fmt.Println(sep) + fmt.Printf("%s%s❯%s ", bold, blue, reset) + if !stdin.Scan() { + fmt.Println() + return + } + fmt.Println(sep) + + switch line := strings.TrimSpace(stdin.Text()); line { + case "": + continue + case "/q", "exit": + return + case "/c": + messages = nil + fmt.Printf("%s⏺ Cleared conversation%s\n", green, reset) + default: + messages = append(messages, message{"user", line}) + if err := agentLoop(&messages); err != nil { + fmt.Printf("%s⏺ Error: %v%s\n", red, err, reset) + continue + } + fmt.Println() + } + } +} 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 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + "unicode/utf8" +) + +func inDir(t *testing.T) string { + t.Helper() + d := t.TempDir() + wd, _ := os.Getwd() + if err := os.Chdir(d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(wd) }) + return d +} + +func TestRead(t *testing.T) { + inDir(t) + os.WriteFile("f", []byte("a\nb\nc\n"), 0644) + + got, _ := toolRead(map[string]any{"path": "f"}) + if want := " 1| a\n 2| b\n 3| c\n"; got != want { + t.Errorf("full: got %q want %q", got, want) + } + got, _ = toolRead(map[string]any{"path": "f", "offset": 1.0, "limit": 1.0}) + if want := " 2| b\n"; got != want { + t.Errorf("window: got %q want %q", got, want) + } + // stringly-typed args, as small local models emit them + got, _ = toolRead(map[string]any{"path": "f", "offset": "1", "limit": "1"}) + if want := " 2| b\n"; got != want { + t.Errorf("string args: got %q want %q", got, want) + } + // no trailing newline on the final line + os.WriteFile("g", []byte("a\nb"), 0644) + got, _ = toolRead(map[string]any{"path": "g"}) + if want := " 1| a\n 2| b"; got != want { + t.Errorf("no-trailing-nl: got %q want %q", got, want) + } + // empty file -> no lines + os.WriteFile("e", nil, 0644) + if got, _ = toolRead(map[string]any{"path": "e"}); got != "" { + t.Errorf("empty: got %q", got) + } + if _, err := toolRead(map[string]any{"path": "nope"}); err == nil { + t.Error("missing file should error") + } +} + +func TestEdit(t *testing.T) { + inDir(t) + os.WriteFile("f", []byte("x y x"), 0644) + + got, err := toolEdit(map[string]any{"path": "f", "old": "x", "new": "z"}) + if err != nil { + t.Fatalf("must be a result, not an error: %v", err) + } + if want := "error: old_string appears 2 times, must be unique (use all=true)"; got != want { + t.Errorf("got %q want %q", got, want) + } + if b, _ := os.ReadFile("f"); string(b) != "x y x" { + t.Errorf("file was modified: %q", b) + } + if got, _ = toolEdit(map[string]any{"path": "f", "old": "x", "new": "z", "all": true}); got != "ok" { + t.Errorf("all=true: got %q", got) + } + if b, _ := os.ReadFile("f"); string(b) != "z y z" { + t.Errorf("all=true result: %q", b) + } + if got, _ = toolEdit(map[string]any{"path": "f", "old": "q", "new": "r"}); got != "error: old_string not found" { + t.Errorf("not found: got %q", got) + } +} + +func TestGlob(t *testing.T) { + inDir(t) + os.MkdirAll("sub", 0755) + os.MkdirAll(".hidden", 0755) + os.WriteFile("old.go", []byte("package p"), 0644) + os.WriteFile("sub/new.go", []byte("package p"), 0644) + os.WriteFile(".hidden/x.go", []byte("package p"), 0644) + os.WriteFile("notgo.txt", nil, 0644) + now := time.Now() + os.Chtimes("old.go", now.Add(-time.Hour), now.Add(-time.Hour)) + os.Chtimes("sub/new.go", now, now) + + got, _ := toolGlob(map[string]any{"pat": "**/*.go"}) + if want := "sub/new.go\nold.go"; got != want { + t.Errorf("newest-first: got %q want %q", got, want) + } + if strings.Contains(got, ".hidden") { + t.Errorf("dot-directory not skipped: %q", got) + } + if got, _ = toolGlob(map[string]any{"pat": "**/*.rs"}); got != "none" { + t.Errorf("no matches: got %q", got) + } +} + +func TestGrepCapsAt50(t *testing.T) { + inDir(t) + for i := 0; i < 8; i++ { + var b strings.Builder + for j := 0; j < 10; j++ { + fmt.Fprintf(&b, "needle %d\n", j) + } + os.WriteFile(fmt.Sprintf("f%d.txt", i), []byte(b.String()), 0644) + } + got, _ := toolGrep(map[string]any{"pat": "needle"}) + if n := len(strings.Split(got, "\n")); n != 50 { + t.Errorf("got %d lines, want 50", n) + } + if got, _ = toolGrep(map[string]any{"pat": "haystack"}); got != "none" { + t.Errorf("no hits: got %q", got) + } + if _, err := toolGrep(map[string]any{"pat": "("}); err == nil { + t.Error("bad regex should error") + } + // RE2: backreferences do not compile + if _, err := toolGrep(map[string]any{"pat": `(a)\1`}); err == nil { + t.Error("backreference should error") + } +} + +func TestBash(t *testing.T) { + if got, _ := toolBash(map[string]any{"cmd": "true"}); got != "(empty)" { + t.Errorf("true: got %q want (empty)", got) + } + got, _ := toolBash(map[string]any{"cmd": "echo out; echo err 1>&2"}) + if !strings.Contains(got, "out") || !strings.Contains(got, "err") { + t.Errorf("stdout+stderr: got %q", got) + } + if got, _ = toolBash(map[string]any{"cmd": "cd /tmp"}); got != "(empty)" { + t.Errorf("cd: got %q", got) + } +} + +func TestBashTimeout(t *testing.T) { + if testing.Short() { + t.Skip("30s") + } + start := time.Now() + got, _ := toolBash(map[string]any{"cmd": "echo before; sleep 60"}) + elapsed := time.Since(start) + if !strings.HasSuffix(got, "(timed out after 30s)") { + t.Errorf("got %q", got) + } + if !strings.HasPrefix(got, "before") { + t.Errorf("drained output lost: %q", got) + } + if elapsed < 29*time.Second || elapsed > 35*time.Second { + t.Errorf("elapsed %v, want ~30s", elapsed) + } +} + +func TestUnknownTool(t *testing.T) { + if got := runTool("nope", json.RawMessage(`{}`)); got != `error: unknown tool "nope"` { + t.Errorf("got %q", got) + } +} + +// Schemas carry "type" and nothing else -- a maxLength >~2000 +// makes gpt-oss-120b fail every tool call with "failed to parse grammar". +func TestSchemas(t *testing.T) { + for i, s := range schemas { + var parsed struct { + Name string `json:"name"` + Schema struct { + Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` + } `json:"input_schema"` + } + if err := json.Unmarshal(s, &parsed); err != nil { + t.Fatalf("tool %s: %v", tools[i].name, err) + } + // The invariant: every property carries "type" and nothing else. + for prop, keys := range parsed.Schema.Properties { + if len(keys) != 1 { + t.Errorf("tool %s prop %s: %d schema keys, want only \"type\"", parsed.Name, prop, len(keys)) + } + if _, ok := keys["type"]; !ok { + t.Errorf("tool %s prop %s: missing \"type\"", parsed.Name, prop) + } + } + if parsed.Schema.Required == nil { + t.Errorf("tool %s: required is null, must be an array", parsed.Name) + } + if parsed.Name != tools[i].name { + t.Errorf("schema %d: name %q want %q", i, parsed.Name, tools[i].name) + } + } + // declaration order, not map order + if want := `"properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"},"all":{"type":"boolean"}}`; !strings.Contains(string(schemas[2]), want) { + t.Errorf("edit schema property order wrong:\n%s", schemas[2]) + } + if want := `"required":["path","old","new"]`; !strings.Contains(string(schemas[2]), want) { + t.Errorf("edit required wrong:\n%s", schemas[2]) + } + if want := `"offset":{"type":"integer"}`; !strings.Contains(string(schemas[0]), want) { + t.Errorf("optional marker should be stripped from the type:\n%s", schemas[0]) + } +} + +func TestDisplay(t *testing.T) { + if got := renderMarkdown("a **b** c"); got != "a "+bold+"b"+reset+" c" { + t.Errorf("bold: got %q", got) + } + if got := renderMarkdown("**a\nb**"); got != bold+"a\nb"+reset { + t.Errorf("dotall: got %q", got) + } + if got := capitalize("read"); got != "Read" { + t.Errorf("got %q", got) + } + // truncation by code points, never bytes + if got := truncate(strings.Repeat("é", 60), 50); len([]rune(got)) != 50 { + t.Errorf("got %d runes", len([]rune(got))) + } + // first declared param present in the input + in := json.RawMessage(`{"limit":5,"path":"/tmp/x"}`) + if got := argPreview("read", in); got != "/tmp/x" { + t.Errorf("preview: got %q", got) + } + if got := argPreview("read", json.RawMessage(`{"limit":5}`)); got != "5" { + t.Errorf("non-string preview: got %q", got) + } + if got := argPreview("nope", in); got != "" { + t.Errorf("unknown tool preview: got %q", got) + } +} + +func TestGlobEngine(t *testing.T) { + d := inDir(t) + os.MkdirAll("a/b", 0755) + os.WriteFile("a/b/c.txt", nil, 0644) + os.WriteFile("a/top.txt", nil, 0644) + + // ** matches zero segments + if got := globExpand("a/**/top.txt"); len(got) != 1 || got[0] != "a/top.txt" { + t.Errorf("zero-segment **: got %v", got) + } + // final segment emits directories too + got := globExpand("a/*") + if len(got) != 2 { + t.Errorf("final segment should emit dir and file: got %v", got) + } + // absolute patterns stay absolute + if got := globExpand(filepath.Join(d, "a", "*.txt")); len(got) != 1 || !strings.HasPrefix(got[0], "/") { + t.Errorf("absolute: got %v", got) + } + // unreadable directory is skipped silently + os.MkdirAll("locked", 0000) + defer os.Chmod("locked", 0755) + if got := globExpand("locked/*"); len(got) != 0 { + t.Errorf("unreadable dir: got %v", got) + } +} + +// Two tool_use blocks in one assistant turn produce two +// result lines and exactly ONE follow-up request whose last message is a user +// message carrying both tool_result entries. +func TestAgentLoopBatchesToolResults(t *testing.T) { + inDir(t) + os.WriteFile("a.txt", []byte("A\n"), 0644) + os.WriteFile("b.txt", []byte("B\n"), 0644) + + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + bodies = append(bodies, body) + if len(bodies) == 1 { + io.WriteString(w, `{"content":[ + {"type":"thinking","thinking":"secret","signature":""}, + {"type":"tool_use","id":"t1","name":"read","input":{"path":"a.txt"}}, + {"type":"tool_use","id":"t2","name":"read","input":{"path":"b.txt"}}]}`) + return + } + io.WriteString(w, `{"content":[{"type":"text","text":"done"}]}`) + })) + defer srv.Close() + + old := apiURL + apiURL = srv.URL + defer func() { apiURL = old }() + + var messages []message + messages = append(messages, message{"user", "read both"}) + if err := agentLoop(&messages); err != nil { + t.Fatal(err) + } + + if len(bodies) != 2 { + t.Fatalf("made %d requests, want exactly 2", len(bodies)) + } + // user, assistant(3 blocks), user(2 tool_results), assistant(text) + if len(messages) != 4 { + t.Fatalf("history has %d messages, want 4: %+v", len(messages), messages) + } + last := bodies[1]["messages"].([]any) + tail := last[len(last)-1].(map[string]any) + if tail["role"] != "user" { + t.Errorf("follow-up tail role = %v, want user", tail["role"]) + } + results, ok := tail["content"].([]any) + if !ok || len(results) != 2 { + t.Fatalf("follow-up tail content = %v, want 2 tool_results", tail["content"]) + } + for i, want := range []string{" 1| A\n", " 1| B\n"} { + r := results[i].(map[string]any) + if r["type"] != "tool_result" || r["content"] != want { + t.Errorf("result %d = %+v, want tool_result %q", i, r, want) + } + } + if got := results[0].(map[string]any)["tool_use_id"]; got != "t1" { + t.Errorf("tool_use_id = %v, want t1", got) + } + + // The thinking block is never displayed, but IS replayed verbatim. + asst := last[1].(map[string]any) + blocks := asst["content"].([]any) + if len(blocks) != 3 { + t.Fatalf("assistant replayed %d blocks, want 3", len(blocks)) + } + if b0 := blocks[0].(map[string]any); b0["type"] != "thinking" || b0["thinking"] != "secret" { + t.Errorf("thinking block not round-tripped verbatim: %+v", b0) + } +} + +func TestAPIErrorTruncatesTo500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + io.WriteString(w, strings.Repeat("é", 900)) + })) + defer srv.Close() + old := apiURL + apiURL = srv.URL + defer func() { apiURL = old }() + + _, err := callAPI(nil) + if err == nil { + t.Fatal("want error") + } + body := strings.TrimPrefix(err.Error(), "HTTP 500: ") + if n := utf8.RuneCountInString(body); n != 500 { + t.Errorf("body is %d code points, want 500", n) + } +} -- cgit v1.2.3