mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Persister les rappels ouvrait trois façons de coller deux `user` dans l'historique, celles-là même qu'appendNudge voulait éviter : sur un gabarit à alternance stricte, chaque tour suivant aurait échoué. - Fin de tour : un rappel resté sans réponse (stop, erreur) ou suivi d'un ajout en cours de réponse est retiré avant persistance (dropStrayNudges), comme avant sa persistance. - Compaction : la queue ne commence plus sur un rappel ; la vraie demande réinjectée devant elle lui était collée. Elle recule d'un groupe d'outils. - isLokiInjected ne se contente plus du préfixe « [system] » : un message de l'utilisateur qui commence ainsi n'est plus pris pour un rappel (sa demande était sautée par la compaction, le vérificateur et le titre). - Relance tool_choice « none » : pas de repli après un stop, qui partait sur un contexte annulé et affichait une erreur. - Tests : appel par le protocole et refus 4xx sous « none » (et rien d'exécuté), frontière de compaction, nettoyage de fin de tour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
489 lines
19 KiB
Go
489 lines
19 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
const sseStop = `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"
|
|
|
|
const sseGlobCall = `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" +
|
|
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n"
|
|
|
|
// scriptedServer répond à la n-ième requête par steps[n] (le dernier se répète)
|
|
// et garde le corps JSON de chaque requête. Un step "500" renvoie une erreur
|
|
// 500, comme llama.cpp sur un appel d'outil qu'il ne sait pas parser.
|
|
func scriptedServer(t *testing.T, steps ...string) func() []map[string]any {
|
|
t.Helper()
|
|
var mu sync.Mutex
|
|
var bodies []map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
mu.Lock()
|
|
bodies = append(bodies, body)
|
|
n := len(bodies) - 1
|
|
mu.Unlock()
|
|
step := steps[min(n, len(steps)-1)]
|
|
switch step {
|
|
case "500":
|
|
http.Error(w, "Failed to parse tool call", http.StatusInternalServerError)
|
|
return
|
|
case "400":
|
|
http.Error(w, "Unsupported param: tool_choice", http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(step))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
u, _ := url.Parse(srv.URL)
|
|
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return func() []map[string]any {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]map[string]any(nil), bodies...)
|
|
}
|
|
}
|
|
|
|
// reqMessages relit les messages d'une requête enregistrée.
|
|
func reqMessages(t *testing.T, body map[string]any) []Message {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(body["messages"])
|
|
var out []Message
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestIsLokiInjected(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
m Message
|
|
want bool
|
|
}{
|
|
{"budget 1", um(budgetNudge(24, 24, 0)), true},
|
|
{"budget 2", um(budgetNudge(48, 24, 1)), true},
|
|
{"budget 3", um(budgetNudge(72, 24, 2)), true},
|
|
{"budget suivants", um(budgetNudge(150, 24, 5)), true},
|
|
{"consigne du 500 en message à part", um(lokiNotePrefix + toolsOffHint), true},
|
|
{"demande qui commence comme un rappel", um(lokiNotePrefix + "réécris ce prompt système"), false},
|
|
{"pensé sans agir", um(thinkNudgeFirst), true},
|
|
{"pensé sans agir, bis", um(thinkNudgeStuck), true},
|
|
{"appel écrit en texte", um(retryCorrective("<tool_call>x")), true},
|
|
{"vraie demande", um("cherche les horaires"), false},
|
|
{"demande qui cite un rappel", um("pourquoi « " + thinkNudgeFirst + " » ?"), false},
|
|
{"assistant", am(lokiNotePrefix + "x"), false},
|
|
{"user multimodal", Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": lokiNotePrefix}}}, false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isLokiInjected(c.m); got != c.want {
|
|
t.Errorf("%s : isLokiInjected = %v, attendu %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le rappel entre dans l'historique tel qu'envoyé, sauf derrière un autre
|
|
// message user (gabarits à alternance stricte) où il reste éphémère.
|
|
func TestAppendNudge(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
before []Message
|
|
persisted bool
|
|
}{
|
|
{"après un résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, true},
|
|
{"après un assistant", []Message{um("q"), am("a")}, true},
|
|
{"après la demande", []Message{um("q")}, false},
|
|
{"après un autre rappel", []Message{um("q"), atc("glob"), tm("r"), um(thinkNudgeFirst)}, false},
|
|
{"fil vide", nil, true},
|
|
}
|
|
for _, c := range cases {
|
|
msgs, extra := appendNudge(append([]Message(nil), c.before...), nil, thinkNudgeFirst)
|
|
if len(msgs) != len(c.before)+1 || msgText(msgs[len(msgs)-1]) != thinkNudgeFirst {
|
|
t.Errorf("%s : rappel absent de la vue du modèle : %+v", c.name, msgs)
|
|
}
|
|
if got := len(extra) == 1; got != c.persisted {
|
|
t.Errorf("%s : persisté = %v, attendu %v", c.name, got, c.persisted)
|
|
}
|
|
if c.persisted && !reflect.DeepEqual(extra[0], msgs[len(msgs)-1]) {
|
|
t.Errorf("%s : persisté %+v ≠ envoyé %+v", c.name, extra[0], msgs[len(msgs)-1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWithTrailingHint(t *testing.T) {
|
|
note := "\n\n" + lokiNotePrefix + "H"
|
|
cases := []struct {
|
|
name string
|
|
in []Message
|
|
want Message // dernier message attendu
|
|
grow bool // un message ajouté plutôt qu'enrichi
|
|
}{
|
|
{"résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, Message{Role: "tool", ToolCallID: "c1", Content: "r" + note}, false},
|
|
{"message user", []Message{um("q")}, um("q" + note), false},
|
|
{"parties []any", []Message{{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}}}},
|
|
Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}, map[string]any{"type": "text", "text": note}}}, false},
|
|
{"parties []map", []Message{{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}}}},
|
|
Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}, {"type": "text", "text": note}}}, false},
|
|
{"assistant en dernier", []Message{um("q"), am("a")}, um(lokiNotePrefix + "H"), true},
|
|
}
|
|
for _, c := range cases {
|
|
orig, _ := json.Marshal(c.in)
|
|
out := withTrailingHint(c.in, "H")
|
|
if after, _ := json.Marshal(c.in); string(after) != string(orig) {
|
|
t.Errorf("%s : l'historique d'origine a été modifié", c.name)
|
|
}
|
|
wantLen := len(c.in)
|
|
if c.grow {
|
|
wantLen++
|
|
}
|
|
if len(out) != wantLen {
|
|
t.Fatalf("%s : %d messages, attendu %d", c.name, len(out), wantLen)
|
|
}
|
|
if !reflect.DeepEqual(out[len(out)-1], c.want) {
|
|
t.Errorf("%s : dernier message %+v, attendu %+v", c.name, out[len(out)-1], c.want)
|
|
}
|
|
if !reflect.DeepEqual(out[:len(c.in)-1], c.in[:len(c.in)-1]) {
|
|
t.Errorf("%s : le début du fil a changé", c.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Les lecteurs de « la demande de l'utilisateur » sautent les rappels.
|
|
func TestRappelsPasPrisPourLaDemande(t *testing.T) {
|
|
msgs := []Message{um("corrige le build"), atc("bash"), tm("ok"), um(budgetNudge(24, 24, 0)), am("fait"),
|
|
um("et les tests"), atc("bash"), tm("ok"), um(thinkNudgeFirst), am("fait")}
|
|
c := &Conversation{Messages: msgs}
|
|
if got := c.lastUserText(); got != "et les tests" {
|
|
t.Errorf("lastUserText = %q", got)
|
|
}
|
|
if got := lastTurnsMessages(msgs, 1); len(got) == 0 || msgText(got[0]) != "et les tests" {
|
|
t.Errorf("dernier échange = %+v", got)
|
|
}
|
|
if got := lastTurnsMessages(msgs, 2); len(got) != len(msgs) {
|
|
t.Errorf("deux échanges : %d messages, attendu %d", len(got), len(msgs))
|
|
}
|
|
if got := convSummary([]Message{um(thinkNudgeFirst), um("bonjour")}); got != "bonjour" {
|
|
t.Errorf("titre = %q", got)
|
|
}
|
|
if tr := renderTranscript([]Message{um(thinkNudgeFirst)}); !strings.HasPrefix(tr, "System: ") {
|
|
t.Errorf("rappel présenté au résumeur comme parole de l'utilisateur : %q", tr)
|
|
}
|
|
}
|
|
|
|
// Compaction après un long tour ponctué de rappels : la VRAIE demande est
|
|
// réinjectée, qu'un rappel traîne dans le torse ou dans la queue.
|
|
func TestCompactionReinjecteLaVraieDemandePasLeRappel(t *testing.T) {
|
|
testHome(t)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- pages lues : horaires trouvés pour le matin, reste l'après-midi"}}}})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
u, _ := url.Parse(srv.URL)
|
|
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page := func(n int) Message {
|
|
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
|
}
|
|
torsoNudge, tailNudge := budgetNudge(24, 24, 0), budgetNudge(48, 24, 1)
|
|
msgs := []Message{um("première question"), am("ok"), um("cherche les horaires du train pour Lyon")}
|
|
for i := 0; i < 12; i++ {
|
|
msgs = append(msgs, atc("web_read"), page(i))
|
|
if i == 3 {
|
|
msgs = append(msgs, um(torsoNudge))
|
|
}
|
|
}
|
|
msgs = append(msgs, um(tailNudge), atc("web_read"), page(13))
|
|
_, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac))
|
|
if msgText(msgs[len(msgs)-3]) != tailNudge || tailStart > len(msgs)-3 {
|
|
t.Fatalf("montage : le rappel de queue n'est pas dans la queue (tailStart=%d)", tailStart)
|
|
}
|
|
out, changed := compactMessages(t.Context(), msgs, Caps{})
|
|
if !changed {
|
|
t.Fatal("pas compacté")
|
|
}
|
|
count := func(s string) int {
|
|
n := 0
|
|
for _, m := range out {
|
|
if m.Role == "user" && msgText(m) == s {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
if n := count("cherche les horaires du train pour Lyon"); n != 1 {
|
|
t.Fatalf("demande en cours présente %d fois après compaction, attendu 1", n)
|
|
}
|
|
if count(torsoNudge) != 0 {
|
|
t.Fatal("le rappel du torse a été réinjecté comme demande en cours")
|
|
}
|
|
if count(tailNudge) != 1 {
|
|
t.Fatal("le rappel de la queue a disparu")
|
|
}
|
|
assertAlternance(t, out)
|
|
}
|
|
|
|
// assertAlternance : jamais deux `user` d'affilée (gabarits stricts).
|
|
func assertAlternance(t *testing.T, msgs []Message) {
|
|
t.Helper()
|
|
for i := 1; i < len(msgs); i++ {
|
|
if msgs[i].Role == "user" && msgs[i-1].Role == "user" {
|
|
t.Fatalf("deux user d'affilée en %d : %q puis %q", i, msgText(msgs[i-1]), msgText(msgs[i]))
|
|
}
|
|
}
|
|
}
|
|
|
|
// La queue protégée ne commence jamais sur un rappel : la vraie demande,
|
|
// réinjectée juste devant elle, lui serait collée (deux user d'affilée).
|
|
func TestCompactBoundsPasSurUnRappel(t *testing.T) {
|
|
nudge := um(budgetNudge(24, 24, 0))
|
|
msgs := []Message{um("cherche"), atc("web_read"), tm(strings.Repeat("x", 800)), nudge, atc("web_read"), tm("court")}
|
|
budget := msgTokens(msgs[3]) + msgTokens(msgs[4]) + msgTokens(msgs[5])
|
|
_, tailStart := compactBounds(msgs, budget)
|
|
if tailStart != 1 {
|
|
t.Fatalf("tailStart = %d, attendu 1 (l'appel d'outil avant le rappel)", tailStart)
|
|
}
|
|
// Sans rappel, la frontière reste celle d'avant.
|
|
plain := []Message{um("cherche"), atc("web_read"), tm(strings.Repeat("x", 800)), um("et ensuite"), atc("web_read"), tm("court")}
|
|
if _, ts := compactBounds(plain, msgTokens(plain[3])+msgTokens(plain[4])+msgTokens(plain[5])); ts != 3 {
|
|
t.Fatalf("frontière sans rappel déplacée : %d", ts)
|
|
}
|
|
}
|
|
|
|
// Ce qui est persisté en fin de tour ne garde aucun rappel en porte-à-faux.
|
|
func TestDropStrayNudges(t *testing.T) {
|
|
n := um(thinkNudgeFirst)
|
|
cases := []struct {
|
|
name string
|
|
in []Message
|
|
want []Message
|
|
}{
|
|
{"rappel répondu", []Message{um("q"), atc("glob"), tm("r"), n, am("fini")}, nil},
|
|
{"rappel resté sans réponse (stop)", []Message{um("q"), atc("glob"), tm("r"), n},
|
|
[]Message{um("q"), atc("glob"), tm("r")}},
|
|
{"ajout en cours de réponse juste derrière", []Message{um("q"), atc("glob"), tm("r"), n, um("et aussi"), am("fini")},
|
|
[]Message{um("q"), atc("glob"), tm("r"), um("et aussi"), am("fini")}},
|
|
{"rappel collé derrière la demande", []Message{um("q"), n, am("fini")}, []Message{um("q"), am("fini")}},
|
|
{"correctif sans réponse", []Message{um("q"), am("<tool_call>"), um(retryCorrective("<tool_call>"))},
|
|
[]Message{um("q"), am("<tool_call>")}},
|
|
{"deux rappels de suite en fin", []Message{um("q"), atc("glob"), tm("r"), n, um(thinkNudgeStuck)},
|
|
[]Message{um("q"), atc("glob"), tm("r")}},
|
|
{"vraie demande en fin", []Message{um("q"), am("a"), um("suite")}, nil},
|
|
}
|
|
for _, c := range cases {
|
|
orig, _ := json.Marshal(c.in)
|
|
got := dropStrayNudges(c.in)
|
|
if after, _ := json.Marshal(c.in); string(after) != string(orig) {
|
|
t.Errorf("%s : l'entrée a été modifiée", c.name)
|
|
}
|
|
want := c.want
|
|
if want == nil {
|
|
want = c.in
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("%s :\n got %+v\n want %+v", c.name, got, want)
|
|
}
|
|
assertAlternance(t, got)
|
|
}
|
|
}
|
|
|
|
// La réduction forcée coupe aux vraies demandes ; un rappel ne sert de coupe
|
|
// qu'à défaut.
|
|
func TestShrinkCoupeAuxVraiesDemandes(t *testing.T) {
|
|
testHome(t)
|
|
if err := SetConfigKey("CTX", "4096"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
big := am(strings.Repeat("raisonnement long ", 400))
|
|
sys := Message{Role: "system", Content: "SYS"}
|
|
out, changed := shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big, um("demande B"), am("ok")}, 0)
|
|
if !changed || len(out) < 2 || msgText(out[1]) != "demande B" {
|
|
t.Fatalf("coupe attendue à « demande B » : %+v", out)
|
|
}
|
|
out, changed = shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big}, 0)
|
|
if !changed || len(out) < 2 || msgText(out[1]) != thinkNudgeFirst {
|
|
t.Fatalf("sans autre demande, la coupe doit retomber sur le rappel : %+v", out)
|
|
}
|
|
}
|
|
|
|
// Le rappel de budget part au moteur ET dans l'historique renvoyé : le tour
|
|
// suivant rejoue les mêmes octets.
|
|
func TestRappelDeBudgetPersisteTelQuEnvoye(t *testing.T) {
|
|
withWorkspace(t)
|
|
if err := SetConfigKey("AGENT_BUDGET", "1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
|
|
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bodies := reqs()
|
|
if len(bodies) != 2 {
|
|
t.Fatalf("%d requêtes, attendu 2", len(bodies))
|
|
}
|
|
sent := reqMessages(t, bodies[1])
|
|
last := sent[len(sent)-1]
|
|
if !strings.HasPrefix(msgText(last), lokiNotePrefix) {
|
|
t.Fatalf("rappel absent de la requête : %+v", last)
|
|
}
|
|
if len(extra) != 3 || !reflect.DeepEqual(extra[2], last) {
|
|
t.Fatalf("historique renvoyé %+v : le rappel doit y être tel qu'envoyé", extra)
|
|
}
|
|
}
|
|
|
|
// « Pensé sans agir » : persisté après un résultat d'outil, éphémère juste
|
|
// après le message de l'utilisateur (deux user d'affilée).
|
|
func TestRelancePenseSansAgirPersistee(t *testing.T) {
|
|
withWorkspace(t)
|
|
reqs := scriptedServer(t, sseGlobCall, sseStop, sseChunk("fini")+sseStop)
|
|
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := len(reqs()); n != 3 {
|
|
t.Fatalf("%d requêtes, attendu 3", n)
|
|
}
|
|
if len(extra) != 3 || extra[2].Role != "user" || msgText(extra[2]) != thinkNudgeFirst {
|
|
t.Fatalf("relance non persistée après l'outil : %+v", extra)
|
|
}
|
|
|
|
reqs = scriptedServer(t, sseStop, sseChunk("fini")+sseStop)
|
|
extra, err = runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bodies := reqs(); len(bodies) != 2 || msgText(reqMessages(t, bodies[1])[1]) != thinkNudgeFirst {
|
|
t.Fatal("la relance doit quand même partir au moteur")
|
|
}
|
|
if len(extra) != 0 {
|
|
t.Fatalf("relance persistée derrière la demande (deux user d'affilée) : %+v", extra)
|
|
}
|
|
}
|
|
|
|
// Relance après un 500 (llama-server local) : mêmes outils, même message 0,
|
|
// tool_choice none et la consigne au bout du dernier message — qui n'entre
|
|
// pas dans l'historique.
|
|
func TestRelance500GardeLePrompt(t *testing.T) {
|
|
testHome(t)
|
|
reqs := scriptedServer(t, "500", sseChunk("voici")+sseStop)
|
|
in := []Message{{Role: "system", Content: "SYS"}, um("question")}
|
|
extra, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bodies := reqs()
|
|
if len(bodies) != 2 {
|
|
t.Fatalf("%d requêtes, attendu 2", len(bodies))
|
|
}
|
|
first, retry := bodies[0], bodies[1]
|
|
if first["tools"] == nil || !reflect.DeepEqual(first["tools"], retry["tools"]) {
|
|
t.Fatal("les outils doivent rester identiques dans la relance")
|
|
}
|
|
if _, ok := first["tool_choice"]; ok {
|
|
t.Fatal("tool_choice posé hors relance")
|
|
}
|
|
if retry["tool_choice"] != "none" {
|
|
t.Fatalf("tool_choice = %v, attendu none", retry["tool_choice"])
|
|
}
|
|
m0, r0 := reqMessages(t, first), reqMessages(t, retry)
|
|
if !reflect.DeepEqual(m0[0], r0[0]) {
|
|
t.Fatalf("message 0 modifié : %+v → %+v", m0[0], r0[0])
|
|
}
|
|
if got := msgText(r0[len(r0)-1]); got != "question\n\n"+lokiNotePrefix+toolsOffHint {
|
|
t.Fatalf("consigne absente du bout du fil : %q", got)
|
|
}
|
|
if len(extra) != 0 {
|
|
t.Fatalf("la consigne ne doit pas être persistée : %+v", extra)
|
|
}
|
|
}
|
|
|
|
// Repli : la relance « none » échoue à son tour (500, ou appel tenté malgré
|
|
// tout) → chemin historique, outils retirés et consigne dans le système.
|
|
func TestRelance500ReplieSurLeCheminHistorique(t *testing.T) {
|
|
leak := `data: {"choices":[{"delta":{"content":"<tool_call>"}}]}` + "\n\n" + sseStop
|
|
for name, second := range map[string]string{"second 500": "500", "refus 4xx": "400", "appel en texte": leak,
|
|
"appel par le protocole": sseGlobCall, "réponse vide": sseStop} {
|
|
t.Run(name, func(t *testing.T) {
|
|
testHome(t)
|
|
reqs := scriptedServer(t, "500", second, sseChunk("voici")+sseStop)
|
|
in := []Message{{Role: "system", Content: "SYS"}, um("question")}
|
|
ran := false
|
|
if _, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
|
ran = ran || ev.ToolUsed != nil
|
|
return true
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ran {
|
|
t.Fatal("un appel émis sous tool_choice none a été exécuté")
|
|
}
|
|
bodies := reqs()
|
|
if len(bodies) != 3 {
|
|
t.Fatalf("%d requêtes, attendu 3", len(bodies))
|
|
}
|
|
last := bodies[2]
|
|
if _, ok := last["tools"]; ok {
|
|
t.Fatal("le repli doit retirer les outils")
|
|
}
|
|
if _, ok := last["tool_choice"]; ok {
|
|
t.Fatal("tool_choice ne doit pas survivre au repli")
|
|
}
|
|
msgs := reqMessages(t, last)
|
|
if !strings.Contains(msgText(msgs[0]), toolsOffHint) || strings.Contains(msgText(msgs[len(msgs)-1]), toolsOffHint) {
|
|
t.Fatalf("consigne du repli mal placée : %+v", msgs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Preset externe : la relance garde le chemin historique dès le premier 500.
|
|
func TestRelance500ExterneInchangee(t *testing.T) {
|
|
testHome(t)
|
|
var mu sync.Mutex
|
|
var bodies []map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
mu.Lock()
|
|
bodies = append(bodies, body)
|
|
n := len(bodies)
|
|
mu.Unlock()
|
|
if n == 1 {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte(sseChunk("voici") + sseStop))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := runChat(t.Context(), []Message{um("question")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(bodies) != 2 {
|
|
t.Fatalf("%d requêtes, attendu 2", len(bodies))
|
|
}
|
|
if _, ok := bodies[1]["tools"]; ok {
|
|
t.Fatal("preset externe : la relance doit retirer les outils, comme avant")
|
|
}
|
|
if _, ok := bodies[1]["tool_choice"]; ok {
|
|
t.Fatal("preset externe : pas de tool_choice")
|
|
}
|
|
}
|