Files
Loki/internal/jean/chat_compact_test.go
T
nathaninline a727394115 v0.6.6 : le compactage automatique du contexte marche vraiment
Le compactage ne se declenchait qu'au DEBUT d'un tour, avec le contexte du
tour precedent. Le tour qui faisait franchir le seuil se terminait donc a
81% sans rien declencher : la jauge restait haute, seul le bouton manuel
apparaissait, et le compactage automatique semblait mort.

- seuil reteste a trois moments au lieu d'un : debut de tour (inchange),
  PENDANT la boucle d'outils, et EN FIN de tour des que le seuil est franchi
- une compaction faite en cours de tour etait perdue : l'appelant
  reconstruisait l'historique en ajoutant les messages du tour a sa copie
  d'AVANT compaction. Le fil complet revenait, la jauge retombait a 43% puis
  explosait a 92% au message suivant. Elle publie desormais sa nouvelle vue
  (StreamEvent.NewHistory), qui REMPLACE l'historique. Meme correction pour
  le filet de secours sur erreur 500 et pour le client terminal.
- la queue protegee reculait jusqu'au message `user` precedent. Pendant une
  longue recherche il n'y en a aucun : la queue avalait toute la sequence
  d'outils, torse vide, compaction sans effet — les pages web qui remplissent
  la fenetre etaient justement celles qui etaient protegees. Elle peut
  maintenant demarrer sur un `assistant`, frontiere tout aussi sure (il part
  avec ses resultats d'outils). Test de regression ajoute.
- une compaction sans effet ne disait plus rien : toast explicite + une ligne
  [compact] par decision dans le journal (ctx, seuil, avant/apres, changé)

UI : la mini-banniere au-dessus de la saisie doublonnait les trois points
animes de l'IA. L'indicateur existant est desormais etiquete « compactage du
contexte… » en accent, et une marque discrete reste DANS le fil a l'endroit
de la coupure — elle survit au rafraichissement, contrairement au toast.
2026-07-30 22:17:40 +02:00

111 lines
4.2 KiB
Go

package jean
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")
}
}
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)
}
}