feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 1m3s
CI / e2e (push) Successful in 11m12s

This commit is contained in:
2026-09-16 14:56:45 -04:00
parent 0841778b99
commit c9e3240ae2
23 changed files with 1351 additions and 151 deletions
+26 -7
View File
@@ -916,16 +916,25 @@ async function main() {
b._panel.remove();
});
// ── 13. Session management ──
await test("sessions persist and can be reopened and deleted", () => {
// ── 13. Session management (#95/#96 — persistent backend history) ──
await test("sessions persist and can be reopened and deleted", async () => {
localStorage.clear();
// Mock server to avoid network calls in tests
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/bookslm/history")) {
if (u.includes("PUT")) return { ok: true, status: 200, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ sessions: [] }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = document.createElement("div");
b._mode = MODE.DIRECTORY;
b._vault = "VaultSessions";
b._directory = "notes";
b._loadHistory();
assert.equal(b._sessions.length, 1, "one session created on first load");
await b._loadHistory();
assert.ok(b._sessions.length >= 1, "at least one session on first load (local or server)");
b._messages.push({ role: "user", content: "Première question" });
b._messages.push({ role: "assistant", content: "Réponse" });
@@ -938,7 +947,7 @@ async function main() {
b._messages.push({ role: "user", content: "Deuxième" });
b._saveHistory();
b._loadSession(firstId);
await b._loadSession(firstId);
assert.equal(b._messages.length, 2, "previous session messages restored");
assert.equal(b._messages[0].content, "Première question");
const first = b._sessions.find((s) => s.id === firstId);
@@ -979,16 +988,26 @@ async function main() {
b.close();
});
await test("legacy single-conversation history is migrated", () => {
await test("legacy single-conversation history is migrated", async () => {
localStorage.clear();
// Mock server to avoid network calls in tests
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/bookslm/history")) {
if (u.includes("PUT")) return { ok: true, status: 200, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ sessions: [] }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._mode = MODE.GENERAL;
localStorage.setItem(
b._legacyHistoryKey(),
JSON.stringify([{ role: "user", content: "Ancienne conversation" }])
);
b._loadHistory();
await b._loadHistory();
assert.equal(b._sessions.length, 1, "legacy history imported as a session");
assert.ok(b._messages.length >= 1, "messages from legacy session available");
assert.equal(b._messages[0].content, "Ancienne conversation");
});
+240
View File
@@ -1131,3 +1131,243 @@ class TestToolEventSources:
assert data["sources"] == [{"title": "T", "url": "https://exemple.fr/t"}]
# Non-web tools keep the key, empty, so the UI never guesses.
assert _json.loads(_tool_event_sse(self._record("read_file", {})).split("data: ", 1)[1])["sources"] == []
class TestAIGatewayHistoryStore:
"""Unit tests for backend.ai_history persistence helpers."""
def test_upsert_roundtrip_summary(self, tmp_path, monkeypatch):
from backend import ai_history
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
session = {
"id": "s-abc",
"title": "Titre",
"mode": "directory",
"vault": "V",
"directory": "dossier",
"documents": [],
"context": "directory-V_dossier",
"createdAt": 10,
"updatedAt": 20,
"messages": [{"role": "user", "content": "Coucou"}, {"role": "assistant", "content": "Hey"}],
}
assert ai_history.upsert_session("alice", session) is not None
assert ai_history.upsert_session("bob", session) is not None # isolated per user store
summaries = ai_history.list_sessions("alice")
assert len(summaries) == 1
assert summaries[0]["message_count"] == 2
assert summaries[0]["preview"] == "Hey"
assert "messages" not in summaries[0]
assert len(ai_history.list_sessions("bob")) == 1
assert ai_history.list_sessions("carol") == []
full = ai_history.get_session("alice", "s-abc")
assert full["messages"][0]["content"] == "Coucou"
assert ai_history.get_session("alice", "s-missing") is None
assert ai_history.delete_session("alice", "s-abc") is True
assert ai_history.delete_session("alice", "s-abc") is False
assert ai_history.get_session("alice", "s-abc") is None
assert len(ai_history.list_sessions("bob")) == 1 # bob unaffected
def test_update_replaces_and_sorts(self, tmp_path, monkeypatch):
from backend import ai_history
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
ai_history.upsert_session("u", {"id": "s-1", "title": "Première", "updatedAt": 1, "messages": []})
ai_history.upsert_session("u", {"id": "s-2", "title": "Seconde", "updatedAt": 2, "messages": []})
ai_history.upsert_session("u", {"id": "s-1", "title": "Première MAJ", "updatedAt": 3, "messages": []})
titles = [s["title"] for s in ai_history.list_sessions("u")]
assert titles == ["Première MAJ", "Seconde"]
assert len(ai_history.list_sessions("u")) == 2
def test_max_sessions_cap(self, tmp_path, monkeypatch):
from backend import ai_history
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
monkeypatch.setattr(ai_history, "MAX_SESSIONS", 3)
for i in range(5):
ai_history.upsert_session("u", {"id": f"s-{i}", "title": f"S{i}", "updatedAt": i, "messages": []})
ids = [s["id"] for s in ai_history.list_sessions("u")]
assert len(ids) == 3
assert ids == ["s-4", "s-3", "s-2"] # most recent kept
def test_invalid_inputs(self, tmp_path, monkeypatch):
from backend import ai_history
monkeypatch.setattr(ai_history, "AI_HISTORY_DIR", tmp_path / "ai_history")
assert ai_history.upsert_session("", {}) is None
assert ai_history.upsert_session("u", {"id": ""}) is None
assert ai_history.get_session("", "s-x") is None
assert ai_history.delete_session("", "s-x") is False
assert list_sessions_for_missing_user_is_empty(tmp_path)
def list_sessions_for_missing_user_is_empty(tmp_path):
# Local wrapper keeps the pytest node independent of the fixture chdir.
from backend import ai_history
old = ai_history.AI_HISTORY_DIR
ai_history.AI_HISTORY_DIR = tmp_path / "empty"
try:
return ai_history.list_sessions("ghost") == []
finally:
ai_history.AI_HISTORY_DIR = old
def _session_payload(self, session_id="s-test1", title="Ma conversation"):
return {
"id": session_id,
"title": title,
"mode": "general",
"vault": None,
"directory": "",
"documents": [],
"context": "general",
"createdAt": 1700000000000,
"updatedAt": 1700000001000,
"messages": [
{"role": "user", "content": "Bonjour"},
{"role": "assistant", "content": "Salut !"},
],
}
def test_history_requires_auth(self, bookslm_client):
"""The history endpoints require authentication."""
resp = bookslm_client.get("/api/ai/bookslm/history")
assert resp.status_code == 401
resp = bookslm_client.put(
"/api/ai/bookslm/history/s-x",
json=self._session_payload(),
)
assert resp.status_code == 401
def test_upsert_then_list_then_get(self, bookslm_client):
"""PUT /history/{id} persists, GET /history lists a summary and GET /history/{id} returns the conversation."""
token, _ = _login_bookslm(bookslm_client)
headers = {"Authorization": f"Bearer {token}"}
resp = bookslm_client.put(
"/api/ai/bookslm/history/s-test1",
json=self._session_payload(),
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["id"] == "s-test1"
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
assert len(listing["sessions"]) == 1
summary = listing["sessions"][0]
assert summary["id"] == "s-test1"
assert summary["title"] == "Ma conversation"
assert summary["message_count"] == 2
assert "messages" not in summary
full = bookslm_client.get("/api/ai/bookslm/history/s-test1", headers=headers).json()
assert full["messages"][0]["content"] == "Bonjour"
assert full["context"] == "general"
def test_update_replaces_session(self, bookslm_client):
"""PUT with the same id replaces the stored conversation."""
token, _ = _login_bookslm(bookslm_client)
headers = {"Authorization": f"Bearer {token}"}
bookslm_client.put(
"/api/ai/bookslm/history/s-upd",
json=self._session_payload("s-upd", "Avant"),
headers=headers,
)
payload = self._session_payload("s-upd", "Après")
resp = bookslm_client.put(
"/api/ai/bookslm/history/s-upd",
json=payload,
headers=headers,
)
assert resp.json()["title"] == "Après"
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
assert len(listing["sessions"]) == 1
assert listing["sessions"][0]["title"] == "Après"
def test_list_is_sorted_by_updated_at(self, bookslm_client):
"""Sessions are listed most recently updated first."""
token, _ = _login_bookslm(bookslm_client)
headers = {"Authorization": f"Bearer {token}"}
old = self._session_payload("s-old", "Ancienne")
old["updatedAt"] = 1000
new = self._session_payload("s-new", "Récente")
new["updatedAt"] = 5000
bookslm_client.put("/api/ai/bookslm/history/s-old", json=old, headers=headers)
bookslm_client.put("/api/ai/bookslm/history/s-new", json=new, headers=headers)
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
ids = [s["id"] for s in listing["sessions"]]
assert ids == ["s-new", "s-old"]
def test_get_unknown_session_returns_404(self, bookslm_client):
"""GET /history/{id} on an unknown id returns 404."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.get(
"/api/ai/bookslm/history/s-unknown",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
def test_delete_removes_session(self, bookslm_client):
"""DELETE /history/{id} removes the conversation for the current user."""
token, _ = _login_bookslm(bookslm_client)
headers = {"Authorization": f"Bearer {token}"}
bookslm_client.put(
"/api/ai/bookslm/history/s-del",
json=self._session_payload("s-del"),
headers=headers,
)
resp = bookslm_client.delete("/api/ai/bookslm/history/s-del", headers=headers)
assert resp.json() == {"ok": True}
listing = bookslm_client.get("/api/ai/bookslm/history", headers=headers).json()
assert listing["sessions"] == []
assert bookslm_client.get("/api/ai/bookslm/history/s-del", headers=headers).status_code == 404
def test_history_is_isolated_per_user(self, bookslm_client):
"""A session belongs to the user who created it, not to others."""
token, _ = _login_bookslm(bookslm_client)
headers = {"Authorization": f"Bearer {token}"}
bookslm_client.put(
"/api/ai/bookslm/history/s-private",
json=self._session_payload("s-private"),
headers=headers,
)
# Provision a second user so isolation can be checked.
from backend.auth.password import hash_password
db = json.loads(Path("data/users.json").read_text(encoding="utf-8"))
db["users"]["bob"] = {
"id": "bob-1",
"username": "bob",
"display_name": "Bob",
"password_hash": hash_password("BobPass123!"),
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
}
Path("data/users.json").write_text(json.dumps(db), encoding="utf-8")
other_token, _ = _login_bookslm(bookslm_client, username="bob", password="BobPass123!")
resp = bookslm_client.get(
"/api/ai/bookslm/history/s-private",
headers={"Authorization": f"Bearer {other_token}"},
)
assert resp.status_code == 404
assert bookslm_client.get(
"/api/ai/bookslm/history",
headers={"Authorization": f"Bearer {other_token}"},
).json()["sessions"] == []