Contexte : corrections de relecture du lot 2 — seul un bloc posé par Loki est retiré, jamais un texte tapé

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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 12:31:48 +02:00
1 parent 39487f9449
commit 5f00b6f041
4 files changed
+63 -8

No files matched your search

+4 -2
View File
@@ -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 <context_update> (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 {
+12 -1
View File
@@ -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
}
+42 -5
View File
@@ -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)
}
}
+5
View File
@@ -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 <context_update> 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