From 50ca864530f5672528cd90032cdc3089f347a9bb Mon Sep 17 00:00:00 2001 From: Bx Date: Tue, 25 Aug 2026 12:48:12 -0700 Subject: initial commit --- main_test.go | 357 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 main_test.go (limited to 'main_test.go') 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