Lot 34 : bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 03:13:27 +00:00
1 parent ff5c6d4571
commit ad52da18a9
19 files changed
+1063 -171

No files matched your search

+1 -1
View File
@@ -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 |
@@ -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 `<context_update>` 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 `<context_update>` 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`.
+7 -45
View File
@@ -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"
}
+19 -54
View File
@@ -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" {
+94
View File
@@ -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 <context_update> 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),
})
}
+169
View File
@@ -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...)
}
+2
View File
@@ -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":
+10 -6
View File
@@ -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{}
+4 -1
View File
@@ -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)
}
+122
View File
@@ -510,6 +510,57 @@ pub(crate) fn strip_context_updates(msgs: Vec<Message>) -> Vec<Message> {
.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<Message> {
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<Message>, block: &[Message]) -> Vec<Message> {
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<S: Summarizer, R: RecallArchive> 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<String> = 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};
+164 -31
View File
@@ -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 `<context_update>` 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<ProjSnapshot>,
#[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<Message>,
pub(super) prefix: String,
pub(super) tools: Vec<Tool>,
pub(super) snap: Option<SnapDecision>,
}
/// 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<Message>,
pub(super) tools: Vec<Tool>,
pub(super) hist: Vec<Message>,
pub(super) snap_turn: Option<SnapTurn>,
}
/// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file.
pub struct ChatService {
pub sessions: Arc<Sessions<GoSessionHooks>>,
@@ -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<Message>, String, Vec<Tool>), String> {
snap: Option<&ProjSnapshot>,
dry: bool,
) -> Result<ViewParts, String> {
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::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
v["prefix"].as_str().unwrap_or("").to_string(),
serde_json::from_value::<Vec<Tool>>(v["tools"].clone()).unwrap_or_default(),
)),
Ok((s, v)) if s.as_u16() == 200 => Ok(ViewParts {
head: serde_json::from_value::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
prefix: v["prefix"].as_str().unwrap_or("").to_string(),
tools: serde_json::from_value::<Vec<Tool>>(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
/// `<context_update>` 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<Message>,
) -> Result<Assembled, String> {
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 <context_update>, 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 {
+59 -1
View File
@@ -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<SnapTurn>,
pub snap_refreshed: Mutex<Option<ProjSnapshot>>,
}
/// 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<ProjSnapshot> {
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<Message>) -> Vec<Message> {
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::<Vec<Message>>(v["block"].clone()).ok(),
serde_json::from_value::<ProjSnapshot>(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()
+14 -7
View File
@@ -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;
+14 -6
View File
@@ -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<u8>,
keep_prewarm: bool,
) -> impl Future<Output = Result<Reply, SendError>> + 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<Message>) -> impl Future<Output = Vec<Message>> + 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<H: TurnHost>(
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<H: TurnHost>(
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<H: TurnHost>(
);
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<H: TurnHost>(
);
if let Some(c) = c {
cb(StreamEvent::DropReasoning);
messages = c;
messages = host.project_refresh(c).await;
extra.clear();
publish_history(cb, kt, &messages);
continue;
+60
View File
@@ -52,6 +52,9 @@ struct Fake {
/// a reçu.
prewarm_match: Mutex<bool>,
sent_keeps: Mutex<Vec<bool>>,
/// PROJ_SNAPSHOT : clé posée pour ce tour ; rafraîchissements demandés.
snap_on: Mutex<bool>,
refreshes: Mutex<usize>,
}
impl Fake {
@@ -173,6 +176,13 @@ impl TurnHost for Fake {
async fn keep_images(&self) -> Option<i64> {
*self.keep.lock().unwrap()
}
async fn project_refresh(&self, mut msgs: Vec<Message>) -> Vec<Message> {
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<Message> = (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);
}
+36 -13
View File
@@ -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<ReasoningEcho>,
saw_usage: bool,
asked: bool,
/// Bloc projet rafraîchi en cours de passe (PROJ_SNAPSHOT).
snap: Option<ProjSnapshot>,
}
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<SnapTurn>,
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
}
}
+4 -5
View File
@@ -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())
}
+23 -1
View File
@@ -141,6 +141,13 @@ pub struct FakeGo {
/// (« check », « begin {corps} », « end {corps} »).
pub prewarm_refuse: Mutex<Option<String>>,
pub prewarm_log: Mutex<Vec<String>>,
/// 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<Vec<serde_json::Value>>,
pub projsnaps: Mutex<std::collections::VecDeque<serde_json::Value>>,
pub projsnap_refresh: Mutex<Option<serde_json::Value>>,
pub projsnap_refreshes: Mutex<Vec<serde_json::Value>>,
}
impl FakeGo {
@@ -360,16 +367,31 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
}
"/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(
+169
View File
@@ -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 = "<context_update from=\"loki\" at=\"x\">\n+ neuf\n</context_update>\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()
);
}