mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Terminal : le vrai code de sortie, la sortie gardée au délai, sans ANSI
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a745d2399e
commit
2e0356cd6c
5 files changed
+175
-4
No files matched your search
@@ -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, "&&")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user