mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Les blocs <context_update> de PROJ_SNAPSHOT étaient reconnus à leur seul préfixe de texte, et le nettoyage tournait AUSSI sans la clé (requête, historique persisté, titre, tâche du vérificateur, export, résumé). Un message de l'utilisateur qui commençait par cette balise — un journal de requête collé par qui travaille sur Loki — perdait son début en silence : le défaut n'était plus identique à l'octet. - Message.CtxUpd marque le message où Loki a posé un bloc (prependCtxUpdate), comme ImgRelay pour les relais d'image ; seuls les messages marqués sont nettoyés, et la marque tombe avec le bloc. - La marque ne quitte jamais Loki : retirée à l'envoi avec celle des relais. - Sans la clé, aucun message n'est marqué : rien n'est retouché. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
628 lines
22 KiB
Go
628 lines
22 KiB
Go
package loki
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// projSnapSetup : base et projet neufs, moteur de comédie qui répond « ok. ».
|
|
// on = PROJ_SNAPSHOT posée.
|
|
func projSnapSetup(t *testing.T, on bool) *moteurEcho {
|
|
t.Helper()
|
|
withWorkspace(t)
|
|
activeProjMu.Lock()
|
|
activeProjCache = ""
|
|
activeProjMu.Unlock()
|
|
setProjectOverride("")
|
|
t.Cleanup(func() {
|
|
activeProjMu.Lock()
|
|
activeProjCache = ""
|
|
activeProjMu.Unlock()
|
|
setProjectOverride("")
|
|
})
|
|
ensureDefaultProject()
|
|
freshTplProbe(t)
|
|
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if on {
|
|
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
m := &moteurEcho{reply: func(int, string) (int, string) {
|
|
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
|
}}
|
|
m.start(t)
|
|
return m
|
|
}
|
|
|
|
// playTurn joue un tour utilisateur complet sur c et rend le corps de la PREMIÈRE
|
|
// requête de ce tour.
|
|
func playTurn(t *testing.T, m *moteurEcho, c *Conversation, caps Caps, text string) string {
|
|
t.Helper()
|
|
before := len(m.all())
|
|
c.mu.Lock()
|
|
c.Messages = append(c.Messages, um(text))
|
|
c.mu.Unlock()
|
|
c.generate(context.Background(), caps, 0.7, c.epoch)
|
|
all := m.all()
|
|
if len(all) <= before {
|
|
t.Fatalf("aucune requête pour %q", text)
|
|
}
|
|
return all[before]
|
|
}
|
|
|
|
// userContents : le texte des messages user d'une requête, dans l'ordre.
|
|
func userContents(t *testing.T, body string) []string {
|
|
t.Helper()
|
|
var out []string
|
|
for _, m := range requestMessages(t, body) {
|
|
if m["role"] == "user" {
|
|
s, _ := m["content"].(string)
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func systemContent(t *testing.T, body string) string {
|
|
t.Helper()
|
|
for _, m := range requestMessages(t, body) {
|
|
if m["role"] == "system" {
|
|
s, _ := m["content"].(string)
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// oldAssembly : l'assemblage de generate d'avant PROJ_SNAPSHOT, recopié tel quel.
|
|
func oldAssembly(caps Caps, msgs []Message) []Message {
|
|
final := msgs
|
|
if caps.Agent {
|
|
final = append(projectSystemMessages(), msgs...)
|
|
if m, ok := codeInstructionsMessage(caps); ok {
|
|
final = append([]Message{m}, final...)
|
|
}
|
|
if sp := readSysPrompt(); sp != "" {
|
|
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
|
}
|
|
}
|
|
sent, _ := prepareTurn(final, caps)
|
|
return normalizeSystemMessages(sent)
|
|
}
|
|
|
|
func jsonValue(t *testing.T, v any) any {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Clé absente : la requête est celle d'avant, à l'octet près — bloc vivant qui
|
|
// suit la page créée, aucune ligne système de plus, rien de persisté en plus.
|
|
func TestProjSnapDefautRienNeChange(t *testing.T) {
|
|
m := projSnapSetup(t, false)
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
for i, text := range []string{"bonjour", "et ensuite ?"} {
|
|
if i == 1 {
|
|
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
want := oldAssembly(caps, append(append([]Message(nil), c.Messages...), um(text)))
|
|
c.mu.Unlock()
|
|
body := playTurn(t, m, c, caps, text)
|
|
var got struct {
|
|
Messages any `json:"messages"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got.Messages, jsonValue(t, want)) {
|
|
t.Fatalf("tour %d : requête différente de l'assemblage d'avant\nreçu %v\nattendu %v", i, got.Messages, jsonValue(t, want))
|
|
}
|
|
if strings.Contains(body, "context_update") || strings.Contains(body, "as of") {
|
|
t.Fatalf("tour %d : trace du bloc figé sans la clé :\n%s", i, body)
|
|
}
|
|
}
|
|
if us := userContents(t, m.all()[1]); !strings.Contains(us[0], "](docker.md)") {
|
|
t.Fatalf("sans la clé, le bloc vivant doit montrer la nouvelle page : %q", us[0])
|
|
}
|
|
b, _ := json.Marshal(c)
|
|
if c.ProjSnap != nil || strings.Contains(string(b), "proj_snap") {
|
|
t.Fatalf("instantané persisté sans la clé : %s", b)
|
|
}
|
|
}
|
|
|
|
// La première page d'un projet vide arrive en mise à jour : le premier message
|
|
// reste identique, la mise à jour est rangée dans l'historique et renvoyée telle
|
|
// quelle ensuite, la suivante n'annonce que la page ajoutée.
|
|
func TestProjSnapPageAjouteeEnMiseAJour(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
b1 := playTurn(t, m, c, caps, "bonjour")
|
|
if !strings.Contains(systemContent(t, b1), projSnapSystemLine) {
|
|
t.Fatal("ligne fixe du système absente avec la clé")
|
|
}
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b2 := playTurn(t, m, c, caps, "et le NAS ?")
|
|
u1, u2 := userContents(t, b1), userContents(t, b2)
|
|
if u2[0] != u1[0] {
|
|
t.Fatalf("premier message changé :\navant %q\naprès %q", u1[0], u2[0])
|
|
}
|
|
last := u2[len(u2)-1]
|
|
if !strings.HasPrefix(last, ctxUpdOpen) || !strings.Contains(last, "](nas.md)") || !strings.HasSuffix(last, "et le NAS ?") {
|
|
t.Fatalf("mise à jour attendue en tête du nouveau message : %q", last)
|
|
}
|
|
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b3 := playTurn(t, m, c, caps, "et docker ?")
|
|
u3 := userContents(t, b3)
|
|
if u3[0] != u1[0] || u3[1] != u2[1] {
|
|
t.Fatalf("messages déjà envoyés modifiés :\n%q\n%q", u3[0], u3[1])
|
|
}
|
|
last = u3[len(u3)-1]
|
|
if !strings.Contains(last, "+ [Docker](docker.md)") || strings.Contains(last, "nas.md") {
|
|
t.Fatalf("seule la page ajoutée doit être annoncée : %q", last)
|
|
}
|
|
// Sans changement : rien de posé.
|
|
b4 := playTurn(t, m, c, caps, "merci")
|
|
if u4 := userContents(t, b4); u4[len(u4)-1] != "merci" {
|
|
t.Fatalf("mise à jour vide posée : %q", u4[len(u4)-1])
|
|
}
|
|
// Persisté et relu : l'état annoncé survit.
|
|
raw, _ := json.Marshal(c)
|
|
c2 := newTestConv()
|
|
c2.loadFrom("x", raw)
|
|
if c2.ProjSnap == nil || c2.ProjSnap.State.Mem["docker.md"] == "" {
|
|
t.Fatalf("instantané non relu : %+v", c2.ProjSnap)
|
|
}
|
|
}
|
|
|
|
// Une nouvelle valeur de tracker remplace sa ligne (« ~ ») ; un tracker supprimé
|
|
// est annoncé comme tel.
|
|
func TestProjSnapTrackerLigneRemplacee(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
b1 := playTurn(t, m, c, caps, "salut")
|
|
if u := userContents(t, b1); !strings.Contains(u[0], "Trackers (tracker tool) as of ") || strings.Contains(u[0], "answer straight from this") {
|
|
t.Fatalf("en-tête figé attendu : %q", u[0])
|
|
}
|
|
if _, err := trackerAdd("poids", "2026-10-03", "73 kg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b2 := playTurn(t, m, c, caps, "mon poids ?")
|
|
u := userContents(t, b2)
|
|
if u[0] != userContents(t, b1)[0] {
|
|
t.Fatal("premier message changé")
|
|
}
|
|
if !strings.Contains(u[len(u)-1], "~ poids — 73 kg (2026-10-03)") {
|
|
t.Fatalf("ligne de tracker attendue : %q", u[len(u)-1])
|
|
}
|
|
}
|
|
|
|
// Une compaction (début, fin, manuelle) reprend le bloc tout neuf au tour suivant
|
|
// et retire les mises à jour de la queue gardée : une vieille valeur ne doit pas
|
|
// suivre la fraîche.
|
|
func TestProjSnapCompactionRafraichit(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
playTurn(t, m, c, caps, "salut")
|
|
if _, err := trackerAdd("poids", "2026-10-02", "72.8 kg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
playTurn(t, m, c, caps, "note-le")
|
|
if !hasContextUpdates(c.Messages) {
|
|
t.Fatal("mise à jour attendue dans l'historique")
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
|
|
}
|
|
// Le message avec la mise à jour en dernier : il tombe dans la queue gardée.
|
|
upd := um(ctxUpdOpen + ` at="x">` + "\n~ poids — 72.4 kg\n" + ctxUpdClose + "\n\nencore")
|
|
upd.CtxUpd = true
|
|
c.Messages = append(c.Messages, am("vu"), upd)
|
|
if _, changed := c.compactAndPublish(context.Background(), c.epoch, "test", append([]Message(nil), c.Messages...), 50000, caps, compactOpts{}); !changed {
|
|
t.Fatal("compaction sans effet")
|
|
}
|
|
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
|
|
t.Fatalf("après compaction : instantané %v, mises à jour restantes %v", c.ProjSnap != nil, hasContextUpdates(c.Messages))
|
|
}
|
|
b := playTurn(t, m, c, caps, "et maintenant ?")
|
|
if strings.Contains(strings.Join(userContents(t, b), " "), ctxUpdOpen) {
|
|
t.Fatalf("mise à jour après le bloc neuf : %s", b)
|
|
}
|
|
if u := userContents(t, b); !strings.Contains(u[0], "72.8 kg") {
|
|
t.Fatalf("bloc neuf attendu avec la dernière valeur : %q", u[0])
|
|
}
|
|
}
|
|
|
|
// Compaction EN COURS de tour : le bloc vivant remplace le figé dans la requête
|
|
// rejouée (la mise à jour avalée par le résumé n'est pas perdue), et c'est lui
|
|
// que le tour suivant renvoie.
|
|
func TestProjSnapCompactionEnTour(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
playTurn(t, m, c, caps, "salut")
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 12; i++ {
|
|
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
|
|
}
|
|
c.Messages = append(c.Messages, am("vu"))
|
|
n := len(m.all())
|
|
m.mu.Lock()
|
|
m.reply = func(k int, _ string) (int, string) {
|
|
if k == n {
|
|
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
|
|
}
|
|
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
|
}
|
|
m.mu.Unlock()
|
|
playTurn(t, m, c, caps, "et le NAS ?")
|
|
all := m.all()
|
|
first, retry := all[n], all[n+1]
|
|
if uf := userContents(t, first); !strings.HasPrefix(uf[len(uf)-1], ctxUpdOpen) {
|
|
t.Fatal("mise à jour attendue dans la requête refusée")
|
|
}
|
|
u := userContents(t, retry)
|
|
if strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
|
|
t.Fatalf("mise à jour restée après la compaction en cours de tour : %q", u)
|
|
}
|
|
if !strings.Contains(u[0], "](nas.md)") {
|
|
t.Fatalf("la page doit être dans le bloc rafraîchi : %q", u[0])
|
|
}
|
|
if c.ProjSnap == nil || c.ProjSnap.State.Mem["nas.md"] == "" || hasContextUpdates(c.Messages) {
|
|
t.Fatalf("instantané rafraîchi non rangé : %+v", c.ProjSnap)
|
|
}
|
|
next := playTurn(t, m, c, caps, "merci")
|
|
head := func(s string) string { return s[:strings.Index(s, "</project_context>")] }
|
|
if head(userContents(t, next)[0]) != head(u[0]) {
|
|
t.Fatal("le tour suivant doit renvoyer le bloc rafraîchi à l'identique")
|
|
}
|
|
}
|
|
|
|
// Changer de projet, de discussion ou repartir à zéro ne garde pas l'instantané.
|
|
func TestProjSnapProjetEtDiscussion(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
playTurn(t, m, c, caps, "salut")
|
|
p, err := createProject("Jardin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setProjectDesc(p.Slug, "Le potager."); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setActiveProject(p.Slug); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := playTurn(t, m, c, caps, "et le jardin ?")
|
|
u := userContents(t, b)
|
|
if !strings.Contains(u[0], "Le potager.") || strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
|
|
t.Fatalf("changement de projet : bloc neuf attendu, sans mise à jour : %q", u[0])
|
|
}
|
|
if c.ProjSnap == nil {
|
|
t.Fatal("instantané attendu")
|
|
}
|
|
// loadFrom : fil neuf, ou enregistré sans le champ.
|
|
c.loadFrom("neuf", nil)
|
|
if c.ProjSnap != nil {
|
|
t.Fatal("fil neuf : instantané hérité")
|
|
}
|
|
c.ProjSnap = &projSnapshot{Key: "x"}
|
|
c.loadFrom("ancien", []byte(`{"messages":[{"role":"user","content":"vieux"}]}`))
|
|
if c.ProjSnap != nil {
|
|
t.Fatal("fil enregistré sans instantané : hérité")
|
|
}
|
|
c.ProjSnap = &projSnapshot{Key: "x"}
|
|
c.Reset()
|
|
if c.ProjSnap != nil {
|
|
t.Fatal("Reset garde l'instantané")
|
|
}
|
|
}
|
|
|
|
// Sans agent : ni bloc ni mise à jour, et les blocs restés d'avant ne partent pas.
|
|
func TestProjSnapSansAgent(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
c := newTestConv()
|
|
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "salut")
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "le NAS ?")
|
|
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := c.ProjSnap.State
|
|
b := playTurn(t, m, c, Caps{}, "réponds sans outils")
|
|
if strings.Contains(b, "context_update") || strings.Contains(b, "project_context") {
|
|
t.Fatalf("agent off : contexte projet envoyé : %s", b)
|
|
}
|
|
if last := c.Messages[len(c.Messages)-2]; msgText(last) != "réponds sans outils" {
|
|
t.Fatalf("agent off : message modifié : %q", msgText(last))
|
|
}
|
|
if !reflect.DeepEqual(c.ProjSnap.State, before) {
|
|
t.Fatal("agent off : état annoncé modifié")
|
|
}
|
|
}
|
|
|
|
// Clé retirée après usage : l'instantané et les blocs sont nettoyés, retour au
|
|
// bloc vivant sans vieille mise à jour derrière lui.
|
|
func TestProjSnapCleRetiree(t *testing.T) {
|
|
m := projSnapSetup(t, true)
|
|
c := newTestConv()
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
playTurn(t, m, c, caps, "salut")
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
playTurn(t, m, c, caps, "le NAS ?")
|
|
if err := SetConfigKey("PROJ_SNAPSHOT", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := playTurn(t, m, c, caps, "et après ?")
|
|
if strings.Contains(b, "context_update") || strings.Contains(b, "as of") {
|
|
t.Fatalf("restes du bloc figé : %s", b)
|
|
}
|
|
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
|
|
t.Fatal("historique non nettoyé")
|
|
}
|
|
}
|
|
|
|
// Rafraîchissement : clé de validité (redémarrage, réglage moteur), système ou
|
|
// outils changés, seuil d'accumulation.
|
|
func TestProjSnapApplyRafraichit(t *testing.T) {
|
|
projSnapSetup(t, true)
|
|
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
caps := Caps{Agent: true, Mem: MemAlways}
|
|
live := projSnapCapture(caps, "t0")
|
|
c := newTestConv()
|
|
c.Messages = []Message{um("a")}
|
|
if r, _ := c.projSnapApplyLocked(live, "k", "s", "t0"); !r {
|
|
t.Fatal("premier tour : instantané attendu")
|
|
}
|
|
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
live2 := projSnapCapture(caps, "t1")
|
|
c.Messages = append(c.Messages, am("b"), um("c"))
|
|
if r, d := c.projSnapApplyLocked(live2, "k", "s", "t1"); r || d == "" {
|
|
t.Fatalf("mise à jour attendue : refresh=%v delta=%q", r, d)
|
|
}
|
|
for _, tc := range []struct{ name, key, sys string }{{"clé", "k2", "s"}, {"système", "k", "s2"}} {
|
|
snap := *c.ProjSnap
|
|
c.Messages = append(c.Messages, am("d"), um("e"))
|
|
if r, _ := c.projSnapApplyLocked(live, tc.key, tc.sys, "t2"); !r {
|
|
t.Fatalf("%s changé : rafraîchissement attendu", tc.name)
|
|
}
|
|
if hasContextUpdates(c.Messages) {
|
|
t.Fatalf("%s : mises à jour restées après rafraîchissement", tc.name)
|
|
}
|
|
c.ProjSnap = &snap
|
|
}
|
|
// Seuil : les mises à jour accumulées dépassent la limite.
|
|
c.ProjSnap.Key, c.ProjSnap.SysHash = "k", "s"
|
|
c.ProjSnap.DeltaTok = projSnapDeltaLimit(c.ProjSnap.SnapTok)
|
|
c.ProjSnap.State = live.state
|
|
if r, _ := c.projSnapApplyLocked(live2, "k", "s", "t3"); !r {
|
|
t.Fatal("seuil dépassé : rafraîchissement attendu")
|
|
}
|
|
// La clé suit le lancement de Loki.
|
|
k1 := projSnapKey(caps, ReadConfig())
|
|
old := projSnapBoot
|
|
projSnapBoot = "autre"
|
|
defer func() { projSnapBoot = old }()
|
|
if projSnapKey(caps, ReadConfig()) == k1 {
|
|
t.Fatal("redémarrage : clé inchangée")
|
|
}
|
|
projSnapBoot = old
|
|
if err := SetConfigKey("REASONING_EFFORT", "high"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if projSnapKey(caps, ReadConfig()) != k1 {
|
|
t.Fatal("un réglage de conversation ne doit pas rafraîchir")
|
|
}
|
|
if err := SetConfigKey("MODEL", "/models/autre.gguf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if projSnapKey(caps, ReadConfig()) == k1 {
|
|
t.Fatal("changement de modèle : clé inchangée")
|
|
}
|
|
}
|
|
|
|
// La prose repart en entier ; les sections faites de lignes, ligne à ligne.
|
|
func TestProjDeltaParType(t *testing.T) {
|
|
projSnapSetup(t, true)
|
|
old := projState{
|
|
Code: "Repository instructions (AGENTS.md) — follow them:\n\nmake test", Desc: "Ancienne description.",
|
|
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md)", "b.md": "- [B](b.md)"},
|
|
Trk: map[string]string{"poids": "poids — 72 kg (2026-10-01)", "pas": "pas — 8000 (2026-10-01)"},
|
|
}
|
|
lv := projLive{
|
|
state: projState{
|
|
Code: "Repository instructions (AGENTS.md) — follow them:\n\ngo test ./...", Desc: "Nouvelle description,\nsur deux lignes.",
|
|
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md) — accroche", "c.md": "- [C](c.md)"},
|
|
Trk: map[string]string{"poids": "poids — 73 kg (2026-10-03)", "eau": "eau — 2 L (2026-10-03)"},
|
|
},
|
|
memOrder: []string{"a.md", "c.md"},
|
|
trkOrder: []string{"poids", "eau"},
|
|
}
|
|
d := projDelta(old, lv, "2026-10-04 09:00")
|
|
for _, want := range []string{
|
|
ctxUpdOpen + ` at="2026-10-04 09:00">`,
|
|
"go test ./...",
|
|
"Nouvelle description,\nsur deux lignes.",
|
|
"~ [A](a.md) — accroche", "+ [C](c.md)", "- b.md (deleted)",
|
|
"~ poids — 73 kg (2026-10-03)", "+ eau — 2 L (2026-10-03)", "- pas — 8000 (2026-10-01) (deleted)",
|
|
} {
|
|
if !strings.Contains(d, want) {
|
|
t.Fatalf("manque %q dans :\n%s", want, d)
|
|
}
|
|
}
|
|
if strings.Contains(d, "make test") || strings.Contains(d, "Ancienne") {
|
|
t.Fatalf("l'ancien texte ne doit pas repartir :\n%s", d)
|
|
}
|
|
// En-tête de l'index modifié : l'index repart en entier.
|
|
lv.state.MemProse = "# Index (revu)"
|
|
lv.memMsg = Message{Content: "Project memory index … # Index (revu)"}
|
|
if d := projDelta(old, lv, "t"); !strings.Contains(d, "Current version, replaces any earlier one — Project memory index") {
|
|
t.Fatalf("prose de l'index modifiée : index complet attendu :\n%s", d)
|
|
}
|
|
if projDelta(old, projLive{state: old}, "t") != "" {
|
|
t.Fatal("rien de changé : mise à jour vide attendue")
|
|
}
|
|
}
|
|
|
|
// Les blocs sont retirés partout où le texte de l'utilisateur est lu : tâche du
|
|
// vérificateur, titre, export, entrée du résumeur ; en texte et en multimodal.
|
|
func TestProjSnapRetireDesLecteurs(t *testing.T) {
|
|
testHome(t)
|
|
blk := ctxUpdOpen + ` at="t">` + "\n+ [A](a.md)\n" + ctxUpdClose + "\n\n"
|
|
c := newTestConv()
|
|
first := um(blk + "corrige le build")
|
|
first.CtxUpd = true
|
|
c.Messages = []Message{first, am("ok")}
|
|
if got := c.lastUserText(); got != "corrige le build" {
|
|
t.Fatalf("tâche du vérificateur : %q", got)
|
|
}
|
|
if got := convSummary(c.Messages); got != "corrige le build" {
|
|
t.Fatalf("titre : %q", got)
|
|
}
|
|
out, err := c.ExportJSON(exportOpts{})
|
|
if err != nil || strings.Contains(string(out), "context_update") {
|
|
t.Fatalf("export : %v %s", err, out)
|
|
}
|
|
multi := []Message{
|
|
{Role: "user", CtxUpd: true, Content: []any{map[string]any{"type": "text", "text": blk}, map[string]any{"type": "text", "text": "vois l'image"}}},
|
|
{Role: "user", CtxUpd: true, Content: []map[string]any{{"type": "text", "text": blk + "suite"}}},
|
|
}
|
|
got := stripContextUpdates(multi)
|
|
if hasContextUpdates(got) || len(got[0].Content.([]any)) != 1 || got[1].Content.([]map[string]any)[0]["text"] != "suite" {
|
|
t.Fatalf("multimodal : %+v", got)
|
|
}
|
|
if hasContextUpdates(multi) == false {
|
|
t.Fatal("l'original ne doit pas être modifié")
|
|
}
|
|
// Un texte qui en parle plus loin n'est pas touché.
|
|
plain := []Message{{Role: "user", CtxUpd: true, Content: "explique " + blk}}
|
|
if s := stripContextUpdates(plain); msgText(s[0]) != msgText(plain[0]) {
|
|
t.Fatal("bloc hors tête retiré")
|
|
}
|
|
// Mise à jour posée puis retirée : retour exact au texte.
|
|
for _, m0 := range []Message{um("x"), {Role: "user", Content: []any{map[string]any{"type": "text", "text": "vois"}}}} {
|
|
m1, ok := prependCtxUpdate(m0, blk)
|
|
if !ok {
|
|
t.Fatal("pose refusée")
|
|
}
|
|
m2, _ := withoutCtxUpdate(m1)
|
|
if !reflect.DeepEqual(jsonValue(t, m2), jsonValue(t, m0)) {
|
|
t.Fatalf("aller-retour : %+v", m2)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Valeurs de la clé ; un preset externe n'est jamais concerné.
|
|
func TestProjSnapEnabled(t *testing.T) {
|
|
for v, want := range map[string]bool{"": false, "off": false, "0": false, "on": true, "1": true, "oui": true, " ON ": true} {
|
|
if got := projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": v}); got != want {
|
|
t.Fatalf("%q : %v", v, got)
|
|
}
|
|
}
|
|
if projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": "on", extKeyFlag: "1"}) {
|
|
t.Fatal("preset externe : jamais de bloc figé")
|
|
}
|
|
}
|
|
|
|
// replaceProjectHead garde le rappel des pages lues, après le bloc neuf.
|
|
func TestReplaceProjectHead(t *testing.T) {
|
|
msgs := []Message{
|
|
{Role: "system", Content: "base"},
|
|
{Role: "system", Content: projectContextPrefix + " — vieux"},
|
|
{Role: "system", Content: memIndexPrefix + " vieux"},
|
|
{Role: "system", Content: memReminderPrefix + " a.md"},
|
|
um("q"),
|
|
}
|
|
block := []Message{{Role: "system", Content: memIndexPrefix + " neuf"}}
|
|
out := replaceProjectHead(msgs, block)
|
|
var got []string
|
|
for _, m := range out {
|
|
got = append(got, msgText(m))
|
|
}
|
|
want := []string{"base", memIndexPrefix + " neuf", memReminderPrefix + " a.md", "q"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("%v", got)
|
|
}
|
|
// Bloc vide au départ : inséré après le système commun.
|
|
out = replaceProjectHead([]Message{{Role: "system", Content: "base"}, um("q")}, block)
|
|
if msgText(out[1]) != memIndexPrefix+" neuf" || msgText(out[2]) != "q" {
|
|
t.Fatalf("%+v", out)
|
|
}
|
|
}
|
|
|
|
// Corrections de relecture du lot 2 : seul un bloc posé par Loki (marqué) est
|
|
// retiré. Sans la clé, un message TAPÉ qui commence par la même balise reste
|
|
// tel quel partout — historique, envoi, titre — et la marque ne part jamais
|
|
// au moteur.
|
|
func TestProjSnapTexteTapeIntact(t *testing.T) {
|
|
testHome(t)
|
|
typed := um(ctxUpdOpen + ` at="x">` + "\ncollé depuis un journal\n" + ctxUpdClose + "\n\nqu'est-ce que c'est ?")
|
|
c := newTestConv()
|
|
c.Messages = []Message{typed}
|
|
if got := stripContextUpdates(c.Messages); msgText(got[0]) != msgText(typed) {
|
|
t.Fatal("texte tapé retouché")
|
|
}
|
|
sent, _, _ := c.turnView(Caps{Agent: true}, c.epoch, append([]Message(nil), c.Messages...), true)
|
|
if msgText(sent[len(sent)-1]) != msgText(typed) || msgText(c.Messages[0]) != msgText(typed) {
|
|
t.Fatalf("texte tapé retouché à l'envoi ou dans l'historique : %q", msgText(sent[len(sent)-1]))
|
|
}
|
|
if got := convSummary(c.Messages); !strings.Contains(got, "collé depuis un journal") {
|
|
t.Fatalf("titre : %q", got)
|
|
}
|
|
marked, ok := prependCtxUpdate(um("suite"), ctxUpdOpen+` at="t">`+"\n+ x\n"+ctxUpdClose+"\n\n")
|
|
if !ok || !marked.CtxUpd {
|
|
t.Fatal("bloc posé sans marque")
|
|
}
|
|
wire, _ := wireMessages([]Message{marked}, false, echoPolicy{})
|
|
b, _ := json.Marshal(wire)
|
|
if strings.Contains(string(b), "ctx_upd") {
|
|
t.Fatalf("marque envoyée au moteur : %s", b)
|
|
}
|
|
if s, ok := withoutCtxUpdate(marked); !ok || s.CtxUpd || msgText(s) != "suite" {
|
|
t.Fatalf("bloc marqué non retiré : %+v", s)
|
|
}
|
|
}
|