From ad52da18a9ef3bfe0c41fa9dc335d4a5024b2883 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:13:27 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2034=20:=20bloc=20projet=20fig=C3=A9=20(PRO?= =?UTF-8?q?J=5FSNAPSHOT)=20dans=20le=20tour=20de=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROJ_SNAPSHOT était le dernier réglage qui gardait la conversation au Go : la façade la tient désormais dans tous les cas (LOKI_CHAT=go reste la porte de sortie). facadeChatBlocker et la décision prise une fois par processus disparaissent. Go (facade_projsnap.go) : - /internal/v1/turn/view, avec la clé et l'agent : tête du tour portant le bloc figé (ou le vivant à rafraîchir), préambule sans date ni dossier, et la décision projsnap (rafraîchir, mise à jour à poser, instantané à ranger, clé et système du tour) ; dry pour le préchauffage ; - /internal/v1/turn/projsnap/refresh : bloc vivant et instantané qu'il devient, pour une compaction en plein tour. Rust : - ChatService::assemble_turn, partagé par le tour et la relance du builder : instantané rangé avec la discussion, mise à jour posée en tête du dernier message utilisateur (prependCtxUpdate), historique nettoyé au rafraîchissement ; - TurnHost::project_refresh après une compaction ou une réduction en plein tour (replaceProjectHead) ; l'instantané posé devient celui de la discussion ; - le préchauffage demande la vue en dry et garde les mises à jour tant que le bloc n'est pas rafraîchi. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...migration-lot34-bloc-projet-fige-design.md | 92 +++++++++ internal/loki/facade_chat.go | 52 +---- internal/loki/facade_chat_test.go | 73 ++----- internal/loki/facade_projsnap.go | 94 +++++++++ internal/loki/facade_projsnap_test.go | 169 +++++++++++++++ internal/loki/web_internal.go | 2 + internal/loki/web_internal_turn.go | 16 +- internal/loki/web_internal_turn_test.go | 5 +- rust/loki-rs/src/chat/compact.rs | 122 +++++++++++ rust/loki-rs/src/chat/generate.rs | 195 +++++++++++++++--- rust/loki-rs/src/chat/host.rs | 60 +++++- rust/loki-rs/src/chat/prewarm.rs | 21 +- rust/loki-rs/src/chat/turn.rs | 20 +- rust/loki-rs/src/chat/turn/tests.rs | 60 ++++++ rust/loki-rs/src/chat/verify.rs | 49 +++-- rust/loki-rs/src/main.rs | 9 +- rust/loki-rs/tests/common/fakego.rs | 24 ++- rust/loki-rs/tests/lot6.rs | 169 +++++++++++++++ 19 files changed, 1063 insertions(+), 171 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md create mode 100644 internal/loki/facade_projsnap.go create mode 100644 internal/loki/facade_projsnap_test.go diff --git a/README.md b/README.md index 0c33076..29a3e38 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` | | `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` | | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | -| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`) et le préchauffage (`PREWARM`). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, le Go la garde de toute façon (décidé au démarrage) | Rust | +| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md new file mode 100644 index 0000000..3a3de30 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md @@ -0,0 +1,92 @@ +# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade + +## Contexte + +Le contexte du projet part en tête du premier message utilisateur : + +- description ; +- index mémoire ; +- trackers ; +- consignes du dépôt ; +- avec la clé, la date et le dossier de travail. + +Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un +tracker reçoit un point. Tout ce qui le suit dans le prompt est alors +recalculé. + +`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par +discussion. Les changements arrivent en `` en tête du +nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc +n'est rafraîchi que quand le début du prompt change de toute façon : +compaction, système ou outils, clé du moteur, projet, mode, trop de mises à +jour accumulées. + +C'était le dernier réglage qui gardait la conversation au Go. + +## Le lot + +L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`), +et la compaction le remet à zéro comme le Go. Il manquait la décision, qui +lit le contexte du projet : elle reste au Go. + +### Go (`facade_projsnap.go`) + +- `/internal/v1/turn/view`, avec la clé et l'agent : + - reçoit l'instantané de la discussion (`snap`) ; + - rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant + s'il faut rafraîchir) et le préambule sans date ni dossier + (`envInCtx`) ; + - rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser, + l'instantané à ranger, la clé et l'empreinte du système du tour. +- `dry` (préchauffage) : la même décision, rien à ranger. +- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il + devient (`projSnapTurn.refresh`). +- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la + décision prise une fois par processus disparaissent. Le Go la remet dès + que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là. + +### Rust + +- `ChatService::assemble_turn`, partagé par le tour et la relance du builder + du mode Code : + - rafraîchi : instantané neuf, blocs `` retirés de + l'historique ; + - mise à jour : posée en tête du dernier message utilisateur + (`prependCtxUpdate`), dans la discussion et dans la requête ; l'état + annoncé ne bouge que si elle a trouvé où se poser ; + - sans la clé : la discussion qui porte encore des blocs en est nettoyée, + comme avant. +- `TurnHost::project_refresh` : après une compaction ou une réduction en + plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les + mises à jour sont retirées. L'instantané posé devient celui de la + discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne + touche pas au début du prompt : pas de rafraîchissement. +- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de + l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour. + +## Tests + +- Go (`facade_projsnap_test.go`) : + - la séquence assemblée par la façade égale celle de `turnView`, outils et + instantané compris ; + - une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`, + état annoncé suivi ; + - sans changement : rien à poser ; + - `dry` sans rien à ranger ; + - clé changée : rafraîchi, bloc vivant en préchauffage ; + - rafraîchissement en plein tour : bloc vivant, instantané à la clé du + tour. +- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade + tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et + pourquoi. +- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc + projet remplacé à sa place (cas du Go). +- Rust, boucle du tour : rafraîchi après une compaction et une réduction ; + pas après un retrait d'images gardées. +- Rust, vrai hôte (`tests/lot6.rs`) : + - instantané rangé ; + - mise à jour posée dans la requête et l'historique (marque retirée à + l'envoi) ; + - rafraîchissement qui retire les mises à jour ; + - compaction en plein tour : bloc vivant envoyé, instantané rangé ; + - préchauffage en `dry`. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 236802c..ddd4a15 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -39,52 +39,14 @@ const ( ) // facadeChat : la façade tient-elle la conversation ? Il faut aussi son -// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et -// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker). +// adresse et le jeton interne, sans lesquels rien ne peut lui être remis. +// Plus aucun réglage ne garde la conversation ici : la façade honore +// NUDGE_IN_TOOL (lot 23), les motifs de relance personnalisés (lot 30), +// SIDE_SLOT et SLOT_PERSIST (lot 31), KEEP_TURN_IMAGES (lot 32), PREWARM +// (lot 33) et PROJ_SNAPSHOT (lot 34, facade_projsnap.go). func facadeChat() bool { return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") && - facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == "" -} - -// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y -// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet -// figé). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa -// sonde de gabarit ; ni les motifs de relance personnalisés (retry_patterns, -// lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et SLOT_PERSIST -// (lot 31) : la façade demande son slot avant chaque requête -// (facade_slots.go) ; ni KEEP_TURN_IMAGES (lot 32) : la façade tient l'état -// du tour, le Go range les images (facade_keepimg.go) ; ni PREWARM (lot 33) : -// la façade assemble le préchauffage, le Go l'inscrit et l'annule -// (facade_prewarm.go). -func facadeChatBlocker(cfg map[string]string) string { - switch { - case projSnapEnabled(cfg): - return "PROJ_SNAPSHOT" - } - return "" -} - -// Décision prise UNE fois par process : passer la conversation d'un détenteur -// à l'autre en cours de route laisserait chacun avec sa copie — un réglage -// changé vaut au redémarrage suivant. La façade la lit au démarrage -// (/internal/v1/chat/holder) et s'y range. -var ( - facadeDecisionMu sync.Mutex - facadeDecisionDone bool - facadeDecisionWhy string -) - -func facadeChatDecision() string { - facadeDecisionMu.Lock() - defer facadeDecisionMu.Unlock() - if !facadeDecisionDone { - facadeDecisionWhy = facadeChatBlocker(ReadConfig()) - facadeDecisionDone = true - if facadeDecisionWhy != "" { - fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy) - } - } - return facadeDecisionWhy + facadeAddr() != "" && internalToken() != "" } // handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou @@ -98,7 +60,7 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"}) return } - why := facadeChatDecision() + why := "adresse de la façade ou jeton interne manquant" if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") { why = facadeChatEnv + " absent" } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 9c3276b..2dade60 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -27,22 +27,8 @@ type fausseFacade struct { internalBody string } -// oublierDecisionFacade : la décision « qui tient la conversation » est prise -// une fois par process ; chaque test repart de zéro. -func oublierDecisionFacade(t *testing.T) { - t.Helper() - reset := func() { - facadeDecisionMu.Lock() - facadeDecisionDone, facadeDecisionWhy = false, "" - facadeDecisionMu.Unlock() - } - reset() - t.Cleanup(reset) -} - func demarrerFacade(t *testing.T) *fausseFacade { t.Helper() - oublierDecisionFacade(t) f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) @@ -266,56 +252,36 @@ func TestFacadeActivationDeProjet(t *testing.T) { } } -// Un réglage que la façade ne sait pas honorer garde la conversation ici : -// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient -// jusqu'au redémarrage. -func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { +// Plus aucun réglage ne garde la conversation ici (lot 34) : avec chacune des +// clés opt-in posée, la façade la tient et le trafic de chat lui est remis. +// Sans LOKI_CHAT=rust, ou sans son adresse : le Go, et pourquoi. +func TestFacadeAucunReglageNeGardeLaConversation(t *testing.T) { testHome(t) f := demarrerFacade(t) t.Setenv(internalTokenEnv, jetonTest) + for _, k := range []string{"PROJ_SNAPSHOT", "NUDGE_IN_TOOL", "SIDE_SLOT", "SLOT_PERSIST", "KEEP_TURN_IMAGES", "PREWARM"} { + if err := SetConfigKey(k, "on"); err != nil { + t.Fatal(err) + } + } + putStr(bkState, "retry_patterns", `["oups"]`) + if !facadeChat() { + t.Fatal("réglages opt-in posés : la façade doit tenir la conversation") + } if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) { t.Fatalf("détenteur : %d %s", w.Code, w.Body.String()) } - // Posé après la décision : sans effet avant le redémarrage. - if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil { - t.Fatal(err) - } - if !facadeChat() { - t.Fatal("décision changée en cours de route") - } - oublierDecisionFacade(t) - if facadeChat() { - t.Fatal("PROJ_SNAPSHOT posé : la conversation doit rester au Go") - } - w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "") - if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "PROJ_SNAPSHOT") { - t.Fatalf("détenteur : %s", w.Body.String()) - } local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } rec := httptest.NewRecorder() withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`))) - if rec.Body.String() != "go" || len(f.recu()) != 0 { - t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu()) - } - for k, want := range map[string]string{ - "PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "", - "SLOT_PERSIST": "", "KEEP_TURN_IMAGES": "", "PREWARM": "", - } { - if got := facadeChatBlocker(map[string]string{k: "on"}); got != want { - t.Errorf("%s : %q", k, got) - } - } - if got := facadeChatBlocker(map[string]string{}); got != "" { - t.Errorf("sans réglage : %q", got) - } - // Les motifs de relance personnalisés ne gardent plus la conversation. - putStr(bkState, "retry_patterns", `["oups"]`) - if got := facadeChatBlocker(map[string]string{}); got != "" { - t.Errorf("retry_patterns : %q", got) + if rec.Body.String() == "go" || len(f.recu()) != 1 { + t.Fatalf("pas remis à la façade : %q %v", rec.Body.String(), f.recu()) } putStr(bkState, "retry_patterns", "") - // Sans LOKI_CHAT=rust : le Go, et pourquoi. - oublierDecisionFacade(t) + t.Setenv(facadeAddrEnv, "") + if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "adresse de la façade") { + t.Fatalf("sans adresse : %s", w.Body.String()) + } t.Setenv(facadeChatEnv, "") if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") { t.Fatalf("sans la variable : %s", w.Body.String()) @@ -1076,7 +1042,6 @@ func TestFacadeBackends(t *testing.T) { func TestFacadeSondeDeGabarit(t *testing.T) { testHome(t) freshTplProbe(t) - oublierDecisionFacade(t) verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" { diff --git a/internal/loki/facade_projsnap.go b/internal/loki/facade_projsnap.go new file mode 100644 index 0000000..6a3d12a --- /dev/null +++ b/internal/loki/facade_projsnap.go @@ -0,0 +1,94 @@ +package loki + +import ( + "net/http" +) + +// facade_projsnap.go — PROJ_SNAPSHOT pour le tour tenu par la façade (lot 34 +// de la migration). La façade range l'instantané avec la discussion (il est +// persisté avec elle, champ proj_snap) et pose les dans son +// historique ; le Go, qui lit le contexte du projet (description, index +// mémoire, trackers, consignes du dépôt, date et dossier de travail), décide : +// +// - /internal/v1/turn/view, avec la clé et l'agent : la tête du tour porte +// le bloc figé (ou le vivant s'il faut rafraîchir), et projsnap dit quoi +// faire de la discussion — rafraîchir (instantané neuf, blocs retirés de +// l'historique) ou poser une mise à jour en tête du dernier message +// utilisateur. dry : la même décision pour le préchauffage, sans rien +// rendre à ranger ; +// - /internal/v1/turn/projsnap/refresh : une compaction ou une réduction +// réécrit l'historique en plein tour, le bloc vivant remplace le figé et +// devient l'instantané du tour suivant. + +// projSnapView : les pièces de turnView avec la clé (assembleTurn, chemin +// PROJ_SNAPSHOT). snap : l'instantané de la discussion (nil : aucun). +func projSnapView(caps Caps, snap *projSnapshot, dry bool) map[string]any { + cfg := ReadConfig() + // Date et dossier de travail : dans le bloc, plus dans le système. + caps.envInCtx = true + asOf := projSnapNow() + live := projSnapCapture(caps, asOf) + key := projSnapKey(caps, cfg) + tools := EnabledTools(caps) + if tools == nil { + tools = []Tool{} + } + head := []Message{} + if sp := readSysPrompt(); sp != "" { + head = append(head, Message{Role: "system", Content: sp}) + } + sysHash := projSnapSysHash(InjectSkills(append([]Message(nil), head...), caps, tools), tools) + refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf) + block := live.msgs + if !refresh { + block = snap.Msgs + } + out := map[string]any{"refresh": refresh, "key": key, "sys_hash": sysHash} + if !dry { + switch { + case refresh: + out["snap"] = newProjSnapshot(live, key, sysHash) + case delta != "": + // Rangé par la façade seulement si la mise à jour a trouvé un + // message utilisateur où se poser (projSnapApplyLocked). + next := *snap + next.State = live.state + next.DeltaTok += tok + out["snap"] = &next + out["delta"] = delta + default: + out["snap"] = snap + } + } + return map[string]any{ + "head": append(head, block...), + "prefix": injectPrefix(caps, tools), + "tools": tools, + "projsnap": out, + } +} + +// handleInternalTurnProjSnapRefresh : POST {"caps","key","sys_hash"} → +// {"block":[…],"snap":{…}} — le bloc vivant et l'instantané qu'il devient +// (projSnapTurn.refresh). +func handleInternalTurnProjSnapRefresh(w http.ResponseWriter, r *http.Request) { + var in struct { + Caps Caps `json:"caps"` + Key string `json:"key"` + SysHash string `json:"sys_hash"` + } + if !readInternalJSON(w, r, &in) { + return + } + caps := in.Caps + caps.envInCtx = true + live := projSnapCapture(caps, projSnapNow()) + block := live.msgs + if block == nil { + block = []Message{} + } + sendJSON(w, http.StatusOK, map[string]any{ + "block": block, + "snap": newProjSnapshot(live, in.Key, in.SysHash), + }) +} diff --git a/internal/loki/facade_projsnap_test.go b/internal/loki/facade_projsnap_test.go new file mode 100644 index 0000000..2641ea8 --- /dev/null +++ b/internal/loki/facade_projsnap_test.go @@ -0,0 +1,169 @@ +package loki + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// projSnapFacade : /internal/v1/turn/view avec la clé, décodé. +type projSnapFacadeView struct { + Head []Message `json:"head"` + Prefix string `json:"prefix"` + Tools []Tool `json:"tools"` + ProjSnap struct { + Refresh bool `json:"refresh"` + Delta string `json:"delta"` + Snap *projSnapshot `json:"snap"` + Key string `json:"key"` + SysHash string `json:"sys_hash"` + } `json:"projsnap"` +} + +func projSnapFacade(t *testing.T, caps Caps, snap *projSnapshot, dry bool) projSnapFacadeView { + t.Helper() + body, _ := json.Marshal(map[string]any{"caps": caps, "snap": snap, "dry": dry}) + w := appelOAIInterne(t, "POST", "/internal/v1/turn/view", string(body)) + if w.Code != 200 { + t.Fatalf("vue : %d %s", w.Code, w.Body.String()) + } + var v projSnapFacadeView + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + return v +} + +// msgsJSON : la séquence telle qu'elle part (forme JSON, sans les champs +// propres au Go). +func msgsJSON(t *testing.T, msgs []Message) string { + t.Helper() + b, err := json.Marshal(msgs) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// La façade assemble ce que turnView aurait envoyé : instantané pris au +// premier tour, mise à jour posée en tête du message suivant après un +// changement, l'instantané de la façade rangé et relu. +func TestFacadeProjSnapCommeTurnView(t *testing.T) { + projSnapSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + caps := Caps{Agent: true, Mem: MemAlways} + + // Premier tour : rien de figé, instantané neuf. + v := projSnapFacade(t, caps, nil, false) + if !v.ProjSnap.Refresh || v.ProjSnap.Snap == nil || v.ProjSnap.Delta != "" { + t.Fatalf("premier tour : %+v", v.ProjSnap) + } + c := newTestConv() + msgs := []Message{um("bonjour")} + c.Messages = append([]Message(nil), msgs...) + sent, tools, _ := c.turnView(caps, c.epoch, msgs, true) + got := withPrefix(append(append([]Message(nil), v.Head...), msgs...), v.Prefix) + if msgsJSON(t, got) != msgsJSON(t, sent) { + t.Fatalf("séquence différente de turnView :\n%s\n%s", msgsJSON(t, got), msgsJSON(t, sent)) + } + if !reflect.DeepEqual(jsonValue(t, v.Tools), jsonValue(t, tools)) { + t.Fatal("outils différents de turnView") + } + if !reflect.DeepEqual(v.ProjSnap.Snap.State, c.ProjSnap.State) || v.ProjSnap.Snap.Key != c.ProjSnap.Key || v.ProjSnap.Snap.SysHash != c.ProjSnap.SysHash { + t.Fatalf("instantané différent :\n%+v\n%+v", v.ProjSnap.Snap, c.ProjSnap) + } + if v.ProjSnap.Key != c.ProjSnap.Key || v.ProjSnap.SysHash != c.ProjSnap.SysHash { + t.Fatal("clé du tour différente") + } + + // Une page ajoutée : bloc figé gardé, mise à jour annoncée. + if err := MemAdd("nas.md", "# NAS\n"); err != nil { + t.Fatal(err) + } + snap := v.ProjSnap.Snap + v2 := projSnapFacade(t, caps, snap, false) + if v2.ProjSnap.Refresh || !strings.Contains(v2.ProjSnap.Delta, "](nas.md)") { + t.Fatalf("mise à jour attendue : %+v", v2.ProjSnap) + } + if msgsJSON(t, v2.Head) != msgsJSON(t, v.Head) { + t.Fatal("bloc figé non gardé") + } + c.Messages = append(c.Messages, am("ok."), um("et le NAS ?")) + msgs2 := append([]Message(nil), c.Messages...) + sent2, _, _ := c.turnView(caps, c.epoch, msgs2, true) + last := msgText(sent2[len(sent2)-1]) + if last != v2.ProjSnap.Delta+"et le NAS ?" { + t.Fatalf("mise à jour différente de turnView :\n%q\n%q", last, v2.ProjSnap.Delta+"et le NAS ?") + } + next := v2.ProjSnap.Snap + if next == nil || next.State.Mem["nas.md"] == "" || next.DeltaTok <= snap.DeltaTok || !reflect.DeepEqual(next.State, c.ProjSnap.State) { + t.Fatalf("état annoncé non suivi : %+v", next) + } + // Sans nouveau changement : rien à poser, instantané inchangé. + v3 := projSnapFacade(t, caps, next, false) + if v3.ProjSnap.Refresh || v3.ProjSnap.Delta != "" || !reflect.DeepEqual(v3.ProjSnap.Snap.State, next.State) { + t.Fatalf("tour sans changement : %+v", v3.ProjSnap) + } + // dry (préchauffage) : la même décision, rien à ranger. + if err := MemAdd("docker.md", "# Docker\n"); err != nil { + t.Fatal(err) + } + d := projSnapFacade(t, caps, next, true) + if d.ProjSnap.Refresh || d.ProjSnap.Snap != nil || d.ProjSnap.Delta != "" || msgsJSON(t, d.Head) != msgsJSON(t, v.Head) { + t.Fatalf("préchauffage : %+v", d.ProjSnap) + } + // Instantané d'un autre lancement (clé) : rafraîchi. + stale := *next + stale.Key = "autre" + if r := projSnapFacade(t, caps, &stale, false); !r.ProjSnap.Refresh { + t.Fatal("clé changée : rafraîchissement attendu") + } + if r := projSnapFacade(t, caps, &stale, true); !r.ProjSnap.Refresh || msgsJSON(t, r.Head) == msgsJSON(t, v.Head) { + t.Fatal("préchauffage d'un instantané périmé : bloc vivant attendu") + } +} + +// Compaction en plein tour : le bloc vivant, et l'instantané qu'il devient +// avec la clé et le système du tour. +func TestFacadeProjSnapRafraichi(t *testing.T) { + projSnapSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + if err := MemAdd("nas.md", "# NAS\n"); err != nil { + t.Fatal(err) + } + w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{"caps":{"Agent":true,"Mem":"always"},"key":"k1","sys_hash":"s1"}`) + if w.Code != 200 { + t.Fatalf("rafraîchissement : %d %s", w.Code, w.Body.String()) + } + var out struct { + Block []Message `json:"block"` + Snap *projSnapshot `json:"snap"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + live := projSnapCapture(Caps{Agent: true, Mem: MemAlways, envInCtx: true}, projSnapNow()) + if msgsJSON(t, out.Block) != msgsJSON(t, live.msgs) || len(out.Block) == 0 { + t.Fatalf("bloc vivant différent : %s", msgsJSON(t, out.Block)) + } + if out.Snap == nil || out.Snap.Key != "k1" || out.Snap.SysHash != "s1" || out.Snap.State.Mem["nas.md"] == "" { + t.Fatalf("instantané : %+v", out.Snap) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{`); w.Code != 400 { + t.Fatalf("corps illisible : %d", w.Code) + } +} + +// withPrefix : ce que la façade fait du préambule rendu (inject_prefix, +// comme InjectSkills avec ce texte). +func withPrefix(msgs []Message, prefix string) []Message { + if prefix == "" { + return msgs + } + if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { + existing, _ := msgs[0].Content.(string) + return append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) + } + return append([]Message{{Role: "system", Content: prefix}}, msgs...) +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index e5b63b1..70a3672 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -73,6 +73,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIServed(w, r) case "/internal/v1/turn/perf": handleInternalTurnPerf(w, r) + case "/internal/v1/turn/projsnap/refresh": + handleInternalTurnProjSnapRefresh(w, r) case "/internal/v1/turn/slot": handleInternalTurnSlot(w, r) case "/internal/v1/prewarm/check": diff --git a/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go index 99bbc47..5f8025d 100644 --- a/internal/loki/web_internal_turn.go +++ b/internal/loki/web_internal_turn.go @@ -80,20 +80,24 @@ func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) { }) } -// handleInternalTurnView : les pièces de turnView (assembleTurn sans -// PROJ_SNAPSHOT) que la façade assemble autour de l'historique : head part -// devant les messages, puis prefix (InjectSkills) fusionné dans le premier -// système qui n'est pas un message projet, ou posé en tête. +// handleInternalTurnView : les pièces de turnView (assembleTurn) que la +// façade assemble autour de l'historique : head part devant les messages, puis +// prefix (InjectSkills) fusionné dans le premier système qui n'est pas un +// message projet, ou posé en tête. Avec PROJ_SNAPSHOT et l'agent, la tête +// porte le bloc figé de la discussion (snap) et projsnap dit quoi en faire +// (facade_projsnap.go) ; dry : pour le préchauffage, rien à ranger. func handleInternalTurnView(w http.ResponseWriter, r *http.Request) { var in struct { - Caps Caps `json:"caps"` + Caps Caps `json:"caps"` + Snap *projSnapshot `json:"snap"` + Dry bool `json:"dry"` } if !readInternalJSON(w, r, &in) { return } caps := in.Caps if caps.Agent && projSnapEnabled(ReadConfig()) { - sendJSON(w, http.StatusConflict, map[string]any{"error": "PROJ_SNAPSHOT actif : le bloc projet figé reste tenu par le Go"}) + sendJSON(w, http.StatusOK, projSnapView(caps, in.Snap, in.Dry)) return } head := []Message{} diff --git a/internal/loki/web_internal_turn_test.go b/internal/loki/web_internal_turn_test.go index 092d036..db67d22 100644 --- a/internal/loki/web_internal_turn_test.go +++ b/internal/loki/web_internal_turn_test.go @@ -101,7 +101,10 @@ func TestTourInterneVue(t *testing.T) { if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil { t.Fatal(err) } - turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 409) + // Avec la clé : le bloc figé et la décision (facade_projsnap.go). + if got := turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 200); got["projsnap"] == nil { + t.Fatalf("PROJ_SNAPSHOT sans décision : %v", got) + } turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200) } diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs index 215ca70..8aea243 100644 --- a/rust/loki-rs/src/chat/compact.rs +++ b/rust/loki-rs/src/chat/compact.rs @@ -510,6 +510,57 @@ pub(crate) fn strip_context_updates(msgs: Vec) -> Vec { .collect() } +/// `prependCtxUpdate` : la mise à jour du bloc projet (PROJ_SNAPSHOT) posée en +/// tête d'un message `user`, marqué `ctx_upd` ; `None` : pas un message où la +/// poser. +pub(crate) fn prepend_ctx_update(m: &Message, block: &str) -> Option { + if m.role != "user" { + return None; + } + let mut out = m.clone(); + match &m.content { + Some(Value::String(c)) => out.content = Some(Value::String(format!("{block}{c}"))), + Some(Value::Array(parts)) => { + let mut v = Vec::with_capacity(parts.len() + 1); + v.push(serde_json::json!({ "type": "text", "text": block })); + v.extend(parts.iter().cloned()); + out.content = Some(Value::Array(v)); + } + _ => return None, + } + out.ctx_upd = true; + Some(out) +} + +/// `replaceProjectHead` : le bloc projet de tête (hors rappel des pages lues, +/// qui appartient à l'historique) remplacé par `block`, à la même place. +pub(crate) fn replace_project_head(msgs: Vec, block: &[Message]) -> Vec { + let end = msgs.iter().take_while(|m| m.role == "system").count(); + let mut out = Vec::with_capacity(msgs.len() + block.len()); + let mut inserted = false; + let mut rest = msgs.into_iter(); + for m in rest.by_ref().take(end) { + let reminder = matches!(&m.content, Some(Value::String(s)) if s.starts_with(super::wire::MEM_REMINDER_PREFIX)); + if super::wire::is_project_system(&m) && !reminder { + if !inserted { + out.extend(block.iter().cloned()); + inserted = true; + } + continue; + } + if reminder && !inserted { + out.extend(block.iter().cloned()); + inserted = true; + } + out.push(m); + } + if !inserted { + out.extend(block.iter().cloned()); + } + out.extend(rest); + out +} + // ---- bornes et assemblage -------------------------------------------------- /// `compactBounds` : frontières tête/queue. Tête = messages système de tête ; @@ -1844,6 +1895,77 @@ impl Compactor<'_, S, R> { #[cfg(test)] mod tests { use super::*; + + // ── PROJ_SNAPSHOT (lot 34, cas de chat_projsnap_test.go) ── + + #[test] + fn mise_a_jour_en_tete_du_message() { + let block = format!("{CTX_UPD_OPEN} at=\"x\">\n+ a\n{CTX_UPD_CLOSE}\n\n"); + let m = prepend_ctx_update(&um("suite"), &block).expect("message user"); + assert!(m.ctx_upd); + assert_eq!(m.content, Some(Value::String(format!("{block}suite")))); + // Retirée à l'identique. + assert_eq!(without_ctx_update(&m).unwrap().content, um("suite").content); + let parts = Message { + role: "user".into(), + content: Some( + serde_json::json!([{"type": "image_url", "image_url": {"url": "loki-img:a.png"}}]), + ), + ..Default::default() + }; + let m = prepend_ctx_update(&parts, &block).unwrap(); + assert_eq!( + m.content.as_ref().unwrap()[0], + serde_json::json!({"type": "text", "text": block}) + ); + assert_eq!(m.content.as_ref().unwrap()[1]["type"], "image_url"); + assert!(prepend_ctx_update(&tm("r"), &block).is_none()); + let empty = Message { + role: "user".into(), + ..Default::default() + }; + assert!(prepend_ctx_update(&empty, &block).is_none()); + } + + #[test] + fn bloc_projet_remplace_a_sa_place() { + use super::super::wire::{MEM_INDEX_PREFIX, MEM_REMINDER_PREFIX, PROJECT_CONTEXT_PREFIX}; + let sys = |c: String| Message { + role: "system".into(), + content: Some(Value::String(c)), + ..Default::default() + }; + let msgs = vec![ + sys("base".into()), + sys(format!("{PROJECT_CONTEXT_PREFIX} — vieux")), + sys(format!("{MEM_INDEX_PREFIX} vieux")), + sys(format!("{MEM_REMINDER_PREFIX} a.md")), + um("q"), + ]; + let block = vec![sys(format!("{MEM_INDEX_PREFIX} neuf"))]; + let out = replace_project_head(msgs, &block); + let got: Vec = out.iter().map(msg_text).collect(); + assert_eq!( + got, + [ + "base".to_string(), + format!("{MEM_INDEX_PREFIX} neuf"), + format!("{MEM_REMINDER_PREFIX} a.md"), + "q".into() + ] + ); + // Bloc vide au départ : inséré après le système commun. + let out = replace_project_head(vec![sys("base".into()), um("q")], &block); + assert_eq!(msg_text(&out[1]), format!("{MEM_INDEX_PREFIX} neuf")); + assert_eq!(msg_text(&out[2]), "q"); + // Rappel des pages lues avant tout bloc : le bloc se pose devant lui. + let out = replace_project_head( + vec![sys(format!("{MEM_REMINDER_PREFIX} a.md")), um("q")], + &block, + ); + assert_eq!(msg_text(&out[0]), format!("{MEM_INDEX_PREFIX} neuf")); + assert_eq!(msg_text(&out[1]), format!("{MEM_REMINDER_PREFIX} a.md")); + } use crate::chat::types::{ToolCall, ToolCallFunc, ToolFunction}; use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index b697394..de083c7 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -22,8 +22,10 @@ //! corrections du builder) suit le tour, avant la compaction de fin //! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé. //! -//! Écart assumé (reste au Go) : PROJ_SNAPSHOT (refusé par `/turn/view`). Le -//! préchauffage (PREWARM, lot 33) part en fin de tour ([`super::prewarm`]). +//! Le bloc projet figé (PROJ_SNAPSHOT, lot 34) : le Go décide avec la vue du +//! tour, la façade range l'instantané et pose les mises à jour +//! ([`ChatService::assemble_turn`]). Le préchauffage (PREWARM, lot 33) part en +//! fin de tour ([`super::prewarm`]). //! Les images gardées (KEEP_TURN_IMAGES, lot 32) //! partent en début et en fin de tour quand il le faut //! ([`ChatService::kept_images_turn_start`]). La télémétrie des complétions @@ -46,18 +48,19 @@ use tokio::sync::watch; use super::compact::{ CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for, ctx_pending, drop_kept_images, estimate_tokens, log_compact, log_ctx_estimate, - log_kept_images_dropped, strip_context_updates, + log_kept_images_dropped, prepend_ctx_update, strip_context_updates, }; use super::engine::EngineHttp; use super::history::{ drop_stray_nudges, history_from_view, inject_prefix, remind_read_mem_pages, tool_used_delta, }; use super::host::{ - ChatShared, EngineSummarizer, GoTurnHost, StoreRecall, TurnCompact, keep_images_state, + ChatShared, EngineSummarizer, GoTurnHost, SnapTurn, StoreRecall, TurnCompact, keep_images_state, }; use super::journal::Delta; use super::keepimg::{has_kept_images, keep_turn_images_on}; use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop}; +use super::persist::ProjSnapshot; use super::sessions::{GoSessionHooks, Sessions}; use super::turn::{Cancel, TurnConfig, run_turn}; use super::types::{Message, ReasoningEcho, StreamEvent, Tool}; @@ -177,6 +180,42 @@ pub(super) struct TurnEnv { pub(super) summarizer: EngineSummarizer, } +/// La décision PROJ_SNAPSHOT rendue avec la vue du tour (`projsnap`). +#[derive(Debug, Clone, Default, Deserialize)] +pub(super) struct SnapDecision { + /// Instantané neuf : blocs `` retirés de l'historique. + #[serde(default)] + pub(super) refresh: bool, + /// Mise à jour à poser en tête du dernier message utilisateur. + #[serde(default)] + pub(super) delta: String, + /// L'instantané à ranger (absent en préchauffage). + #[serde(default)] + pub(super) snap: Option, + #[serde(default)] + pub(super) key: String, + #[serde(default)] + pub(super) sys_hash: String, +} + +/// Les pièces de la vue d'envoi. +pub(super) struct ViewParts { + pub(super) head: Vec, + pub(super) prefix: String, + pub(super) tools: Vec, + pub(super) snap: Option, +} + +/// Une étape assemblée : ce qui part, les outils, l'historique d'où part la +/// vue (`historyFromView`), et de quoi rafraîchir le bloc projet en plein +/// tour (PROJ_SNAPSHOT). +pub(super) struct Assembled { + pub(super) sent: Vec, + pub(super) tools: Vec, + pub(super) hist: Vec, + pub(super) snap_turn: Option, +} + /// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file. pub struct ChatService { pub sessions: Arc>, @@ -523,23 +562,34 @@ impl ChatService { Some(compacted) } - /// `/internal/v1/turn/view` : tête (prompt du preset, consignes, - /// projet), préambule et outils du tour ; `Err` : le message à journaliser. + /// Les pièces de la vue d'envoi (`/internal/v1/turn/view`) : tête du Go + /// (prompt du preset, consignes, projet), préambule, outils ; avec + /// PROJ_SNAPSHOT et l'agent, la décision sur le bloc figé (`snap` : + /// l'instantané de la discussion ; `dry` : préchauffage, rien à ranger). pub(super) async fn turn_view( &self, caps: &Value, - ) -> Result<(Vec, String, Vec), String> { + snap: Option<&ProjSnapshot>, + dry: bool, + ) -> Result { match self .shared .store - .internal_post("/internal/v1/turn/view", &json!({ "caps": caps })) + .internal_post( + "/internal/v1/turn/view", + &json!({ "caps": caps, "snap": snap, "dry": dry }), + ) .await { - Ok((s, v)) if s.as_u16() == 200 => Ok(( - serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), - v["prefix"].as_str().unwrap_or("").to_string(), - serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), - )), + Ok((s, v)) if s.as_u16() == 200 => Ok(ViewParts { + head: serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), + prefix: v["prefix"].as_str().unwrap_or("").to_string(), + tools: serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), + snap: v + .get("projsnap") + .filter(|p| p.is_object()) + .and_then(|p| serde_json::from_value(p.clone()).ok()), + }), Ok((_, v)) => Err(v["error"] .as_str() .unwrap_or("vue du tour refusée") @@ -548,6 +598,90 @@ impl ChatService { } } + /// `turnView` : la séquence envoyée pour une étape de la discussion (tour, + /// relance du builder) — tête du Go, historique, préambule. Avec + /// PROJ_SNAPSHOT : l'instantané de la discussion rangé, rafraîchi (blocs + /// `` retirés de l'historique), ou complété d'une mise à + /// jour posée en tête du dernier message utilisateur, dans la discussion + /// et dans `msgs`. Sans la clé, une discussion qui porte encore des blocs + /// d'une époque où elle servait en est nettoyée. + pub(super) async fn assemble_turn( + &self, + epoch: i64, + caps: &Value, + mut msgs: Vec, + ) -> Result { + let conv = self.sessions.conv(); + let snap = conv.with_state(|st| st.proj_snap.clone()); + let v = self.turn_view(caps, snap.as_ref(), false).await?; + let agent = caps.get("Agent").and_then(Value::as_bool).unwrap_or(false); + let mut snap_turn = None; + let hist = match v.snap { + Some(d) => { + let applied = conv.with_state_if(epoch, |st| { + if d.refresh { + st.messages = strip_context_updates(std::mem::take(&mut st.messages)); + st.proj_snap = d.snap.clone(); + return (true, false); + } + if d.delta.is_empty() { + return (false, false); + } + // Pas de message utilisateur où la poser : l'état annoncé + // ne bouge pas, elle partira au tour suivant. + let Some(m) = st + .messages + .last() + .and_then(|m| prepend_ctx_update(m, &d.delta)) + else { + return (false, false); + }; + if let Some(last) = st.messages.last_mut() { + *last = m; + } + st.proj_snap = d.snap.clone(); + (false, true) + }); + if let Some((refresh, updated)) = applied { + if refresh { + msgs = strip_context_updates(msgs); + } + if updated + && let Some(m) = msgs.last().and_then(|m| prepend_ctx_update(m, &d.delta)) + && let Some(last) = msgs.last_mut() + { + *last = m; + } + } + snap_turn = Some(SnapTurn { + caps: caps.clone(), + key: d.key, + sys_hash: d.sys_hash, + }); + msgs + } + None => { + if agent { + conv.with_state_if(epoch, |st| { + if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) { + st.proj_snap = None; + st.messages = strip_context_updates(std::mem::take(&mut st.messages)); + } + }); + } + strip_context_updates(msgs) + } + }; + let mut sent = v.head; + sent.extend(hist.iter().cloned()); + Ok(Assembled { + sent: inject_prefix(sent, &v.prefix), + tools: v.tools, + hist, + snap_turn, + }) + } + /// Configuration, point de chat et résumeur d'un tour (lus une fois). pub(super) async fn turn_env(&self) -> TurnEnv { let store = &self.shared.store; @@ -827,30 +961,21 @@ impl ChatService { } } - // Vue d'envoi : tête du Go (prompt du preset, consignes, projet), - // l'historique sans bloc , le préambule. - let (head, prefix, tools) = match self.turn_view(&prep.turn_caps).await { - Ok(v) => v, + // Vue d'envoi : tête du Go (prompt du preset, consignes, projet ou + // bloc figé), l'historique, le préambule. + let Assembled { + sent, + tools, + hist, + snap_turn, + } = match self.assemble_turn(epoch, &prep.turn_caps, msgs).await { + Ok(a) => a, Err(e) => { conv.append_delta(epoch, obj("error", e)); self.finish(epoch, &cancel, lease, turn_start).await; return; } }; - if agent { - // Une discussion qui porte encore des blocs d'une époque où - // PROJ_SNAPSHOT servait en est nettoyée (comme assembleTurn). - conv.with_state_if(epoch, |st| { - if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) { - st.proj_snap = None; - st.messages = strip_context_updates(std::mem::take(&mut st.messages)); - } - }); - } - let hist = strip_context_updates(msgs); - let mut sent = head; - sent.extend(hist.iter().cloned()); - let sent = inject_prefix(sent, &prefix); let temperature = if prep.temperature == 0.0 { 0.7 @@ -889,6 +1014,8 @@ impl ChatService { queued: Box::new(move || me.drain_queued(epoch)), perf_kind: super::perf::KIND_MAIN, side_slot: Default::default(), + projsnap: snap_turn, + snap_refreshed: Default::default(), }; // Chaque événement devient un delta du journal. @@ -967,6 +1094,9 @@ impl ChatService { } } }; + // Bloc projet rafraîchi par une compaction en cours de tour + // (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie. + let refreshed = host.take_refreshed_snap(); drop(host); // Vue modèle rangée : outils puis réponse finale — sauf si un reset @@ -975,6 +1105,9 @@ impl ChatService { if let Some(base) = new_base.take() { st.messages = base; } + if let Some(s) = refreshed { + st.proj_snap = Some(s); + } st.messages.extend(res.extra.iter().cloned()); if !content.trim().is_empty() { let a = Message { diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index c56db96..71f1d2e 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -37,7 +37,7 @@ use super::compact::{ parse_summary_response, }; use super::engine::{EngineHttp, classify, friendly_error}; -use super::persist::{Persister, StoreSink, ToolResJob}; +use super::persist::{Persister, ProjSnapshot, StoreSink, ToolResJob}; use super::stream::{CacheHintFn, PreflightFn, RetryPatterns}; use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost}; use super::types::{AskEvent, DiffLine, Message, Tool}; @@ -366,6 +366,30 @@ pub struct GoTurnHost { /// La requête qui part va sur le second slot (posé par `engine_slot`, /// lu par `send` pour le ticket « en vol »). pub side_slot: AtomicBool, + /// PROJ_SNAPSHOT : la clé et le système du tour (`None` sans la clé), et + /// l'instantané posé par un rafraîchissement en cours de tour. + pub projsnap: Option, + pub snap_refreshed: Mutex>, +} + +/// PROJ_SNAPSHOT (lot 34) : ce qu'un tour avec la clé garde pour rafraîchir +/// le bloc projet quand une compaction réécrit l'historique en plein tour +/// (`projSnapTurn`). +#[derive(Debug, Clone)] +pub struct SnapTurn { + pub caps: Value, + pub key: String, + pub sys_hash: String, +} + +impl GoTurnHost { + /// L'instantané posé en cours de tour (`takeRefreshed`), s'il y en a un. + pub fn take_refreshed_snap(&self) -> Option { + self.snap_refreshed + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + } } /// Délai d'une demande de slot : le rechargement SLOT_PERSIST relit jusqu'à @@ -738,6 +762,40 @@ impl TurnHost for GoTurnHost { self.shared.echo_refusals.refuse(model); } + async fn project_refresh(&self, msgs: Vec) -> Vec { + let Some(t) = &self.projsnap else { + return msgs; + }; + let got = self + .shared + .store + .internal_post( + "/internal/v1/turn/projsnap/refresh", + &json!({ "caps": t.caps, "key": t.key, "sys_hash": t.sys_hash }), + ) + .await; + let (block, snap) = match got { + Ok((code, v)) if code.is_success() => ( + serde_json::from_value::>(v["block"].clone()).ok(), + serde_json::from_value::(v["snap"].clone()).ok(), + ), + _ => (None, None), + }; + // Go injoignable : le bloc figé reste, sans les mises à jour (le début + // du prompt change de toute façon) ; l'instantané n'est pas remplacé. + let msgs = super::compact::strip_context_updates(msgs); + match (block, snap) { + (Some(block), Some(snap)) => { + *self + .snap_refreshed + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(snap); + super::compact::replace_project_head(msgs, &block) + } + _ => msgs, + } + } + fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool { self.perf_kind == super::perf::KIND_MAIN && self.shared.prewarm.live() diff --git a/rust/loki-rs/src/chat/prewarm.rs b/rust/loki-rs/src/chat/prewarm.rs index 218dd93..65f35be 100644 --- a/rust/loki-rs/src/chat/prewarm.rs +++ b/rust/loki-rs/src/chat/prewarm.rs @@ -319,10 +319,10 @@ impl ChatService { if conv.is_generating() { return Err("moteur occupé"); } - let (mut msgs, ctx_now, peak) = conv.with_state(|st| { + let (mut msgs, ctx_now, peak, snap) = conv.with_state(|st| { let msgs = st.messages.clone(); let used = ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo); - (msgs, used, st.gen_peak) + (msgs, used, st.gen_peak, st.proj_snap.clone()) }); // Sans message utilisateur, le contexte projet irait en tête de la // sentinelle : rien à préparer. @@ -338,13 +338,20 @@ impl ChatService { if compact_needed(&env.ccfg, &msgs, ctx_now, peak) { return Err("compaction attendue au prochain tour"); } - let (head, prefix, tools) = self - .turn_view(&turn_caps) + // Mêmes décisions que le vrai tour (PROJ_SNAPSHOT compris), sans rien + // ranger : bloc figé gardé avec ses mises à jour, ou rafraîchi. + let v = self + .turn_view(&turn_caps, snap.as_ref(), true) .await .map_err(|_| "vue du tour refusée")?; - let mut sent = head; - sent.extend(strip_context_updates(msgs)); - let sent = inject_prefix(sent, &prefix); + let tools = v.tools; + let hist = match &v.snap { + Some(d) if !d.refresh => msgs, + _ => strip_context_updates(msgs), + }; + let mut sent = v.head; + sent.extend(hist); + let sent = inject_prefix(sent, &v.prefix); let reasoning = turn_reasoning(&env.cfg, &self.shared.effort); let pol = turn_echo_policy(false, || env.echo.clone()); self.shared.images.prefetch(&self.shared.store, &sent).await; diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs index 027bcbe..5a9b6e2 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -42,8 +42,9 @@ //! préchauffage en vol le garde ([`TurnHost::prewarm_keeps`]) ; toute autre //! l'annule ([`super::prewarm`]). //! -//! Non porté : PROJ_SNAPSHOT. Avec cette clé, le Go garde la conversation -//! (`facadeChatBlocker`, décidé au démarrage). +//! Bloc projet figé (PROJ_SNAPSHOT, lot 34) : une compaction ou une réduction +//! en plein tour fait remplacer le bloc figé par le vivant +//! ([`TurnHost::project_refresh`]). use std::collections::HashMap; use std::future::Future; @@ -150,6 +151,13 @@ pub trait TurnHost: Send + Sync { body: Vec, keep_prewarm: bool, ) -> impl Future> + Send; + /// PROJ_SNAPSHOT : une compaction ou une réduction vient de réécrire + /// l'historique en plein tour — le début du prompt change de toute façon, + /// le bloc projet figé est remplacé par le vivant et les mises à jour + /// retirées (`projSnapTurn.refresh`). Sans la clé : tel quel. + fn project_refresh(&self, msgs: Vec) -> impl Future> + Send { + async { msgs } + } /// PREWARM : la requête qui part (empreintes cumulées de ses messages, /// corps) prolonge-t-elle exactement le préchauffage en vol ? fn prewarm_keeps(&self, _prefix: &[u64], _payload: &Value) -> bool { @@ -691,7 +699,7 @@ async fn run_turn_loop( if let Some(c) = c { s.compacted_retry = true; log_compact(ccfg, "réactif", 0, &messages, &c, true); - messages = c; + messages = host.project_refresh(c).await; extra.clear(); publish_history(cb, kt, &messages); continue; @@ -703,7 +711,7 @@ async fn run_turn_loop( if changed { s.shrink_retries += 1; log_compact(ccfg, "réduction", actual, &messages, &c, true); - messages = c; + messages = host.project_refresh(c).await; extra.clear(); publish_history(cb, kt, &messages); continue; @@ -1124,7 +1132,7 @@ async fn run_turn_loop( ); if let Some(c) = c { s.last_est = 0; - messages = c; + messages = host.project_refresh(c).await; extra.clear(); publish_history(cb, kt, &messages); } @@ -1183,7 +1191,7 @@ async fn run_turn_loop( ); if let Some(c) = c { cb(StreamEvent::DropReasoning); - messages = c; + messages = host.project_refresh(c).await; extra.clear(); publish_history(cb, kt, &messages); continue; diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs index 31e4981..78cb478 100644 --- a/rust/loki-rs/src/chat/turn/tests.rs +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -52,6 +52,9 @@ struct Fake { /// a reçu. prewarm_match: Mutex, sent_keeps: Mutex>, + /// PROJ_SNAPSHOT : clé posée pour ce tour ; rafraîchissements demandés. + snap_on: Mutex, + refreshes: Mutex, } impl Fake { @@ -173,6 +176,13 @@ impl TurnHost for Fake { async fn keep_images(&self) -> Option { *self.keep.lock().unwrap() } + async fn project_refresh(&self, mut msgs: Vec) -> Vec { + if *self.snap_on.lock().unwrap() { + *self.refreshes.lock().unwrap() += 1; + msgs.insert(0, text_msg("system", "BLOC VIVANT")); + } + msgs + } fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool { // Ce que l'hôte reçoit : les empreintes des messages envoyés et le // corps de la requête. @@ -1094,3 +1104,53 @@ async fn prechauffage_garde_par_la_requete_qui_le_prolonge() { assert_eq!(r.err, None); assert_eq!(*host.sent_keeps.lock().unwrap(), [false]); } + +/// PROJ_SNAPSHOT : une compaction ou une réduction en plein tour fait +/// rafraîchir le bloc projet (le début du prompt change de toute façon) ; +/// l'historique publié et la requête rejouée le portent. Le retrait d'images +/// gardées, lui, ne touche pas au début du prompt. +#[tokio::test] +async fn bloc_projet_rafraichi_par_la_compaction() { + let host = Fake::new(vec![ + Script::Status(400, "the request exceeds the available context size".into()), + answer("après compaction"), + ]); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + *host.snap_on.lock().unwrap() = true; + let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None, "{:?}", host.bodies.lock().unwrap()); + assert_eq!(*host.refreshes.lock().unwrap(), 1); + assert!(evs.iter().any(|e| matches!(e, StreamEvent::NewHistory(h) + if h.first().and_then(|m| m.content.as_ref()) == Some(&json!("BLOC VIVANT"))))); + assert_eq!(host.body(1)["messages"][0]["content"], "BLOC VIVANT"); + // Réduction forcée (pas de résumé possible) : rafraîchi aussi. + let host = Fake::new(vec![ + Script::Status( + 400, + "request (400000 tokens) exceeds the available context size (32768 tokens)".into(), + ), + answer("ok"), + ]); + *host.snap_on.lock().unwrap() = true; + let hist: Vec = (0..12) + .map(|i| um(&format!("message {i} {}", "x".repeat(400)))) + .collect(); + let (r, _) = run_hist(&host, &config(&[]), hist).await; + assert_eq!(r.err, None); + assert_eq!(*host.refreshes.lock().unwrap(), 1); + assert!( + host.phases.lock().unwrap().len() == 1, + "résumé tenté puis réduction" + ); + assert_eq!(host.body(1)["messages"][0]["content"], "BLOC VIVANT"); + // Images gardées retirées au filet réactif : pas de rafraîchissement. + let host = Fake::new(vec![ + Script::Status(400, "the request exceeds the available context size".into()), + answer_u("fini", 0), + ]); + *host.snap_on.lock().unwrap() = true; + let hist = vec![um("q"), tool_res("[ok]"), kept_relay(1000), um("suite")]; + let (r, _) = run_hist(&host, &config(&[]), hist).await; + assert_eq!(r.err, None); + assert_eq!(*host.refreshes.lock().unwrap(), 0); +} diff --git a/rust/loki-rs/src/chat/verify.rs b/rust/loki-rs/src/chat/verify.rs index 055dca0..9b9e97e 100644 --- a/rust/loki-rs/src/chat/verify.rs +++ b/rust/loki-rs/src/chat/verify.rs @@ -23,12 +23,13 @@ use serde::Deserialize; use serde_json::{Value, json}; use tokio::sync::watch; -use super::compact::{CompactCaps, is_loki_injected, strip_context_updates, without_ctx_update}; -use super::generate::{ChatService, TurnEnv, obj}; -use super::history::{drop_stray_nudges, history_from_view, inject_prefix, tool_used_delta}; -use super::host::{GoTurnHost, TurnCompact}; +use super::compact::{CompactCaps, is_loki_injected, without_ctx_update}; +use super::generate::{Assembled, ChatService, TurnEnv, obj}; +use super::history::{drop_stray_nudges, history_from_view, tool_used_delta}; +use super::host::{GoTurnHost, SnapTurn, TurnCompact}; use super::lease::{EngineLease, LeaseSignal}; use super::persist::BK_CHAT; +use super::persist::ProjSnapshot; use super::turn::{Cancel, TurnConfig, run_turn}; use super::types::{Message, ReasoningEcho, StreamEvent, Tool}; use super::wire::{TurnReasoning, with_echo}; @@ -166,6 +167,8 @@ struct PassOut { echo: Option, saw_usage: bool, asked: bool, + /// Bloc projet rafraîchi en cours de passe (PROJ_SNAPSHOT). + snap: Option, } fn cap_bool(caps: &Value, k: &str) -> bool { @@ -304,8 +307,18 @@ impl ChatService { // Travail annexe : le Go efface le slot à la fin s'il a servi // (les requêtes de la passe le lui disent en passant). let side = side_job_begin(&self.shared.store).await; - self.run_pass(pe, epoch, msgs, tools, v["caps"].clone(), true, &[], run) - .await; + self.run_pass( + pe, + epoch, + msgs, + tools, + v["caps"].clone(), + true, + &[], + None, + run, + ) + .await; side_job_end(&self.shared.store, side).await; } other => { @@ -359,18 +372,19 @@ impl ChatService { return false; }; // Même contexte que le tour de build : sinon le début du prompt change - // et tout le cache est à refaire. - let (head, prefix, tools) = match self.turn_view(pe.turn_caps).await { - Ok(v) => v, + // et tout le cache est à refaire (PROJ_SNAPSHOT compris). + let Assembled { + sent, + tools, + hist, + snap_turn, + } = match self.assemble_turn(epoch, pe.turn_caps, msgs).await { + Ok(a) => a, Err(e) => { conv.append_delta(epoch, obj("error", e)); return false; } }; - let hist = strip_context_updates(msgs); - let mut sent = head; - sent.extend(hist.iter().cloned()); - let sent = inject_prefix(sent, &prefix); let out = self .run_pass( pe, @@ -380,6 +394,7 @@ impl ChatService { pe.turn_caps.clone(), false, &hist, + snap_turn, run, ) .await; @@ -387,6 +402,10 @@ impl ChatService { if let Some(base) = out.new_base { st.messages = base; } + // Même règle que le tour : le bloc rafraîchi en cours de passe. + if let Some(s) = out.snap { + st.proj_snap = Some(s); + } st.messages.extend(out.extra); if !out.content.trim().is_empty() { let a = Message { @@ -421,6 +440,7 @@ impl ChatService { caps: Value, isolated: bool, base: &[Message], + snap_turn: Option, run: &mut PassRun<'_>, ) -> PassOut { let conv = self.sessions.conv().clone(); @@ -463,6 +483,8 @@ impl ChatService { super::perf::KIND_MAIN }, side_slot: Default::default(), + projsnap: snap_turn, + snap_refreshed: Default::default(), }; let mut out = PassOut::default(); let mut cb = |ev: StreamEvent| -> bool { @@ -546,6 +568,7 @@ impl ChatService { } }; out.extra = res.extra; + out.snap = host.take_refreshed_snap(); out } } diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index e340eb5..942912f 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -247,8 +247,8 @@ fn serve() -> ExitCode { let sup = child::Supervisor::spawn(go_bin, go_port, env); wait_for(backend, Duration::from_secs(30)).await; let engine_paths = engine::Paths::from_env(); - // Le Go peut garder la conversation malgré LOKI_CHAT=rust (réglage que - // la façade ne sait pas encore honorer) : sa décision fait foi. + // Le Go confirme qu'il remet la conversation (adresse et jeton bien + // reçus) : sa réponse fait foi. let chat_rust = chat_rust && match go_keeps_chat(backend, &token).await { Ok(None) => true, @@ -415,9 +415,8 @@ fn serve() -> ExitCode { } /// Conversation tenue par la façade : c'est le défaut. `LOKI_CHAT=go` (ou -/// 0/off/false/no) la rend au Go — porte de sortie. Le Go la garde de toute -/// façon si un réglage que la façade ne sait pas honorer est posé -/// (`facadeChatBlocker`). +/// 0/off/false/no) la rend au Go — porte de sortie. Plus aucun réglage ne la +/// garde au Go (lot 34). fn chat_held_by_facade() -> bool { chat_wanted(std::env::var("LOKI_CHAT").ok().as_deref()) } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 8e22d9a..26dac21 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -141,6 +141,13 @@ pub struct FakeGo { /// (« check », « begin {corps} », « end {corps} »). pub prewarm_refuse: Mutex>, pub prewarm_log: Mutex>, + /// PROJ_SNAPSHOT : corps reçus par /internal/v1/turn/view ; décisions à + /// rendre (une par appel, dans l'ordre) ; réponse de + /// /internal/v1/turn/projsnap/refresh et corps reçus. + pub turn_view_bodies: Mutex>, + pub projsnaps: Mutex>, + pub projsnap_refresh: Mutex>, + pub projsnap_refreshes: Mutex>, } impl FakeGo { @@ -360,16 +367,31 @@ async fn handle(st: Arc, req: Request) -> Response } "/internal/v1/turn/view" => { st.turn_log.lock().unwrap().push("view".into()); + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + st.turn_view_bodies.lock().unwrap().push(v); if let Some(v) = st.turn_view.lock().unwrap().clone() { return resp(200, v.to_string()); } - let out = serde_json::json!({ + let mut out = serde_json::json!({ "head": [{"role": "system", "content": "Tu es Loki."}], "prefix": "PRÉAMBULE", "tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}], }); + if let Some(d) = st.projsnaps.lock().unwrap().pop_front() { + out["projsnap"] = d; + } resp(200, out.to_string()) } + "/internal/v1/turn/projsnap/refresh" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + st.projsnap_refreshes.lock().unwrap().push(v); + match st.projsnap_refresh.lock().unwrap().clone() { + Some(r) => resp(200, r.to_string()), + None => resp(500, r#"{"error":"non prévu"}"#), + } + } "/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() { None => resp(200, r#"{"holder":"rust"}"#), Some(why) => resp( diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 8921402..b651dbd 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -695,6 +695,8 @@ async fn tour_complet_par_le_go() { queued: Box::new(Vec::new), perf_kind: "main", side_slot: Default::default(), + projsnap: None, + snap_refreshed: Default::default(), }; let bash = Tool { kind: "function".into(), @@ -1072,6 +1074,8 @@ async fn hote_et_sonde_de_gabarit() { queued: Box::new(Vec::new), perf_kind: "main", side_slot: Default::default(), + projsnap: None, + snap_refreshed: Default::default(), } }; let bash = Tool { @@ -2172,6 +2176,14 @@ async fn prechauffage_par_la_facade() { .iter() .any(|p| p["kind"] == "prewarm" && p["stats"]["prompt_tokens_total"] == 60) ); + // La vue du préchauffage est demandée sans rien à ranger (PROJ_SNAPSHOT). + assert!( + go.turn_view_bodies + .lock() + .unwrap() + .iter() + .any(|b| b["dry"] == true) + ); } /// Le Go refuse (moteur occupé) : rien ne part ; annulé par une autre @@ -2215,3 +2227,160 @@ async fn prechauffage_refuse_ou_annule() { .all(|p| p["kind"] != "prewarm") ); } + +fn proj_snap(desc: &str, delta_tok: i64) -> serde_json::Value { + serde_json::json!({ + "at": 1, + "msgs": [{"role": "system", "content": format!("Project context — {desc}")}], + "state": {"desc": desc}, + "key": "k1", + "sys_hash": "h1", + "delta_tok": delta_tok + }) +} + +/// Lot 34 : PROJ_SNAPSHOT tenu par la façade. Le Go décide (instantané pris, +/// mise à jour, rafraîchissement) ; la façade range l'instantané avec la +/// discussion et pose la mise à jour en tête du message, dans la requête et +/// dans l'historique. +#[tokio::test] +async fn bloc_projet_fige_par_la_facade() { + use loki_rs::chat::persist::ProjSnapshot; + let (url, seen) = fake_engine(vec![answer("un"), answer("deux"), answer("trois")]).await; + let (svc, go, _stop, _t) = service(Some(&url)).await; + let delta = "\n+ neuf\n\n\n"; + go.projsnaps.lock().unwrap().extend([ + serde_json::json!({"refresh": true, "snap": proj_snap("figé", 0), "key": "k1", "sys_hash": "h1"}), + serde_json::json!({"refresh": false, "delta": delta, "snap": proj_snap("neuf", 5), "key": "k1", "sys_hash": "h1"}), + serde_json::json!({"refresh": true, "snap": proj_snap("frais", 0), "key": "k1", "sys_hash": "h1"}), + ]); + let stored = |svc: &ChatService| -> ProjSnapshot { + svc.sessions + .conv() + .with_state(|st| st.proj_snap.clone()) + .expect("instantané rangé") + }; + let as_snap = |v: serde_json::Value| -> ProjSnapshot { serde_json::from_value(v).unwrap() }; + + svc.send(serde_json::json!({"message": "bonjour", "cid": "s1"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert_eq!(stored(&svc), as_snap(proj_snap("figé", 0))); + let views = go.turn_view_bodies.lock().unwrap().clone(); + assert_eq!(views[0]["snap"], serde_json::Value::Null); + assert_eq!(views[0]["dry"], false); + + // Un changement : mise à jour en tête du nouveau message, rangée telle + // quelle (le tour suivant renvoie les mêmes octets). + svc.send(serde_json::json!({"message": "suite", "cid": "s2"})) + .await + .unwrap(); + until_done(&svc, 2).await; + let views = go.turn_view_bodies.lock().unwrap().clone(); + assert_eq!( + as_snap(views[1]["snap"].clone()), + as_snap(proj_snap("figé", 0)) + ); + let sent = seen.lock().unwrap()[1].1.clone(); + let msgs = sent["messages"].as_array().unwrap(); + assert_eq!(msgs.last().unwrap()["content"], format!("{delta}suite")); + assert!( + msgs.last().unwrap().get("ctx_upd").is_none(), + "marque envoyée au moteur" + ); + let hist = svc.sessions.conv().with_state(|st| st.messages.clone()); + let upd = hist.iter().find(|m| m.ctx_upd).expect("mise à jour rangée"); + assert_eq!( + upd.content, + Some(serde_json::json!(format!("{delta}suite"))) + ); + assert_eq!(stored(&svc), as_snap(proj_snap("neuf", 5))); + + // Rafraîchi : instantané neuf, mise à jour retirée de l'historique. + svc.send(serde_json::json!({"message": "fin", "cid": "s3"})) + .await + .unwrap(); + until_done(&svc, 3).await; + let sent = seen.lock().unwrap()[2].1.clone(); + assert!( + sent["messages"] + .as_array() + .unwrap() + .iter() + .any(|m| m["content"] == "suite"), + "{sent}" + ); + let hist = svc.sessions.conv().with_state(|st| st.messages.clone()); + assert!(hist.iter().all(|m| !m.ctx_upd)); + assert_eq!(stored(&svc), as_snap(proj_snap("frais", 0))); +} + +/// Réduction forcée en plein tour : le bloc vivant remplace le figé, et +/// devient l'instantané du tour suivant. +#[tokio::test] +async fn bloc_projet_rafraichi_en_plein_tour() { + use loki_rs::chat::persist::ProjSnapshot; + let (url, seen) = fake_engine(vec![ + "STATUS 400\nrequest (400000 tokens) exceeds the available context size (32768 tokens)".into(), + serde_json::json!({"choices": [{"message": {"content": "résumé des échanges"}, "finish_reason": "stop"}]}) + .to_string(), + answer("ok"), + ]) + .await; + let (svc, go, _stop, _t) = service(Some(&url)).await; + // Un historique assez long pour qu'il y ait de quoi résumer. + svc.sessions.conv().with_state(|st| { + for i in 0..8 { + st.messages.push(loki_rs::chat::types::Message { + role: if i % 2 == 0 { "user" } else { "assistant" }.into(), + content: Some(serde_json::json!(format!( + "échange {i} {}", + "x".repeat(800) + ))), + ..Default::default() + }); + } + }); + go.projsnaps.lock().unwrap().push_back(serde_json::json!( + {"refresh": true, "snap": proj_snap("figé", 0), "key": "k1", "sys_hash": "h1"} + )); + *go.projsnap_refresh.lock().unwrap() = Some(serde_json::json!({ + "block": [{"role": "system", "content": "Project context — vivant"}], + "snap": proj_snap("vivant", 0) + })); + *go.turn_view.lock().unwrap() = None; + svc.send(serde_json::json!({"message": "bonjour", "cid": "m1"})) + .await + .unwrap(); + until_done(&svc, 1).await; + let refreshes = go.projsnap_refreshes.lock().unwrap().clone(); + assert_eq!(refreshes.len(), 1, "{refreshes:?}"); + assert_eq!( + ( + refreshes[0]["key"].clone(), + refreshes[0]["sys_hash"].clone() + ), + (serde_json::json!("k1"), serde_json::json!("h1")) + ); + let sent = seen.lock().unwrap().last().unwrap().1.clone(); + assert!( + sent["messages"] + .as_array() + .unwrap() + .iter() + .any(|m| m["content"] + .as_str() + .is_some_and(|c| c.contains("Project context — vivant"))), + "{sent}" + ); + let stored: ProjSnapshot = svc + .sessions + .conv() + .with_state(|st| st.proj_snap.clone()) + .unwrap(); + assert_eq!( + stored, + serde_json::from_value(proj_snap("vivant", 0)).unwrap() + ); +}