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