mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Une compaction, un dernier message rendu autrement au tour suivant ou une tâche planifiée qui a pris le slot laissent au message suivant un recalcul inévitable : des secondes sur un 27B, des dizaines sur un MoE aux experts en RAM, avant le premier mot. Nouvelle clé PREWARM (off par défaut) : dès que le moteur local est libre, Loki lui envoie la requête du prochain tour suivie d'un message utilisateur « . », max_tokens 1, sans flux. Le moteur calcule le préfixe et pose un point de reprise au début de ce message ; le vrai tour ne calcule plus que le sien. Sans la clé, aucune requête de plus (testé). - assemblage partagé, contenu vivant : turnViewDry (même code que turnView, sans rien toucher à la discussion, décision PROJ_SNAPSHOT via projSnapDecide), wireMessages, turnReasoning et buildChatPayload, extraits de runChat ; corps identique à l'octet près à celui d'avant (matrice d'options et de modes, testée contre une copie de l'ancien code) - jamais devant un vrai travail : toute requête de Loki l'annule au départ (compteur de llm_slots.go, sous son verrou), sauf un tour de chat dont les messages sérialisés, outils et arguments du gabarit prolongent exactement le préfixe préchauffé - moteur local, un seul slot (/props total_slots), pas pendant un tour, une tâche, un bench, un travail annexe ni une requête en vol ; un seul à la fois ; pas si le prochain tour compactera, ni à moins de 10 min de minuit - requête brute : ni compaction, ni relance, ni effort appris, ni CtxUsed, ni stats ; erreurs lâchées (une ligne de journal par statut) ; rien persisté - déclencheurs : fin de tour sans file d'attente, fin de tâche (projet forcé levé, slot effacé) ; full = aussi changement de discussion après 3 s - capacités du dernier tour gardées en mémoire par discussion ; mode Code = rôle d'un message ordinaire, une demande de plan diverge et annule - télémétrie kind=prewarm ; la vérification du cache RAM après une tâche se fait sur lui Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
526 lines
18 KiB
Go
526 lines
18 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// moteurPrewarm : faux llama-server qui note TOUTES les requêtes (chemin et
|
|
// corps), sert /health et /props, et répond aux complétions streamées comme aux
|
|
// autres.
|
|
type moteurPrewarm struct {
|
|
mu sync.Mutex
|
|
reqs []prewarmReq
|
|
slots int
|
|
}
|
|
|
|
type prewarmReq struct{ path, body string }
|
|
|
|
func (m *moteurPrewarm) start(t *testing.T) {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var buf bytes.Buffer
|
|
_, _ = buf.ReadFrom(r.Body)
|
|
m.mu.Lock()
|
|
m.reqs = append(m.reqs, prewarmReq{r.URL.Path, buf.String()})
|
|
slots := m.slots
|
|
m.mu.Unlock()
|
|
switch r.URL.Path {
|
|
case "/health":
|
|
sendJSON(w, 200, map[string]any{"status": "ok"})
|
|
case "/props":
|
|
sendJSON(w, 200, map[string]any{"total_slots": slots, "build_info": "b9000-x"})
|
|
default:
|
|
if strings.Contains(buf.String(), `"stream":true`) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n"))
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{
|
|
"choices": []any{map[string]any{"message": map[string]any{"content": "x"}}},
|
|
"usage": map[string]any{"prompt_tokens": 120, "completion_tokens": 1},
|
|
"timings": map[string]any{"prompt_n": 4, "cache_n": 116, "predicted_n": 1},
|
|
})
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
u, _ := url.Parse(srv.URL)
|
|
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (m *moteurPrewarm) all() []prewarmReq {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return append([]prewarmReq(nil), m.reqs...)
|
|
}
|
|
|
|
// completions : corps des complétions, streamées ou non.
|
|
func (m *moteurPrewarm) completions() []string {
|
|
var out []string
|
|
for _, r := range m.all() {
|
|
if r.path == "/v1/chat/completions" {
|
|
out = append(out, r.body)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
|
// préchauffage synchrone. keys : réglages posés.
|
|
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
|
|
t.Helper()
|
|
withWorkspace(t)
|
|
activeProjMu.Lock()
|
|
activeProjCache = ""
|
|
activeProjMu.Unlock()
|
|
setProjectOverride("")
|
|
t.Cleanup(func() {
|
|
activeProjMu.Lock()
|
|
activeProjCache = ""
|
|
activeProjMu.Unlock()
|
|
setProjectOverride("")
|
|
})
|
|
ensureDefaultProject()
|
|
freshTplProbe(t)
|
|
convEnsureActive()
|
|
prewarmAsync = false
|
|
prewarmCaps.mu.Lock()
|
|
prewarmCaps.m = nil
|
|
prewarmCaps.mu.Unlock()
|
|
t.Cleanup(func() {
|
|
prewarmAsync = true
|
|
prewarmCaps.mu.Lock()
|
|
prewarmCaps.m = nil
|
|
prewarmCaps.mu.Unlock()
|
|
})
|
|
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k, v := range keys {
|
|
if err := SetConfigKey(k, v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
m := &moteurPrewarm{slots: 1}
|
|
m.start(t)
|
|
return m
|
|
}
|
|
|
|
// prewarmTurn : un tour de chat comme StartTurn le lance (capacités notées pour
|
|
// le préchauffage), joué jusqu'au bout, préchauffage éventuel compris.
|
|
func prewarmTurn(t *testing.T, c *Conversation, caps Caps, text string) {
|
|
t.Helper()
|
|
prewarmNoteCaps(convActiveID(), caps)
|
|
c.mu.Lock()
|
|
c.Messages = append(c.Messages, um(text))
|
|
c.mu.Unlock()
|
|
c.generate(context.Background(), withTurnRole(caps, text), 0.7, c.epoch)
|
|
}
|
|
|
|
// Clé absente : pas une requête de plus au moteur — ni complétion, ni /health,
|
|
// ni /props — et la conversation est celle d'avant.
|
|
func TestPrewarmDefautAucuneRequete(t *testing.T) {
|
|
m := prewarmSetup(t, nil)
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
prewarmTurn(t, c, caps, "bonjour")
|
|
prewarmTurn(t, c, caps, "et ensuite ?")
|
|
reqs := m.all()
|
|
if len(reqs) != 2 {
|
|
t.Fatalf("2 requêtes attendues (une par tour), %d reçues : %+v", len(reqs), reqs)
|
|
}
|
|
for _, r := range reqs {
|
|
if r.path != "/v1/chat/completions" || strings.Contains(r.body, `"max_tokens"`) || !strings.Contains(r.body, `"stream":true`) {
|
|
t.Fatalf("requête inattendue sans la clé : %s %s", r.path, r.body)
|
|
}
|
|
}
|
|
// La fin de tour ne lance rien non plus en direct.
|
|
c.prewarm(prewarmTurnEnd)
|
|
if n := len(m.all()); n != 2 {
|
|
t.Fatalf("prewarm sans la clé a parlé au moteur (%d requêtes)", n)
|
|
}
|
|
}
|
|
|
|
// Preset externe : la clé est sans effet.
|
|
func TestPrewarmJamaisExterne(t *testing.T) {
|
|
cfg := map[string]string{"PREWARM": "on", extKeyFlag: "1", extKeyURL: "http://127.0.0.1:1"}
|
|
if prewarmMode(cfg) != "" {
|
|
t.Fatal("PREWARM actif sur un preset externe")
|
|
}
|
|
for v, want := range map[string]string{"": "", "off": "", "on": "on", "1": "on", "full": "full", "FULL": "full", "x": ""} {
|
|
if got := prewarmMode(map[string]string{"PREWARM": v}); got != want {
|
|
t.Fatalf("PREWARM=%q : %q, attendu %q", v, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Avec la clé : la requête de préchauffage est celle du prochain tour, sentinelle
|
|
// en plus — mêmes messages jusqu'au dernier, mêmes outils et arguments du
|
|
// gabarit — avec max_tokens 1, sans flux. Rien n'en reste dans la discussion,
|
|
// et la télémétrie la range sous kind=prewarm.
|
|
func TestPrewarmEgalTourSuivantPlusSentinelle(t *testing.T) {
|
|
m := prewarmSetup(t, map[string]string{"PREWARM": "on", "REASONING_EFFORT": "high", "TOP_P": "0.9"})
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
prewarmTurn(t, c, caps, "bonjour")
|
|
comps := m.completions()
|
|
if len(comps) != 2 {
|
|
t.Fatalf("tour + préchauffage attendus, %d complétions", len(comps))
|
|
}
|
|
var pw map[string]any
|
|
if err := json.Unmarshal([]byte(comps[1]), &pw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pw["stream"] != false || pw["max_tokens"] != float64(1) || pw["stream_options"] != nil {
|
|
t.Fatalf("champs de réponse du préchauffage : stream=%v max_tokens=%v stream_options=%v", pw["stream"], pw["max_tokens"], pw["stream_options"])
|
|
}
|
|
c.mu.Lock()
|
|
for _, msg := range c.Messages {
|
|
if s, _ := msg.Content.(string); s == prewarmSentinel {
|
|
c.mu.Unlock()
|
|
t.Fatal("la sentinelle a été rangée dans la discussion")
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
prewarmTurn(t, c, caps, "et ensuite ?")
|
|
comps = m.completions()
|
|
var real map[string]any
|
|
if err := json.Unmarshal([]byte(comps[2]), &real); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pm, rm := pw["messages"].([]any), real["messages"].([]any)
|
|
if len(pm) != len(rm) {
|
|
t.Fatalf("préchauffage %d messages, tour suivant %d", len(pm), len(rm))
|
|
}
|
|
if !reflect.DeepEqual(pm[:len(pm)-1], rm[:len(rm)-1]) {
|
|
t.Fatalf("préfixe différent du tour suivant :\npréchauffage %v\ntour %v", pm[:len(pm)-1], rm[:len(rm)-1])
|
|
}
|
|
if last := pm[len(pm)-1].(map[string]any); last["role"] != "user" || last["content"] != prewarmSentinel {
|
|
t.Fatalf("dernier message du préchauffage : %v", last)
|
|
}
|
|
for _, k := range append([]string{"temperature", "top_p", "model"}, prewarmOptsKeys...) {
|
|
if !reflect.DeepEqual(pw[k], real[k]) {
|
|
t.Fatalf("champ %s : préchauffage %v, tour %v", k, pw[k], real[k])
|
|
}
|
|
}
|
|
found := false
|
|
for _, r := range perfLog.snapshot() {
|
|
if r.Kind == perfPrewarm {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("aucune complétion kind=prewarm dans la télémétrie")
|
|
}
|
|
}
|
|
|
|
// Les garde-fous : plus d'un slot, tour en cours, capacités inconnues, travail
|
|
// annexe — aucune complétion de préchauffage.
|
|
func TestPrewarmGardeFous(t *testing.T) {
|
|
m := prewarmSetup(t, map[string]string{"PREWARM": "on"})
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true}
|
|
c.mu.Lock()
|
|
c.Messages = []Message{um("bonjour"), am("salut")}
|
|
c.mu.Unlock()
|
|
count := func() int { return len(m.completions()) }
|
|
|
|
// Capacités inconnues (aucun tour joué dans cette discussion).
|
|
c.prewarm(prewarmTurnEnd)
|
|
if count() != 0 {
|
|
t.Fatal("préchauffage sans capacités connues")
|
|
}
|
|
prewarmNoteCaps(convActiveID(), caps)
|
|
|
|
m.mu.Lock()
|
|
m.slots = 2
|
|
m.mu.Unlock()
|
|
c.prewarm(prewarmTurnEnd)
|
|
if count() != 0 {
|
|
t.Fatal("préchauffage avec deux slots")
|
|
}
|
|
m.mu.Lock()
|
|
m.slots = 1
|
|
m.mu.Unlock()
|
|
|
|
c.mu.Lock()
|
|
c.Generating = true
|
|
c.mu.Unlock()
|
|
c.prewarm(prewarmTurnEnd)
|
|
c.mu.Lock()
|
|
c.Generating = false
|
|
c.mu.Unlock()
|
|
if count() != 0 {
|
|
t.Fatal("préchauffage pendant un tour")
|
|
}
|
|
|
|
sideJobs.Add(1)
|
|
c.prewarm(prewarmTurnEnd)
|
|
sideJobs.Add(-1)
|
|
if count() != 0 {
|
|
t.Fatal("préchauffage pendant un travail annexe")
|
|
}
|
|
|
|
end := engineRequestStart()
|
|
c.prewarm(prewarmTurnEnd)
|
|
end()
|
|
if count() != 0 {
|
|
t.Fatal("préchauffage avec une requête de Loki en vol")
|
|
}
|
|
|
|
c.prewarm(prewarmTurnEnd)
|
|
if count() != 1 {
|
|
t.Fatalf("toutes conditions réunies : 1 préchauffage attendu, %d", count())
|
|
}
|
|
}
|
|
|
|
// Toute requête de Loki annule le préchauffage en vol, sauf un tour de chat qui
|
|
// prolonge exactement son préfixe.
|
|
func TestPrewarmAnnuleSaufPrefixeExact(t *testing.T) {
|
|
hist := []Message{{Role: "system", Content: "sys"}, um("a"), am("b")}
|
|
pwMsgs := append(append([]Message(nil), hist...), um(prewarmSentinel))
|
|
real := append(append([]Message(nil), hist...), um("suite"))
|
|
payload := map[string]any{"tools": []string{"t"}, "parallel_tool_calls": false}
|
|
other := []Message{{Role: "system", Content: "sys"}, um("a"), am("c"), um("suite")}
|
|
|
|
// Sans préchauffage en vol, rien n'est calculé.
|
|
if prewarmKeeper(perfMain, real, payload) != nil {
|
|
t.Fatal("empreintes calculées sans préchauffage en vol")
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
keep func() func(*prewarmRun) bool // construit préchauffage en vol
|
|
kept bool
|
|
}{
|
|
{"tour qui prolonge", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, real, payload) }, true},
|
|
{"autres outils", func() func(*prewarmRun) bool {
|
|
return prewarmKeeper(perfMain, real, map[string]any{"tools": []string{"u"}, "parallel_tool_calls": false})
|
|
}, false},
|
|
{"autre historique", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, other, payload) }, false},
|
|
{"préfixe seul, rien après", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, hist, payload) }, false},
|
|
{"compaction", func() func(*prewarmRun) bool { return prewarmKeeper(perfCompact, real, payload) }, false},
|
|
{"requête quelconque", func() func(*prewarmRun) bool { return nil }, false},
|
|
}
|
|
for _, tc := range cases {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
run := &prewarmRun{n: len(pwMsgs) - 1, prefix: perfPrefix(pwMsgs)[len(pwMsgs)-2], opts: prewarmOptsHash(payload), cancel: cancel, done: make(chan struct{})}
|
|
end, ok := prewarmBegin(run)
|
|
if !ok {
|
|
t.Fatal("prewarmBegin refusé, moteur libre")
|
|
}
|
|
done := engineRequestStartKeep(tc.keep())
|
|
if kept := ctx.Err() == nil; kept != tc.kept {
|
|
t.Fatalf("%s : gardé=%v, attendu %v", tc.name, kept, tc.kept)
|
|
}
|
|
done()
|
|
end()
|
|
cancel()
|
|
}
|
|
// Une requête en vol interdit d'en démarrer un.
|
|
busy := engineRequestStart()
|
|
if _, ok := prewarmBegin(&prewarmRun{cancel: func() {}}); ok {
|
|
t.Fatal("préchauffage inscrit avec une requête en vol")
|
|
}
|
|
busy()
|
|
if prewarmLive.Load() {
|
|
t.Fatal("prewarmLive resté levé")
|
|
}
|
|
}
|
|
|
|
// turnViewDry ne touche à rien (instantané, mises à jour, nettoyage) et rend,
|
|
// jusqu'au dernier message, ce que turnView enverra.
|
|
func TestTurnViewDryNeTouchePas(t *testing.T) {
|
|
for _, on := range []bool{false, true} {
|
|
m := projSnapSetup(t, on)
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
playTurn(t, m, c, caps, "bonjour")
|
|
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !on {
|
|
// Reste d'une époque avec la clé : turnView le nettoierait.
|
|
c.mu.Lock()
|
|
c.ProjSnap = &projSnapshot{Key: "x"}
|
|
c.mu.Unlock()
|
|
}
|
|
c.mu.Lock()
|
|
before, _ := json.Marshal(c)
|
|
hist := append([]Message(nil), c.Messages...)
|
|
c.mu.Unlock()
|
|
|
|
dry, _ := c.turnViewDry(caps, append(append([]Message(nil), hist...), um(prewarmSentinel)), true)
|
|
c.mu.Lock()
|
|
after, _ := json.Marshal(c)
|
|
c.mu.Unlock()
|
|
if !bytes.Equal(before, after) {
|
|
t.Fatalf("on=%v : turnViewDry a modifié la discussion", on)
|
|
}
|
|
sent, _, _ := c.turnView(caps, c.epoch, append(append([]Message(nil), hist...), um("suite")), true)
|
|
dw, sw := normalizeSystemMessages(dry), normalizeSystemMessages(sent)
|
|
if len(dw) != len(sw) || !reflect.DeepEqual(jsonValue(t, dw[:len(dw)-1]), jsonValue(t, sw[:len(sw)-1])) {
|
|
t.Fatalf("on=%v : préfixe de turnViewDry ≠ turnView\n%v\n%v", on, jsonValue(t, dw), jsonValue(t, sw))
|
|
}
|
|
}
|
|
}
|
|
|
|
// oldChatPayload : la construction du corps dans runChat avant buildChatPayload,
|
|
// recopiée telle quelle.
|
|
func oldChatPayload(ep chatEndpoint, messages []Message, temperature float64, tools []Tool, disableTools, toolChoiceNone, echoOff bool, effort string, kwargs map[string]any) map[string]any {
|
|
sent := normalizeSystemMessages(messages)
|
|
if toolChoiceNone {
|
|
sent = withTrailingHint(sent, toolsOffHint)
|
|
}
|
|
pol := currentEchoPolicy()
|
|
if ep.External || echoOff {
|
|
pol = echoPolicy{}
|
|
}
|
|
sent, _ = echoMessages(sent, pol)
|
|
payload := map[string]any{
|
|
"model": ep.Model,
|
|
"messages": expandImageRefs(sent),
|
|
"stream": true,
|
|
"temperature": temperature,
|
|
"stream_options": map[string]any{"include_usage": true},
|
|
}
|
|
applySampling(payload)
|
|
if effort != "" {
|
|
payload["reasoning_effort"] = effort
|
|
}
|
|
if kwargs != nil && !ep.External {
|
|
payload["chat_template_kwargs"] = kwargs
|
|
}
|
|
if len(tools) > 0 && !disableTools {
|
|
payload["tools"] = tools
|
|
payload["parallel_tool_calls"] = false
|
|
if toolChoiceNone {
|
|
payload["tool_choice"] = "none"
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// oldTurnReasoning : le calcul du raisonnement dans runChat d'avant, recopié.
|
|
func oldTurnReasoning(cfg map[string]string) (string, string, bool, map[string]any) {
|
|
effortWanted := reasoningEffortValue(cfg["REASONING_EFFORT"])
|
|
reasoningEffort := effortResolve(effortWanted)
|
|
thinkOff := reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
|
|
return effortWanted, reasoningEffort, thinkOff, reasoningTemplateKwargs(thinkOff, reasoningEffort)
|
|
}
|
|
|
|
// Refactorisation : le corps construit par wireMessages + buildChatPayload est,
|
|
// octet pour octet, celui d'avant — sur toute la matrice des options d'une
|
|
// requête (outils coupés ou neutralisés, intensité, gabarit, API externe,
|
|
// raisonnement renvoyé, images).
|
|
func TestBuildChatPayloadIdentiqueAvant(t *testing.T) {
|
|
m := prewarmSetup(t, map[string]string{"TOP_P": "0.9", "TOP_K": "20", "REASONING_ECHO": "on"})
|
|
_ = m
|
|
tools := EnabledTools(Caps{Agent: true, Mem: MemAlways})
|
|
if len(tools) == 0 {
|
|
t.Fatal("aucun outil pour la matrice")
|
|
}
|
|
msgs := []Message{
|
|
{Role: "system", Content: "sys"},
|
|
projectContextMessageForTest(),
|
|
um("bonjour"),
|
|
{Role: "assistant", Content: "salut", ReasoningContent: "je pense", ReasoningModel: echoTestModel},
|
|
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et ça ?"}, map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}}},
|
|
}
|
|
eps := []chatEndpoint{{URL: "http://localhost:1/v1/chat/completions", Model: "loki"}, {URL: "https://api.example/v1/chat/completions", Model: "gpt", External: true}}
|
|
for _, effortCfg := range []string{"", "high", "none"} {
|
|
cfg := map[string]string{"REASONING_EFFORT": effortCfg, "REASONING": "on"}
|
|
w1, e1, o1, k1 := oldTurnReasoning(cfg)
|
|
w2, e2, o2, k2 := turnReasoning(cfg)
|
|
if w1 != w2 || e1 != e2 || o1 != o2 || !reflect.DeepEqual(k1, k2) {
|
|
t.Fatalf("turnReasoning(%q) différent d'avant", effortCfg)
|
|
}
|
|
for _, ep := range eps {
|
|
for _, tl := range [][]Tool{nil, tools} {
|
|
for _, disable := range []bool{false, true} {
|
|
for _, none := range []bool{false, true} {
|
|
for _, echoOff := range []bool{false, true} {
|
|
want := oldChatPayload(ep, msgs, 0.7, tl, disable, none, echoOff, e2, k2)
|
|
sent, _ := wireMessages(msgs, none, turnEchoPolicy(ep.External || echoOff))
|
|
got := buildChatPayload(ep, sent, 0.7, chatPayloadOpts{tools: tl, disableTools: disable, toolChoiceNone: none, effort: e2, kwargs: k2})
|
|
wb, _ := json.Marshal(want)
|
|
gb, _ := json.Marshal(got)
|
|
if !bytes.Equal(wb, gb) {
|
|
t.Fatalf("corps différent (effort=%q ext=%v outils=%d off=%v none=%v echoOff=%v)\navant %s\naprès %s",
|
|
effortCfg, ep.External, len(tl), disable, none, echoOff, wb, gb)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// projectContextMessageForTest : un message projet, que normalizeSystemMessages
|
|
// déplace en tête du premier message utilisateur.
|
|
func projectContextMessageForTest() Message {
|
|
return renderProjectContext("Projet de test")
|
|
}
|
|
|
|
// Refactorisation, de bout en bout : la requête d'un tour joué par generate est
|
|
// celle de l'assemblage et du corps d'avant, pour chaque combinaison de modes.
|
|
func TestGenerateCorpsIdentiqueAvant(t *testing.T) {
|
|
m := prewarmSetup(t, map[string]string{"REASONING_EFFORT": "high", "TOP_P": "0.9"})
|
|
matrix := []Caps{
|
|
{},
|
|
{Agent: true},
|
|
{Agent: true, Mem: MemAlways},
|
|
{Agent: true, Mem: MemOnDemand, Internet: true},
|
|
{Agent: true, Code: true, Role: "builder"},
|
|
{Agent: true, Code: true, Role: "planner"},
|
|
}
|
|
for i, caps := range matrix {
|
|
c := newTestConv()
|
|
c.mu.Lock()
|
|
c.Messages = []Message{um("bonjour"), am("salut")}
|
|
history := append(append([]Message(nil), c.Messages...), um("suite"))
|
|
c.mu.Unlock()
|
|
_, effort, _, kwargs := oldTurnReasoning(ReadConfig())
|
|
final := oldAssembly(caps, history) // normalisé
|
|
want := oldChatPayload(resolveChatEndpoint(), final, 0.7, EnabledTools(caps), false, false, false, effort, kwargs)
|
|
before := len(m.completions())
|
|
prewarmTurn(t, c, caps, "suite")
|
|
comps := m.completions()
|
|
if len(comps) <= before {
|
|
t.Fatalf("cas %d : aucune requête", i)
|
|
}
|
|
var got any
|
|
if err := json.Unmarshal([]byte(comps[before]), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got, jsonValue(t, want)) {
|
|
t.Fatalf("cas %d (%+v) : corps différent d'avant\nreçu %v\nattendu %v", i, caps, got, jsonValue(t, want))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrewarmNearMidnight(t *testing.T) {
|
|
loc := time.Local
|
|
if !prewarmNearMidnight(time.Date(2026, 10, 4, 23, 55, 0, 0, loc)) {
|
|
t.Fatal("23:55 doit s'abstenir")
|
|
}
|
|
if prewarmNearMidnight(time.Date(2026, 10, 4, 23, 45, 0, 0, loc)) || prewarmNearMidnight(time.Date(2026, 10, 4, 0, 5, 0, 0, loc)) {
|
|
t.Fatal("23:45 et 00:05 doivent préchauffer")
|
|
}
|
|
}
|