Files
claude-botandClaude Opus 5.5 7f29ebb878
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
chore: follow homelab to konairius/homelab
Flux source URL, flux-git rotator verify repo, review-bot REPOS, pipeline API,
clone and push URLs, the heating-can source label and the review-bot and
kuma-sync Go module paths. Merge right after the transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:43:23 +02:00

220 lines
8.8 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeAnthropic answers each Messages request with the next scripted event stream.
type fakeAnthropic struct {
t *testing.T
srv *httptest.Server
mu sync.Mutex
streams []string
status []int
bodies []map[string]any
betas []string
}
func block(index int, cb string) string {
return `{"type":"content_block_start","index":` + itoa(index) + `,"content_block":` + cb + `}` + "\n" +
`{"type":"content_block_stop","index":` + itoa(index) + `}`
}
func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
// messageStream renders event lines as the SSE stream of one message ending in stop.
func messageStream(model, stop string, events ...string) string {
lines := []string{`{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"` + model +
`","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}`}
for _, e := range events {
lines = append(lines, strings.Split(e, "\n")...)
}
lines = append(lines, `{"type":"message_delta","delta":{"stop_reason":"`+stop+`"},"usage":{"output_tokens":5}}`,
`{"type":"message_stop"}`)
var b strings.Builder
for _, l := range lines {
var ev struct {
Type string `json:"type"`
}
_ = json.Unmarshal([]byte(l), &ev)
b.WriteString("event: " + ev.Type + "\ndata: " + l + "\n\n")
}
return b.String()
}
func newFakeAnthropic(t *testing.T) *fakeAnthropic {
f := &fakeAnthropic{t: t}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" || r.Header.Get("X-Api-Key") != "anthropic-key" {
http.Error(w, `{"type":"error","error":{"type":"authentication_error","message":"no"}}`, http.StatusUnauthorized)
return
}
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
f.mu.Lock()
defer f.mu.Unlock()
f.bodies = append(f.bodies, in)
f.betas = append(f.betas, r.Header.Get("Anthropic-Beta"))
if len(f.status) > 0 {
st := f.status[0]
f.status = f.status[1:]
w.Header().Set("Retry-After", "0")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(st)
_, _ = io.WriteString(w, `{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}`)
return
}
if len(f.streams) == 0 {
http.Error(w, "no more scripted streams", http.StatusInternalServerError)
return
}
s := f.streams[0]
f.streams = f.streams[1:]
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, s)
}))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeAnthropic) backend() *Anthropic {
return newAnthropic(Config{AnthropicURL: f.srv.URL, AnthropicKey: "anthropic-key", MCPURL: "https://mcp.example/mcp",
MCPToken: "read-token", MCPTools: []string{"get_file_contents", "pull_request_read"}, AnthropicEffort: "high"})
}
const toolUse = `{"type":"mcp_tool_use","id":"t1","name":"get_file_contents","server_name":"gitea","input":{"path":"a.yaml"}}`
const toolResult = `{"type":"mcp_tool_result","tool_use_id":"t1","is_error":false,"content":[{"type":"text","text":"x: 1"}]}`
func textBlock(index int, text string) string {
b, _ := json.Marshal(text)
return `{"type":"content_block_start","index":` + itoa(index) + `,"content_block":{"type":"text","text":""}}` + "\n" +
`{"type":"content_block_delta","index":` + itoa(index) + `,"delta":{"type":"text_delta","text":` + string(b) + `}}` + "\n" +
`{"type":"content_block_stop","index":` + itoa(index) + `}`
}
func TestAnthropicResumesAPausedTurnAndTakesTheFinalText(t *testing.T) {
f := newFakeAnthropic(t)
f.streams = []string{
messageStream("claude-opus-5-5", "pause_turn", textBlock(0, "Let me look."), block(1, toolUse), block(2, toolResult)),
messageStream("claude-opus-5-5", "end_turn", block(0, toolUse), block(1, toolResult),
textBlock(2, "## Summary\n\nok\n\nVerdict: comment")),
}
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of konairius/homelab.")
if err != nil {
t.Fatal(err)
}
if a.Text != "## Summary\n\nok\n\nVerdict: comment" || a.ToolCalls != 2 || len(a.Violations) != 0 {
t.Errorf("answer %+v", a)
}
if len(f.bodies) != 2 {
t.Fatalf("requests %d, want 2", len(f.bodies))
}
if msgs := f.bodies[1]["messages"].([]any); len(msgs) != 2 || msgs[1].(map[string]any)["role"] != "assistant" {
t.Errorf("the paused turn must be sent back: %v", msgs)
}
body := f.bodies[0]
servers := body["mcp_servers"].([]any)
srv := servers[0].(map[string]any)
if srv["url"] != "https://mcp.example/mcp" || srv["authorization_token"] != "read-token" || srv["name"] != "gitea" {
t.Errorf("mcp server: %v", srv)
}
toolset := body["tools"].([]any)[0].(map[string]any)
configs := toolset["configs"].(map[string]any)
if toolset["type"] != "mcp_toolset" || toolset["default_config"].(map[string]any)["enabled"] != false ||
len(configs) != 2 || configs["get_file_contents"].(map[string]any)["enabled"] != true {
t.Errorf("toolset must enable only the listed tools: %v", toolset)
}
if body["output_config"].(map[string]any)["effort"] != "high" || body["fallbacks"] != "default" {
t.Errorf("effort/fallbacks: %v %v", body["output_config"], body["fallbacks"])
}
if !strings.Contains(f.betas[0], "mcp-client-2025-11-20") || !strings.Contains(f.betas[0], "server-side-fallback-2026-07-01") {
t.Errorf("betas: %q", f.betas[0])
}
}
func TestAnthropicFlagsToolsOutsideTheList(t *testing.T) {
f := newFakeAnthropic(t)
f.streams = []string{messageStream("claude-haiku-5-5", "end_turn",
block(0, strings.Replace(toolUse, "get_file_contents", "create_or_update_file", 1)), textBlock(1, "## Summary\n\nx"))}
a, err := f.backend().Ask(context.Background(), "claude-haiku-5-5", "task")
if err != nil || len(a.Violations) != 1 || a.Violations[0] != "create_or_update_file" {
t.Errorf("answer %+v, err %v", a, err)
}
if _, ok := f.bodies[0]["fallbacks"]; ok {
t.Error("haiku has no server-side fallback")
}
}
func TestAnthropicRateLimitIsRetryable(t *testing.T) {
f := newFakeAnthropic(t)
f.status = []int{429}
_, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "task")
api := asAPIError(err)
if !retryable(err) || api == nil || api.Status != 429 || api.Provider != "anthropic" || !api.HasRetryAfter {
t.Errorf("want a retryable anthropic 429 with Retry-After, got %#v (%v)", api, err)
}
}
// With the selector on, the review runs on the model TypeSafe picks; when TypeSafe fails, on the
// default model.
func TestSelectorPicksTheBackend(t *testing.T) {
for _, tc := range []struct {
name string
answer string
status int
wantModel string
}{
{"chosen", `{"answers":{"model":{"type":"choice","choice":"anthropic:claude-opus-5-5","confidence":0.8,` +
`"probabilities":{"anthropic:claude-opus-5-5":0.9,"mistral:mistral-large-4-0":0.1}}}}`, 200, "claude-opus-5-5"},
{"failed", `{"error":"overloaded"}`, 529, "mistral-large-4-0"},
{"unknown model", `{"answers":{"model":{"type":"choice","choice":"x:y"}}}`, 200, "mistral-large-4-0"},
} {
t.Run(tc.name, func(t *testing.T) {
f := newFixture(t)
fa := newFakeAnthropic(t)
fa.streams = []string{messageStream("claude-opus-5-5", "end_turn",
textBlock(0, "## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: comment"))}
var state map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
state = in["state"].(map[string]any)
q := in["questions"].(map[string]any)["model"].(map[string]any)
if r.Header.Get("Authorization") != "Bearer ts-key" || in["model"] != "jev-latest" || q["type"] != "choice" ||
q["criteria"].(map[string]any)["anthropic:claude-opus-5-5"] != "strong" {
http.Error(w, "unexpected request", http.StatusBadRequest)
return
}
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.answer)
}))
t.Cleanup(ts.Close)
f.rev.cfg.Models = []Model{{Backend: "mistral", Name: "mistral-large-4-0", Description: "default"},
{Backend: "anthropic", Name: "claude-opus-5-5", Description: "strong"}}
f.rev.backends["anthropic"] = fa.backend()
f.rev.setReady("anthropic", true)
f.rev.selector = &TypeSafe{url: ts.URL, key: "ts-key", model: "jev-latest", http: &http.Client{Timeout: 5 * time.Second}}
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
if len(posted) != 1 || !strings.Contains(posted[0]["body"], "model `"+tc.wantModel+"`") {
t.Fatalf("want one review by %s, got %v", tc.wantModel, posted)
}
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "konairius/homelab" {
t.Errorf("selector state: %v", state)
}
})
}
}