Files
Loki/internal/ajean/chat_compact_test.go
T
nathaninline 110458c133 Renommage AJEAN (1/3) : module, chemins de donnees et auto-MAJ
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc
installe. Trois briques :

- module github.com/nathaninline/ajean, cmd/ajean, internal/ajean,
  package ajean. Purement interne.

- AjeanHome() remplace JeanHome(). Les defauts deviennent
  %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant
  au premier lancement. La migration est un os.Rename dans le dossier
  parent de l'ancien chemin : intra-volume, donc atomique et instantane
  meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue
  (handle ouvert sous Windows, droits, volume en lecture seule) on
  CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais
  de suppression, le pire cas est "rien n'a bouge".
  $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que
  /etc/default/ajean et /etc/default/jean : un chemin impose a la main
  est un ordre, on ne le migre pas.

- l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>,
  dans cet ordre. Indispensable dans les deux sens : les binaires deja
  installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir
  s'installer depuis une release anterieure au renommage.

Couvert par des tests, dont le repli quand un handle ouvert bloque le
rename sous Windows.
2026-08-04 13:59:25 +02:00

111 lines
4.3 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")
}
}
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)
}
}