diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Makefile | 4 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | main.go | 611 | ||||
| -rw-r--r-- | main_test.go | 357 |
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 | ||
| 2 | bin/ | ||
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ac3cdd --- /dev/null +++ b/Makefile | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | .PHONY: all | ||
| 2 | all: build | ||
| 3 | build: | ||
| 4 | go build -o bin/pico . | ||
| @@ -0,0 +1,3 @@ | |||
| 1 | module pico | ||
| 2 | |||
| 3 | go 1.26 | ||
| @@ -0,0 +1,611 @@ | |||
| 1 | // pico — a minimal single-binary CLI coding agent. | ||
| 2 | package main | ||
| 3 | |||
| 4 | import ( | ||
| 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 | |||
| 25 | const ( | ||
| 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 | |||
| 37 | var ( | ||
| 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 | |||
| 43 | func 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 | |||
| 58 | type param struct { | ||
| 59 | name string | ||
| 60 | typ string // trailing "?" marks optional | ||
| 61 | } | ||
| 62 | |||
| 63 | type tool struct { | ||
| 64 | name string | ||
| 65 | desc string | ||
| 66 | params []param | ||
| 67 | run func(in map[string]any) (string, error) | ||
| 68 | } | ||
| 69 | |||
| 70 | var 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 | |||
| 85 | var 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. | ||
| 97 | var 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 | |||
| 114 | func jstr(s string) string { | ||
| 115 | enc, _ := json.Marshal(s) | ||
| 116 | return string(enc) | ||
| 117 | } | ||
| 118 | |||
| 119 | // ------------------------------------------------------------- arg coercion | ||
| 120 | |||
| 121 | func 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 | |||
| 128 | func 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 | |||
| 140 | func 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 | |||
| 149 | func 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 | |||
| 173 | func 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 | |||
| 180 | func 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 | |||
| 210 | func argPath(in map[string]any) string { | ||
| 211 | if p := argStr(in, "path", "."); p != "" { | ||
| 212 | return p | ||
| 213 | } | ||
| 214 | return "." | ||
| 215 | } | ||
| 216 | |||
| 217 | func 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 | |||
| 232 | func 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 | |||
| 267 | func 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 | |||
| 323 | func 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 | |||
| 339 | func 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 | |||
| 354 | func 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. | ||
| 400 | func readDir(dir string) []os.DirEntry { | ||
| 401 | es, err := os.ReadDir(dir) | ||
| 402 | if err != nil { | ||
| 403 | return nil | ||
| 404 | } | ||
| 405 | return es | ||
| 406 | } | ||
| 407 | |||
| 408 | func 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 | |||
| 417 | type message struct { | ||
| 418 | Role string `json:"role"` | ||
| 419 | Content any `json:"content"` | ||
| 420 | } | ||
| 421 | |||
| 422 | type toolResult struct { | ||
| 423 | Type string `json:"type"` | ||
| 424 | ToolUseID string `json:"tool_use_id"` | ||
| 425 | Content string `json:"content"` | ||
| 426 | } | ||
| 427 | |||
| 428 | type 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 | |||
| 436 | var systemPrompt = func() string { | ||
| 437 | cwd, _ := os.Getwd() | ||
| 438 | return "Concise coding assistant. cwd: " + cwd | ||
| 439 | }() | ||
| 440 | |||
| 441 | func 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 | |||
| 489 | func 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 | |||
| 523 | var boldRe = regexp.MustCompile(`(?s)\*\*(.*?)\*\*`) | ||
| 524 | |||
| 525 | func renderMarkdown(s string) string { return boldRe.ReplaceAllString(s, bold+"$1"+reset) } | ||
| 526 | |||
| 527 | func 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. | ||
| 535 | func truncate(s string, n int) string { | ||
| 536 | if utf8.RuneCountInString(s) <= n { | ||
| 537 | return s | ||
| 538 | } | ||
| 539 | return string([]rune(s)[:n]) | ||
| 540 | } | ||
| 541 | |||
| 542 | func 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 | |||
| 563 | func 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 | |||
| 576 | func 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 @@ | |||
| 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 | } | ||
