Files
Loki/internal/loki/parity_subagent_test.go
T
Claude e8563d992c Lot 47 : journal du sous-agent indépendant du poste
Le briefing machine des vues des rôles nomme l'hôte et le compte : masqués
dans le journal ({{HOTE}}, {{COMPTE}}), qui différait sur le poste de la CI.
Il nomme aussi le système et l'architecture : journal tiré sous Linux
seulement, comme les autres journaux qui dépendent du poste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:15:53 +00:00

226 lines
8.7 KiB
Go

//go:build linux
package loki
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/user"
"path/filepath"
"strings"
"sync"
"testing"
)
// parity_subagent_test.go — journal de référence de l'outil subagent du mode
// Code (lot 47 de la migration Rust) : refus avant tout appel au moteur, puis
// des tours délégués contre un faux moteur scripté (rapport après le dernier
// outil, texte vide, rapport tronqué, refus du moteur, cadrage du planner).
// Chaque étape garde ce que le rôle a envoyé au moteur (système identique à
// la vue du rôle, message de la tâche, température, outils). Les vues des
// rôles (/internal/v1/turn/subagent) sont rangées avec le journal.
//
// Rangé dans rust/loki-rs/tests/parity_subagent/subagent.json ; le test
// subagent_comme_le_go de tests/lot6.rs le rejoue par un tour de la façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSubagent
const paritySubagentFile = "../../rust/loki-rs/tests/parity_subagent/subagent.json"
// Une réponse scriptée du faux moteur.
type paritySubReply struct {
Tool string `json:"tool,omitempty"` // appel d'outil (arguments : Args)
Args string `json:"args,omitempty"`
Content string `json:"content,omitempty"`
Status int `json:"status,omitempty"` // refus du moteur (corps : Content)
}
type paritySubRequest struct {
SystemIsView bool `json:"system_is_view"`
User string `json:"user"`
Temperature any `json:"temperature"`
Tools []string `json:"tools"`
Messages int `json:"messages"`
}
type paritySubStep struct {
Name string `json:"name"`
Args map[string]any `json:"args"`
ParentRole string `json:"parent_role,omitempty"`
Replies []paritySubReply `json:"replies,omitempty"`
Result string `json:"result"`
Requests []paritySubRequest `json:"requests"`
}
func paritySubSSE(r paritySubReply) string {
chunk := func(v any) string {
b, _ := json.Marshal(v)
return "data: " + string(b) + "\n\n"
}
var b strings.Builder
if r.Tool != "" {
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{
"index": 0, "id": "s1", "type": "function", "function": map[string]any{"name": r.Tool, "arguments": r.Args},
}}}}}}))
if r.Content != "" {
// Texte écrit avant l'appel : la narration, pas le rapport.
b.Reset()
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": r.Content}}}}))
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{
"index": 0, "id": "s1", "type": "function", "function": map[string]any{"name": r.Tool, "arguments": r.Args},
}}}}}}))
}
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "tool_calls"}}}))
} else {
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": r.Content}}}}))
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}, "usage": map[string]any{"prompt_tokens": 40, "completion_tokens": 2}}))
}
b.WriteString("data: [DONE]\n\n")
return b.String()
}
func TestPariteSubagent(t *testing.T) {
ws := withWorkspace(t)
convEnsureActive()
_ = os.WriteFile(filepath.Join(ws, "cache.go"), []byte("package x\n// cache LRU\n"), 0o644)
var mu sync.Mutex
var queue []paritySubReply
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
w.WriteHeader(404)
return
}
raw, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(raw, &body)
mu.Lock()
bodies = append(bodies, body)
var rep paritySubReply
if len(queue) > 0 {
rep, queue = queue[0], queue[1:]
}
mu.Unlock()
if rep.Status != 0 {
w.WriteHeader(rep.Status)
_, _ = w.Write([]byte(rep.Content))
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(paritySubSSE(rep)))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
views := map[string]any{}
for _, role := range subagentRoles {
caps, system := subagentView(role)
views[role] = map[string]any{"system": system, "tools": EnabledTools(caps), "caps": caps}
}
var steps []paritySubStep
run := func(st paritySubStep) {
mu.Lock()
queue, bodies = append([]paritySubReply(nil), st.Replies...), nil
mu.Unlock()
parent := Caps{Agent: true, Code: true, Role: st.ParentRole}
st.Result = toolSubagent(context.Background(), st.Args, parent)
mu.Lock()
defer mu.Unlock()
st.Requests = []paritySubRequest{}
for _, b := range bodies {
msgs, _ := b["messages"].([]any)
req := paritySubRequest{Temperature: b["temperature"], Messages: len(msgs), Tools: []string{}}
if len(msgs) >= 2 {
sys, _ := msgs[0].(map[string]any)["content"].(string)
role, _ := st.Args["role"].(string)
_, want := subagentView(strings.TrimSpace(role))
req.SystemIsView = sys == want
req.User, _ = msgs[1].(map[string]any)["content"].(string)
}
tools, _ := b["tools"].([]any)
for _, tl := range tools {
f, _ := tl.(map[string]any)["function"].(map[string]any)
n, _ := f["name"].(string)
req.Tools = append(req.Tools, n)
}
st.Requests = append(st.Requests, req)
}
steps = append(steps, st)
}
a := func(role, task string) map[string]any { return map[string]any{"role": role, "task": task} }
run(paritySubStep{Name: "rôle inconnu", Args: a("architecte", "x")})
run(paritySubStep{Name: "verifier refusé", Args: a("verifier", "x")})
run(paritySubStep{Name: "sans rôle", Args: map[string]any{"task": "x"}})
run(paritySubStep{Name: "tâche vide", Args: a("explorer", " ")})
run(paritySubStep{Name: "tâche absente", Args: map[string]any{"role": "planner"}})
run(paritySubStep{Name: "pas de récursion", Args: a("explorer", "x"), ParentRole: "explorer"})
run(paritySubStep{Name: "vérificateur qui délègue", Args: a("planner", "x"), ParentRole: "verifier"})
run(paritySubStep{Name: "réponse directe", Args: a("explorer", " Où est le cache ? "),
Replies: []paritySubReply{{Content: " Dans cache.go:2. \n"}}})
run(paritySubStep{Name: "rapport après le dernier outil", Args: a(" code-reviewer ", "Relis cache.go"),
Replies: []paritySubReply{
{Tool: "grep", Args: `{"pattern":"cache"}`, Content: "Je cherche d'abord."},
{Tool: "read", Args: `{"path":"cache.go"}`},
{Content: "RAS : cache.go:2 documente le LRU."},
}})
run(paritySubStep{Name: "rien répondu", Args: a("explorer", "Vide ?"),
Replies: []paritySubReply{{Content: " "}}})
run(paritySubStep{Name: "rien après l'outil", Args: a("explorer", "Encore ?"),
Replies: []paritySubReply{{Tool: "grep", Args: `{"pattern":"x"}`, Content: "narration"}, {Content: ""}}})
run(paritySubStep{Name: "rapport tronqué", Args: a("explorer", "Tout ?"),
Replies: []paritySubReply{{Content: strings.Repeat("é", 6100)}}})
run(paritySubStep{Name: "cadrage du planner", Args: a("planner", "Découpe le chantier"),
Replies: []paritySubReply{{Content: "1. lire\n2. écrire"}}})
run(paritySubStep{Name: "moteur qui refuse", Args: a("explorer", "Panne ?"),
Replies: []paritySubReply{{Status: 400, Content: `{"error":{"message":"requête refusée"}}`}}})
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"views": views, "steps": steps}); err != nil {
t.Fatal(err)
}
got := bytes.ReplaceAll(buf.Bytes(), []byte(ws), []byte("{{WS}}"))
if h := os.Getenv("LOKI_HOME"); h != "" {
got = bytes.ReplaceAll(got, []byte(h), []byte("{{HOME}}"))
}
// Le briefing machine nomme l'hôte et le compte : propres au poste.
if host, _ := os.Hostname(); host != "" {
got = bytes.ReplaceAll(got, []byte("host="+host+","), []byte("host={{HOTE}},"))
}
if u, err := user.Current(); err == nil && u.Username != "" {
got = bytes.ReplaceAll(got, []byte(", user="+u.Username+","), []byte(", user={{COMPTE}},"))
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(paritySubagentFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paritySubagentFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(paritySubagentFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if !bytes.Equal(got, want) {
t.Errorf("subagent ne produit plus le journal rangé dans %s.\n"+
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", paritySubagentFile)
}
}