| 1 | package tool_test |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/provider" |
| 10 | "reasonix/internal/tool" |
| 11 | _ "reasonix/internal/tool/builtin" |
| 12 | ) |
| 13 | |
| 14 | func TestBuiltinToolContractDocumentation(t *testing.T) { |
| 15 | entries := tool.BuiltinContractEntries() |
| 16 | if len(entries) == 0 { |
| 17 | t.Fatal("no built-in tool contract entries") |
| 18 | } |
| 19 | doc, err := os.ReadFile("../../docs/TOOL_CONTRACT.md") |
| 20 | if err != nil { |
| 21 | t.Fatalf("read docs/TOOL_CONTRACT.md: %v", err) |
| 22 | } |
| 23 | text := string(doc) |
| 24 | for _, e := range entries { |
| 25 | if !strings.Contains(text, "| `"+e.Name+"` |") { |
| 26 | t.Errorf("documentation missing table row for %s", e.Name) |
| 27 | } |
| 28 | if !strings.Contains(text, "| `"+e.Name+"` | "+boolString(e.ReadOnly)+" |") { |
| 29 | t.Errorf("documentation missing read-only flag for %s", e.Name) |
| 30 | } |
| 31 | if strings.TrimSpace(e.Description) == "" { |
| 32 | t.Errorf("%s has empty description", e.Name) |
| 33 | } |
| 34 | if !json.Valid(e.Schema) { |
| 35 | t.Errorf("%s schema is invalid JSON: %s", e.Name, e.Schema) |
| 36 | } |
| 37 | if got := string(provider.CanonicalizeSchema(e.Schema)); got != string(e.Schema) { |
| 38 | t.Errorf("%s schema is not canonical", e.Name) |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func boolString(v bool) string { |
| 44 | if v { |
| 45 | return "true" |
| 46 | } |
| 47 | return "false" |
| 48 | } |
| 49 | |
| 50 | // acceptsDefaultSnip lists built-in tools that deliberately take the |
| 51 | // ReadOnly-tiered default snip geometry instead of implementing |
| 52 | // tool.SnipHinter. A tool belongs here only if its output has no special shape |
| 53 | // a generic head/tail split would garble — typically tools whose results are |
| 54 | // short (todo_write, complete_step) or already structured small (edit results). |
| 55 | // Membership is an explicit decision, not a fallback: a new or renamed built-in |
| 56 | // that lands in neither this set nor SnipHinter fails TestEveryBuiltinDeclaresSnipStance, |
| 57 | // which is the guard against a context-maintenance strategy silently desyncing |
| 58 | // from the tool surface. |
| 59 | var acceptsDefaultSnip = map[string]bool{ |
| 60 | "bash_output": true, // streamed job output; tailing handled by the job, not the snip pass |
| 61 | "code_index": true, |
| 62 | "complete_step": true, |
| 63 | "delete_range": true, |
| 64 | "delete_symbol": true, |
| 65 | "edit_file": true, |
| 66 | "kill_shell": true, |
| 67 | "move_file": true, |
| 68 | "multi_edit": true, |
| 69 | "notebook_edit": true, |
| 70 | "todo_write": true, |
| 71 | "update_goal": true, |
| 72 | "wait": true, |
| 73 | "write_file": true, |
| 74 | } |
| 75 | |
| 76 | func TestEveryBuiltinDeclaresSnipStance(t *testing.T) { |
| 77 | for _, b := range tool.Builtins() { |
| 78 | name := b.Name() |
| 79 | _, hints := b.(tool.SnipHinter) |
| 80 | switch { |
| 81 | case hints && acceptsDefaultSnip[name]: |
| 82 | t.Errorf("%s both implements SnipHinter and is listed in acceptsDefaultSnip; remove it from the list", name) |
| 83 | case !hints && !acceptsDefaultSnip[name]: |
| 84 | t.Errorf("built-in %q declares no snip stance: implement tool.SnipHinter for a tailored geometry, or add it to acceptsDefaultSnip if the ReadOnly-tiered default is right (this guards against a renamed/new tool silently taking a generic default)", name) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 |