feat: menu + — Mémoire de l'agent — phase 4/8 (v7.54.0)
FlowDeck CI / lint (push) Successful in 2m13s
FlowDeck CI / test (push) Failing after 3h10m19s
FlowDeck CI / docker (push) Skipped

- Le moteur n'envoie jamais l'historique (system + objectif courant seulement)
  : chaque run repartait de zéro. Nouveau service app/services/agent_memory.py —
  une ligne résumé par conversation (table agent_memory, migration 31),
  écrite en fin de run et ré-injectée au contexte du run suivant.
- context_for() : bloc « ## Mémoire de la conversation », budget 4000 car.
  (troncature gauche + marqueur). remember() : 1 note/échange (180 → 700 car.),
  20 notes max, upsert, erreurs journalisées — un run ne peut pas échouer à
  cause de la mémoire. Toggle OFF = ni lecture ni écriture.
- Toggle « Mémoire » dans le menu + : entrée racine à l'état réel (🧠/💭 +
  libellé), PATCH /api/agent/conversations/{id} avec memory_enabled ajouté au
  whitelist, état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT
  (settings.agent_memory_default) + memory_enabled dans la réponse de création.
- Décision : mémoire d'espace (lignes workspace_id NULL) remise — YAGNI, rien
  ne l'écrivait ; noté ponytail: dans le service avec le chemin de montée.
- Tests : tests/test_v754_memory.py (8) dont 2 runs réels du moteur (FakeLLM
  capture le prompt : mémoire présente ON / absente OFF) ; test_v751 et
  test_v753 adaptés (2 sections « bientôt » restantes). Suite complète 1293
  verts (-n auto, 2e run ; 1 échec intermittent sur test_v56_import URL au
  1er run, vert isolé + vert au run suivant = flake préexistant), ruff 0,
  eslint 0 problème. OpenAPI 516 chemins / 7.54.0.
