diff options
| author | Bx <bx@boxstep.io> | 2026-08-25 12:48:12 -0700 |
|---|---|---|
| committer | Bx <bx@boxstep.io> | 2026-08-25 12:48:12 -0700 |
| commit | 50ca864530f5672528cd90032cdc3089f347a9bb (patch) | |
| tree | d74100954e3d952f17f55c9a1ad1b526255708b2 /main_test.go | |
| download | pico-50ca864530f5672528cd90032cdc3089f347a9bb.tar.gz pico-50ca864530f5672528cd90032cdc3089f347a9bb.zip | |
initial commit
Diffstat (limited to '')
| -rw-r--r-- | main_test.go | 357 |
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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 17 | func 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 | |||
| 28 | func 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 | |||
| 61 | func 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 | |||
| 86 | func 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 | |||
| 110 | func 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 | |||
| 135 | func 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 | |||
| 148 | func 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 | |||
| 166 | func 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". | ||
| 174 | func 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 | |||
| 214 | func 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 | |||
| 241 | func 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. | ||
| 271 | func 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 | |||
| 339 | func 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 | } | ||
