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" + "![capture de exemple.com](/api/chat/image?path=captures/c1/x.jpg)" 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) } }