diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go
index 2c5b0a8..51907ff 100644
--- a/internal/loki/chat_compact.go
+++ b/internal/loki/chat_compact.go
@@ -785,13 +785,26 @@ func summaryRuneCap() int {
// - finish_reason=length : le texte est gardé (le début d'un bon résumé vaut
// mieux que rien), marqué « […] » et tracé dans le journal.
func cleanSummary(content, reasoning, finish string) (string, error) {
- c := content
- if i := strings.LastIndex(c, thinkClose); i >= 0 {
- c = c[i+len(thinkClose):]
+ c := strings.TrimSpace(content)
+ // Le raisonnement ne se retire qu'en TÊTE : soit un bloc …
+ // qui ouvre la réponse, soit un sans avant lui (gabarit qui
+ // ouvre le bloc dans le prompt). Couper au DERNIER , comme avant,
+ // amputait tout le début d'un résumé de session Code qui cite la balise (un
+ // parseur, un gabarit) — et une balise citée après un en milieu de
+ // texte n'est pas du raisonnement non plus.
+ for {
+ i := strings.Index(c, thinkClose)
+ if i < 0 {
+ break
+ }
+ if o := strings.Index(c[:i], ""); o > 0 {
+ break
+ }
+ c = strings.TrimSpace(c[i+len(thinkClose):])
+ if !strings.HasPrefix(c, "") {
+ break
+ }
}
- c = strings.TrimSpace(c)
- // En TÊTE seulement : un résumé de session Code peut citer la balise
- // (un parseur, un gabarit) sans être du raisonnement.
if strings.HasPrefix(c, "") {
return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé")
}
@@ -801,18 +814,19 @@ func cleanSummary(content, reasoning, finish string) (string, error) {
}
return "", fmt.Errorf("résumé: texte vide")
}
- cut := false
+ cut := ""
if r := []rune(c); len(r) > summaryRuneCap() {
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
- cut = true
+ cut = fmt.Sprintf("par le filet de %d caractères", summaryRuneCap())
}
if finish == "length" {
- fmt.Fprintf(os.Stderr, "[compact] résumé coupé par max_tokens=%d (%d caractères gardés)\n",
- compactSummaryBudget(), len([]rune(c)))
- cut = true
+ cut = fmt.Sprintf("par max_tokens=%d", compactSummaryBudget())
}
- if cut {
+ if cut != "" {
+ // Tracé dans les deux cas : le filet ne mord que sur une API qui
+ // ignore max_tokens, et c'est l'ÉTAT D'AVANCEMENT qui tombe.
+ fmt.Fprintf(os.Stderr, "[compact] résumé coupé %s (%d caractères gardés)\n", cut, len([]rune(c)))
c += " […]"
}
return c, nil
diff --git a/internal/loki/chat_compact_test.go b/internal/loki/chat_compact_test.go
index c5eb6cd..30196ef 100644
--- a/internal/loki/chat_compact_test.go
+++ b/internal/loki/chat_compact_test.go
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"strings"
+ "sync"
"testing"
)
@@ -259,6 +260,11 @@ func TestCompactageModeCodeGardeLEtat(t *testing.T) {
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
func TestCleanSummary(t *testing.T) {
testHome(t)
+ // Fenêtre fixée : le filet (budget×6) en dépend, le cas « long » ne doit pas
+ // basculer au-delà si la valeur par défaut de CTX change.
+ if err := SetConfigKey("CTX", "65536"); err != nil {
+ t.Fatal(err)
+ }
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
huge := strings.Repeat("x", summaryRuneCap()+500)
cases := []struct {
@@ -272,6 +278,11 @@ func TestCleanSummary(t *testing.T) {
{"length gardé et marqué", "- début du résumé", "", "length", "- début du résumé […]", false},
{"au-delà du filet", huge, "", "stop", strings.Repeat("x", summaryRuneCap()) + " […]", false},
{"balise citée en milieu", "- le parseur retire en tête", "", "stop", "- le parseur retire en tête", false},
+ {"bloc cité en milieu gardé entier", "- A\n- le parseur retire x en tête\n- B", "", "stop", "- A\n- le parseur retire x en tête\n- B", false},
+ {"think retiré, balise citée après", "r\n- A\n- le parseur coupe à \n- B", "", "stop", "- A\n- le parseur coupe à \n- B", false},
+ {"ouverture dans le prompt", "je réfléchis\n- résumé", "", "stop", "- résumé", false},
+ {"deux blocs en tête", "ab\n- résumé", "", "stop", "- résumé", false},
+ {"second bloc jamais refermé", "a\nb", "", "length", "", true},
{"think jamais refermé", "je réfléchis encore", "", "length", "", true},
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
{"think vide de réponse", "x ", "", "stop", "", true},
@@ -289,9 +300,18 @@ func TestCleanSummary(t *testing.T) {
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
func TestSummarizeLongNotTruncated(t *testing.T) {
testHome(t)
+ if err := SetConfigKey("CTX", "65536"); err != nil {
+ t.Fatal(err)
+ }
long := strings.Repeat("ü", 7000)
+ // Réponse changée entre deux requêtes : derrière un mutex, le handler tourne
+ // dans une goroutine du serveur.
+ var mu sync.Mutex
var reply map[string]any
+ setReply := func(r map[string]any) { mu.Lock(); reply = r; mu.Unlock() }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
_ = json.NewEncoder(w).Encode(reply)
}))
t.Cleanup(srv.Close)
@@ -299,12 +319,12 @@ func TestSummarizeLongNotTruncated(t *testing.T) {
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
- reply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}}
+ setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}})
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
if err != nil || got != long {
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
}
- reply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}}
+ setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}})
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
}