mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Chaque événement de frappe (un par ligne) appelait bodyLineCount sur le corps ENTIER pour remplir body_lines : un reste de coût quadratique, léger mais inutile, puisque argPreview tient déjà le compte des sauts de ligne. - argPreview.lineCount : même règle que bodyLineCount (saut de ligne final sans ligne de plus), en temps constant. - Le test de décodage par morceaux vérifie l'égalité avec bodyLineCount à chaque préfixe (fuzz compris). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
484 lines
15 KiB
Go
484 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()
|
||
saved := retryRes
|
||
savedCustom := retryCustom
|
||
defer func() { retryRes, retryCustom = saved, savedCustom }()
|
||
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
|
||
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é")
|
||
}
|
||
retryCustom = false
|
||
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)
|
||
}
|
||
}
|