Files
Loki/internal/ajean/chat_stuck_test.go
T
nathaninline acf11ff66a v0.8.3 : le stop arrete vraiment, le chat ne se bloque plus, raisonnement off = off
Bug 1 (chat bloque, remonte par Nathan) : trois causes cumulees.
- runShell naissait d'un contexte independant du tour : stop n'arretait rien.
  La commande herite desormais du contexte du tour, et la boucle d'outils
  s'interrompt des que l'arret est demande.
- cmd.WaitDelay : une commande laissant un process detache gardait les tubes
  ouverts, donc Wait ne revenait JAMAIS (ni delai, ni stop).
- Reset debloque toujours l'etat de generation : « nouvelle conversation » etait
  le geste tente quand le chat coince, il ne servait a rien. Un tour perime ne
  libere plus le tour courant.
Trouve en testant : le workspace disparu cassait TOUTES les commandes suivantes
(chdir: no such file or directory) jusqu'au redemarrage ; il est recree au besoin.

Bug 2 (raisonnement, remonte par Nathan) : l'UI ecrivait « off » en EFFACANT la
ligne REASONING, or sans drapeau le moteur suit le gabarit du modele, qui
raisonne. L'UI ecrit maintenant on|off et « off » part en --reasoning off, apres
avoir demande au binaire s'il connait le drapeau (ik_llama.cpp ne le connait pas
et refuserait de demarrer). Presets existants inchanges ; l'editeur dit desormais
« non precise : le modele decide » quand la cle est absente.
2026-08-08 11:20:22 +02:00

113 lines
3.5 KiB
Go

package ajean
import (
"context"
"os"
"runtime"
"strings"
"testing"
"time"
)
// Le bouton stop doit VRAIMENT arrêter une commande shell. Elle naissait d'un
// contexte indépendant du tour : arrêter la génération ne l'arrêtait pas, et le
// tour restait suspendu jusqu'à l'expiration du délai (5 min au maximum).
func TestRunShellObeitAuStop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("commande de veille propre à Unix")
}
testHome(t)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
cancel()
}()
start := time.Now()
out := runShell(ctx, "sleep 30", 300)
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("la commande a survécu à l'annulation (%s) : %q", d, out)
}
if !strings.Contains(out, "interrompue") {
t.Errorf("résultat attendu « interrompue », reçu %q", out)
}
}
// Une commande qui laisse un process en arrière-plan garde les tubes de sortie
// ouverts. Sans WaitDelay, Wait attend leur fermeture — donc pour toujours — et
// le tour ne se terminait JAMAIS (seul un redémarrage du service débloquait).
func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("syntaxe de détachement propre à Unix")
}
testHome(t)
start := time.Now()
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
if d := time.Since(start); d > 10*time.Second {
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
}
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
t.Errorf("sortie inattendue : %q", out)
}
}
// Le dossier de travail est résolu une fois par process : s'il disparaît, plus
// AUCUNE commande ne passait (« chdir : no such file or directory ») jusqu'au
// redémarrage du service. Il doit être recréé au besoin.
func TestRunShellRecreeLeWorkspaceDisparu(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("echo se comporte différemment sous cmd.exe")
}
testHome(t)
ws := agentWorkspace()
if ws == "" {
t.Skip("pas de workspace résolu")
}
if err := os.RemoveAll(ws); err != nil {
t.Fatal(err)
}
out := runShell(context.Background(), "echo vivant", 10)
if !strings.Contains(out, "vivant") {
t.Fatalf("commande cassée par la disparition du workspace : %q", out)
}
}
// Reset doit TOUJOURS rendre la main, même sur un tour resté coincé : c'est le
// geste que l'on tente quand le chat est bloqué. Avant, Generating restait vrai
// et tout envoi suivant était refusé « génération en cours » jusqu'au
// redémarrage du service.
func TestResetDebloqueUnTourCoince(t *testing.T) {
c := newTestConv()
// Simule un tour parti et jamais terminé (commande accrochée, moteur disparu).
c.mu.Lock()
c.Generating = true
c.mu.Unlock()
c.Reset()
if st := c.state(); st["generating"] != false {
t.Fatal("après Reset, la conversation doit être déclarée libre")
}
}
// Le tour abandonné se termine parfois APRÈS le démarrage du suivant. Sa fin ne
// doit pas déclarer « libre » une génération toute neuve.
func TestTourPerimeNeLiberePasLeTourCourant(t *testing.T) {
c := newTestConv()
oldEpoch := c.epoch
c.Reset() // le tour d'epoch `oldEpoch` devient périmé
c.mu.Lock()
c.Generating = true // un nouveau tour démarre
c.mu.Unlock()
// Fin (tardive) du tour périmé : même code que le defer de generate.
c.mu.Lock()
if c.epoch == oldEpoch {
c.Generating = false
}
c.mu.Unlock()
if st := c.state(); st["generating"] != true {
t.Fatal("un tour périmé a libéré le tour courant")
}
}