feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
CI / lint (push) Successful in 1m23s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 11m8s

This commit is contained in:
2026-09-15 22:53:15 -04:00
parent 431fb5626d
commit f630771150
13 changed files with 477 additions and 80 deletions
+95 -13
View File
@@ -406,17 +406,86 @@ async function main() {
await test("agent tool calls collapse into a discreet steps block", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity([
{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } },
{ name: "search", ok: true },
]);
], msg, false);
assert.equal(trace.tagName, "DETAILS", "collapsible details element");
const summary = trace.querySelector("summary");
assert.ok(summary, "summary present");
assert.ok(summary.textContent.trim().length > 0, "summary shows the steps label");
const label = summary.querySelector(".bookslm-steps-label");
assert.ok(label && label.textContent.length > 0, "summary shows the steps label");
assert.ok(summary.querySelector(".bookslm-chevron"), "chevron after the label");
assert.equal(summary.textContent.includes(">"), false, "no legacy '> ' prefix");
assert.equal(summary.querySelector(".bookslm-steps-dots"), null, "no indicator when idle");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside");
});
await test("the steps header carries the animated indicator while running", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity(
[{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }],
msg, true);
const summary = trace.querySelector("summary");
const dots = summary.querySelector(".bookslm-steps-dots");
assert.ok(dots, "dots shown before the label while the agent works");
assert.equal(dots.querySelectorAll("i").length, 3, "three animated dots");
assert.equal(dots.getAttribute("aria-hidden"), "true");
assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers");
});
await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => {
const b = new BooksLM();
const msg = {};
const calls = [
{ name: "", ok: true, step: { key: "thought", params: { value: "Je dois d'abord lister le vault." } } },
{ name: "list_vaults", ok: true, step: { key: "vaults", params: {} } },
];
const trace = b._renderToolActivity(calls, msg, false);
const thought = trace.querySelector("details.bookslm-thought");
assert.ok(thought, "the note is its own collapsible sub-section");
assert.ok(thought.querySelector(".bookslm-thought-label"), "labelled (Réflexion / Thought)");
assert.equal(thought.querySelector(".bookslm-thought-text").textContent,
"Je dois d'abord lister le vault.", "the thought text sits inside");
assert.equal(thought.open, false, "collapsed by default → ▶");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 1, "the note is not a plain line");
// Expanding is remembered so the per-token re-render cannot collapse it.
thought.open = true;
thought.dispatchEvent(new Event("toggle"));
assert.equal(msg._thoughtOpen["Je dois d'abord lister le vault."], true);
const again = b._renderToolActivity(calls, msg, false);
assert.equal(again.querySelector("details.bookslm-thought").open, true,
"the open state survives a re-render");
});
await test("web searches add a sources section with clickable links", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity([
{
name: "web_search", ok: true, step: { key: "web_search", params: { value: "météo Paris" } },
sources: [
{ title: "Météo Paris", url: "https://exemple.fr/meteo" },
{ title: "", url: "https://exemple.fr/b" },
],
},
], msg, false);
const box = trace.querySelector("details.bookslm-web-sources");
assert.ok(box, "sources section present");
assert.equal(box.open, true, "visible by default so the links show up");
const links = box.querySelectorAll(".bookslm-web-sources-list a");
assert.equal(links.length, 2, "one link per source");
assert.equal(links[0].getAttribute("href"), "https://exemple.fr/meteo");
assert.equal(links[0].target, "_blank");
assert.equal(links[0].rel, "noopener noreferrer");
assert.equal(links[1].textContent, "https://exemple.fr/b", "falls back to the URL label");
// No web search → no sources section.
const bare = b._renderToolActivity([{ name: "read_file", ok: true }], {}, false);
assert.equal(bare.querySelector(".bookslm-web-sources"), null);
});
await test("_stepText uses the backend label key, falls back to tool name", () => {
const b = new BooksLM();
// No locales loaded in the JSDOM harness → t() returns the key, so the
@@ -1518,22 +1587,35 @@ async function main() {
assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker");
});
// ── 42b. Assistant activity indicator (#82) ──
await test("activity indicator reflects the request workflow", () => {
// ── 42b. Assistant activity indicator (#91 — plus de barre de chargement) ──
await test("no loading bar above the composer; activity is recorded as state", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const el = b._panel.querySelector(".bookslm-activity");
assert.ok(el, "activity element present");
assert.ok(el.classList.contains("hidden"), "hidden initially");
assert.equal(b._panel.querySelector(".bookslm-activity"), null,
"the composer loading bar is gone");
// _setActivity stays the single entry point of the streaming code paths.
b._setActivity("working", "Envoi…");
assert.ok(!el.classList.contains("hidden"), "shown while working");
assert.ok(el.classList.contains("working"));
assert.equal(el.querySelector(".bookslm-activity-text").textContent, "Envoi…");
b._setActivity("error", "Échec");
assert.ok(el.classList.contains("error"));
assert.equal(b._activityKind, "working");
assert.equal(b._activityText, "Envoi…");
b._setActivity("idle");
assert.ok(el.classList.contains("hidden"), "hidden when idle");
assert.equal(b._activityKind, "idle");
b._panel.remove();
});
await test("plain chat shows the pending indicator inside the empty answer", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._messages = [{ role: "assistant", content: "", toolCalls: [], sources: [] }];
b._isLoading = true;
b._renderMessages();
const pending = b._panel.querySelector(".bookslm-bubble.assistant .bookslm-pending .bookslm-dots");
assert.ok(pending, "animated dots shown while the first token is pending");
assert.equal(pending.querySelectorAll("i").length, 3, "three dots");
b._isLoading = false;
b._renderMessages();
assert.equal(b._panel.querySelector(".bookslm-pending"), null, "indicator gone once settled");
b._panel.remove();
});
+51
View File
@@ -1024,3 +1024,54 @@ class TestEffectiveModel:
assert _effective_model("nope-not-a-provider", None) == ""
assert _effective_model(None, None) == ""
class TestToolEventSources:
"""Web sources surfaced in the « sources » section of the steps block."""
@staticmethod
def _record(name, result):
from backend.agent.loop import ToolCallRecord
return ToolCallRecord(name=name, arguments={}, ok=True, result=result)
def test_web_search_results_become_sources(self):
from backend.bookslm_routes import _tool_sources
rec = self._record("web_search", {"results": [
{"title": "Météo Paris", "url": "https://exemple.fr/meteo"},
{"title": "Sans URL", "url": ""},
{"title": "", "url": "https://exemple.fr/b"},
]})
sources = _tool_sources(rec)
assert sources == [
{"title": "Météo Paris", "url": "https://exemple.fr/meteo"},
{"title": "https://exemple.fr/b", "url": "https://exemple.fr/b"},
]
def test_fetch_url_yields_one_source(self):
from backend.bookslm_routes import _tool_sources
rec = self._record("fetch_url", {"url": "https://exemple.fr/a", "title": "Titre"})
assert _tool_sources(rec) == [{"title": "Titre", "url": "https://exemple.fr/a"}]
def test_other_tools_yield_no_sources(self):
from backend.bookslm_routes import _tool_sources
assert _tool_sources(self._record("read_file", {"content": "x"})) == []
assert _tool_sources(self._record("web_search", {"error": "boom"})) == []
assert _tool_sources(self._record("fetch_url", "not-a-dict")) == []
def test_sse_payload_carries_the_sources(self):
import json as _json
from backend.bookslm_routes import _tool_event_sse
rec = self._record("web_search", {"results": [{"title": "T", "url": "https://exemple.fr/t"}]})
payload = _tool_event_sse(rec)
assert payload.startswith("event: tool\ndata: ")
data = _json.loads(payload.split("data: ", 1)[1])
assert data["name"] == "web_search"
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"] == []
+24
View File
@@ -67,6 +67,30 @@ class TestWebSearch:
with pytest.raises(ToolError):
web.web_search(_ctx(), web.WebSearchInput(query=" "))
def test_empty_result_set_warns_about_blocked_engines(self, monkeypatch):
"""An all-blocked instance answers 200 with no results: the model must
be told instead of retrying the same search until the quota burns."""
monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={
"results": [],
"number_of_results": 0,
"unresponsive_engines": [["duckduckgo", "CAPTCHA"], ["google", "access denied"]],
}))
out = web.web_search(_ctx(), web.WebSearchInput(query="meteo montreal"))
assert out["count"] == 0
assert out["unresponsive_engines"] == ["duckduckgo", "google"]
assert "warning" in out
assert "indisponibles" in out["warning"]
def test_results_carry_no_warning(self, monkeypatch):
monkeypatch.setattr(web.httpx, "get", lambda *a, **kw: FakeResponse(json_data={
"results": [{"title": "A", "url": "https://a.dev", "content": "x"}],
"unresponsive_engines": [["brave", "rate limited"]],
}))
out = web.web_search(_ctx(), web.WebSearchInput(query="pizza"))
assert out["count"] == 1
assert "warning" not in out
assert out["unresponsive_engines"] == ["brave"]
def test_engine_unavailable_maps_to_tool_error(self, monkeypatch):
def boom(*a, **kw):
raise web.httpx.ConnectError("down")