diff --git a/internal/loki/chat_keep_images.go b/internal/loki/chat_keep_images.go index a1e5d8a..5373977 100644 --- a/internal/loki/chat_keep_images.go +++ b/internal/loki/chat_keep_images.go @@ -117,13 +117,15 @@ func logKeptImagesDropped(phase string, freed int) { func stripRelayTags(msgs []Message) []Message { var out []Message for i, m := range msgs { - if !m.ImgRelay { + // La marque des blocs (PROJ_SNAPSHOT) non plus ne + // quitte jamais Loki. + if !m.ImgRelay && !m.CtxUpd { continue } if out == nil { out = append([]Message(nil), msgs...) } - m.ImgRelay = false + m.ImgRelay, m.CtxUpd = false, false out[i] = m } if out == nil { diff --git a/internal/loki/chat_projsnap.go b/internal/loki/chat_projsnap.go index 694fab8..c1d9af7 100644 --- a/internal/loki/chat_projsnap.go +++ b/internal/loki/chat_projsnap.go @@ -367,10 +367,20 @@ func stripCtxUpdText(s string) (string, bool) { // withoutCtxUpdate : le message sans son bloc de tête, ok=false s'il n'en avait // pas (message rendu tel quel). Contenu texte ou multimodal ([]any, []map). +// Seulement un message que Loki a marqué (CtxUpd) : sans la clé, aucun ne +// l'est, et un texte tapé par l'utilisateur n'est jamais retouché. func withoutCtxUpdate(m Message) (Message, bool) { - if m.Role != "user" { + if m.Role != "user" || !m.CtxUpd { return m, false } + m, ok := withoutCtxUpdateText(m) + if ok { + m.CtxUpd = false + } + return m, ok +} + +func withoutCtxUpdateText(m Message) (Message, bool) { switch c := m.Content.(type) { case string: if s, ok := stripCtxUpdText(c); ok { @@ -463,6 +473,7 @@ func prependCtxUpdate(m Message, block string) (Message, bool) { default: return m, false } + m.CtxUpd = true return m, true } diff --git a/internal/loki/chat_projsnap_test.go b/internal/loki/chat_projsnap_test.go index 91240c2..09f0c3e 100644 --- a/internal/loki/chat_projsnap_test.go +++ b/internal/loki/chat_projsnap_test.go @@ -247,7 +247,9 @@ func TestProjSnapCompactionRafraichit(t *testing.T) { 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. - c.Messages = append(c.Messages, am("vu"), um(ctxUpdOpen+` at="x">`+"\n~ poids — 72.4 kg\n"+ctxUpdClose+"\n\nencore")) + 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") } @@ -511,7 +513,9 @@ func TestProjSnapRetireDesLecteurs(t *testing.T) { testHome(t) blk := ctxUpdOpen + ` at="t">` + "\n+ [A](a.md)\n" + ctxUpdClose + "\n\n" c := newTestConv() - c.Messages = []Message{um(blk + "corrige le build"), am("ok")} + 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) } @@ -523,8 +527,8 @@ func TestProjSnapRetireDesLecteurs(t *testing.T) { t.Fatalf("export : %v %s", err, out) } multi := []Message{ - {Role: "user", Content: []any{map[string]any{"type": "text", "text": blk}, map[string]any{"type": "text", "text": "vois l'image"}}}, - {Role: "user", Content: []map[string]any{{"type": "text", "text": blk + "suite"}}}, + {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" { @@ -534,7 +538,7 @@ func TestProjSnapRetireDesLecteurs(t *testing.T) { t.Fatal("l'original ne doit pas être modifié") } // Un texte qui en parle plus loin n'est pas touché. - plain := []Message{um("explique " + blk)} + 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é") } @@ -588,3 +592,36 @@ func TestReplaceProjectHead(t *testing.T) { 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) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index a265272..877e72c 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -38,6 +38,11 @@ type Message struct { // relais et non comme une demande (isLokiInjected) ; retiré à l'envoi // (wireMessages). Faux : JSON inchangé. ImgRelay bool `json:"img_relay,omitempty"` + // CtxUpd : Loki a posé un bloc en tête de ce message + // utilisateur (PROJ_SNAPSHOT, chat_projsnap.go) — jamais sans la clé. Seuls + // les messages marqués sont nettoyés : un texte TAPÉ qui commencerait par la + // même balise reste tel quel. Persisté, retiré à l'envoi (wireMessages). + CtxUpd bool `json:"ctx_upd,omitempty"` // imgTokens : coût mesuré par le moteur de l'image d'un relais GARDÉ dans // l'historique (KEEP_TURN_IMAGES) ; 0 = image non gardée, ou déjà retirée. // En mémoire seulement : un fil rechargé a de toute façon perdu ses images