mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
- Au démarrage, une lecture ratée de la base (verrou bbolt transitoire pendant le chevauchement des process au redémarrage du conteneur) était confondue avec une conversation absente. Pire que l'amont chez nous : getStr rend "" dans les deux cas, donc convEnsureActive forgeait un NOUVEL identifiant et l'écrasait — le fil en cours devenait orphelin, en silence. On sonde désormais la base avec son erreur AVANT toute écriture, on réessaie quatre fois, et un échec durable est journalisé sans que rien ne soit touché. - Une capture d'écran disparaissait sans laisser de trace : stripImageParts aplatissait le message en ne gardant que sa légende. Le marqueur imageLostMarker rend la perte VISIBLE, pour que le modèle sache reprendre une capture au lieu de la redécrire de mémoire. - maxLogEvents 20000 → 200000 : un seul tour à très long raisonnement tronquait déjà le journal de rejeu, et l'utilisateur perdait le début de sa conversation à l'écran. - Keepalive WebSocket des postes distants (ping toutes les 25 s, des DEUX côtés). Sans lui, un poste au repos était coupé au bout de ~60-100 s par les intermédiaires qui ferment les canaux inactifs, puis reconnecté après backoff — les déconnexions à répétition sur tous les postes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.7 KiB
Go
222 lines
8.7 KiB
Go
package loki
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Le dossier de captures ne doit pas croître sans fin — mais la capture qui
|
|
// vient d'être prise doit survivre au ménage, même seule et plus lourde que le
|
|
// plafond : sinon le modèle renvoie à l'utilisateur un lien vers un fichier que
|
|
// pruneCaptures a effacé dans la foulée.
|
|
func TestPruneCapturesGardeLaDerniere(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// 25 captures d'âges croissants, au-delà du plafond en NOMBRE.
|
|
var last string
|
|
for i := 0; i < 25; i++ {
|
|
p := filepath.Join(dir, fmt.Sprintf("shot-%02d.jpg", i))
|
|
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// mtime croissant : shot-00 est la plus ancienne.
|
|
mt := time.Now().Add(time.Duration(i-25) * time.Minute)
|
|
if err := os.Chtimes(p, mt, mt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last = p
|
|
}
|
|
pruneCaptures(dir, last)
|
|
|
|
ents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ents) != maxCaptureFiles {
|
|
t.Fatalf("%d fichiers restants, attendu %d", len(ents), maxCaptureFiles)
|
|
}
|
|
if _, err := os.Stat(last); err != nil {
|
|
t.Fatalf("la capture la plus récente a été supprimée : %v", err)
|
|
}
|
|
// Les plus anciennes doivent être parties, pas les récentes.
|
|
if _, err := os.Stat(filepath.Join(dir, "shot-00.jpg")); err == nil {
|
|
t.Fatal("la capture la plus ancienne aurait dû être supprimée")
|
|
}
|
|
}
|
|
|
|
// Supprimer une discussion doit emporter ses captures : sans ça, des images que
|
|
// plus aucun message n'affiche restent sur le disque pour toujours. Et elle ne
|
|
// doit emporter QUE les siennes.
|
|
func TestConvDeleteSupprimeLesCaptures(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
a := convEnsureActive()
|
|
b := convNew() // b devient active, a reste
|
|
|
|
shot := func(id string) string {
|
|
dir, _ := captureDirFor(id)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := filepath.Join(dir, "vue.jpg")
|
|
if err := os.WriteFile(p, []byte("jpeg"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
shotA, shotB := shot(a), shot(b)
|
|
|
|
if err := convDelete(a); err != nil {
|
|
t.Fatalf("suppression de la discussion : %v", err)
|
|
}
|
|
if _, err := os.Stat(shotA); err == nil {
|
|
t.Fatal("la capture de la discussion supprimée est toujours là")
|
|
}
|
|
if _, err := os.Stat(shotB); err != nil {
|
|
t.Fatalf("la capture d'une AUTRE discussion a été supprimée : %v", err)
|
|
}
|
|
}
|
|
|
|
// Le modèle annonçait « je ne vois pas l'image » alors que la vision était
|
|
// activée : sa description d'outil le lui disait, et la capture ne lui était
|
|
// jamais transmise. Les deux doivent suivre l'état réel du projecteur.
|
|
func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
// Sans projecteur, la sonde moteur ne doit même pas être consultée : on
|
|
// s'assure que son cache est froid pour que le test reste hermétique.
|
|
visionProbeMu.Lock()
|
|
visionProbeAt, visionProbeSeen = time.Now(), false
|
|
visionProbeMu.Unlock()
|
|
if got := screenshotVisionNote(); !strings.Contains(got, "ne vois pas") {
|
|
t.Fatalf("sans projecteur, la description doit annoncer l'absence de vision : %q", got)
|
|
}
|
|
// Une capture existe, mais sans projecteur elle ne doit PAS partir au modèle :
|
|
// llama-server rejette un contenu image sans --mmproj.
|
|
dir, rel := captureDirFor(convEnsureActive())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shot := filepath.Join(dir, "vue.jpg")
|
|
if err := os.WriteFile(shot, []byte("\xff\xd8\xff jpeg"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
relPath := rel + "/vue.jpg"
|
|
if _, ok := screenshotImageMessage(relPath); ok {
|
|
t.Fatal("image transmise au modèle alors qu'aucun projecteur n'est configuré")
|
|
}
|
|
|
|
// Projecteur configuré ET moteur qui déclare la vision (on amorce le cache
|
|
// de la sonde : aucun llama-server ne tourne pendant les tests) →
|
|
// description ET transmission changent.
|
|
if err := SetConfigKey("MMPROJ", "mmproj-test.gguf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
visionProbeMu.Lock()
|
|
visionProbeAt, visionProbeSeen = time.Now(), true
|
|
visionProbeMu.Unlock()
|
|
defer func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() }()
|
|
if got := screenshotVisionNote(); strings.Contains(got, "ne vois pas") {
|
|
t.Fatalf("avec projecteur, la description ne doit plus nier la vision : %q", got)
|
|
}
|
|
msg, ok := screenshotImageMessage(relPath)
|
|
if !ok {
|
|
t.Fatal("image non transmise alors que le projecteur est configuré")
|
|
}
|
|
if msg.Role != "user" {
|
|
t.Fatalf("role %q : l'image doit voyager dans un message user, un message tool ne porte que du texte", msg.Role)
|
|
}
|
|
parts, _ := msg.Content.([]map[string]any)
|
|
if len(parts) != 2 || parts[1]["type"] != "image_url" {
|
|
t.Fatalf("contenu multimodal attendu (text + image_url), obtenu %#v", msg.Content)
|
|
}
|
|
}
|
|
|
|
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
|
|
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
|
|
// tour ne passait. stripImageParts guérit les conversations existantes en
|
|
// retirant les parties image et en aplatissant le texte restant — en laissant
|
|
// imageLostMarker, pour que le modèle sache qu'une image A ÉTÉ montrée et qu'il
|
|
// doit en reprendre une plutôt que la redécrire de mémoire.
|
|
func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
|
msgs := []Message{
|
|
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
|
|
{Role: "user", Content: []any{ // message multimodal persisté (via JSON)
|
|
map[string]any{"type": "text", "text": "Voici la capture demandée."},
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
|
}},
|
|
}
|
|
out := stripImageParts(msgs)
|
|
if out[0].Content.(string) != "bonjour" {
|
|
t.Fatalf("message texte modifié : %#v", out[0].Content)
|
|
}
|
|
got, ok := out[1].Content.(string)
|
|
if !ok || got != "Voici la capture demandée."+imageLostMarker {
|
|
t.Fatalf("le message multimodal doit être aplati en texte, sans l'image mais avec le marqueur de perte, obtenu %#v", out[1].Content)
|
|
}
|
|
if strings.Contains(got, "base64") {
|
|
t.Fatalf("le base64 de l'image ne doit plus apparaître : %q", got)
|
|
}
|
|
}
|
|
|
|
// msgText est le point de passage de TOUTE la chaîne de compaction (estimation
|
|
// du contexte, transcript donné au résumeur). S'il n'extrait que les parties
|
|
// `text`, un message qui porte une capture ne pèse que sa légende et le résumeur
|
|
// ignore jusqu'à l'existence de l'image : elle disparaît sans laisser de trace.
|
|
func TestMsgTextSignaleLaPresenceDUneImage(t *testing.T) {
|
|
// Message VIVANT tel que construit par screenshotImageMessage ([]map[string]any).
|
|
live := Message{Role: "user", Content: []map[string]any{
|
|
{"type": "text", "text": "Capture demandée :"},
|
|
{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
|
}}
|
|
// Même message RELU depuis le JSON persisté ([]any de map génériques).
|
|
reloaded := Message{Role: "user", Content: []any{
|
|
map[string]any{"type": "text", "text": "Capture demandée :"},
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
|
}}
|
|
for name, m := range map[string]Message{"vivant": live, "relu": reloaded} {
|
|
got := msgText(m)
|
|
if !strings.Contains(got, "Capture demandée :") {
|
|
t.Fatalf("%s : le texte de la légende doit être conservé, obtenu %q", name, got)
|
|
}
|
|
if !strings.Contains(got, imageLostMarker) {
|
|
t.Fatalf("%s : la présence de l'image doit être signalée, obtenu %q", name, got)
|
|
}
|
|
if strings.Contains(got, "base64") {
|
|
t.Fatalf("%s : le base64 ne doit jamais entrer dans le transcript : %q", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le chemin de la capture est extrait du texte rendu par l'outil : si le format
|
|
// de ce texte change, le relais vers le modèle casse en silence.
|
|
func TestCapturedRelPath(t *testing.T) {
|
|
res := "Capture enregistrée (captures/c1/x.jpg, 42 Ko).\n" +
|
|
"Pour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n" +
|
|
""
|
|
if got := capturedRelPath(res); got != "captures/c1/x.jpg" {
|
|
t.Fatalf("chemin extrait %q", got)
|
|
}
|
|
if got := capturedRelPath("[erreur] capture impossible"); got != "" {
|
|
t.Fatalf("une erreur ne doit produire aucun chemin, obtenu %q", got)
|
|
}
|
|
}
|
|
|
|
// Une capture unique plus lourde que le plafond en OCTETS ne doit pas s'effacer
|
|
// elle-même : il ne resterait alors rien à montrer.
|
|
func TestPruneCapturesNEffacePasUneCaptureUniqueTropLourde(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "enorme.jpg")
|
|
if err := os.WriteFile(p, make([]byte, maxCaptureBytes+1), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pruneCaptures(dir, p)
|
|
if _, err := os.Stat(p); err != nil {
|
|
t.Fatalf("la capture courante a été supprimée : %v", err)
|
|
}
|
|
}
|