From 2e0356cd6c1ac760fe90603ef0008c8aab63d0fb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:34:42 +0200 Subject: [PATCH] =?UTF-8?q?Terminal=20:=20le=20vrai=20code=20de=20sortie,?= =?UTF-8?q?=20la=20sortie=20gard=C3=A9e=20au=20d=C3=A9lai,=20sans=20ANSI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris de shell.ts / shell-tail.ts et diagnostics.ts d'OpenFox (2.0.160). - « cmd | tail -N » : le code de sortie devenait celui de tail (0) et un build en échec passait pour réussi. Le tail est retiré de la commande et Loki garde lui-même les N dernières lignes : même sortie, vrai code. - Délai dépassé : la sortie déjà produite est rendue (où en était le build, quel test bloquait) au lieu d'un « [timeout] » sec, avec le conseil de passer par bash_bg pour un serveur. - Couleurs et séquences ANSI retirées : du bruit en tokens. - La durée suit le code de sortie (« exit: 0 · 1.2s »). - Mode Code : « cmd & » refusé, avec renvoi vers bash_bg — le process orphelin n'avait ni sortie lisible ni moyen d'être arrêté. - Diagnostics LSP : erreurs d'abord, avec le compte total ; au-delà de la limite, des indices de style passaient devant l'erreur de compilation. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_shell_hygiene.go | 60 ++++++++++++++++++++++++ internal/loki/chat_shell_hygiene_test.go | 58 +++++++++++++++++++++++ internal/loki/chat_tools.go | 29 ++++++++++-- internal/loki/llm_client.go | 6 +++ internal/loki/lsp.go | 26 ++++++++++ 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 internal/loki/chat_shell_hygiene.go create mode 100644 internal/loki/chat_shell_hygiene_test.go diff --git a/internal/loki/chat_shell_hygiene.go b/internal/loki/chat_shell_hygiene.go new file mode 100644 index 0000000..e7ea424 --- /dev/null +++ b/internal/loki/chat_shell_hygiene.go @@ -0,0 +1,60 @@ +package loki + +// chat_shell_hygiene.go — ce que le terminal rend au modèle, nettoyé (repris de +// shell.ts / shell-tail.ts d'OpenFox, 2.0.160) : +// +// - « cmd | tail -N » : le code de sortie devenait celui de tail (0), et un +// build en échec passait pour réussi. On retire le tail de la commande et +// on garde nous-mêmes les N dernières lignes : même sortie, vrai code. +// - séquences ANSI (couleurs, curseur) : du bruit en tokens, illisible ; +// - « cmd & » en mode Code : le process orphelin n'a ni sortie ni arrêt — +// bash_bg est fait pour ça. + +import ( + "regexp" + "strconv" + "strings" +) + +// tailPipeRe : « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en +// toute fin de commande. +var tailPipeRe = regexp.MustCompile(`\s*\|\s*tail(?:\s+-n\s*(\d+)|\s+-(\d+))?\s*$`) + +// splitTailPipe retire un « | tail -N » final. Renvoie la commande sans lui et +// N (0 = pas de tail ; 10 = tail sans nombre, sa valeur par défaut). +func splitTailPipe(command string) (string, int) { + m := tailPipeRe.FindStringSubmatchIndex(command) + if m == nil { + return command, 0 + } + n := 10 + for _, g := range []int{2, 4} { + if m[g] >= 0 { + if v, err := strconv.Atoi(command[m[g]:m[g+1]]); err == nil && v > 0 { + n = v + } + } + } + rest := strings.TrimSpace(command[:m[0]]) + if rest == "" { + return command, 0 + } + return rest, n +} + +// shellANSIRe : séquences CSI (couleurs, curseur) et OSC (titres de fenêtre). +var shellANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +func stripANSI(s string) string { + if !strings.Contains(s, "\x1b") { + return s + } + return shellANSIRe.ReplaceAllString(s, "") +} + +// trailingBackground : la commande se termine par un « & » de mise en +// arrière-plan (pas « && »). +func trailingBackground(command string) bool { + c := strings.TrimSpace(command) + return strings.HasSuffix(c, "&") && !strings.HasSuffix(c, "&&") +} diff --git a/internal/loki/chat_shell_hygiene_test.go b/internal/loki/chat_shell_hygiene_test.go new file mode 100644 index 0000000..1836b2d --- /dev/null +++ b/internal/loki/chat_shell_hygiene_test.go @@ -0,0 +1,58 @@ +package loki + +import ( + "context" + "runtime" + "strings" + "testing" +) + +func TestSplitTailPipe(t *testing.T) { + cas := map[string]struct { + cmd string + n int + }{ + "go build ./... 2>&1 | tail -20": {"go build ./... 2>&1", 20}, + "make | tail -n 5": {"make", 5}, + "make|tail -n40": {"make", 40}, + "go test | tail": {"go test", 10}, + "tail -f log.txt": {"tail -f log.txt", 0}, + "cat a | tail -5 | grep x": {"cat a | tail -5 | grep x", 0}, + "echo ok": {"echo ok", 0}, + } + for in, want := range cas { + if cmd, n := splitTailPipe(in); cmd != want.cmd || n != want.n { + t.Errorf("%q → (%q, %d), attendu (%q, %d)", in, cmd, n, want.cmd, want.n) + } + } + if !trailingBackground("./serveur &") || trailingBackground("make && make test") || trailingBackground("ls 2>&1") { + t.Error("trailingBackground se trompe") + } + if got := stripANSI("\x1b[1;31mFAIL\x1b[0m ok"); got != "FAIL ok" { + t.Errorf("stripANSI = %q", got) + } +} + +// « cmd | tail -N » rend le VRAI code de sortie, et seulement N lignes. +func TestShellTailGardeLeCodeDeSortie(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell POSIX") + } + withWorkspace(t) + out := runShell(context.Background(), "sh -c 'printf \"l1\nl2\nl3\n\"; exit 2' | tail -n 2", 10) + if !strings.HasPrefix(out, "exit: 2") || strings.Contains(out, "l1") || !strings.Contains(out, "l3") { + t.Fatalf("sortie = %q", out) + } +} + +// Au délai dépassé, la sortie déjà produite est rendue. +func TestShellTimeoutGardeLaSortie(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell POSIX") + } + withWorkspace(t) + out := runShell(context.Background(), "echo avant; sleep 5", 1) + if !strings.Contains(out, "timeout") || !strings.Contains(out, "avant") { + t.Fatalf("sortie = %q", out) + } +} diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index 1e1aca9..7ad339e 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -224,6 +224,10 @@ func runShell(parent context.Context, command string, timeoutSec int) string { if timeoutSec > toolMaxTimeout { timeoutSec = toolMaxTimeout } + // « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes + // (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail. + command, keepLines := splitTailPipe(command) + start := time.Now() ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second) defer cancel() cmd := newShellCmd(ctx, command) @@ -254,9 +258,27 @@ func runShell(parent context.Context, command string, timeoutSec int) string { // issue connue était de redémarrer loki-ui. cmd.WaitDelay = 2 * time.Second err := cmd.Run() + // Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande + // demandait un tail. + clean := func(s string) string { return tailOutput(stripANSI(s)) } + out := stdout.String() + if keepLines > 0 { + out = tailLines(out, keepLines) + } + out = clean(out) + errOut := clean(stderr.String()) switch { case errors.Is(ctx.Err(), context.DeadlineExceeded): - return fmt.Sprintf("[timeout après %ds]", timeoutSec) + // La sortie déjà produite reste utile (où le build en était, quel test + // bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox). + msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec) + if out != "" { + msg += "\n\nstdout (partiel):\n" + out + } + if errOut != "" { + msg += "\n\nstderr (partiel):\n" + errOut + } + return msg case errors.Is(parent.Err(), context.Canceled): return "[commande interrompue]" } @@ -268,9 +290,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string { return fmt.Sprintf("[erreur: %v]", err) } } - out := tailOutput(stdout.String()) - errOut := tailOutput(stderr.String()) - parts := []string{fmt.Sprintf("exit: %d", exit)} + // « exit: N » en tête : tasks_script.go s'y fie. La durée suit. + parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))} if out != "" { parts = append(parts, "stdout:\n"+out) } diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index a92cd51..e1ae97e 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1728,6 +1728,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = refusedCommandResult(reason) break } + // Mode Code : « cmd & » laisse un process orphelin, sans sortie + // lisible ni moyen de l'arrêter — bash_bg est fait pour ça. + if caps.Code && trailingBackground(label) { + result = "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash." + break + } if tgt := agentTargetSlug(); tgt != "" { // Cible = un poste distant : la commande s'exécute LÀ-BAS via le // canal du poste (fail-closed : nodeCall renvoie une erreur si le diff --git a/internal/loki/lsp.go b/internal/loki/lsp.go index 067b50f..a90b0d2 100644 --- a/internal/loki/lsp.go +++ b/internal/loki/lsp.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "sync" "time" @@ -145,10 +146,35 @@ func lspDiagnosticsFor(absPath string) string { if len(diags) == 0 { return "" } + // Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà + // de lspMaxDiags on tronque, et des indices de style passaient devant + // l'erreur de compilation qui comptait. Le compte annonce le total. + rank := func(s int) int { + if s <= 0 { + return 1 // sévérité absente : traitée comme une erreur + } + return s + } + sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) }) + nErr, nWarn := 0, 0 + for _, d := range diags { + switch rank(d.Severity) { + case 1: + nErr++ + case 2: + nWarn++ + } + } + total := len(diags) if len(diags) > lspMaxDiags { diags = diags[:lspMaxDiags] } var b strings.Builder + fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn) + if total > len(diags) { + fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total) + } + b.WriteString("\n") for _, d := range diags { sev := "info" switch d.Severity {