mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 13:38:11 +02:00
internal/loki/parity_turn_test.go passe sept tours par la vraie route /api/chat/send du Go contre un faux moteur — réponse simple, raisonnement, outil puis réponse, « pensé sans agir », erreur du moteur, et en mode Code un contrat déjà tenu et un contrat abandonné après deux corrections — et range dans rust/loki-rs/tests/parity_turn/ le journal d'affichage du tour (coalescé, mesures et horodatages masqués), l'historique rangé, les corps envoyés au moteur, ainsi que les réponses du Go que la façade demandera pendant ce tour (/turn/start, /turn/view, /turn/verifier). Ce que le prompt de la machine porte de propre au lancement (dossiers temporaires, discussion, date, hôte) est masqué des deux côtés. tests/lot6.rs (parite_du_tour_complet) rejoue les sept tours par chat::generate et retrouve journal, historique et requêtes à l'identique, numérotation du journal coalescé comprise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
283 lines
9.1 KiB
Go
283 lines
9.1 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// parity_turn_test.go — journaux de référence d'un TOUR COMPLET, tel que le
|
|
// voit l'interface (lot 6 de la migration Rust).
|
|
//
|
|
// Chaque scénario passe par la vraie route /api/chat/send du Go, contre un
|
|
// faux moteur : le journal d'affichage du tour (deltas, une fois le tour fini
|
|
// et coalescé), l'historique rangé et les corps envoyés au moteur sont rangés
|
|
// dans rust/loki-rs/tests/parity_turn/<nom>.json, avec ce que la façade
|
|
// demandera au Go pendant le même tour (réponses de /internal/v1/turn/start
|
|
// et /turn/view). Le test Rust (tests/lot6.rs, parite_du_tour_complet) rejoue
|
|
// le tour par chat::generate et doit retrouver ces journaux.
|
|
//
|
|
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTourComplet
|
|
|
|
const parityTurnDir = "../../rust/loki-rs/tests/parity_turn"
|
|
|
|
type parityTurnScenario struct {
|
|
Name string `json:"name"`
|
|
Cfg map[string]string `json:"cfg"`
|
|
// Agent : mode agent allumé (réglage de la machine, clé d'état « agent »).
|
|
Agent bool `json:"agent,omitempty"`
|
|
// Code : discussion en mode Code, avec ces critères d'acceptation.
|
|
Code bool `json:"code,omitempty"`
|
|
Criteria []Criterion `json:"criteria,omitempty"`
|
|
Send map[string]any `json:"send"`
|
|
Replies []parityReply `json:"replies"`
|
|
ToolResults map[string]string `json:"tool_results"`
|
|
}
|
|
|
|
type parityTurnExpect struct {
|
|
Requests []any `json:"requests"`
|
|
Log []map[string]any `json:"log"`
|
|
Messages []Message `json:"messages"`
|
|
Status int `json:"status"`
|
|
}
|
|
|
|
type parityTurnFixture struct {
|
|
parityTurnScenario
|
|
// Réponses du Go à la façade pendant ce tour.
|
|
Start any `json:"start"`
|
|
View any `json:"view"`
|
|
Verifier any `json:"verifier,omitempty"`
|
|
Expect parityTurnExpect `json:"expect"`
|
|
}
|
|
|
|
func parityTurnScenarios() []parityTurnScenario {
|
|
send := func(msg string) map[string]any { return map[string]any{"message": msg} }
|
|
base := func(name string, s map[string]any, replies ...parityReply) parityTurnScenario {
|
|
s["cid"] = "c-" + name // un envoi par scénario : jamais pris pour un réessai
|
|
return parityTurnScenario{
|
|
Name: name, Cfg: map[string]string{"MEM_MODE": "off"}, Send: s, Replies: replies,
|
|
ToolResults: map[string]string{"bash": "a.txt\nb.txt", "read": "1: ligne"},
|
|
}
|
|
}
|
|
agent := func(sc parityTurnScenario) parityTurnScenario {
|
|
sc.Agent = true
|
|
return sc
|
|
}
|
|
code := func(sc parityTurnScenario, crit ...Criterion) parityTurnScenario {
|
|
sc.Agent, sc.Code, sc.Criteria = true, true, crit
|
|
return sc
|
|
}
|
|
return []parityTurnScenario{
|
|
base("reponse_simple", send("Bonjour"),
|
|
pOK(pContent("Bon"), pContent("jour !"), pFinish("stop"))),
|
|
base("raisonnement", send("Combien ?"),
|
|
pOK(pReasoning("Je compte."), pContent("Quatre."), pFinish("stop"))),
|
|
agent(base("outil_puis_reponse", send("Liste les fichiers"),
|
|
pOK(pToolCall(0, "c1", "bash", `{"command":"ls"}`), pFinish("tool_calls")),
|
|
pOK(pContent("Deux fichiers."), pFinish("stop")))),
|
|
agent(base("pense_sans_agir", send("Cherche"),
|
|
pOK(pReasoning("Il faudrait lister."), pFinish("stop")),
|
|
pOK(pContent("Voilà."), pFinish("stop")))),
|
|
code(base("code_contrat_deja_tenu", send("Corrige le test"),
|
|
pOK(pContent("Corrigé."), pFinish("stop"))),
|
|
Criterion{ID: 1, Text: "les tests passent", Status: "passed"}),
|
|
code(base("code_contrat_abandonne", send("Construis le jeu"),
|
|
pOK(pContent("r0"), pFinish("stop")), pOK(pContent("r1"), pFinish("stop")),
|
|
pOK(pContent("r2"), pFinish("stop")), pOK(pContent("v1"), pFinish("stop")),
|
|
pOK(pContent("r3"), pFinish("stop")), pOK(pContent("v2"), pFinish("stop")),
|
|
pOK(pContent("r4"), pFinish("stop")), pOK(pContent("v3"), pFinish("stop"))),
|
|
Criterion{ID: 1, Text: "le build passe", Status: "pending"}),
|
|
base("erreur_moteur", send("Bonjour"),
|
|
parityReply{Status: 400, Lines: []string{`{"error":{"code":400,"message":"requête invalide","type":"invalid_request_error"}}`}}),
|
|
}
|
|
}
|
|
|
|
// parityTurnLog : le journal du tour, mesures et horaires masqués.
|
|
func parityTurnLog(evs []LogEvent) []map[string]any {
|
|
out := make([]map[string]any, 0, len(evs))
|
|
for _, e := range evs {
|
|
d := map[string]any{}
|
|
for k, v := range e.Delta {
|
|
switch k {
|
|
case "stats":
|
|
v = "…"
|
|
case "ts0":
|
|
continue // horodatage du premier fragment coalescé
|
|
}
|
|
d[k] = v
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runParityTurn(t *testing.T, sc parityTurnScenario) parityTurnFixture {
|
|
t.Helper()
|
|
testHome(t)
|
|
t.Setenv(internalTokenEnv, jetonTest)
|
|
var (
|
|
mu sync.Mutex
|
|
requests []any
|
|
next int
|
|
)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/health" {
|
|
io.WriteString(w, `{"status":"ok"}`)
|
|
return
|
|
}
|
|
if !strings.HasSuffix(r.URL.Path, "/chat/completions") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
raw, _ := io.ReadAll(r.Body)
|
|
var body any
|
|
_ = json.Unmarshal(raw, &body)
|
|
mu.Lock()
|
|
requests = append(requests, body)
|
|
i := next
|
|
next++
|
|
mu.Unlock()
|
|
if i >= len(sc.Replies) {
|
|
t.Errorf("%s : requête %d non scriptée", sc.Name, i+1)
|
|
http.Error(w, "non scripté", http.StatusTeapot)
|
|
return
|
|
}
|
|
rep := sc.Replies[i]
|
|
if rep.Status == 200 {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
}
|
|
w.WriteHeader(rep.Status)
|
|
var b strings.Builder
|
|
for _, l := range rep.Lines {
|
|
b.WriteString(l + "\n\n")
|
|
}
|
|
io.WriteString(w, b.String())
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
u, _ := url.Parse(srv.URL)
|
|
cfg := map[string]string{"PORT": u.Port()}
|
|
for k, v := range sc.Cfg {
|
|
cfg[k] = v
|
|
}
|
|
if err := WriteConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setAgentEnabled(sc.Agent); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
execToolHook = func(_ context.Context, name string, _ map[string]any, _ string, _ Caps) toolExecResult {
|
|
if r, ok := sc.ToolResults[name]; ok {
|
|
return toolExecResult{Result: r}
|
|
}
|
|
return toolExecResult{Result: "[erreur] outil inconnu : " + name}
|
|
}
|
|
t.Cleanup(func() { execToolHook = nil })
|
|
|
|
conv.Reset()
|
|
if sc.Code {
|
|
id := convEnsureActive()
|
|
if err := setConvMode(id, "code"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
critSave(id, sc.Criteria)
|
|
}
|
|
body, _ := json.Marshal(sc.Send)
|
|
// Ce que la façade obtiendra du Go pour ce même envoi.
|
|
start := turnJSON(t, "/internal/v1/turn/start", string(body), 200)
|
|
caps, _ := json.Marshal(map[string]any{"caps": start["turn_caps"]})
|
|
view := turnJSON(t, "/internal/v1/turn/view", string(caps), 200)
|
|
var verifier any
|
|
if sc.Code {
|
|
verifier = turnJSON(t, "/internal/v1/turn/verifier", string(caps), 200)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handleChatSend(w, httptest.NewRequest("POST", "/api/chat/send", bytes.NewReader(body)))
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
conv.mu.Lock()
|
|
done := !conv.Generating
|
|
conv.mu.Unlock()
|
|
if done || time.Now().After(deadline) {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
persistQ.flush()
|
|
conv.mu.Lock()
|
|
exp := parityTurnExpect{
|
|
Requests: requests,
|
|
Log: parityTurnLog(conv.Log),
|
|
Messages: append([]Message{}, conv.Messages...),
|
|
Status: w.Code,
|
|
}
|
|
conv.mu.Unlock()
|
|
if exp.Requests == nil {
|
|
exp.Requests = []any{}
|
|
}
|
|
return parityTurnFixture{parityTurnScenario: sc, Start: start, View: view, Verifier: verifier, Expect: exp}
|
|
}
|
|
|
|
// parityMasks : ce que le prompt de la machine porte de propre à CE lancement
|
|
// (dossiers temporaires, discussion, date, hôte). La façade reçoit ce prompt
|
|
// tel quel par /turn/view : masqué ici, il l'est des deux côtés.
|
|
var parityMasks = []struct {
|
|
re *regexp.Regexp
|
|
with string
|
|
}{
|
|
{regexp.MustCompile(`(/private)?` + regexp.QuoteMeta(filepath.Clean(os.TempDir())) + `/Test[^/"\\]*/[0-9]+`), "{{TMP}}"},
|
|
{regexp.MustCompile(`\bc[0-9]{15,}\b`), "{{CONV}}"},
|
|
{regexp.MustCompile(`Date: [0-9]{4}-[0-9]{2}-[0-9]{2}`), "Date: {{DATE}}"},
|
|
{regexp.MustCompile(`Machine: host=[^,]*, [a-z0-9]+/[a-z0-9]+(, user=[^,.]*)?`), "Machine: host={{HOST}}"},
|
|
}
|
|
|
|
func parityMask(b []byte) []byte {
|
|
for _, m := range parityMasks {
|
|
b = m.re.ReplaceAll(b, []byte(m.with))
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestPariteTourComplet(t *testing.T) {
|
|
update := os.Getenv("LOKI_PARITY_UPDATE") == "1"
|
|
for _, sc := range parityTurnScenarios() {
|
|
t.Run(sc.Name, func(t *testing.T) {
|
|
got, err := parityJSON(runParityTurn(t, sc))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = parityMask(got)
|
|
path := filepath.Join(parityTurnDir, sc.Name+".json")
|
|
if update {
|
|
if err := os.MkdirAll(parityTurnDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, got, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
want, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("le tour Go ne produit plus le journal rangé dans %s.\n"+
|
|
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTourComplet,\n"+
|
|
"puis cargo test --test lot6 parite_du_tour_complet dit si le port Rust suit.\n--- obtenu ---\n%s", path, got)
|
|
}
|
|
})
|
|
}
|
|
}
|