Files
Loki/internal/ajean/chat_compact_test.go
T
nathaninline 9dedba0e47 v0.7.7 : la recherche internet ne tourne plus en rond
Le compactage du contexte detruisait ce dont l'IA avait besoin pour continuer
une recherche, ce qui la faisait boucler ou repartir de zero.

- Compactage : le resume est desormais fabrique a partir du torse ORIGINAL. On
  degraissait les resultats d'outils (page web -> marqueur) AVANT de resumer :
  le resumeur ne voyait plus aucune information trouvee, donc le resume n'en
  contenait aucune et l'IA relancait les memes recherches sans fin. Les
  resultats sont seulement raccourcis (1200 car.) pour le resumeur ; le torse
  degraisse ne sert plus que de repli si le resume echoue.
- Compactage : la demande EN COURS est preservee telle quelle. Pendant une
  boucle d'outils il n'y a aucun message user recent, elle tombait donc dans le
  torse et se dissolvait dans le resume, tandis que le TOUT PREMIER message de
  la conversation restait epingle en tete : l'IA repondait a la question du
  debut et abandonnait la recherche.
- Compactage : prompt du resumeur refait (findings, sources deja consultees,
  etat d'avancement) et plafond du resume porte de 1500 a 2200 caracteres.
- Appels repetes : l'avertissement passe EN TETE du resultat et, des la 2e
  redemande identique, le contenu n'est plus renvoye. Colle a la fin d'un
  resultat de plusieurs milliers de caracteres, il passait inapercu.
- Marqueur d'effacement du compactage : dit explicitement de ne PAS rappeler
  l'outil (l'ancien texte se lisait comme une invitation a re-telecharger).
- Outils web : sortie bornee a 8000 caracteres comme le shell. Un
  web_read(limit=500) pouvait injecter 25000 caracteres d'un coup et declencher
  les compactages en cascade.
- Bulles d'outils : la borne d'affichage passe de 4000 a 12000 caracteres, donc
  l'etiquette « ~N tok » cesse d'afficher toujours ~1004 (la valeur du plafond)
  et donne la vraie taille. Le chemin « appel identique non rejoue » envoyait
  lui le resultat brut sans troncature.
2026-08-04 22:28:19 +02:00

173 lines
6.7 KiB
Go