This commit is contained in:
2026-10-06 22:53:15 -04:00
parent a68e8848ea
commit 64c85caffc
15 changed files with 389 additions and 31 deletions
+36
View File
@@ -1,5 +1,41 @@
# Changelog - FlowDeck
## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8)
### Added
- **Mémoire de l'agent** — le moteur n'envoie jamais l'historique des messages
(`AgentEngine.run()` ne construit que système + objectif courant) : chaque run
repartait de zéro. Désormais une **ligne résumé par conversation**
(`app/services/agent_memory.py`, table `agent_memory`, migration 31) est
écrite à la fin de chaque run et **ré-injectée au contexte du run suivant**
quand le toggle est activé.
- `context_for()` : bloc « ## Mémoire de la conversation », **budget 4 000
caractères** (troncature par la gauche avec marqueur) ;
- `remember()` : 1 note par échange (objectif 180 car. → réponse 700 car.),
**20 notes max**, upsert, et **jamais une erreur ne fait échouer un run** ;
- OFF = ni lecture ni écriture (contexte strict du run courant).
- **Toggle « Mémoire » dans le menu +** — entrée racine à l'état réel
(🧠 activée / 💭 désactivée avec le libellé correspondant), bascule via
`PATCH /api/agent/conversations/{id}` (`memory_enabled` ajouté au whitelist),
état persisté et reflété à l'ouverture/à la création de la conversation.
- **`agent_conversations.memory_enabled`** (migration **31**, défaut `1`) +
config **`AGENT_MEMORY_DEFAULT`** (`settings.agent_memory_default`) appliquée
à la création d'une conversation ; la réponse de création expose la valeur.
### Tests
- `tests/test_v754_memory.py` — **8 tests** : colonne + table créées par la
migration, défaut ON à la création, toggle persisté relu en base,
injection ON / absence OFF / persistance à travers un cycle OFF→ON,
budget et nombre de notes bornés, **2 runs réels du moteur** (FakeLLM qui
capture le prompt : mémoire présente ON, absente OFF), câblage front/back.
- `test_v751` + `test_v753` adaptés : **2 sections « bientôt »** restantes
(connecteurs, plugins).
- **Suite complète : 1293 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8)
### Added
+28 -15
View File
@@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
---
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-3 ✅ 2026-10-06 · phases 4-8 planifiées)
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-4 ✅ 2026-10-06 · phases 5-8 planifiées)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
@@ -390,17 +390,31 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06)
- [ ] **Table `agent_memory`** (`workspace_id`, `kind` summary|fact, `content`,
`updated_at`) + colonne `conversations.memory_enabled`
- [ ] **Service `app/services/agent_memory.py`** — extraction en fin de run (résumé +
faits saillants ; LLM sans outils, repli déterministe hors-ligne)
- [ ] **Injection dans `ContextBuilder` si le toggle est ON** ; OFF = contexte strict
de la conversation courante
- [ ] **Toggle « Mémoire »** dans le menu + : état visible, persisté par conversation,
défaut `AGENT_MEMORY_DEFAULT`
- [ ] **Tests** — injection ON / absence OFF / persistance / budget de tokens borné
- [x] **Table `agent_memory`** — **une ligne résumé par conversation**
(`conversation_id` PK, upsert) + colonne `agent_conversations.memory_enabled`
(migration **31**, défaut `1`) ; la variante « mémoire d'espace » prévue au
design est **remise** (YAGNI : rien ne l'écrit — `ponytail:` noté dans le service)
- [x] **Service `app/services/agent_memory.py`** — **extraction déterministe**
(pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.),
20 notes max, écrite en fin de run via `agent_memory.remember()` ; toute
erreur est journalisée, **un run ne peut pas échouer à cause de la mémoire**
- [x] **Injection** — `AgentEngine.run()` appelle `agent_memory.context_for()` après
`ContextBuilder.build()` : bloc « ## Mémoire de la conversation », **budget
4 000 caractères** (troncature par la gauche + marqueur) ; OFF = ni lecture ni
écriture → contexte strict du run courant
- [x] **Toggle « Mémoire »** dans le menu + — entrée racine à l'état réel
(🧠/💭 + libellé), `PATCH /api/agent/conversations/{id}` (`memory_enabled`
ajouté au whitelist), état lu à l'ouverture et à la création, défaut
`AGENT_MEMORY_DEFAULT` (config + réponse de création)
- [x] **Tests** — `tests/test_v754_memory.py` : **8 tests** dont **2 runs réels du
moteur** (FakeLLM capture le prompt : mémoire présente ON / absente OFF),
budget et nombre de notes bornés, toggle relu en base ; `test_v751` +
`test_v753` adaptés (2 sections « bientôt ») ; **suite complète 1293 verts**,
`ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L
@@ -442,10 +456,9 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 3 livrées le
2026-10-06 (v7.51.0, v7.52.0, v7.53.0)**, phases 4-8 à valider une par une avant
« go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et
ROADMAP à jour.*
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 4 livrées le
2026-10-06 (v7.51.0 → v7.54.0)**, phases 5-8 à valider une par une avant « go ».
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
+1 -1
View File
@@ -1 +1 @@
7.53.0
7.54.0
+5
View File
@@ -126,6 +126,11 @@ class Settings(BaseSettings):
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
# ── Mémoire de l'agent (v7.54.0) ──
# État initial du toggle « Mémoire » à la création d'une conversation ;
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
agent_memory_default: bool = True
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.53.0",
version="7.54.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+22
View File
@@ -1584,3 +1584,25 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_collections_task "
"ON collections(is_task, workspace_id)"
)
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
"""Toggle par conversation + table des résumés mémorisés.
Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`.
"""
if "memory_enabled" not in columns(conn, "agent_conversations"):
conn.execute(
"ALTER TABLE agent_conversations "
"ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS agent_memory (
conversation_id INTEGER PRIMARY KEY
REFERENCES agent_conversations(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'summary',
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
+10 -4
View File
@@ -199,15 +199,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
ws = _workspace_id(request)
with get_conn() as conn:
agent = _default_agent(conn, user_id)
mem_default = 1 if settings.agent_memory_default else 0
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
VALUES (?,?,?,?,?,?)""",
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
VALUES (?,?,?,?,?,?,?)""",
(agent["id"], user_id, body.get("title", "New conversation"),
json.dumps({"workspace_id": ws}),
body.get("provider", ""), body.get("model", "")),
body.get("provider", ""), body.get("model", ""), mem_default),
)
conn.commit()
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"),
"status": "created", "memory_enabled": mem_default}
@router.get("/conversations/{conversation_id}")
@@ -249,6 +251,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
if body.get(col) is not None:
sets.append(f"{col}=?")
params.append(str(body[col]))
# v7.54.0 — toggle « Mémoire » de la conversation (0/1).
if body.get("memory_enabled") is not None:
sets.append("memory_enabled=?")
params.append(1 if body["memory_enabled"] else 0)
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
+8
View File
@@ -18,6 +18,7 @@ import re
from app.config import settings
from app.db import get_conn
from app.services import agent_memory
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
@@ -156,6 +157,11 @@ class AgentEngine:
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
system = self._build_system_prompt(agent, skills)
context = self.ctx.build(mentions=mentions, files=files)
# v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie
# jamais l'historique, sans elle chaque run repart de zéro.
memory = agent_memory.context_for(conversation_id)
if memory:
context = (context + "\n\n" + memory) if context else memory
if extra_context and extra_context.strip():
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
@@ -304,6 +310,8 @@ class AgentEngine:
self._persist_message(conversation_id, "assistant", final_text,
model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text)
# v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF).
agent_memory.remember(conversation_id, objective, final_text)
# v6.4.0: emit agent.run.finished (outbound webhooks only).
await _fire_agent_webhook("agent.run.finished", {
"conversation_id": conversation_id,
+89
View File
@@ -0,0 +1,89 @@
"""Mémoire de l'agent (v7.54.0).
Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée
au contexte du run suivant — le moteur n'envoie jamais l'historique des messages
(`AgentEngine.run()` ne construit que system + objectif courant), donc sans
mémoire chaque run repart de zéro.
Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni
écriture (contexte strict du run courant).
ponytail : mémoire par conversation seulement. Si besoin de notes partagées
entre conversations, ajouter des lignes avec ``conversation_id NULL`` +
``workspace_id`` et élargir la lecture dans ``context_for()``.
"""
from __future__ import annotations
import logging
import sqlite3
from app.db import get_conn
logger = logging.getLogger(__name__)
INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt
NOTE_LINES = 20 # échanges max conservés
NOTE_OBJECTIVE = 180 # troncature de l'objectif
NOTE_ANSWER = 700 # troncature de la réponse
def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool:
row = conn.execute(
"SELECT memory_enabled FROM agent_conversations WHERE id=?",
(conversation_id,),
).fetchone()
if row is None:
return False
return bool(row["memory_enabled"])
def _one_line(text: str, limit: int) -> str:
return " ".join((text or "").split())[:limit]
def context_for(conversation_id: int) -> str:
"""Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide)."""
with get_conn() as conn:
if not _enabled(conn, conversation_id):
return ""
row = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?",
(conversation_id,),
).fetchone()
content = ((row["content"] if row else "") or "").strip()
if not content:
return ""
if len(content) > INJECT_BUDGET:
content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:]
return "## Mémoire de la conversation (échanges précédents)\n" + content
def remember(conversation_id: int, objective: str, final_text: str) -> None:
"""Ajoute un échange à la mémoire — no-op si le toggle est OFF.
Ne doit **jamais** faire échouer un run : toute erreur est journalisée.
"""
note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}"
try:
with get_conn() as conn:
if not _enabled(conn, conversation_id):
return
row = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?",
(conversation_id,),
).fetchone()
lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()]
lines.append(note)
if len(lines) > NOTE_LINES:
lines = lines[-NOTE_LINES:]
conn.execute(
"INSERT INTO agent_memory (conversation_id, content, updated_at) "
"VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(conversation_id) DO UPDATE SET "
"content=excluded.content, updated_at=CURRENT_TIMESTAMP",
(conversation_id, "\n".join(lines)),
)
conn.commit()
except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run
logger.exception("Agent memory save failed for conversation #%s", conversation_id)
+4 -2
View File
@@ -1,7 +1,9 @@
# V74 — Menu + de l'assistant : hub de contexte (design)
> **Statut** : design — **phases 1 à 3 livrées (v7.51.0 → v7.53.0, 2026-10-06)** ;
> phases 4-8 restent à livrer. Adaptations faites en cours de route : le parcours
> **Statut** : design — **phases 1 à 4 livrées (v7.51.0 → v7.54.0, 2026-10-06)** ;
> phases 5-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
> modèle ici : une seule ligne résumé **par conversation** (pas de lignes
> `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
> dossier = `icon === 'folder'`), la gestion des skills est une **section du menu
> avec formulaire intégré** (pas une modale — `PATCH /api/agent/skills/{id}`
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.53.0"
"version": "7.54.0"
},
"paths": {
"/auth/register": {
+42 -3
View File
@@ -46,7 +46,7 @@
{key:'plugins', icon:'🧩', label:'Add plugins',
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Mémoire persistante — bientôt (phase 4)', disabled:true}
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
];
var FD_PLUS_FILES = [
{key:'f-search', icon:'🔍', label:'Rechercher…',
@@ -646,7 +646,8 @@
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled,
updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
@@ -663,7 +664,8 @@
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
var conv = {id:d.id, title:d.title, memory_enabled:d.memory_enabled,
updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
@@ -1140,8 +1142,44 @@
if(s === 'skills-edit') return []; // formulaire, pas de liste
if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
if(s === 'root'){
// l'entrée Mémoire reflète l'état réel du toggle de la conversation
var on = this.memoryOn;
return FD_PLUS_MENU.map(function(it){
if(it.key !== 'memory') return it;
return {key:'memory', icon: on ? '🧠' : '💭', label:'Mémoire (on/off)',
sub: on ? 'Activée — résumés des échanges injectés à chaque run'
: 'Désactivée — contexte strict du run courant',
action:'memory'};
});
}
return FD_PLUS_MENU;
},
// État du toggle mémoire de la conversation courante (défaut : activée).
get memoryOn(){
if(!this.currentConv) return true;
var v = this.currentConv.memory_enabled;
return v === undefined || v === null ? true : !!Number(v);
},
// Bascule le toggle mémoire (PATCH /api/agent/conversations/{id}).
toggleMemory(){
var self = this, conv = this.currentConv;
if(!conv || !conv.id){
this.toast('Ouvrez une conversation pour activer la mémoire.', true);
return;
}
var next = this.memoryOn ? 0 : 1;
fetch('/api/agent/conversations/' + conv.id, {
method: 'PATCH', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({memory_enabled: next})
}).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; }
conv.memory_enabled = next;
self.toast(next ? 'Mémoire activée — résumés injectés à chaque run.'
: 'Mémoire désactivée — contexte strict du run courant.');
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
// Sous-menu Compétences : builtin (alias affiché) + installés, puis les 2 actions.
_skillsItems(){
var out = [];
@@ -1319,6 +1357,7 @@
this.plusSection = 'browse'; this.plusCrumb = []; this.plusLoadBrowse(null); return;
}
// ── Compétences (v7.52.0) ──
if(it.action === 'memory'){ this.toggleMemory(); return; }
if(it.action === 'skills'){ this.plusSection = 'skills'; this.plusFocus = 0; return; }
if(it.action === 'skill'){ this.closePlus(); this.useSuggestion(it.suggestion); return; }
if(it.action === 'skills-manage'){
+2 -2
View File
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
assert f"key:{key}" in root, key
assert "action:'files'" in root and "action:'skills'" in root
# phases 1-3 livrées → il reste 3 sections « bientôt » (connecteurs, plugins, mémoire)
assert root.count("disabled:true") == 3
# phases 1-4 livrées → il reste 2 sections « bientôt » (connecteurs, plugins)
assert root.count("disabled:true") == 2
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
+2 -2
View File
@@ -64,8 +64,8 @@ def test_canvases_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'canvases'" in root
# phases 1-3 livrées → 3 sections « bientôt » restantes (connecteurs, plugins, mémoire)
assert root.count("disabled:true") == 3
# phases 1-4 livrées → 2 sections « bientôt » restantes (connecteurs, plugins)
assert root.count("disabled:true") == 2
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
assert action in src, action
# piège /use : la clé builtin part DANS LE BODY, pas en query string
+138
View File
@@ -0,0 +1,138 @@
"""v7.54.0 — Mémoire de l'agent : toggle par conversation, injection au contexte."""
import asyncio
from pathlib import Path
from app.db import get_conn
from app.services import agent_memory
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
ENGINE = ROOT / "app" / "services" / "agent_engine.py"
def _conv(client) -> tuple[int, dict]:
r = client.post("/api/agent/conversations", json={"title": "mémoire"})
assert r.status_code == 200, r.text
return r.json()["id"], r.json()
def test_migration_created_column_and_table(client):
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_conversations)")}
assert "memory_enabled" in cols
tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='agent_memory'"
).fetchone()
assert tbl is not None
def test_create_conversation_defaults_to_memory_on(client):
_, created = _conv(client)
assert created["memory_enabled"] == 1 # settings.agent_memory_default
def test_patch_conversation_toggles_memory_persisted(client):
cid, _ = _conv(client)
assert client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0}).status_code == 200
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
assert rows[cid]["memory_enabled"] == 0
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
rows = {c["id"]: c for c in client.get("/api/agent/conversations").json()["conversations"]}
assert rows[cid]["memory_enabled"] == 1
def test_context_for_on_off_and_persistence(client):
cid, _ = _conv(client)
agent_memory.remember(cid, "Où en est le design system ?", "Les tokens sont dans design-tokens.css.")
ctx = agent_memory.context_for(cid)
assert "Mémoire de la conversation" in ctx
assert "design-tokens.css" in ctx
# OFF → plus rien n'est lu ni écrit
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
assert agent_memory.context_for(cid) == ""
agent_memory.remember(cid, "Effacé ?", "Ne doit pas apparaître.")
assert agent_memory.context_for(cid) == ""
# ON à nouveau → la mémoire écrite avant le OFF est toujours là (persistée)
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 1})
assert "design-tokens.css" in agent_memory.context_for(cid)
assert "Ne doit pas apparaître" not in agent_memory.context_for(cid)
def test_memory_budget_and_note_lines_bounded(client):
cid, _ = _conv(client)
for i in range(30):
agent_memory.remember(cid, f"question {i} " + "x" * 300, "réponse " + "y" * 900)
ctx = agent_memory.context_for(cid)
header = "## Mémoire de la conversation (échanges précédents)"
assert ctx.startswith(header)
assert len(ctx) <= agent_memory.INJECT_BUDGET + len(header) + 60
with get_conn() as conn:
content = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?", (cid,)
).fetchone()["content"]
assert content.count("\n") + 1 <= agent_memory.NOTE_LINES
async def _drain(engine, cid, objective):
"""Consomme le générateur SSE de engine.run()."""
return [ev async for ev in engine.run(cid, objective)]
class _Resp:
def __init__(self, text=""):
self.text = text
self.tool_calls = []
self.usage = {}
class _RecorderLLM:
def __init__(self):
self.last_user = ""
async def complete(self, messages, *, model=None, tools=None, stream=False):
self.last_user = next(m["content"] for m in messages if m["role"] == "user")
return _Resp("C'est noté.")
def test_engine_run_injects_memory_when_on(client):
"""Le run réel pousse la mémoire dans le prompt utilisateur (toggle ON)."""
from app.services.agent_engine import AgentEngine
cid, _ = _conv(client)
agent_memory.remember(cid, "Priorité du sprint", "Livrer le menu + en priorité.")
fake = _RecorderLLM()
engine = AgentEngine(1, llm=fake)
asyncio.run(_drain(engine, cid, "Quelle est la priorité ?"))
assert "Mémoire de la conversation" in fake.last_user
assert "menu +" in fake.last_user
def test_engine_run_omits_memory_when_off(client):
from app.services.agent_engine import AgentEngine
cid, _ = _conv(client)
agent_memory.remember(cid, "Secret", "ne pas injecter")
client.patch(f"/api/agent/conversations/{cid}", json={"memory_enabled": 0})
fake = _RecorderLLM()
engine = AgentEngine(1, llm=fake)
asyncio.run(_drain(engine, cid, "Bonjour"))
assert "Mémoire de la conversation" not in fake.last_user
assert "ne pas injecter" not in fake.last_user
def test_memory_menu_toggle_wired():
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'memory'" in root
assert "bientôt (phase 4)" not in root
# phases 1-4 livrées → connecteurs + plugins restent désactivés
assert root.count("disabled:true") == 2
assert "toggleMemory()" in src
assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8")
assert "agent_memory.context_for(" in engine
assert "agent_memory.remember(" in engine