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

+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)
}