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) } }