fix: assistant IA toujours en mode agent, retrait du bouton toggle #187

- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
This commit is contained in:
2026-10-07 08:16:56 -04:00
parent 69c3817579
commit 778fa65b4c
8 changed files with 99 additions and 88 deletions
+15 -26
View File
@@ -159,7 +159,8 @@ async function main() {
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
};
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) {
// #187: plain-text sends now go to /agent — accept both bookslm endpoints.
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) {
capturedBody = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => reader } };
}
@@ -1171,38 +1172,28 @@ 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 () => {
// ── 14. Agent mode & confirmations (B5 / #187) ──
await test("#187: no agent toggle — plain requests always target /agent", 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");
assert.equal(b._agentMode, undefined, "agent-mode state removed");
assert.equal(b._toggleAgentMode, undefined, "toggle method removed");
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");
b._abortCtrl = null;
await b._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "default targets /agent");
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), null, "no legacy persistence");
localStorage.clear();
});
await test("header exposes the agent-mode toggle", () => {
await test("header no longer 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");
assert.equal(panel.querySelector(".bookslm-btn-agent"), null, "toggle button removed");
panel.remove();
localStorage.clear();
});
@@ -1236,7 +1227,6 @@ async function main() {
};
const b = new BooksLM();
b._agentMode = true;
b._panel = b._render();
document.body.appendChild(b._panel);
const msg = {
@@ -1418,7 +1408,7 @@ async function main() {
let posted = null;
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) posted = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
};
await b._sendMessage();
@@ -1432,10 +1422,9 @@ async function main() {
localStorage.clear();
});
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
await test("images route to /chat even in agent mode", async () => {
// ── 32. Images force the plain chat endpoint (#81 / #187: always-on agent) ──
await test("images route to /chat, text to /agent", async () => {
const b = new BooksLM();
b._agentMode = true;
let url = "";
globalThis.fetch = async (u) => {
url = String(u);
+25
View File
@@ -940,6 +940,31 @@ class TestBooksLMAgentEndpoint:
assert "create_file" in captured["system"]
assert '"action": "create_file"' not in captured["system"]
def test_agent_llm_uses_resolved_provider(self, bookslm_client, monkeypatch):
"""#187: the agent loop must call the provider resolved by
_resolve_provider_name (not the raw request value), so the provider
tag reported by SSE matches the engine that actually answered."""
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse
seen = {}
async def fake_chat_completion(messages, **kwargs):
seen["provider"] = kwargs.get("provider")
return LLMResponse(content="ok")
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json={"directory": "", "message": "salut", "mode": "general", "provider": "nope"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert seen["provider"] == "openrouter"
def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
+10
View File
@@ -93,6 +93,16 @@ class TestRegistry:
props = read["function"]["parameters"]["properties"]
assert "vault" in props and "path" in props
def test_schemas_cached_but_caller_safe(self):
"""#187: repeated calls hit the cache and a caller mutating its copy
(e.g. stripping keys) must never corrupt the next request."""
a = get_tool_schemas()
b = get_tool_schemas()
assert a == b
assert all(x is not y for x, y in zip(a, b)), "top-level dicts must be fresh copies"
a[0]["poisoned"] = True
assert "poisoned" not in get_tool_schemas()[0]
def test_duplicate_tool_name_raises(self):
from backend.tools.registry import tool