Files
magpie/internal/gateway/classify_test.go
T

384 lines
15 KiB
Go

package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
)
// clsUp is a Chat vendor that classifies: it answers what answer says,
// after wait, and keeps what it was asked.
type clsUp struct {
mu sync.Mutex
answer string
fail int
wait time.Duration
asked []string
agents []string
}
func (u *clsUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
u.mu.Lock()
u.asked = append(u.asked, string(b))
answer, fail, wait := u.answer, u.fail, u.wait
u.mu.Unlock()
if wait > 0 {
select {
case <-time.After(wait):
case <-r.Context().Done():
return
}
}
w.Header().Set("Content-Type", "application/json")
if fail != 0 {
w.WriteHeader(fail)
io.WriteString(w, `{"error":{"message":"classifier down"}}`)
return
}
io.WriteString(w, `{"id":"c","choices":[{"index":0,"message":{"role":"assistant","content":`+quote(answer)+`},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":1}}`)
}
func (u *clsUp) n() int {
u.mu.Lock()
defer u.mu.Unlock()
return len(u.asked)
}
func (u *clsUp) last() string {
u.mu.Lock()
defer u.mu.Unlock()
return u.asked[len(u.asked)-1]
}
func (u *clsUp) set(answer string, fail int) {
u.mu.Lock()
u.answer, u.fail = answer, fail
u.mu.Unlock()
}
// intented is ruled's group with a classifier, c/cls, and the rules given.
func intented(t *testing.T, rules ...provider.Rule) (*Server, *ruleUp, *ruleUp, *clsUp) {
t.Helper()
s, a, b := ruled(t)
c := &clsUp{answer: "0"}
srv := httptest.NewServer(c)
t.Cleanup(srv.Close)
if err := provider.Save(provider.Provider{ID: "c", Name: "C", Key: "kc", Models: []string{"cls"}, Chat: srv.URL + "/v1"}); err != nil {
t.Fatal(err)
}
if err := catalog.SaveLive("c", srv.URL+"/v1", []catalog.Model{{ID: "cls", Context: 32000}}); err != nil {
t.Fatal(err)
}
g, _, _ := provider.FindGroup("group/r")
g.Rules, g.Classifier = rules, "c/cls"
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
return s, a, b, c
}
func postOK(t *testing.T, s *Server, session, body string) (string, Route) {
t.Helper()
code, out := postAs(t, s, session, body)
if code != 200 {
t.Fatalf("%d %s", code, out)
}
return out, lastRoute(s)
}
// A rule with an intent: the classifier is asked as the turn begins, with
// the user's words only, and the turn goes where its answer says; the
// turn's tool rounds don't ask again.
func TestIntentRoutesATurn(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "writing or fixing tests"})
c.set("1", 0)
msg := "add a unit test for the parser <system-reminder>secret agent context</system-reminder>"
out, r := postOK(t, s, "s1", chat(msg, nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule == nil || r.Rule.N != 1 || r.Rule.Use != "b/big" {
t.Fatalf("turn 1: %s %+v", out, r.Rule)
}
cl := r.Rule.Classified
if cl == nil || cl.By != "c/cls" || cl.Intent != "writing or fixing tests" || cl.Error != "" || cl.Cached || len(cl.Intents) != 1 {
t.Fatalf("classified %+v", cl)
}
if len(r.Rule.When) != 1 || r.Rule.When[0] != `intent "writing or fixing tests"` {
t.Fatalf("when %v", r.Rule.When)
}
asked := c.last()
if !strings.Contains(asked, `"model":"cls"`) || !strings.Contains(asked, `1. writing or fixing tests`) ||
!strings.Contains(asked, "add a unit test for the parser") || strings.Contains(asked, "secret agent context") ||
!strings.Contains(asked, `"temperature":0`) {
t.Fatalf("asked %s", asked)
}
// its tool rounds stay, and the classifier isn't asked again
out, r = postOK(t, s, "s1", chat(msg, nil, 2, ""))
if !strings.Contains(out, "from kb") || !r.Rule.Held || r.Rule.N != 1 || r.Rule.Classified != nil || c.n() != 1 {
t.Fatalf("within: %s %+v %d", out, r.Rule, c.n())
}
// the next turn is another kind: no rule, so it stays where it was
// (the group's affinity), with the classifier's answer in the trace
c.set("0", 0)
_, r = postOK(t, s, "s1", chat(msg, []string{"what does this function return?"}, 0, ""))
if r.Rule.N != 0 || r.Rule.Classified == nil || r.Rule.Classified.Intent != "" || c.n() != 2 {
t.Fatalf("turn 2: %+v %d", r.Rule, c.n())
}
if !strings.Contains(c.last(), "what does this function return?") || strings.Contains(c.last(), "add a unit test") {
t.Fatalf("turn 2 asked about %s", c.last())
}
// it was told what the turn before was, for a message that only
// carries on from it; after a turn that was none, nothing
if r.Rule.Classified.After != "writing or fixing tests" || !strings.Contains(c.last(), "was of kind 1") {
t.Fatalf("turn 2 not told of turn 1: %+v %s", r.Rule.Classified, c.last())
}
_, r = postOK(t, s, "s1", chat(msg, []string{"what does this function return?", "go on"}, 0, ""))
if r.Rule.Classified == nil || r.Rule.Classified.After != "" || strings.Contains(c.last(), "was of kind") || c.n() != 3 {
t.Fatalf("turn 3: %+v %s", r.Rule.Classified, c.last())
}
// the classifier's call is magpie's own, in the usage
var seen bool
for _, call := range s.Recent() {
if call.Model == "c/cls" && call.Agent == "magpie-router" {
seen = true
}
}
if !seen {
t.Fatal("the classifier's call isn't recorded as magpie-router's")
}
}
// Several intents: the classifier chooses among those of the rules that
// may match, each once, and the first rule with its answer decides.
func TestIntentChoosesAmongRules(t *testing.T) {
s, _, _, c := intented(t,
provider.Rule{Use: "a/small", Intent: "a quick question"},
provider.Rule{Use: "b/big", Intent: "Planning a large change"},
provider.Rule{Use: "b/big", Intent: "planning a large change", Agents: []string{"codex"}},
provider.Rule{Use: "b/big", Intent: "reviewing code", Images: true}, // no image: not offered
)
c.set("2", 0)
out, r := postOK(t, s, "s2", chat("design the new sync engine", nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule.N != 2 {
t.Fatalf("%s %+v", out, r.Rule)
}
if got := r.Rule.Classified.Intents; len(got) != 2 || got[0] != "a quick question" || got[1] != "Planning a large change" {
t.Fatalf("intents %q", got)
}
if strings.Contains(c.last(), "reviewing code") || strings.Count(strings.ToLower(c.last()), "planning a large change") != 1 {
t.Fatalf("asked %s", c.last())
}
// an answer that is no number of the list matches nothing
for _, bad := range []string{"3", "maybe", ""} {
c.set(bad, 0)
out, r = postOK(t, s, "s2-"+bad, chat("tell me about "+bad+" things", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule.N != 0 || r.Rule.Classified.Error == "" {
t.Fatalf("answer %q: %s %+v", bad, out, r.Rule)
}
classified.Lock()
delete(classified.failed, "c/cls") // an answer that isn't one rests it too; let it be asked again
classified.Unlock()
}
// an answer with words around the number is read
c.set("Kind 1.", 0)
_, r = postOK(t, s, "s2-words", chat("what's a goroutine", nil, 0, ""))
if r.Rule.N != 1 || r.Rule.Classified.Intent != "a quick question" {
t.Fatalf("%+v", r.Rule)
}
}
// The classifier isn't asked when no rule that could be the first to
// match waits on an intent.
func TestIntentNotAskedWhenItCantMatter(t *testing.T) {
s, _, _, c := intented(t,
provider.Rule{Use: "b/big", Tokens: 1000},
provider.Rule{Use: "b/big", Intent: "debugging", Agents: []string{"codex"}},
provider.Rule{Use: "a/small", Intent: "debugging"},
)
// a long request: the tokens rule is first to match whatever it says
_, r := postOK(t, s, "n1", chat(long(2000), nil, 0, ""))
if r.Rule.N != 1 || r.Rule.Classified != nil || c.n() != 0 {
t.Fatalf("long: %+v %d", r.Rule, c.n())
}
// a short one: rule 3 may match, rule 2 isn't for this agent
c.set("1", 0)
_, r = postOK(t, s, "n2", chat("why does this panic", nil, 0, ""))
if r.Rule.N != 3 || r.Rule.Classified == nil || len(r.Rule.Classified.Intents) != 1 || c.n() != 1 {
t.Fatalf("short: %+v %d", r.Rule, c.n())
}
// an image alone has no words to classify: not asked, nothing matches
g, ms, _ := provider.FindGroup("group/r")
img := &Request{Messages: []Message{{Role: "user", Parts: []Part{{Kind: Image}}}}}
asked := 0
hit := ruleFor("img", g, ms, img, "claude", func(string, []string, before, bool, string) (verdict, error) {
asked++
return verdict{Intent: "debugging"}, nil
})
if hit.N != 0 || hit.Classified == nil || !strings.Contains(hit.Classified.Error, "no words") || asked != 0 {
t.Fatalf("image: %+v %+v", hit, hit.Classified)
}
}
// A classifier that fails or is slow leaves the turn to the rest of the
// rules; after a failure it rests rather than making each turn wait.
func TestIntentClassifierFails(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "refactoring"})
c.set("", 500)
out, r := postOK(t, s, "f1", chat("rename this package", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule.N != 0 || !strings.Contains(r.Rule.Classified.Error, "classifier down") {
t.Fatalf("down: %s %+v", out, r.Rule)
}
asked := c.n()
c.set("1", 0)
out, r = postOK(t, s, "f2", chat("rename that package", nil, 0, ""))
if !strings.Contains(out, "from ka") || !strings.Contains(r.Rule.Classified.Error, "not asked again") || c.n() != asked {
t.Fatalf("resting: %s %+v %d/%d", out, r.Rule, c.n(), asked)
}
// once it has rested, it's asked again
classified.Lock()
f := classified.failed["c/cls"]
f.at = time.Now().Add(-classifyRest - time.Second)
classified.failed["c/cls"] = f
classified.Unlock()
out, r = postOK(t, s, "f3", chat("rename it back", nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule.N != 1 || r.Rule.Classified.Error != "" {
t.Fatalf("rested: %s %+v", out, r.Rule)
}
// slow: past the timeout it's no answer
was := classifyTimeout
classifyTimeout = 200 * time.Millisecond
t.Cleanup(func() { classifyTimeout = was })
c.mu.Lock()
c.wait = 2 * time.Second
c.mu.Unlock()
t0 := time.Now()
out, r = postOK(t, s, "f4", chat("rename everything", nil, 0, ""))
if !strings.Contains(out, "from ka") || !strings.Contains(r.Rule.Classified.Error, "no answer") || time.Since(t0) > 1500*time.Millisecond {
t.Fatalf("slow: %s %+v %s", out, r.Rule, time.Since(t0))
}
}
// A classifier that answers something else is up: the turn goes on without
// an intent, and the next one asks it again rather than resting.
func TestIntentOddAnswerDoesNotRest(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "refactoring"})
c.set("", 0)
out, r := postOK(t, s, "o1", chat("rename this package", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule.N != 0 || !strings.Contains(r.Rule.Classified.Error, "not a number") {
t.Fatalf("empty: %s %+v", out, r.Rule)
}
c.set("1", 0)
out, r = postOK(t, s, "o2", chat("rename that package", nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule.N != 1 || r.Rule.Classified.Error != "" || c.n() != 2 {
t.Fatalf("after: %s %+v %d", out, r.Rule, c.n())
}
}
// A classifier that reasons is asked to reason least, with room for its
// reasoning and the number; one whose levels aren't known is asked plainly.
func TestIntentClassifierEffort(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "refactoring"})
c.set("1", 0)
postOK(t, s, "e1", chat("rename this package", nil, 0, ""))
if asked := c.last(); strings.Contains(asked, "reasoning_effort") || !strings.Contains(asked, `"max_tokens":2048`) {
t.Fatalf("plain: %s", asked)
}
p, _ := provider.Find("c")
for levels, want := range map[string]string{"low,high,max": "low", "none,low,high": "none", "minimal,medium": "minimal"} {
if err := catalog.SaveLive("c", p.Chat, []catalog.Model{{ID: "cls", Context: 32000, Efforts: strings.Split(levels, ",")}}); err != nil {
t.Fatal(err)
}
postOK(t, s, "e-"+levels, chat("rename "+levels, nil, 0, ""))
if asked := c.last(); !strings.Contains(asked, `"reasoning_effort":"`+want+`"`) {
t.Fatalf("%s: %s", levels, asked)
}
}
}
// The same message is classified once: another conversation starting with
// it (an agent retrying, a subagent) reads the answer kept.
func TestIntentAnswerKept(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "refactoring"})
c.set("1", 0)
_, r := postOK(t, s, "k1", chat("split main.go", nil, 0, ""))
if r.Rule.N != 1 || r.Rule.Classified.Cached {
t.Fatalf("%+v", r.Rule)
}
_, r = postOK(t, s, "k2", chat("split main.go", nil, 0, ""))
if r.Rule.N != 1 || !r.Rule.Classified.Cached || c.n() != 1 {
t.Fatalf("%+v %d", r.Rule, c.n())
}
}
func TestUserText(t *testing.T) {
req := &Request{Messages: []Message{
{Role: "user", Parts: []Part{{Kind: Text, Text: "first"}}},
{Role: "assistant", Parts: []Part{{Kind: Text, Text: "ok"}}},
{Role: "user", Parts: []Part{{Kind: Text, Text: "<system-reminder>\nx\n</system-reminder>"}, {Kind: Text, Text: " second "}}},
}}
if got := userText(req); got != "second" {
t.Fatalf("%q", got)
}
big := strings.Repeat("a", 3000) + strings.Repeat("m", 5000) + strings.Repeat("z", 1000)
got := userText(&Request{Messages: []Message{{Role: "user", Parts: []Part{{Kind: Text, Text: big}}}}})
if !strings.HasPrefix(got, strings.Repeat("a", 3000)+"\n…\n") || !strings.HasSuffix(got, strings.Repeat("z", 1000)) || strings.Contains(got, "m") {
t.Fatalf("%d", len(got))
}
for in, want := range map[string]string{"1": "x", "2": "y", " 0 ": "", "2\n": "y"} {
if got, err := readIntent(in, []string{"x", "y"}); err != nil || got != want {
t.Fatalf("%q: %q %v", in, got, err)
}
}
for _, in := range []string{"3", "-", "none", ""} {
if _, err := readIntent(in, []string{"x", "y"}); err == nil {
t.Fatalf("%q read", in)
}
}
}
// A group whose effort is auto with a classifier that isn't Jev (a chat
// model, Jev Router on OpenRouter as much as any) has it rate each turn in
// words: the level's number, the turn's requests at that reasoning. With
// an intent too, it is asked each in a call of its own.
func TestChatClassifierPicksTheEffort(t *testing.T) {
s, a, _, c := intented(t)
g, _, _ := provider.FindGroup("group/r")
g.Effort, g.Classifier = provider.EffortAuto, "c/cls"
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
c.set("3", 0) // high
_, r := postOK(t, s, "s1", chat("find why the cache races", nil, 0, `,"reasoning_effort":"low"`))
if r.Rule == nil || r.Rule.Pick != "high" || r.Rule.Classified == nil || r.Rule.Classified.Effort != "high" || r.Rule.Classified.Error != "" || c.n() != 1 {
t.Fatalf("%+v %d", r.Rule, c.n())
}
if asked := c.last(); !strings.Contains(asked, "3. high") || !strings.Contains(asked, "find why the cache races") {
t.Fatalf("asked %s", asked)
}
if !strings.Contains(a.last, `"reasoning_effort":"high"`) {
t.Fatalf("sent %s", a.last)
}
// the next turn is told what the one before needed
c.set("9", 0)
_, r = postOK(t, s, "s1", chat("find why the cache races", []string{"go on"}, 0, `,"reasoning_effort":"low"`))
if r.Rule.Classified.AfterEffort != "high" || !strings.Contains(c.last(), "needed level 3") || !strings.Contains(r.Rule.Classified.Error, "not a level from 1 to 4") || r.Rule.Pick != "" {
t.Fatalf("turn 2: %+v %s", r.Rule.Classified, c.last())
}
// an intent as well: two calls, one for each
g.Rules = []provider.Rule{{Use: "b/big", Intent: "debugging"}}
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
c.set("1", 0)
_, r = postOK(t, s, "s2", chat("the parser drops a token", nil, 0, `,"reasoning_effort":"medium"`))
if r.Rule.N != 1 || r.Rule.Classified.Intent != "debugging" || r.Rule.Pick != "low" || c.n() != 4 {
t.Fatalf("both: %+v %+v %d", r.Rule, r.Rule.Classified, c.n())
}
}