mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
- LSP (lsp.go) : un signal resté dans le tampon par une republication tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même que le serveur ait vu la nouvelle version. Le canal est vidé, une publication d'une version antérieure est ignorée, et check attend 350 ms de calme après la dernière publication (liste vide puis analyse : c'est l'analyse qui compte), toujours borné par lspDiagWait. Au passage : interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par check) — dead devient atomique. - run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui l'effaçait (AJEAN 7403a8a0). - Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par toute écriture du bucket d'état, par la date/taille du fichier (autre process) et au bout d'une seconde. - MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil, plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8). - Recall : les blocs d'une compaction s'archivent en une seule transaction au lieu d'une par bloc (AJEAN #130). - Compactage en cours de tour : après un essai qui n'a rien réduit, pas de nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN 68659e73). - Motifs de relance : un motif vide (qui correspondait à tout) est écarté, une surcharge sans motif utilisable retombe sur l'embarqué, et un changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869, c52c5bf7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
493 lines
15 KiB
Go
493 lines
15 KiB
Go
package loki
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"math/rand"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// chunked découpe s en morceaux de 1 à max octets (graine fixe : reproductible).
|
||
func chunked(s string, r *rand.Rand, max int) []string {
|
||
var out []string
|
||
for len(s) > 0 {
|
||
n := 1 + r.Intn(max)
|
||
if n > len(s) {
|
||
n = len(s)
|
||
}
|
||
out = append(out, s[:n])
|
||
s = s[n:]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// checkArgPreview rejoue args morceau par morceau et compare à chaque étape
|
||
// argPreview au previewArgDone historique — à une barre oblique finale près,
|
||
// qu'argPreview garde en attente au lieu de l'écrire.
|
||
func checkArgPreview(t *testing.T, args, key string, chunks []string) {
|
||
t.Helper()
|
||
a := newArgPreview(key)
|
||
prefix := ""
|
||
for _, c := range chunks {
|
||
prefix += c
|
||
a.update(prefix)
|
||
want, wantDone := previewArgDone(prefix, key)
|
||
got := a.value()
|
||
if a.phase == 3 && a.pos == len(prefix)-1 && prefix[a.pos] == '\\' {
|
||
got += "\\"
|
||
}
|
||
if got != want || a.done() != wantDone {
|
||
t.Fatalf("args=%q préfixe=%q : argPreview=(%q,%v), previewArgDone=(%q,%v)", args, prefix, got, a.done(), want, wantDone)
|
||
}
|
||
if n := strings.Count(a.value(), "\n"); n != a.lines {
|
||
t.Fatalf("lines=%d, la valeur en compte %d", a.lines, n)
|
||
}
|
||
if n := bodyLineCount(a.value()); n != a.lineCount() {
|
||
t.Fatalf("lineCount=%d, bodyLineCount=%d (valeur %q)", a.lineCount(), n, a.value())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestArgPreviewMatchesPreviewArgDone(t *testing.T) {
|
||
r := rand.New(rand.NewSource(7))
|
||
alphabet := []string{"a", "é", "\n", "\t", "\r", "\\", "\"", "<", "&", "
", "/", " ", "x", "content", ":", "{"}
|
||
var cases []string
|
||
for i := 0; i < 300; i++ {
|
||
var b strings.Builder
|
||
for j := r.Intn(200); j > 0; j-- {
|
||
b.WriteString(alphabet[r.Intn(len(alphabet))])
|
||
}
|
||
v := b.String()
|
||
m, _ := json.Marshal(map[string]string{"file": "a/b.go", "content": v})
|
||
cases = append(cases, string(m))
|
||
// Ordre inverse et clé citée avant la vraie : même règle de première occurrence.
|
||
cases = append(cases, `{"content" : "`+strings.ReplaceAll(v, `"`, `\"`)+`","file":"x"}`)
|
||
cases = append(cases, `{"note":"le champ \"content\" vient après","content":"`+strings.ReplaceAll(v, `"`, `\"`)+`"}`)
|
||
}
|
||
cases = append(cases,
|
||
`{"content":"été \\u0041 fin"}`,
|
||
`{"content":"ligne1\nligne2\\`,
|
||
`{"content":`, `{"cont`, `{"content":"`, `{"content":"x"}`, ``,
|
||
)
|
||
for _, args := range cases {
|
||
for _, max := range []int{1, 3, 17, 4096} {
|
||
checkArgPreview(t, args, "content", chunked(args, r, max))
|
||
}
|
||
}
|
||
}
|
||
|
||
func FuzzArgPreview(f *testing.F) {
|
||
f.Add(`{"file":"a","content":"x\ny\\\"zA"}`, int64(1))
|
||
f.Add(`{"content":"a\\`, int64(2))
|
||
f.Add(`{"content" : "x", "content":"y"}`, int64(3))
|
||
f.Fuzz(func(t *testing.T, args string, seed int64) {
|
||
checkArgPreview(t, args, "content", chunked(args, rand.New(rand.NewSource(seed)), 9))
|
||
})
|
||
}
|
||
|
||
func TestBodyTail(t *testing.T) {
|
||
lines := func(n int, w int) string {
|
||
var b strings.Builder
|
||
for i := 0; i < n; i++ {
|
||
b.WriteString(strings.Repeat("x", w) + strconv.Itoa(i) + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
long := strings.Repeat("é", 5000) // une seule ligne de 10 000 octets
|
||
for _, tc := range []struct {
|
||
name string
|
||
in string
|
||
cut bool
|
||
maxLine int
|
||
}{
|
||
{"vide", "", false, 0},
|
||
{"court", "a\nb\nc", false, 3},
|
||
{"40 lignes pile", lines(39, 3) + "fin", false, 40},
|
||
{"beaucoup de lignes", lines(500, 3) + "fin", true, bodyTailLines},
|
||
{"lignes larges", lines(100, 300), true, bodyTailLines},
|
||
{"ligne seule énorme", long, true, 1},
|
||
} {
|
||
got, cut := bodyTail(tc.in)
|
||
if cut != tc.cut || !strings.HasSuffix(tc.in, got) {
|
||
t.Errorf("%s : cut=%v, suffixe=%v", tc.name, cut, strings.HasSuffix(tc.in, got))
|
||
}
|
||
if len(got) > bodyTailBytes {
|
||
t.Errorf("%s : %d octets", tc.name, len(got))
|
||
}
|
||
if n := strings.Count(got, "\n") + 1; got != "" && n > tc.maxLine {
|
||
t.Errorf("%s : %d lignes", tc.name, n)
|
||
}
|
||
if cut {
|
||
if start := len(tc.in) - len(got); tc.in[start-1] != '\n' && !strings.Contains(tc.name, "seule") {
|
||
t.Errorf("%s : coupé en milieu de ligne", tc.name)
|
||
}
|
||
if got != "" && !json.Valid([]byte(strconv.Quote(got))) || strings.ContainsRune(got, '�') {
|
||
t.Errorf("%s : caractère coupé", tc.name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBodyLineCount(t *testing.T) {
|
||
for in, want := range map[string]int{"": 0, "\n": 0, "a": 1, "a\n": 1, "a\nb": 2, "a\nb\n": 2, "\n\n": 2} {
|
||
if got := bodyLineCount(in); got != want {
|
||
t.Errorf("bodyLineCount(%q) = %d, attendu %d", in, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Les événements de frappe d'un gros write ne portent qu'une fin bornée du
|
||
// corps, avec le vrai nombre de lignes ; les arguments exécutés restent entiers.
|
||
func TestWriteTypingBodyBounded(t *testing.T) {
|
||
var content strings.Builder
|
||
for i := 0; i < 3000; i++ {
|
||
content.WriteString("ligne numéro " + strconv.Itoa(i) + " du fichier\n")
|
||
}
|
||
withWorkspace(t)
|
||
args, _ := json.Marshal(map[string]string{"file": "gros.txt", "content": content.String()})
|
||
var calls int32
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
if atomic.AddInt32(&calls, 1) > 1 {
|
||
_, _ = w.Write([]byte(sseChunk("fini")))
|
||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||
return
|
||
}
|
||
s := string(args)
|
||
for len(s) > 0 {
|
||
n := 37
|
||
if n > len(s) {
|
||
n = len(s)
|
||
}
|
||
for n < len(s) && !utf8.RuneStart(s[n]) {
|
||
n++
|
||
}
|
||
piece, _ := json.Marshal(s[:n])
|
||
s = s[n:]
|
||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"write","arguments":` + string(piece) + `}}]}}]}` + "\n\n"))
|
||
}
|
||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n"))
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
u, _ := url.Parse(srv.URL)
|
||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var sent, last int
|
||
var lastEv *ToolUsedEvent
|
||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "écris"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||
if tu := ev.ToolUsed; tu != nil && tu.Typing && tu.Body != "" {
|
||
if len(tu.Body) > bodyTailBytes {
|
||
t.Fatalf("corps de %d octets dans un événement de frappe", len(tu.Body))
|
||
}
|
||
if tu.BodyLines < last {
|
||
t.Fatalf("body_lines recule : %d après %d", tu.BodyLines, last)
|
||
}
|
||
last = tu.BodyLines
|
||
sent += len(tu.Body)
|
||
lastEv = tu
|
||
}
|
||
return true
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if lastEv == nil || !lastEv.BodyTail || lastEv.BodyLines < 2990 {
|
||
t.Fatalf("dernier événement = %+v", lastEv)
|
||
}
|
||
if !strings.HasSuffix(content.String(), lastEv.Body) {
|
||
t.Fatalf("la fin diffusée n'est pas la fin du corps : %q", lastEv.Body)
|
||
}
|
||
// Linéaire : 3000 lignes × 4 Kio au plus, loin des centaines de Mo d'avant.
|
||
if sent > 3000*bodyTailBytes {
|
||
t.Fatalf("%d octets de corps diffusés", sent)
|
||
}
|
||
// Ce qui est exécuté, lui, est entier.
|
||
if b, err := os.ReadFile(filepath.Join(agentCwd(), "gros.txt")); err != nil || string(b) != content.String() {
|
||
t.Fatalf("fichier écrit : %d octets, err=%v", len(b), err)
|
||
}
|
||
}
|
||
|
||
// replayScan rejoue le balayage du flux : un morceau sans caractère déclencheur
|
||
// n'est pas balayé. Renvoie l'indice du morceau qui coupe le flux, -1 sinon.
|
||
func replayScan(chunks []string, windowed bool) int {
|
||
var b strings.Builder
|
||
scanned := 0
|
||
for i, c := range chunks {
|
||
b.WriteString(c)
|
||
if !strings.ContainsAny(c, "<`{[_.") {
|
||
continue
|
||
}
|
||
s := b.String()
|
||
var hit bool
|
||
if windowed {
|
||
hit = textualToolCallFrom(s, scanned)
|
||
scanned = len(s)
|
||
} else {
|
||
hit = textualToolCall(s)
|
||
}
|
||
if hit {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func TestTextualToolCallWindowMatchesFullScan(t *testing.T) {
|
||
r := rand.New(rand.NewSource(11))
|
||
prose := []string{
|
||
"Voici la suite de l'analyse, ligne par ligne. ",
|
||
"Le fichier contient trois fonctions ; aucune n'est exportée.\n",
|
||
"```go\nfunc main() { fmt.Println(\"x\") }\n```\n",
|
||
"- point important\n",
|
||
"le JSON {\"name\": \"loki\"} de config, ",
|
||
"sans ponctuation du tout mais avec des mots très longs ",
|
||
"\n\n",
|
||
"Un <div class=\"x\"> en HTML. ",
|
||
"été à la plage ",
|
||
}
|
||
filler := func(n int) string {
|
||
var b strings.Builder
|
||
for b.Len() < n {
|
||
b.WriteString(prose[r.Intn(len(prose))])
|
||
}
|
||
return b.String()
|
||
}
|
||
bad := []string{
|
||
"<tool_call>{\"name\":\"bash\"}</tool_call>",
|
||
"\n{\"name\": \"bash\", \"arguments\": {}}",
|
||
"\n \n {\"name\":" + strings.Repeat(" \n", 400) + "\"mem_" + strings.Repeat("a", 700) + "\"",
|
||
"\n" + strings.Repeat(" ", 900) + "{\"name\": \"web_open\"",
|
||
"<function=" + strings.Repeat("b", 900) + ">",
|
||
" functions." + strings.Repeat("c", 600) + strings.Repeat(" ", 300) + "(",
|
||
"Je lance default_api:bash ",
|
||
"```tool_code\nprint(1)\n```",
|
||
"<parameter=command>",
|
||
"[TOOL_REQUEST]",
|
||
}
|
||
neutral := []string{
|
||
" milieu de ligne {\"name\": \"bash\"} ", // `^` : seulement en début de ligne
|
||
strings.Repeat("x", 800) + " {\"name\": \"bash\"",
|
||
"<tool_cal> presque",
|
||
}
|
||
var texts []string
|
||
for i := 0; i < 120; i++ {
|
||
head := filler(500 + r.Intn(5000))
|
||
tail := filler(r.Intn(1500))
|
||
switch i % 3 {
|
||
case 0:
|
||
texts = append(texts, head+bad[r.Intn(len(bad))]+tail)
|
||
case 1:
|
||
texts = append(texts, head+neutral[r.Intn(len(neutral))]+tail)
|
||
default:
|
||
texts = append(texts, head+tail)
|
||
}
|
||
}
|
||
// Motif à cheval sur un morceau sans caractère déclencheur.
|
||
texts = append(texts, filler(3000)+"<tool"+"_call"+">"+" puis du texte sans rien")
|
||
hits, misses := 0, 0
|
||
for _, s := range texts {
|
||
for _, max := range []int{3, 23, 200} {
|
||
ch := chunked(s, r, max)
|
||
if s == texts[len(texts)-1] {
|
||
ch = append(chunked(s[:len(s)-len("<tool_call> puis du texte sans rien")], r, max), "<tool", "_call", ">", " puis du texte", " sans rien")
|
||
}
|
||
w, f := replayScan(ch, true), replayScan(ch, false)
|
||
if w != f {
|
||
t.Fatalf("fenêtré coupe au morceau %d, balayage complet au %d (taille %d, max %d)", w, f, len(s), max)
|
||
}
|
||
if f >= 0 {
|
||
hits++
|
||
} else {
|
||
misses++
|
||
}
|
||
}
|
||
}
|
||
if hits == 0 || misses == 0 {
|
||
t.Fatalf("corpus déséquilibré : %d coupures, %d sans", hits, misses)
|
||
}
|
||
}
|
||
|
||
// La fenêtre est bien une fenêtre sur un texte ordinaire, et démarre en début
|
||
// de ligne.
|
||
func TestRetryScanStartWindows(t *testing.T) {
|
||
s := strings.Repeat("Une phrase ordinaire, avec sa ponctuation.\n", 500)
|
||
start := retryScanStart(s, len(s))
|
||
if start == 0 || len(s)-start > 2*retryScanMargin {
|
||
t.Fatalf("fenêtre de %d octets sur %d", len(s)-start, len(s))
|
||
}
|
||
if s[start-1] != '\n' {
|
||
t.Fatal("la fenêtre ne démarre pas en début de ligne")
|
||
}
|
||
// Une ligne sans fin plus longue que le recul permis : balayage complet.
|
||
if got := retryScanStart(strings.Repeat("a b ", 20000), 80000); got != 0 {
|
||
t.Fatalf("start = %d, attendu 0", got)
|
||
}
|
||
}
|
||
|
||
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
|
||
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
|
||
retryPatterns()
|
||
retryMu.Lock()
|
||
saved := retryRes
|
||
savedCustom := retryCustom
|
||
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
|
||
retryChecked = time.Now() // pas de relecture pendant le test
|
||
retryMu.Unlock()
|
||
defer func() {
|
||
retryMu.Lock()
|
||
retryRes, retryCustom = saved, savedCustom
|
||
retryMu.Unlock()
|
||
}()
|
||
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
|
||
if !textualToolCallFrom(s, len(s)-10) {
|
||
t.Fatal("motif ancré en tête manqué")
|
||
}
|
||
retryMu.Lock()
|
||
retryCustom = false
|
||
retryMu.Unlock()
|
||
if textualToolCallFrom(s, len(s)-10) {
|
||
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
|
||
}
|
||
}
|
||
|
||
// sseStream : un lot = une écriture, les événements dans l'ordre, et un lot
|
||
// trop gros est découpé.
|
||
type countingRW struct {
|
||
*httptest.ResponseRecorder
|
||
writes int
|
||
}
|
||
|
||
func (c *countingRW) Write(b []byte) (int, error) {
|
||
c.writes++
|
||
return c.ResponseRecorder.Write(b)
|
||
}
|
||
|
||
func TestSSEStreamBatches(t *testing.T) {
|
||
rw := &countingRW{ResponseRecorder: httptest.NewRecorder()}
|
||
s := &sseStream{w: rw, mu: &sync.Mutex{}, frame: func(m map[string]any) ([]byte, bool) {
|
||
return []byte("data: " + strconv.Itoa(m["seq"].(int)) + "\n\n"), true
|
||
}}
|
||
for i := 1; i <= 10; i++ {
|
||
if !s.queue(map[string]any{"seq": i}) {
|
||
t.Fatal("queue")
|
||
}
|
||
}
|
||
if rw.writes != 0 {
|
||
t.Fatalf("%d écritures avant flush", rw.writes)
|
||
}
|
||
s.flush()
|
||
if rw.writes != 1 {
|
||
t.Fatalf("%d écritures pour un lot", rw.writes)
|
||
}
|
||
for i := 11; i <= 10+2*sseBatchEvents; i++ {
|
||
s.queue(map[string]any{"seq": i})
|
||
}
|
||
s.emit(map[string]any{"seq": 11 + 2*sseBatchEvents})
|
||
if rw.writes != 4 {
|
||
t.Fatalf("%d écritures, attendu 4", rw.writes)
|
||
}
|
||
var want strings.Builder
|
||
for i := 1; i <= 11+2*sseBatchEvents; i++ {
|
||
want.WriteString("data: " + strconv.Itoa(i) + "\n\n")
|
||
}
|
||
if rw.Body.String() != want.String() {
|
||
t.Fatal("ordre des événements perdu")
|
||
}
|
||
}
|
||
|
||
// Le direct passe par queue puis flush, dans l'ordre des seq.
|
||
func TestSubscribeSinkLiveBatchOrder(t *testing.T) {
|
||
c := newTestConv()
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
var mu sync.Mutex
|
||
var seqs []int
|
||
flushed := 0
|
||
caught := make(chan struct{}, 1)
|
||
sink := tailSink{
|
||
emit: func(m map[string]any) bool {
|
||
if _, ok := m["caught_up"]; ok {
|
||
caught <- struct{}{}
|
||
}
|
||
return true
|
||
},
|
||
queue: func(m map[string]any) bool {
|
||
mu.Lock()
|
||
if s, ok := m["seq"].(int); ok {
|
||
seqs = append(seqs, s)
|
||
}
|
||
mu.Unlock()
|
||
return true
|
||
},
|
||
flush: func() bool { mu.Lock(); flushed++; mu.Unlock(); return true },
|
||
}
|
||
go c.subscribeSink(ctx, 0, -1, "", sink)
|
||
<-caught
|
||
const n = 500
|
||
for i := 0; i < n; i++ {
|
||
c.appendDelta(c.epoch, map[string]any{"content": "t"})
|
||
}
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for {
|
||
mu.Lock()
|
||
got, f := len(seqs), flushed
|
||
mu.Unlock()
|
||
if got == n && f > 0 {
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatalf("%d/%d événements reçus", got, n)
|
||
}
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
for i, s := range seqs {
|
||
if s != i+1 {
|
||
t.Fatalf("seq %d en position %d", s, i)
|
||
}
|
||
}
|
||
}
|
||
|
||
func BenchmarkWriteTypingPreview(b *testing.B) {
|
||
var content strings.Builder
|
||
for content.Len() < 120<<10 {
|
||
content.WriteString("une ligne de code assez ordinaire, avec \"guillemets\"\n")
|
||
}
|
||
args, _ := json.Marshal(map[string]string{"file": "x", "content": content.String()})
|
||
s := string(args)
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
a := newArgPreview("content")
|
||
last := -1
|
||
for end := 8; end <= len(s); end += 8 {
|
||
a.update(s[:end])
|
||
if a.lines > last {
|
||
last = a.lines
|
||
bodyTail(a.value())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func BenchmarkTextualToolCallWindowed(b *testing.B) {
|
||
s := strings.Repeat("Une phrase ordinaire, avec sa ponctuation. Et un point.\n", 1500)
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
textualToolCallFrom(s, len(s)-8)
|
||
}
|
||
}
|