| 1 | package bot |
| 2 | |
| 3 | // The sidecar half of the views contract (docs/views.md): normalizeViewSpec |
| 4 | // fails closed with errors the model can fix. The client fold's half is |
| 5 | // pinned by test/event-vectors/views.json + test/views.test.mjs — keep the |
| 6 | // caps here and there in step. |
| 7 | |
| 8 | import ( |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func viewInput(mut func(map[string]any)) map[string]any { |
| 14 | in := map[string]any{ |
| 15 | "title": "Top GitHub issues", |
| 16 | "glyph": "⬢", |
| 17 | "items": []any{ |
| 18 | map[string]any{"title": "Crash on login", "subtitle": "#412 · amy", "note": "urgent", "url": "https://github.com/o/r/issues/412", "accent": true}, |
| 19 | map[string]any{"title": "Dark mode flicker"}, |
| 20 | }, |
| 21 | } |
| 22 | if mut != nil { |
| 23 | mut(in) |
| 24 | } |
| 25 | return in |
| 26 | } |
| 27 | |
| 28 | func TestNormalizeViewSpec(t *testing.T) { |
| 29 | spec, err := normalizeViewSpec(viewInput(nil)) |
| 30 | if err != nil { |
| 31 | t.Fatalf("valid spec refused: %v", err) |
| 32 | } |
| 33 | items := spec["items"].([]map[string]any) |
| 34 | if len(items) != 2 || items[0]["title"] != "Crash on login" || items[0]["accent"] != true { |
| 35 | t.Fatalf("items: %+v", items) |
| 36 | } |
| 37 | // Positional ids keep the schema's keyed list stable. |
| 38 | if items[0]["id"] != "i0" || items[1]["id"] != "i1" { |
| 39 | t.Errorf("ids: %v %v", items[0]["id"], items[1]["id"]) |
| 40 | } |
| 41 | // Absent optionals are absent, not "". |
| 42 | if _, present := items[1]["url"]; present { |
| 43 | t.Errorf("empty url should be omitted: %+v", items[1]) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | func TestNormalizeViewSpecRefusals(t *testing.T) { |
| 48 | cases := []struct { |
| 49 | name string |
| 50 | mut func(map[string]any) |
| 51 | want string |
| 52 | }{ |
| 53 | {"no title", func(m map[string]any) { m["title"] = "" }, "title"}, |
| 54 | {"title too long", func(m map[string]any) { m["title"] = strings.Repeat("x", 81) }, "title"}, |
| 55 | {"no items", func(m map[string]any) { m["items"] = []any{} }, "items"}, |
| 56 | {"items not array", func(m map[string]any) { m["items"] = "nope" }, "items"}, |
| 57 | {"item missing title", func(m map[string]any) { m["items"] = []any{map[string]any{"subtitle": "s"}} }, "items[0].title"}, |
| 58 | {"http url", func(m map[string]any) { m["items"] = []any{map[string]any{"title": "t", "url": "http://x.example"}} }, "https"}, |
| 59 | {"javascript url", func(m map[string]any) { m["items"] = []any{map[string]any{"title": "t", "url": "javascript:alert(1)"}} }, "https"}, |
| 60 | {"long glyph", func(m map[string]any) { m["glyph"] = "abcdef" }, "glyph"}, |
| 61 | } |
| 62 | for _, c := range cases { |
| 63 | t.Run(c.name, func(t *testing.T) { |
| 64 | if _, err := normalizeViewSpec(viewInput(c.mut)); err == nil || !strings.Contains(err.Error(), c.want) { |
| 65 | t.Errorf("want refusal mentioning %q, got %v", c.want, err) |
| 66 | } |
| 67 | }) |
| 68 | } |
| 69 | |
| 70 | // 51 items refuse (the model trims — unlike the fold, which clamps). |
| 71 | many := make([]any, 51) |
| 72 | for i := range many { |
| 73 | many[i] = map[string]any{"title": "row"} |
| 74 | } |
| 75 | if _, err := normalizeViewSpec(viewInput(func(m map[string]any) { m["items"] = many })); err == nil || !strings.Contains(err.Error(), "items") { |
| 76 | t.Errorf("51 items: %v", err) |
| 77 | } |
| 78 | |
| 79 | // Over the byte cap refuses honestly. |
| 80 | big := make([]any, 50) |
| 81 | for i := range big { |
| 82 | big[i] = map[string]any{"title": strings.Repeat("t", 300), "note": strings.Repeat("n", 500)} |
| 83 | } |
| 84 | if _, err := normalizeViewSpec(viewInput(func(m map[string]any) { m["items"] = big })); err == nil || !strings.Contains(err.Error(), "large") { |
| 85 | t.Errorf("oversized view: %v", err) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestCreateViewToolDeclared(t *testing.T) { |
| 90 | // The tool must be in both palettes: the conversational brain's and the |
| 91 | // research loop's (where it is the one non-service tool). |
| 92 | has := func(tools []anthTool) bool { |
| 93 | for _, tl := range tools { |
| 94 | if tl.Name == "create_view" { |
| 95 | return true |
| 96 | } |
| 97 | } |
| 98 | return false |
| 99 | } |
| 100 | r := &Runtime{} |
| 101 | if !has(r.replyTools(&ThreadCtx{Kind: "dm"})) { |
| 102 | t.Error("create_view missing from the conversational palette") |
| 103 | } |
| 104 | if !has(researchTools([]string{"github"})) { |
| 105 | t.Error("create_view missing from the research palette") |
| 106 | } |
| 107 | } |
| 108 | |