From ef11454a2144118c6a4e17883c167a09ae331760 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:48:38 +0000 Subject: [PATCH] API interne d'un tour de discussion tenu par Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce qui décrit un tour reste au Go le temps d'être porté (lot 7) ; la façade le demande ici : - /turn/start : pièces jointes et vision (userMessageContent), capacités demandées et celles du tour (rôle du mode Code figé), nom du modèle, puce « mode Code » (une fois par discussion) ; - /turn/view : la tête de turnView sans PROJ_SNAPSHOT (prompt du preset, consignes du dépôt, contexte du projet), le préambule d'InjectSkills et les outils du tour, MCP compris ; 409 sous PROJ_SNAPSHOT ; - /turn/done : notification Web Push de fin de tour ; - /conv/active : la discussion active des outils suit la façade ; - /conv/dropped : fichiers, travaux, critères, mode et résultats d'outils d'une discussion supprimée. injectPrefix et codeHintDue sortent d'InjectSkills et de maybeCodeHint, sans changement de comportement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- internal/loki/code_mode.go | 16 ++- internal/loki/llm_client.go | 32 +++-- internal/loki/web_internal.go | 10 ++ internal/loki/web_internal_turn.go | 164 ++++++++++++++++++++++++ internal/loki/web_internal_turn_test.go | 129 +++++++++++++++++++ 5 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 internal/loki/web_internal_turn.go create mode 100644 internal/loki/web_internal_turn_test.go diff --git a/internal/loki/code_mode.go b/internal/loki/code_mode.go index 8b4d285..2d4ae37 100644 --- a/internal/loki/code_mode.go +++ b/internal/loki/code_mode.go @@ -86,18 +86,26 @@ func convHasRepo() bool { // maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y // prête. Une seule fois par discussion : une suggestion répétée est du bruit. func maybeCodeHint(c *Conversation, epoch int, text string) { + if codeHintDue(text) { + c.appendDelta(epoch, map[string]any{"code_hint": true}) + } +} + +// codeHintDue dit si la puce doit apparaître pour ce message, et la marque +// alors comme montrée pour la discussion active (une fois par discussion). +func codeHintDue(text string) bool { if convCodeMode() || !agentEnabled() { - return + return false } id := convEnsureActive() if getBool(bkChat, hintKey(id)) { - return + return false } if !looksLikeCode(text) && !convHasRepo() { - return + return false } _ = putBool(bkChat, hintKey(id), true) - c.appendDelta(epoch, map[string]any{"code_hint": true}) + return true } // --- API --------------------------------------------------------------------- diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index c7d63ab..e4ee640 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -263,6 +263,24 @@ func globalCaps() Caps { // skills directory (when skills are enabled). Merges with an existing system // message if present. tools = the tools sent on this same turn (see prepareTurn). func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message { + prefix := injectPrefix(caps, tools) + if prefix == "" { + return msgs + } + // Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le + // préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait + // plus le sortir du bloc système commun (voir isProjectSystem). + if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { + existing, _ := msgs[0].Content.(string) + merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) + return merged + } + return append([]Message{{Role: "system", Content: prefix}}, msgs...) +} + +// injectPrefix : le texte qu'InjectSkills pose en tête — préambule d'agent +// décidé, puis briefing machine ; "" s'il n'y a rien à dire. +func injectPrefix(caps Caps, tools []Tool) string { var parts []string // Decisive-agent preamble (anti-loop) — only when the model actually has // tools, otherwise it nudges a plain chat model to "call tools" it doesn't @@ -273,19 +291,7 @@ func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message { if mp := machineSystemPrompt(caps); mp != "" { parts = append(parts, mp) } - if len(parts) == 0 { - return msgs - } - prefix := strings.Join(parts, "\n\n") - // Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le - // préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait - // plus le sortir du bloc système commun (voir isProjectSystem). - if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { - existing, _ := msgs[0].Content.(string) - merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) - return merged - } - return append([]Message{{Role: "system", Content: prefix}}, msgs...) + return strings.Join(parts, "\n\n") } // prepareTurn calcule UNE fois les outils du tour, puis le préambule à partir diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index df3a83d..4259abd 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -85,6 +85,16 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalEngineStop(w, r) case "/internal/v1/recall/archive": handleInternalRecallArchive(w, r) + case "/internal/v1/turn/start": + handleInternalTurnStart(w, r) + case "/internal/v1/turn/view": + handleInternalTurnView(w, r) + case "/internal/v1/turn/done": + handleInternalTurnDone(w, r) + case "/internal/v1/conv/active": + handleInternalConvActive(w, r) + case "/internal/v1/conv/dropped": + handleInternalConvDropped(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go new file mode 100644 index 0000000..c198f68 --- /dev/null +++ b/internal/loki/web_internal_turn.go @@ -0,0 +1,164 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +// API interne d'un tour de discussion tenu par la façade Rust (lot 6). +// +// La boucle, le journal et la persistance sont en Rust ; ce qui décrit le +// tour reste ici, le temps d'être porté (lot 7) : pièces jointes et vision, +// rôle du mode Code, prompt du preset, consignes du dépôt, contexte du +// projet, préambule d'agent, outils (MCP compris), puce « mode Code », +// notification Web Push, et l'état par discussion que les outils lisent. +// +// POST /internal/v1/turn/start corps de /api/chat/send +// → {files, caps, turn_caps, content, model, code_hint} +// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools} +// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go) +// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés) +// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils) +// POST /internal/v1/conv/dropped {"id":…} → 204 (fichiers, jobs, critères, +// mode, résultats d'outils) + +const internalTurnMaxBody = 8 << 20 + +func readInternalJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return false + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalTurnMaxBody+1)) + if err != nil || len(raw) > internalTurnMaxBody || json.Unmarshal(raw, v) != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "corps illisible"}) + return false + } + return true +} + +// handleInternalTurnStart : ce que StartTurn et EnqueueOrStart tirent de la +// requête avant de lancer (ou de mettre en file) le tour. caps : capacités +// demandées (file d'attente, préchauffage) ; turn_caps : celles du tour, rôle +// du mode Code figé ; content : le message utilisateur vu par le modèle. +func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) { + var body chatReq + if !readInternalJSON(w, r, &body) { + return + } + files := attachFiles(body.Files) + if strings.TrimSpace(body.Message) == "" && len(files) == 0 { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "message vide"}) + return + } + rememberUserTZ(body.TZ) + caps := capsFromBody(body) + turnCaps := withTurnRole(caps, body.Message) + prompt := body.Message + if strings.TrimSpace(prompt) == "" { + prompt = "Prends-en connaissance." + } + if files == nil { + files = []attachInfo{} + } + sendJSON(w, http.StatusOK, map[string]any{ + "files": files, + "caps": caps, + "turn_caps": turnCaps, + "content": userMessageContent(files, prompt), + "model": chatModelName(), + "code_hint": !turnCaps.Code && codeHintDue(body.Message), + }) +} + +// 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. +func handleInternalTurnView(w http.ResponseWriter, r *http.Request) { + var in struct { + Caps Caps `json:"caps"` + } + 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"}) + return + } + head := []Message{} + if caps.Agent { + if sp := readSysPrompt(); sp != "" { + head = append(head, Message{Role: "system", Content: sp}) + } + if m, ok := codeInstructionsMessage(caps); ok { + head = append(head, m) + } + head = append(head, projectSystemMessages()...) + } + tools := EnabledTools(caps) + if tools == nil { + tools = []Tool{} + } + sendJSON(w, http.StatusOK, map[string]any{"head": head, "prefix": injectPrefix(caps, tools), "tools": tools}) +} + +func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) { + var in struct { + Ms int64 `json:"ms"` + } + if !readInternalJSON(w, r, &in) { + return + } + if hasPushSubs() { + d := time.Duration(in.Ms) * time.Millisecond + go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(d)) + } + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalConvActive : la façade a changé de discussion active. Les +// outils exécutés ici (dossier de la discussion, mode Code, critères) lisent +// l'identifiant en cache : il suit. +func handleInternalConvActive(w http.ResponseWriter, r *http.Request) { + var in struct { + ID string `json:"id"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.ID) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"}) + return + } + convActiveMu.Lock() + convActiveRemember(in.ID) + convActiveMu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalConvDropped : ce qui part avec une discussion supprimée par +// la façade (mêmes effets que la fin de convDelete). +func handleInternalConvDropped(w http.ResponseWriter, r *http.Request) { + var in struct { + ID string `json:"id"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.ID) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"}) + return + } + stopConvJobs(in.ID) + critDrop(in.ID) + dropConvMode(in.ID) + dropConvFiles(in.ID) + deleteToolResultsFor(in.ID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/web_internal_turn_test.go b/internal/loki/web_internal_turn_test.go new file mode 100644 index 0000000..b02339b --- /dev/null +++ b/internal/loki/web_internal_turn_test.go @@ -0,0 +1,129 @@ +package loki + +import ( + "encoding/json" + "strings" + "testing" +) + +func turnJSON(t *testing.T, path, body string, want int) map[string]any { + t.Helper() + w := appelOAIInterne(t, "POST", path, body) + if w.Code != want { + t.Fatalf("%s : %d %s", path, w.Code, w.Body.String()) + } + var m map[string]any + if w.Body.Len() > 0 { + if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + } + return m +} + +// Début d'un tour : message vu par le modèle, modèle, capacités du tour, +// puce « mode Code » une seule fois par discussion. +func TestTourInterneDebut(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + turnJSON(t, "/internal/v1/turn/start", `{"message":" "}`, 400) + if err := SetConfigKey("MODEL", "/data/models/Qwen3-8B.gguf"); err != nil { + t.Fatal(err) + } + got := turnJSON(t, "/internal/v1/turn/start", `{"message":"salut"}`, 200) + if got["content"] != "salut" || got["model"] != "Qwen3-8B" || got["code_hint"] != false { + t.Fatalf("début : %v", got) + } + if files, _ := got["files"].([]any); files == nil || len(files) != 0 { + t.Fatalf("files : %v", got["files"]) + } + if c, _ := got["caps"].(map[string]any); c["Agent"] != false { + t.Fatalf("caps sans agent : %v", c) + } + + // Agent actif : un message qui ressemble à du code fait apparaître la puce, + // une seule fois. + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + got = turnJSON(t, "/internal/v1/turn/start", "{\"message\":\"corrige main.go\"}", 200) + if got["code_hint"] != true { + t.Fatalf("puce attendue : %v", got) + } + if got = turnJSON(t, "/internal/v1/turn/start", "{\"message\":\"corrige main.go\"}", 200); got["code_hint"] != false { + t.Fatalf("puce montrée deux fois : %v", got) + } + + // Mode Code : rôle du tour figé (plan demandé → planner). + if err := setConvMode(convEnsureActive(), "code"); err != nil { + t.Fatal(err) + } + got = turnJSON(t, "/internal/v1/turn/start", `{"message":"fais un plan"}`, 200) + tc, _ := got["turn_caps"].(map[string]any) + c, _ := got["caps"].(map[string]any) + if tc["Code"] != true || tc["Role"] == "" || c["Role"] != "" { + t.Fatalf("rôle du tour : caps=%v turn_caps=%v", c, tc) + } +} + +// Vue d'un tour : rien du projet sans agent ; tête et préambule avec ; refus +// sous PROJ_SNAPSHOT. +func TestTourInterneVue(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + got := turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200) + if h, _ := got["head"].([]any); len(h) != 0 { + t.Fatalf("sans agent, rien du projet : %v", got["head"]) + } + off := Caps{Mem: MemOff} + if got["prefix"] != injectPrefix(off, EnabledTools(off)) { + t.Fatalf("préambule sans agent : %v", got["prefix"]) + } + if err := saveSysPrompt("Réponds en vers."); err != nil { + t.Fatal(err) + } + got = turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true,"Mem":"off"}}`, 200) + tools, _ := got["tools"].([]any) + prefix, _ := got["prefix"].(string) + head, _ := got["head"].([]any) + if len(tools) == 0 || prefix == "" || len(head) == 0 { + t.Fatalf("agent : %d outils, préambule %q, tête %v", len(tools), prefix, head) + } + if h0, _ := head[0].(map[string]any); h0["content"] != "Réponds en vers." { + t.Fatalf("prompt du preset en tête : %v", head[0]) + } + // Même préambule que celui que pose InjectSkills. + var caps Caps + _ = json.Unmarshal([]byte(`{"Agent":true,"Mem":"off"}`), &caps) + if want := injectPrefix(caps, EnabledTools(caps)); prefix != want { + t.Fatal("préambule différent de celui d'InjectSkills") + } + if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil { + t.Fatal(err) + } + turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 409) + turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200) +} + +// État par discussion : l'active suit la façade ; une discussion supprimée +// emporte son mode ; fin de tour sans abonnés push. +func TestTourInterneDiscussions(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + turnJSON(t, "/internal/v1/conv/active", `{"id":"c99"}`, 204) + if got := convActiveCached(); got != "c99" { + t.Fatalf("active en cache : %q", got) + } + turnJSON(t, "/internal/v1/conv/active", `{"id":" "}`, 400) + if err := setConvMode("c99", "code"); err != nil { + t.Fatal(err) + } + turnJSON(t, "/internal/v1/conv/dropped", `{"id":"c99"}`, 204) + if getStr(bkChat, modeKey("c99")) != "" { + t.Fatal("mode de la discussion supprimée resté en base") + } + turnJSON(t, "/internal/v1/turn/done", `{"ms":1200}`, 204) + if w := appelOAIInterne(t, "POST", "/internal/v1/conv/dropped", "{"); w.Code != 400 || strings.Count(w.Body.String(), "error") != 1 { + t.Fatalf("corps illisible : %d %q", w.Code, w.Body.String()) + } +}