feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+94
View File
@@ -513,6 +513,100 @@ async function main() {
assert.equal(b._messages[0].content, "Ancienne conversation");
});
// ── 14. Agent mode & confirmations (B5) ──
await test("agent mode toggle persists and switches the endpoint", async () => {
localStorage.clear();
const b = new BooksLM();
assert.equal(b._agentMode, false, "agent mode off by default");
b._toggleAgentMode();
assert.equal(b._agentMode, true);
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
const b2 = new BooksLM();
assert.equal(b2._agentMode, true, "persisted across instances");
let url = null;
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
b2._abortCtrl = null;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
b2._agentMode = false;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
localStorage.clear();
});
await test("header exposes the agent-mode toggle", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
const btn = panel.querySelector(".bookslm-btn-agent");
assert.ok(btn, "agent toggle button present");
assert.equal(btn.getAttribute("aria-pressed"), "false");
btn.click();
assert.equal(btn.getAttribute("aria-pressed"), "true");
panel.remove();
localStorage.clear();
});
await test("_lineDiff marks additions and deletions", () => {
const b = new BooksLM();
const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd");
const adds = diff.filter((l) => l.t === "+").map((l) => l.s);
const dels = diff.filter((l) => l.t === "-").map((l) => l.s);
assert.ok(dels.includes("b"), "removed line marked");
assert.ok(adds.includes("B"), "changed line marked as addition");
assert.ok(adds.includes("d"), "appended line marked");
});
await test("confirmation card resumes the agent run with confirm payload", async () => {
localStorage.clear();
const encoder = new TextEncoder();
let posted = null;
const sse =
'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' +
'event: message\ndata: {"token":"Fait."}\n\n' +
"event: done\ndata: {}\n\n";
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/agent")) {
posted = JSON.parse(opts.body);
const chunks = [encoder.encode(sse)];
const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) };
return { ok: true, status: 200, body: { getReader: () => reader } };
}
return { ok: true, status: 200, json: async () => ({ raw: "old content" }) };
};
const b = new BooksLM();
b._agentMode = true;
b._panel = b._render();
document.body.appendChild(b._panel);
const msg = {
role: "assistant",
content: "",
sources: [],
toolCalls: [],
confirmation: {
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } },
messages: [{ role: "system", content: "s" }],
},
payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] },
};
b._messages = [msg];
await b._applyConfirmation(msg);
assert.equal(msg.confirmation, null, "confirmation resolved");
assert.ok(posted, "resumed via /agent");
assert.equal(posted.confirm.error.tool, "edit_file");
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
const cont = b._messages[b._messages.length - 1];
assert.equal(cont.content, "Fait.");
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
b._panel.remove();
localStorage.clear();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+59
View File
@@ -186,6 +186,65 @@ class TestConfirmationAndLimits:
# ═══════════════════════════════════════════════════════════════════
class TestConfirmationResume:
@pytest.mark.asyncio
async def test_resume_applies_pending_and_continues(self, monkeypatch):
seen = {}
def handler(ctx, params):
seen["ctx_confirmed"] = ctx.confirmed
return {"done": True}
_register(monkeypatch, "_write", handler, risk=ToolRisk.WRITE)
# First run pauses on the mutating tool.
llm1 = ScriptedLLM([LLMResponse(tool_calls=[ToolCall(id="1", name="_write", arguments={"x": 1})])])
paused = await run_agent([{"role": "user", "content": "write"}], ctx=_ctx(), llm=llm1)
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
assert paused.pending["error"]["id"] == "1"
# Resume: the pending call is applied (one-shot confirm), then the loop
# continues and produces the final answer.
llm2 = ScriptedLLM([LLMResponse(content="applied")])
resumed = await run_agent(
[{"role": "user", "content": "write"}],
ctx=_ctx(),
llm=llm2,
resume_messages=paused.messages,
confirm_pending=paused.pending,
)
assert resumed.stopped == STOP_DONE
assert resumed.content == "applied"
assert len(resumed.tool_calls) == 1
assert resumed.tool_calls[0].ok is True
# The one-shot confirmation must not leak into the context.
assert seen["ctx_confirmed"] is False
# The tool result is fed back to the model on the resumed turn.
tool_msgs = [m for m in llm2.calls[0]["messages"] if m.get("role") == "tool"]
assert len(tool_msgs) == 1
assert tool_msgs[0]["tool_call_id"] == "1"
@pytest.mark.asyncio
async def test_resume_without_assistant_message_reconstructs_it(self, monkeypatch):
_register(monkeypatch, "_write", lambda ctx, params: {"done": True}, risk=ToolRisk.WRITE)
llm = ScriptedLLM([LLMResponse(content="ok")])
pending = {"error": {"tool": "_write", "arguments": {}, "id": "call_9"}}
result = await run_agent(
[{"role": "user", "content": "write"}],
ctx=_ctx(),
llm=llm,
resume_messages=[{"role": "user", "content": "write"}],
confirm_pending=pending,
)
assert result.stopped == STOP_DONE
assistant_tool_msgs = [
m for m in llm.calls[0]["messages"]
if m.get("role") == "assistant" and m.get("tool_calls")
]
assert len(assistant_tool_msgs) == 1
assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9"
class TestAgentPermissions:
@pytest.mark.asyncio
async def test_permission_denied_recorded(self, client):
+92
View File
@@ -199,3 +199,95 @@ class TestGeminiToolsConversion:
converted = _gemini_tools(TOOLS)
assert converted[0]["functionDeclarations"][0]["name"] == "read_file"
assert converted[0]["functionDeclarations"][0]["description"] == "Read a file"
# ═══════════════════════════════════════════════════════════════════
# Streaming (B4)
# ═══════════════════════════════════════════════════════════════════
class _StreamResponse:
"""Fake httpx streaming response yielding pre-scripted SSE lines."""
def __init__(self, lines, status=200):
self.lines = lines
self.status_code = status
def raise_for_status(self):
if self.status_code >= 400:
raise _http_error(self.status_code)
async def aiter_lines(self):
for line in self.lines:
yield line
class _FakeAsyncClient:
"""Minimal stand-in for ``httpx.AsyncClient`` used in stream mode."""
captured: dict = {}
response: _StreamResponse | None = None
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def stream(self, method, url, headers=None, json=None):
_FakeAsyncClient.captured = {"method": method, "url": url, "headers": headers, "json": json}
resp = _FakeAsyncClient.response
class _Ctx:
async def __aenter__(self_inner):
return resp
async def __aexit__(self_inner, *exc):
return False
return _Ctx()
class TestStreamCompletion:
@pytest.mark.asyncio
async def test_openai_stream_yields_deltas(self, monkeypatch):
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _StreamResponse([
'data: {"choices":[{"delta":{"content":"Bon"}}]}',
"",
'data: {"choices":[{"delta":{"content":"jour"}}]}',
"data: not-json",
"data: [DONE]",
])
chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])]
assert "".join(chunks) == "Bonjour"
assert _FakeAsyncClient.captured["json"]["stream"] is True
@pytest.mark.asyncio
async def test_gemini_stream_yields_text(self, monkeypatch):
monkeypatch.setitem(ai_chat.PROVIDERS, "gemini", GEMINI_CFG)
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: GEMINI_CFG)
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _StreamResponse([
'data: {"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}',
'data: {"candidates":[{"content":{"parts":[{"text":"lut"}]}}]}',
])
chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])]
assert "".join(chunks) == "salut"
assert "streamGenerateContent" in _FakeAsyncClient.captured["url"]
@pytest.mark.asyncio
async def test_stream_raises_on_http_error(self, monkeypatch):
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _StreamResponse([], status=500)
with pytest.raises(httpx.HTTPStatusError):
async for _ in ai_chat.stream_completion([{"role": "user", "content": "hi"}]):
pass
+4 -2
View File
@@ -621,13 +621,15 @@ class TestResolveSafePath:
def test_resolve_traversal_attempt(self):
from backend.main import _resolve_safe_path
from backend.services.errors import ServiceError
from pathlib import Path
root = Path("/vaults/TestVault")
try:
_resolve_safe_path(root, "../../etc/passwd")
assert False, "Should have raised"
except Exception as e:
assert "traversal" in str(e).lower() or "403" in str(e)
except ServiceError as e:
assert e.status == 403
assert e.code == "path_outside_vault"
class TestBackupFile:
+62
View File
@@ -823,3 +823,65 @@ class TestBooksLMAgentEndpoint:
assert resp.status_code == 200
assert "event: confirmation" in resp.text
assert "_agent_write" in resp.text
def test_agent_confirmation_resume(self, bookslm_client, monkeypatch):
import re
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
from backend.tools import registry
from backend.tools.api import ToolRisk
from backend.tools.registry import ToolSpec
from backend.tools.schemas import ListVaultsInput
executed = {}
def handler(ctx, params):
executed["ran"] = True
return {"ok": True}
spec = ToolSpec(
name="_agent_write_resume",
description="write for tests",
input_model=ListVaultsInput,
handler=handler,
risk=ToolRisk.WRITE,
)
monkeypatch.setitem(registry._REGISTRY, "_agent_write_resume", spec)
responses = [LLMResponse(tool_calls=[ToolCall(id="c1", name="_agent_write_resume", arguments={})])]
async def fake_chat_completion(messages, **kwargs):
return responses.pop(0)
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
payload = {"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"}
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert "event: confirmation" in resp.text
assert "_agent_write_resume" in resp.text
assert executed.get("ran") is not True
match = re.search(r"event: confirmation\ndata: (.*)", resp.text)
assert match, resp.text
confirmation = json.loads(match.group(1))
assert confirmation["pending"]["error"]["tool"] == "_agent_write_resume"
# Resume: apply the pending call, then the model answers.
responses.append(LLMResponse(content="C'est fait."))
resume_payload = dict(payload, confirm=confirmation["pending"], confirm_messages=confirmation["messages"])
resp2 = bookslm_client.post(
"/api/ai/bookslm/agent",
json=resume_payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp2.status_code == 200
assert executed.get("ran") is True
assert "C'est fait." in resp2.text
assert "event: message" in resp2.text
+154
View File
@@ -0,0 +1,154 @@
# tests/test_services.py — Unit tests for the shared business-logic services (A2)
"""Tests for backend.services: vaults, files, search, and error mapping.
The index-dependent tests reuse the ``client`` fixture from conftest, which
builds the in-memory index for a ``TestVault``.
"""
from pathlib import Path
import pytest
from backend.services.errors import ServiceError
from backend.services.files import read_file_text, read_raw_file
from backend.services.search import list_tags, search_vaults
from backend.services.vaults import browse_directory, list_accessible_vaults
ADMIN = {"username": "admin", "role": "admin", "vaults": ["*"]}
LIMITED = {"username": "limited", "vaults": ["OtherVault"]}
# ═══════════════════════════════════════════════════════════════════
# Vaults
# ═══════════════════════════════════════════════════════════════════
class TestListAccessibleVaults:
def test_admin_sees_vault(self, client):
names = {v["name"] for v in list_accessible_vaults(ADMIN)}
assert "TestVault" in names
def test_limited_user_sees_none(self, client):
assert list_accessible_vaults(LIMITED) == []
def test_metadata_fields(self, client):
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
assert set(vault) == {"name", "file_count", "tag_count", "type"}
assert vault["file_count"] >= 1
class TestBrowseDirectory:
def test_root_lists_children(self, client):
data = browse_directory("TestVault", "")
names = {i["name"] for i in data["items"]}
assert "note1.md" in names
assert "Projets" in names
def test_missing_path_raises_not_found(self, client):
with pytest.raises(ServiceError) as exc:
browse_directory("TestVault", "nope")
assert exc.value.code == "not_found"
def test_unknown_vault_raises_not_found(self, client):
with pytest.raises(ServiceError) as exc:
browse_directory("MissingVault", "")
assert exc.value.code == "not_found"
# ═══════════════════════════════════════════════════════════════════
# Files
# ═══════════════════════════════════════════════════════════════════
class TestReadFile:
def test_read_text_redacts_secrets(self, client):
from backend.indexer import get_vault_data
root = Path(get_vault_data("TestVault")["path"])
secret = root / "svc_secret.md"
fake_jwt = "eyJ" + "a" * 30 + "." + "b" * 30 + "." + "c" * 30
secret.write_text(f"token: {fake_jwt}\n", encoding="utf-8")
try:
data = read_file_text("TestVault", "svc_secret.md")
assert "[JWT MASQUÉ]" in data["content"]
finally:
secret.unlink()
def test_read_text_too_large(self, client):
from backend.indexer import get_vault_data
root = Path(get_vault_data("TestVault")["path"])
big = root / "svc_big.txt"
big.write_text("x" * 1000, encoding="utf-8")
try:
with pytest.raises(ServiceError) as exc:
read_file_text("TestVault", "svc_big.txt", max_bytes=10)
assert exc.value.code == "file_too_large"
finally:
big.unlink()
def test_read_raw_no_redaction(self, client):
data = read_raw_file("TestVault", "note1.md")
assert "Python" in data["raw"]
def test_read_missing_raises_not_found(self, client):
with pytest.raises(ServiceError) as exc:
read_raw_file("TestVault", "missing.md")
assert exc.value.code == "not_found"
def test_traversal_is_rejected(self, client):
with pytest.raises(ServiceError) as exc:
read_raw_file("TestVault", "../../etc/passwd")
assert exc.value.code == "path_outside_vault"
# ═══════════════════════════════════════════════════════════════════
# Search
# ═══════════════════════════════════════════════════════════════════
class TestSearchService:
def test_pagination(self, client):
data = search_vaults("Python", limit=1, offset=0)
assert data["limit"] == 1
assert len(data["results"]) <= 1
assert data["total"] >= 1
def test_returns_api_shape(self, client):
data = search_vaults("Python")
assert set(data) >= {"query", "vault_filter", "tag_filter", "count", "total", "offset", "limit", "results"}
def test_tags(self, client):
tags = list_tags("TestVault")
assert "python" in tags
# ═══════════════════════════════════════════════════════════════════
# Service → Tool error mapping
# ═══════════════════════════════════════════════════════════════════
class TestServiceErrorMapping:
def test_not_found_maps_to_tool_not_found(self, client):
from backend.tools.api import ToolContext, ToolNotFoundError, call_tool
ctx = ToolContext(user=ADMIN, audit_enabled=False)
with pytest.raises(ToolNotFoundError):
call_tool("read_file", ctx, {"vault": "TestVault", "path": "nope.md"})
def test_file_too_large_preserves_code(self, client, monkeypatch):
from backend.indexer import get_vault_data
from backend.tools import service as service_mod
from backend.tools.api import ToolContext, ToolError, call_tool
monkeypatch.setattr(service_mod, "TOOL_MAX_READ_BYTES", 10)
root = Path(get_vault_data("TestVault")["path"])
big = root / "svc_tool_big.txt"
big.write_text("x" * 1000, encoding="utf-8")
try:
ctx = ToolContext(user=ADMIN, audit_enabled=False)
with pytest.raises(ToolError) as exc:
call_tool("read_file", ctx, {"vault": "TestVault", "path": "svc_tool_big.txt"})
assert exc.value.code == "file_too_large"
finally:
big.unlink()