feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
This commit is contained in:
+95
-13
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user