From 432308c5b2f0f9bc8adf427abf8fcb2bd3447d04 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 14:11:59 +0200 Subject: [PATCH] =?UTF-8?q?Compaction=20:=20une=20compaction=20en=20cours?= =?UTF-8?q?=20de=20tour=20ne=20fait=20plus=20dispara=C3=AEtre=20le=20rappe?= =?UTF-8?q?l=20des=20pages=20lues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Après une compaction en cours de tour, generate rangeait la vue publiée en retirant TOUS les messages système de tête. Ceux que la vue d'envoi avait posés (préambule, prompt du preset, contexte du projet ou bloc figé) n'ont rien à faire dans l'historique ; mais le rappel « [MEMORY PAGES REMINDER] », posé par une compaction de début de tour, en fait partie — il partait avec eux, et le modèle perdait la liste des pages à relire. - historyFromView : un système de tête n'est gardé que s'il figurait en tête de l'historique d'où la vue est partie (la compaction garde la tête telle quelle) ; un système fusionné par InjectSkills devant le préambule est rendu tel qu'il était. - Même correction pour la reprise du builder (mode Code) et le terminal, où le /sys de l'utilisateur disparaissait de la même façon. - Test : la compaction réactive en plein tour garde le rappel, avec et sans PROJ_SNAPSHOT, et ne range aucun système injecté ; échoue sur l'ancien code. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_cmd.go | 12 ++-- internal/loki/chat_conversation.go | 57 ++++++++++++++++--- internal/loki/chat_history_view_test.go | 76 +++++++++++++++++++++++++ internal/loki/code_verify.go | 11 ++-- 4 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 internal/loki/chat_history_view_test.go diff --git a/internal/loki/chat_cmd.go b/internal/loki/chat_cmd.go index 4717daf..3b59da3 100644 --- a/internal/loki/chat_cmd.go +++ b/internal/loki/chat_cmd.go @@ -76,19 +76,17 @@ func cmdChat(args []string) error { fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]")) } sent, tools := prepareTurn(msgs, caps) + hist := msgs extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) case ev.NewHistory != nil: // Compaction survenue pendant le tour : elle REMPLACE l'historique (elle - // contient déjà le tour en cours), préfixe système injecté retiré. Sans - // ça le terminal repartirait du fil complet au tour suivant. - base := ev.NewHistory - for len(base) > 0 && base[0].Role == "system" { - base = base[1:] - } - msgs = append([]Message(nil), base...) + // contient déjà le tour en cours), préfixe système injecté retiré — + // le /sys de l'utilisateur, lui, reste (historyFromView). Sans ça le + // terminal repartirait du fil complet au tour suivant. + msgs = historyFromView(ev.NewHistory, hist) fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]")) case ev.Stats != nil: stats = ev.Stats diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 21dde87..5078819 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -438,6 +438,47 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s return compacted, true } +// historyFromView rend l'historique à ranger après une compaction en cours de +// tour : la vue publiée (NewHistory), sans les messages système que la vue +// d'envoi avait posés en tête — préambule et prompt du preset, consignes du +// dépôt, contexte du projet ou bloc figé. +// +// Retirer TOUS les systèmes de tête effaçait aussi ceux de l'historique +// lui-même : le rappel des pages mémoire lues ([MEMORY PAGES REMINDER]), posé +// par une compaction de début de tour, disparaissait à la première compaction +// en cours de tour, et le modèle perdait la liste des pages à relire. Un +// système de tête n'est donc gardé que s'il figurait en tête de hist, +// l'historique dont la vue est partie : la compaction garde la tête telle +// quelle, et la vue d'envoi n'y ajoute que ses propres messages. Seule +// exception, le premier : InjectSkills a pu fusionner le préambule devant un +// système de l'historique (« préambule\n\nsystème ») — on rend alors celui-ci. +func historyFromView(view, hist []Message) []Message { + own := map[string]int{} + for _, m := range hist { + if m.Role != "system" { + break + } + own[msgText(m)]++ + } + var out []Message + i := 0 + for ; i < len(view) && view[i].Role == "system"; i++ { + k := msgText(view[i]) + if own[k] > 0 { + own[k]-- + out = append(out, view[i]) + continue + } + if i == 0 && len(hist) > 0 && hist[0].Role == "system" { + if h := msgText(hist[0]); h != "" && own[h] > 0 && strings.HasSuffix(k, "\n\n"+h) { + own[h]-- + out = append(out, hist[0]) + } + } + } + return append(out, view[i:]...) +} + // convState renvoie un instantané léger (pour /api/chat/state). func (c *Conversation) state() map[string]any { c.mu.Lock() @@ -848,6 +889,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // projet est celui, figé, de la discussion, et ses changements partent en // tête de ce message — après la compaction de début de tour, sous c.mu. sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent) + hist := msgs // l'historique d'où part la vue : voir historyFromView runCtx := ctx if snapTurn != nil { runCtx = withProjSnapTurn(ctx, snapTurn) @@ -913,15 +955,12 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.appendDelta(epoch, map[string]any{"tool_used": tu}) case ev.NewHistory != nil: // Compaction faite en cours de tour : elle remplace la base au lieu de - // s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire le - // préfixe système injecté à la volée (prompt perso + skills, fusionnés en - // UN message system en tête) : il n'appartient pas à l'historique persisté - // et doit rester modifiable à chaud. - base := ev.NewHistory - for len(base) > 0 && base[0].Role == "system" { - base = base[1:] - } - newBase = append([]Message(nil), base...) + // s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire ce + // que la vue d'envoi a posé en tête (prompt perso + préambule, contexte + // du projet) : il n'appartient pas à l'historique persisté et doit + // rester modifiable à chaud. Le rappel des pages lues, lui, en fait + // partie (historyFromView). + newBase = historyFromView(ev.NewHistory, hist) case ev.Echo != nil: echo = ev.Echo case ev.Compacting != nil: diff --git a/internal/loki/chat_history_view_test.go b/internal/loki/chat_history_view_test.go new file mode 100644 index 0000000..9d7352e --- /dev/null +++ b/internal/loki/chat_history_view_test.go @@ -0,0 +1,76 @@ +package loki + +import ( + "reflect" + "strings" + "testing" +) + +// Compaction EN COURS de tour : la vue publiée perd ce que la vue d'envoi avait +// posé en tête (préambule, prompt du preset, contexte du projet), mais garde le +// rappel des pages lues, qui appartient à l'historique. +func TestCompactionEnTourGardeLeRappel(t *testing.T) { + for _, on := range []bool{false, true} { + m := projSnapSetup(t, on) + if err := saveSysPrompt("Réponds en français."); err != nil { + t.Fatal(err) + } + if err := MemAdd("nas.md", "# NAS\n"); err != nil { + t.Fatal(err) + } + reminder, _ := buildReminderMessage([]string{"nas.md"}) + c := newTestConv() + c.Messages = []Message{reminder} + for i := 0; i < 12; i++ { + c.Messages = append(c.Messages, um("continue "+strings.Repeat("x", 600)), am("étape "+strings.Repeat("détail ", 300))) + } + 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{Agent: true, Mem: MemAlways}, "et le NAS ?") + if len(m.all()) < n+2 { + t.Fatalf("clé %v : pas de requête rejouée après compaction", on) + } + if len(c.Messages) == 0 || !reflect.DeepEqual(c.Messages[0], reminder) { + t.Fatalf("clé %v : rappel des pages lues perdu : %+v", on, c.Messages[0]) + } + for _, msg := range c.Messages[1:] { + if msg.Role == "system" { + t.Fatalf("clé %v : système injecté rangé dans l'historique : %q", on, msgText(msg)) + } + } + if len(c.Messages) >= 25 { + t.Fatalf("clé %v : compaction non rangée (%d messages)", on, len(c.Messages)) + } + if err := saveSysPrompt(""); err != nil { + t.Fatal(err) + } + } +} + +func TestHistoryFromView(t *testing.T) { + rem := Message{Role: "system", Content: memReminderPrefix + " Memory pages you read earlier: a.md"} + sys := Message{Role: "system", Content: "/sys perso"} + u := um("q") + cases := []struct { + name string + view, hist []Message + want []Message + }{ + {"injectés seuls", []Message{{Role: "system", Content: "préambule"}, {Role: "system", Content: projectContextPrefix + " x"}, u}, []Message{u}, []Message{u}}, + {"rappel gardé", []Message{{Role: "system", Content: "préambule"}, {Role: "system", Content: projectContextPrefix + " x"}, rem, u}, []Message{rem, u}, []Message{rem, u}}, + {"système fusionné rendu", []Message{{Role: "system", Content: "préambule\n\n/sys perso"}, u}, []Message{sys, u}, []Message{sys, u}}, + {"sans système", []Message{u}, []Message{u}, []Message{u}}, + } + for _, tc := range cases { + if got := historyFromView(tc.view, tc.hist); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s : %+v, attendu %+v", tc.name, got, tc.want) + } + } +} diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index 6441244..95d3fad 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -177,13 +177,10 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur } if ev.NewHistory != nil { // Préfixe système injecté retiré, comme dans generate : il n'appartient - // pas à l'historique persisté. Ignorer cet événement laissait la - // compaction perdue et c.Messages incohérent (fil complet + tour). - base := ev.NewHistory - for len(base) > 0 && base[0].Role == "system" { - base = base[1:] - } - newBase = append([]Message(nil), base...) + // pas à l'historique persisté (le rappel des pages lues, si). Ignorer + // cet événement laissait la compaction perdue et c.Messages incohérent + // (fil complet + tour). + newBase = historyFromView(ev.NewHistory, msgs) } if ev.Echo != nil { echo = ev.Echo