package ajean
import (
"strings"
"testing"
)
// helpers pour construire des historiques de test lisibles.
func um(s string) Message { return Message{Role: "user", Content: s} }
func am(s string) Message { return Message{Role: "assistant", Content: s} }
func atc(name string) Message {
return Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}}
}
func tm(s string) Message { return Message{Role: "tool", ToolCallID: "c1", Content: s} }
func TestCompactBoundsProtectsHead(t *testing.T) {
msgs := []Message{
{Role: "system", Content: "sys"},
um("premier"), am("r1"),
um("q2"), am("r2"),
um("q3"), am("r3"),
}
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
head, tail := compactBounds(msgs, 1)
if head != 2 { // system + premier user
t.Fatalf("head = %d, attendu 2", head)
}
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
if r := msgs[tail].Role; r != "user" && r != "assistant" {
t.Fatalf("la queue doit démarrer sur un user ou un assistant, obtenu %q", r)
}
if tail <= head {
t.Fatalf("torse vide (tail=%d head=%d) alors qu'il y a du milieu à compacter", tail, head)
}
}
// La queue ne doit jamais démarrer entre un assistant+tool_calls et ses
// résultats `tool` : elle recule jusqu'à la frontière sûre précédente (user ou
// assistant), donc l'assistant part dans la queue AVEC ses résultats.
func TestCompactBoundsKeepsToolPairs(t *testing.T) {
msgs := []Message{
um("q1"), am("r1"),
um("q2"), atc("bash"), tm("sortie longue"), am("r2"),
um("q3"), am("r3"),
}
// budget moyen qui, sans le recul, couperait au milieu du tour outillé.
_, tail := compactBounds(msgs, msgTokens(msgs[6])+msgTokens(msgs[7])+msgTokens(msgs[5])+1)
if r := msgs[tail].Role; r != "user" && r != "assistant" {
t.Fatalf("la queue démarre sur %q : frontière non sûre (orphelin tool possible)", r)
}
// Vérifie qu'aucun message `tool` de la queue n'a perdu son assistant parent.
for i := tail; i < len(msgs); i++ {
if msgs[i].Role == "tool" {
if i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool") {
t.Fatalf("message tool orphelin à l'index %d de la queue", i)
}
}
}
}
// Régression : DEUXIÈME compaction à l'intérieur d'un même tour. Une longue
// boucle d'outils (recherche web : dix pages lues d'affilée) ne contient AUCUN
// message `user` — reculer jusqu'à un `user` faisait donc avaler toute la
// séquence par la queue, torse vide, compaction sans effet. La queue doit
// pouvoir démarrer sur un `assistant`, et les gros résultats d'outils du début
// doivent se retrouver dans le torse (donc résumés).
func TestCompactBoundsSplitsLongToolLoop(t *testing.T) {
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("premier"), am("ok"), // head : 1er user protégé
um("cherche des trucs"), // la dernière demande utilisateur du tour
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
head, tail := compactBounds(msgs, estimateTokens(msgs)/4)
if tail <= head {
t.Fatalf("torse vide (tail=%d head=%d) : la boucle d'outils n'est pas compactable", tail, head)
}
if r := msgs[tail].Role; r != "user" && r != "assistant" {
t.Fatalf("la queue démarre sur %q : frontière non sûre", r)
}
// Aucun `tool` orphelin en queue.
for i := tail; i < len(msgs); i++ {
if msgs[i].Role == "tool" && (i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool")) {
t.Fatalf("message tool orphelin à l'index %d de la queue", i)
}
}
// Le torse doit bien contenir des résultats d'outils (c'est ce qui remplit
// la fenêtre) — sinon la compaction ne libérerait rien.
tools := 0
for _, m := range msgs[head:tail] {
if m.Role == "tool" {
tools++
}
}
if tools == 0 {
t.Fatal("aucun résultat d'outil dans le torse : rien à gagner à compacter")
}
}
// Régression : après compaction pendant une recherche web, la DEMANDE EN COURS
// doit encore figurer telle quelle dans l'historique. Sans ça, le seul message
// `user` restant était le tout premier de la conversation (épinglé en tête) et
// le modèle répondait à celui-là au lieu de continuer la recherche.
func TestCompactKeepsPendingRequest(t *testing.T) {
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("première question de la conversation"), am("ok"),
um("cherche les horaires du train pour Lyon"),
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
// summarizeTranscript échoue (pas de llama-server en test) → torse dégraissé,
// ce qui n'enlève rien à ce qu'on vérifie ici : la demande doit survivre.
out, _ := compactMessages(t.Context(), msgs, Caps{})
found := false
for _, m := range out {
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
found = true
}
}
if !found {
t.Fatal("la demande en cours a disparu de l'historique compacté")
}
}
// Régression : ce qu'on envoie au RÉSUMEUR doit encore contenir les résultats
// d'outils. On effaçait les résultats (dégraissage) AVANT de résumer : le
// résumeur ne voyait que des marqueurs, le résumé ne pouvait donc porter aucune
// information trouvée, et l'IA relançait la même recherche après chaque
// compactage — sans jamais s'arrêter.
func TestSummaryInputKeepsToolFindings(t *testing.T) {
fait := "le train de 14h12 part quai 3"
torso := []Message{
atc("web_read"),
tm(fait + strings.Repeat(" blabla de remplissage", 300)),
}
// Même transformation que compactMessages avant l'appel au résumeur.
forSummary := make([]Message, len(torso))
for i, m := range torso {
forSummary[i] = m
if m.Role == "tool" {
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
}
}
}
tr := renderTranscript(forSummary)
if !strings.Contains(tr, fait) {
t.Fatal("le fait trouvé n'atteint pas le résumeur : le résumé sera vide d'information")
}
if strings.Contains(tr, compactPrunedMarker) {
t.Fatal("le résumeur reçoit des marqueurs d'effacement au lieu du contenu")
}
if len([]rune(tr)) > 4000 {
t.Fatalf("transcription non bornée (%d runes) : le résumeur va déborder", len([]rune(tr)))
}
}
func TestEstimateTokensGrows(t *testing.T) {
small := estimateTokens([]Message{um("court")})
big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")})
if big <= small {
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
}
}