mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 04:36:44 +02:00
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:
19 files changed
+1063
-171
No files matched your search
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user