package bot // The sidecar half of the views contract (docs/views.md): normalizeViewSpec // fails closed with errors the model can fix. The client fold's half is // pinned by test/event-vectors/views.json + test/views.test.mjs — keep the // caps here and there in step. import ( "strings" "testing" ) func viewInput(mut func(map[string]any)) map[string]any { in := map[string]any{ "title": "Top GitHub issues", "glyph": "⬢", "items": []any{ map[string]any{"title": "Crash on login", "subtitle": "#412 · amy", "note": "urgent", "url": "https://github.com/o/r/issues/412", "accent": true}, map[string]any{"title": "Dark mode flicker"}, }, } if mut != nil { mut(in) } return in } func TestNormalizeViewSpec(t *testing.T) { spec, err := normalizeViewSpec(viewInput(nil)) if err != nil { t.Fatalf("valid spec refused: %v", err) } items := spec["items"].([]map[string]any) if len(items) != 2 || items[0]["title"] != "Crash on login" || items[0]["accent"] != true { t.Fatalf("items: %+v", items) } // Positional ids keep the schema's keyed list stable. if items[0]["id"] != "i0" || items[1]["id"] != "i1" { t.Errorf("ids: %v %v", items[0]["id"], items[1]["id"]) } // Absent optionals are absent, not "". if _, present := items[1]["url"]; present { t.Errorf("empty url should be omitted: %+v", items[1]) } } func TestNormalizeViewSpecRefusals(t *testing.T) { cases := []struct { name string mut func(map[string]any) want string }{ {"no title", func(m map[string]any) { m["title"] = "" }, "title"}, {"title too long", func(m map[string]any) { m["title"] = strings.Repeat("x", 81) }, "title"}, {"no items", func(m map[string]any) { m["items"] = []any{} }, "items"}, {"items not array", func(m map[string]any) { m["items"] = "nope" }, "items"}, {"item missing title", func(m map[string]any) { m["items"] = []any{map[string]any{"subtitle": "s"}} }, "items[0].title"}, {"http url", func(m map[string]any) { m["items"] = []any{map[string]any{"title": "t", "url": "http://x.example"}} }, "https"}, {"javascript url", func(m map[string]any) { m["items"] = []any{map[string]any{"title": "t", "url": "javascript:alert(1)"}} }, "https"}, {"long glyph", func(m map[string]any) { m["glyph"] = "abcdef" }, "glyph"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if _, err := normalizeViewSpec(viewInput(c.mut)); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("want refusal mentioning %q, got %v", c.want, err) } }) } // 51 items refuse (the model trims — unlike the fold, which clamps). many := make([]any, 51) for i := range many { many[i] = map[string]any{"title": "row"} } if _, err := normalizeViewSpec(viewInput(func(m map[string]any) { m["items"] = many })); err == nil || !strings.Contains(err.Error(), "items") { t.Errorf("51 items: %v", err) } // Over the byte cap refuses honestly. big := make([]any, 50) for i := range big { big[i] = map[string]any{"title": strings.Repeat("t", 300), "note": strings.Repeat("n", 500)} } if _, err := normalizeViewSpec(viewInput(func(m map[string]any) { m["items"] = big })); err == nil || !strings.Contains(err.Error(), "large") { t.Errorf("oversized view: %v", err) } } func TestCreateViewToolDeclared(t *testing.T) { // The tool must be in both palettes: the conversational brain's and the // research loop's (where it is the one non-service tool). has := func(tools []anthTool) bool { for _, tl := range tools { if tl.Name == "create_view" { return true } } return false } r := &Runtime{} if !has(r.replyTools(&ThreadCtx{Kind: "dm"})) { t.Error("create_view missing from the conversational palette") } if !has(researchTools([]string{"github"})) { t.Error("create_view missing from the research palette") } }