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