mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 20:26:35 +02:00
Quand la façade tient la conversation, /api/chat/export, /api/chat/mode et /api/chat/criteria rejoignent ses routes (chatx.rs, facadeChatRoutes côté Go), sans nouvelle variable. L'export Markdown ou JSON part de la conversation vivante (le Go relisait la copie enregistrée, en retard d'un tour au besoin) : raisonnement replié, outils dans leur état final, compaction signalée, export partiel annoncé, JSON indenté et HTML échappé, sans mise à jour de contexte ni image en base64. Le mode et les critères (scellés, vingt au plus, statuts ramenés) vont sur le fil de la façade. Le reste du mode Code (indice, travaux de fond, outil criteria) reste au Go et lit les mêmes clés. Parité : parity_chatx_test.go tire du Go l'export d'une conversation riche sous 22 jeux d'options et 23 appels au mode et aux critères (base et fil après chacun, erreurs JSON imbriquées comprises), rejoués octet pour octet par tests/chatx.rs ; tests du relais mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
234 lines
10 KiB
Go
234 lines
10 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// parity_chatx_test.go — journal de référence de l'export, du mode Code et
|
|
// des critères de la discussion (lot 19 de la migration Rust) : l'export
|
|
// Markdown et JSON d'une conversation riche (pièces jointes, raisonnement en
|
|
// deltas, outils avec et sans sortie, compaction, images en base64, rappels
|
|
// de Loki, mise à jour de contexte) sous une vingtaine de jeux d'options lus
|
|
// dans la requête, puis les routes du mode et des critères avec la base après
|
|
// chaque appel. Rangé dans rust/loki-rs/tests/parity_chatx/chatx.json ;
|
|
// tests/chatx.rs le rejoue par la façade (conversation tenue par elle).
|
|
//
|
|
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteChatX
|
|
|
|
const parityChatXFile = "../../rust/loki-rs/tests/parity_chatx/chatx.json"
|
|
|
|
var (
|
|
parityExportDateRe = regexp.MustCompile(`Exportée le \d{2}/\d{2}/\d{4} à \d{2}:\d{2}`)
|
|
parityExportedAtRe = regexp.MustCompile(`"exported_at": "[^"]+"`)
|
|
)
|
|
|
|
// parityExportMask : la date de l'export et la version, propres à chaque
|
|
// exécution et à chaque binaire.
|
|
func parityExportMask(s string) string {
|
|
s = parityExportDateRe.ReplaceAllString(s, "Exportée le {{DATE}}")
|
|
s = parityExportedAtRe.ReplaceAllString(s, `"exported_at": "{{DATE}}"`)
|
|
return strings.ReplaceAll(s, Version, "{{VERSION}}")
|
|
}
|
|
|
|
type parityChatXStep struct {
|
|
Name string `json:"name"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Body string `json:"body,omitempty"`
|
|
Status int `json:"status"`
|
|
Out any `json:"out"`
|
|
Mode string `json:"mode"` // chat/mode:<id> après l'appel
|
|
Crit any `json:"criteria"` // chat/crit:<id> après l'appel
|
|
Deltas []any `json:"deltas"` // événements ajoutés au fil
|
|
}
|
|
|
|
func TestPariteChatX(t *testing.T) {
|
|
testHome(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
img := "data:image/png;base64," + strings.Repeat("QUJD", 600)
|
|
msgs := []Message{
|
|
{Role: "user", CtxUpd: true, Content: ctxUpdOpen + ` v="1">projet</context_update>` + "\n\nBonjour <b> & Cie"},
|
|
{Role: "user", Content: ctxUpdOpen + ` v="2">tapé</context_update> par l'utilisateur`},
|
|
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "c1", Type: "function",
|
|
Function: ToolCallFunc{Name: "bash", Arguments: `{"cmd":"ls"}`}}}},
|
|
{Role: "tool", Content: "a.txt\nb.txt", ToolCallID: "c1"},
|
|
{Role: "assistant", Content: "Voilà.", ReasoningContent: "je liste", ReasoningModel: "m"},
|
|
{Role: "user", Content: thinkNudgeFirst},
|
|
{Role: "user", Content: []any{
|
|
map[string]any{"type": "text", "text": "Regarde"},
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": img, "detail": "low"}},
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://x/y.png"}},
|
|
"brut",
|
|
}},
|
|
{Role: "assistant", Content: "Une image."},
|
|
{Role: "user", Content: "Dernier tour"},
|
|
{Role: "assistant", Content: "Fin."},
|
|
}
|
|
ev := func(seq int, d map[string]any) LogEvent { return LogEvent{Seq: seq, TS: int64(1000 + seq), Delta: d} }
|
|
log := []LogEvent{
|
|
ev(1, map[string]any{"user": "Bonjour <b> & Cie", "files": []any{map[string]any{"name": "rapport.pdf", "size": 10}, map[string]any{"name": ""}, "x"}}),
|
|
ev(2, map[string]any{"reasoning_content": "je "}),
|
|
ev(3, map[string]any{"reasoning_content": "liste\n"}),
|
|
ev(4, map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls *.txt\n[a]", "result": "a.txt\nb.txt\n\n", "done": true}}),
|
|
ev(5, map[string]any{"tool_used": map[string]any{"name": "mem_read", "result": " ", "done": true}}),
|
|
ev(5, map[string]any{"tool_used": map[string]any{"name": "git_`log`", "label": " `--oneline` [main] ", "done": true}}),
|
|
ev(5, map[string]any{"tool_used": map[string]any{"name": "en_cours", "label": "pas fini"}}),
|
|
ev(6, map[string]any{"content": "Voi"}),
|
|
ev(7, map[string]any{"content": "là.\n"}),
|
|
ev(8, map[string]any{"stats": map[string]any{"tok_s": 12.5}}),
|
|
ev(9, map[string]any{"compacted": true}),
|
|
ev(10, map[string]any{"user": "", "files": []any{map[string]any{"name": "photo.png"}}}),
|
|
ev(11, map[string]any{"content": "Une image."}),
|
|
ev(12, map[string]any{"user": "Dernier tour"}),
|
|
ev(13, map[string]any{"tool_used": map[string]any{"name": "web_open", "label": "https://ex.com/a_b*c", "result": "titre"}}),
|
|
ev(14, map[string]any{"content": "Fin."}),
|
|
}
|
|
c := &Conversation{Messages: msgs, Log: log, Seq: 14, CtxUsed: 4321}
|
|
raw, err := json.Marshal(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
type exportCase struct {
|
|
Query string `json:"query"`
|
|
Opts any `json:"opts"`
|
|
Body string `json:"body"`
|
|
Filename string `json:"filename_ext"`
|
|
}
|
|
var exports []exportCase
|
|
for _, q := range []string{
|
|
"", "format=json", "format=md", "format=JSON", "reasoning=0", "reasoning=true&tools=off",
|
|
"tools=0", "tools=0&format=json", "results=0", "results=0&format=json", "reasoning=0&format=json",
|
|
"turns=1", "turns=2&format=json", "turns=9", "turns=0", "turns=-2", "turns=abc", "turns=1&results=0&reasoning=0",
|
|
"tools=on&results=yes", "format=json&format=md", "turns=3&format=json", "turns=4&format=json",
|
|
} {
|
|
vals, _ := url.ParseQuery(q)
|
|
o := exportOptsFromQuery(vals)
|
|
var body []byte
|
|
var ext string
|
|
if o.Format == "json" {
|
|
body, err = c.ExportJSON(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ext = "json"
|
|
} else {
|
|
body = []byte(c.ExportMarkdown(o))
|
|
ext = "md"
|
|
}
|
|
exports = append(exports, exportCase{Query: q, Opts: map[string]any{"format": o.Format, "reasoning": o.Reasoning,
|
|
"tools": o.Tools, "results": o.Results, "turns": o.Turns}, Body: parityExportMask(string(body)), Filename: ext})
|
|
}
|
|
empty := &Conversation{}
|
|
emptyMD := parityExportMask(empty.ExportMarkdown(defaultExportOpts()))
|
|
emptyJSON, _ := empty.ExportJSON(defaultExportOpts())
|
|
|
|
// --- mode et critères, conversation tenue ici ---
|
|
id := convEnsureActive()
|
|
conv.mu.Lock()
|
|
conv.Log = nil
|
|
conv.mu.Unlock()
|
|
run := func(st parityChatXStep) parityChatXStep {
|
|
conv.mu.Lock()
|
|
before := len(conv.Log)
|
|
conv.mu.Unlock()
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body))
|
|
if st.Path == "/api/chat/mode" {
|
|
handleChatMode(rec, req)
|
|
} else {
|
|
handleChatCriteria(rec, req)
|
|
}
|
|
st.Status = rec.Code
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &st.Out)
|
|
st.Mode = getStr(bkChat, modeKey(id))
|
|
var crit any
|
|
if b := getBytes(bkChat, critKey(id)); len(b) > 0 {
|
|
_ = json.Unmarshal(b, &crit)
|
|
}
|
|
st.Crit = crit
|
|
conv.mu.Lock()
|
|
for _, e := range conv.Log[before:] {
|
|
d := map[string]any{}
|
|
for k, v := range e.Delta {
|
|
if k != "ts" {
|
|
d[k] = v
|
|
}
|
|
}
|
|
b, _ := json.Marshal(d)
|
|
var x any
|
|
_ = json.Unmarshal(b, &x)
|
|
st.Deltas = append(st.Deltas, x)
|
|
}
|
|
conv.mu.Unlock()
|
|
return st
|
|
}
|
|
long := make([]string, 0, 25)
|
|
for i := 0; i < 25; i++ {
|
|
long = append(long, `{"text":"c`+string(rune('a'+i))+`"}`)
|
|
}
|
|
var steps []parityChatXStep
|
|
for _, st := range []parityChatXStep{
|
|
{Name: "mode par défaut", Method: "GET", Path: "/api/chat/mode"},
|
|
{Name: "mode code", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"code"}`},
|
|
{Name: "mode relu", Method: "GET", Path: "/api/chat/mode"},
|
|
{Name: "mode inconnu", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"Code"}`},
|
|
{Name: "mode corps illisible", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":`},
|
|
{Name: "mode type faux", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":true}`},
|
|
{Name: "mode en PUT", Method: "PUT", Path: "/api/chat/mode", Body: `{"mode":"chat"}`},
|
|
{Name: "critères en GET", Method: "GET", Path: "/api/chat/criteria"},
|
|
{Name: "critères", Method: "POST", Path: "/api/chat/criteria",
|
|
Body: `{"criteria":[{"text":"compile"},{"id":5,"text":"tests <ok> & verts","status":"passed","note":"vu"},{"text":" "},{"id":0,"text":"doc","status":"bizarre"},{"id":2,"text":"lint","status":"failed"},{"text":"perf","status":"completed"}]}`},
|
|
{Name: "mode relu avec critères", Method: "GET", Path: "/api/chat/mode"},
|
|
{Name: "critères au-delà de vingt", Method: "POST", Path: "/api/chat/criteria",
|
|
Body: `{"criteria":[` + strings.Join(long, ",") + `]}`},
|
|
{Name: "critères vidés", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[]}`},
|
|
{Name: "critères absents", Method: "POST", Path: "/api/chat/criteria", Body: `{}`},
|
|
{Name: "critères null", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":null}`},
|
|
{Name: "critères corps vide", Method: "POST", Path: "/api/chat/criteria", Body: ``},
|
|
{Name: "critères illisibles", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[`},
|
|
{Name: "critère id texte", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"id":"2","text":"x"}]}`},
|
|
{Name: "critère texte nombre", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"text":3}]}`},
|
|
{Name: "critères pas une liste", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":"a"}`},
|
|
{Name: "critère pas un objet", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":["a",{"text":"b"}]}`},
|
|
{Name: "critère id décimal", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"id":1.5,"text":"x"}]}`},
|
|
{Name: "critère clés en majuscules", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"ID":3,"TEXT":"maj","Status":"passed"},null]}`},
|
|
{Name: "mode chat", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"chat"}`},
|
|
} {
|
|
steps = append(steps, run(st))
|
|
}
|
|
|
|
got, err := parityJSON(map[string]any{
|
|
"conversation": json.RawMessage(raw), "exports": exports,
|
|
"empty_md": emptyMD, "empty_json": parityExportMask(string(emptyJSON)), "steps": steps,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
|
if err := os.MkdirAll(filepath.Dir(parityChatXFile), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(parityChatXFile, got, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
want, err := os.ReadFile(parityChatXFile)
|
|
if err != nil {
|
|
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Errorf("l'export, le mode ou les critères ne produisent plus le journal rangé dans %s.\n"+
|
|
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteChatX,\n"+
|
|
"puis cargo test --test chatx dit si le port Rust suit.\n--- obtenu ---\n%s", parityChatXFile, got)
|
|
}
|
|
}
|