mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
En mode Code, le Go n'annonce web_screenshot que si Playwright est installé : présent dans le conteneur qui avait tiré les journaux, absent des machines de la CI, d'où deux tours de référence qui ne s'y retrouvaient plus. Le test retire Playwright du PATH : le journal ne dépend plus de la machine qui le tire (Linux et macOS vérifiés sur la sortie de la CI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
300 lines
9.6 KiB
Go
300 lines
9.6 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"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)
|
|
// Playwright installé ou non selon la machine : web_screenshot entrerait ou
|
|
// non dans les outils du mode Code. Hors du PATH, le journal ne dépend plus
|
|
// de la machine qui le tire.
|
|
for {
|
|
p, err := exec.LookPath("playwright")
|
|
if err != nil {
|
|
break
|
|
}
|
|
var keep []string
|
|
for _, d := range filepath.SplitList(os.Getenv("PATH")) {
|
|
if filepath.Clean(d) != filepath.Dir(p) {
|
|
keep = append(keep, d)
|
|
}
|
|
}
|
|
t.Setenv("PATH", strings.Join(keep, string(os.PathListSeparator)))
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|