feat(assistant): #91 steps humains en direct + outils web_search/fetch_url (labels backend, SSE step, garde SSRF)
This commit is contained in:
@@ -335,7 +335,7 @@ async function main() {
|
||||
await test("agent tool calls collapse into a discreet steps block", () => {
|
||||
const b = new BooksLM();
|
||||
const trace = b._renderToolActivity([
|
||||
{ name: "read_file", ok: true },
|
||||
{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } },
|
||||
{ name: "search", ok: true },
|
||||
]);
|
||||
assert.equal(trace.tagName, "DETAILS", "collapsible details element");
|
||||
@@ -345,6 +345,18 @@ async function main() {
|
||||
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside");
|
||||
});
|
||||
|
||||
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
|
||||
// fallback path (legacy name) is what surfaces; assert it never throws
|
||||
// and produces a non-empty string for both shapes.
|
||||
const withStep = b._stepText({ name: "read_file", step: { key: "file_read", params: { value: "a.md" } } });
|
||||
const legacy = b._stepText({ name: "read_file" });
|
||||
assert.equal(typeof withStep, "string");
|
||||
assert.equal(typeof legacy, "string");
|
||||
assert.ok(legacy.length > 0);
|
||||
});
|
||||
|
||||
// ── 5. Source guards: no window.prompt() and authenticated BooksLM ──
|
||||
await test("ai.js no longer uses window.prompt()", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Unit tests for the Notion-style step labels (backend.tools.labels)."""
|
||||
|
||||
from backend.agent.loop import ToolCallRecord
|
||||
from backend.tools.labels import GENERIC_KEY, _STEP_LABELS, tool_step_label
|
||||
from backend.tools.registry import list_tools
|
||||
from backend.tools.context import ToolScope
|
||||
|
||||
|
||||
class TestToolStepLabel:
|
||||
def test_every_in_app_tool_has_a_label(self):
|
||||
# A registered tool without a mapping still degrades gracefully to the
|
||||
# generic key, but the curated coverage must stay complete for the UI.
|
||||
names = {spec.name for spec in list_tools(scope=ToolScope.IN_APP)}
|
||||
assert names.issubset(set(_STEP_LABELS)), names - set(_STEP_LABELS)
|
||||
|
||||
def test_label_argument_names_match_input_models(self):
|
||||
# A mapped primary argument that the tool's input model does not
|
||||
# actually declare would silently render "{value}" placeholders —
|
||||
# verify every (name, arg) pair against the real schemas.
|
||||
from backend.tools.registry import get_tool
|
||||
|
||||
for name, (_key, arg) in _STEP_LABELS.items():
|
||||
if arg is None:
|
||||
continue
|
||||
spec = get_tool(name)
|
||||
assert spec is not None, name
|
||||
fields = spec.input_model.model_fields
|
||||
assert arg in fields, f"{name}: input model has no field '{arg}'"
|
||||
|
||||
def test_named_tool_returns_key_and_value(self):
|
||||
label = tool_step_label("read_file", {"vault": "V", "path": "notes/a.md"})
|
||||
assert label["key"] == "file_read"
|
||||
assert label["params"]["value"] == "notes/a.md"
|
||||
|
||||
def test_search_tool_surfaces_the_query(self):
|
||||
label = tool_step_label("search_fulltext", {"q": "pizza", "vault": "V"})
|
||||
assert label["key"] == "search"
|
||||
assert label["params"]["value"] == "pizza"
|
||||
|
||||
def test_no_argument_tools_have_empty_params(self):
|
||||
label = tool_step_label("list_vaults", {})
|
||||
assert label["key"] == "vaults"
|
||||
assert label["params"] == {}
|
||||
|
||||
def test_missing_argument_degrades_to_key_only(self):
|
||||
label = tool_step_label("read_file", {})
|
||||
assert label["key"] == "file_read"
|
||||
assert "value" not in label["params"]
|
||||
|
||||
def test_unknown_tool_falls_back_to_generic(self):
|
||||
label = tool_step_label("future_tool_name", {"x": 1})
|
||||
assert label["key"] == GENERIC_KEY
|
||||
assert label["params"]["tool"] == "future tool name"
|
||||
|
||||
def test_arguments_none_is_accepted(self):
|
||||
assert tool_step_label("list_tags", None)["key"] == "tags"
|
||||
|
||||
def test_tool_call_record_carries_step(self):
|
||||
rec = ToolCallRecord(
|
||||
name="read_file",
|
||||
arguments={"path": "a.md"},
|
||||
ok=True,
|
||||
result={},
|
||||
step=tool_step_label("read_file", {"path": "a.md"}),
|
||||
)
|
||||
assert rec.step["key"] == "file_read"
|
||||
assert rec.step["params"]["value"] == "a.md"
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for the web tools (backend.tools.web): web_search + fetch_url."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.tools.web as web
|
||||
from backend.tools.api import get_tool_schemas
|
||||
from backend.tools.context import ToolContext, ToolError, ToolMode, ToolRisk
|
||||
from backend.tools.registry import get_tool
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: Any = None, json_data: Any = None, status_code: int = 200,
|
||||
content: bytes = b"", headers: dict | None = None, url: str = "https://example.com/x"):
|
||||
self._payload = payload
|
||||
self._json = json_data
|
||||
self.status_code = status_code
|
||||
self.content = content
|
||||
self.headers = headers or {}
|
||||
self.url = url
|
||||
self.encoding = "utf-8"
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise web.httpx.HTTPStatusError("boom", request=None, response=self) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(user={"username": "tester", "vaults": []}, mode=ToolMode.IN_APP)
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_tools_registered_read_only_in_app(self):
|
||||
for name in ("web_search", "fetch_url"):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None, name
|
||||
assert spec.risk == ToolRisk.READ
|
||||
assert name in [s["function"]["name"] for s in get_tool_schemas()]
|
||||
|
||||
|
||||
class TestWebSearch:
|
||||
def test_returns_trimmed_results(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, params=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["params"] = params
|
||||
return FakeResponse(json_data={
|
||||
"results": [
|
||||
{"title": "A", "url": "https://a.dev", "content": "x" * 900, "score": 1.0,
|
||||
"publishedDate": None},
|
||||
] * 12
|
||||
})
|
||||
|
||||
monkeypatch.setattr(web.httpx, "get", fake_get)
|
||||
out = web.web_search(_ctx(), web.WebSearchInput(query="pizza", max_results=3))
|
||||
assert out["engine"] == "searxng"
|
||||
assert out["count"] == 3
|
||||
assert len(out["results"][0]["snippet"]) <= 600
|
||||
assert captured["params"]["q"] == "pizza"
|
||||
|
||||
def test_empty_query_rejected(self):
|
||||
with pytest.raises(ToolError):
|
||||
web.web_search(_ctx(), web.WebSearchInput(query=" "))
|
||||
|
||||
def test_engine_unavailable_maps_to_tool_error(self, monkeypatch):
|
||||
def boom(*a, **kw):
|
||||
raise web.httpx.ConnectError("down")
|
||||
|
||||
monkeypatch.setattr(web.httpx, "get", boom)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.web_search(_ctx(), web.WebSearchInput(query="x"))
|
||||
assert ei.value.code == "web_search_unavailable"
|
||||
|
||||
|
||||
class TestFetchUrl:
|
||||
def test_html_converted_to_text(self, monkeypatch):
|
||||
html = (b"<html><head><title>T&</title><style>b{}</style>"
|
||||
b"<script>evil()</script></head><body><p>hello</p><ul>"
|
||||
b"<li>one</li><li>two</li></ul></body></html>")
|
||||
monkeypatch.setattr(web.httpx, "get",
|
||||
lambda *a, **k: FakeResponse(content=html,
|
||||
headers={"content-type": "text/html; charset=utf-8"}))
|
||||
out = web.fetch_url(_ctx(), web.FetchUrlInput(url="https://example.com/x"))
|
||||
assert out["title"] == "T&"
|
||||
assert "hello" in out["text"]
|
||||
assert "one" in out["text"] and "two" in out["text"]
|
||||
assert "evil" not in out["text"]
|
||||
assert out["status"] == 200
|
||||
|
||||
def test_private_address_rejected(self):
|
||||
for url in ("http://127.0.0.1/admin", "http://169.254.1.1/x", "http://localhost:8000/api"):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(url=url))
|
||||
assert ei.value.code in ("ssrf_blocked", "dns_error", "invalid_scheme"), url
|
||||
|
||||
def test_non_http_scheme_rejected(self):
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(url="file:///etc/passwd"))
|
||||
assert ei.value.code == "invalid_scheme"
|
||||
|
||||
def test_binary_content_rejected(self, monkeypatch):
|
||||
monkeypatch.setattr(web.httpx, "get",
|
||||
lambda *a, **k: FakeResponse(content=b"%PDF-1.4...",
|
||||
headers={"content-type": "application/pdf"}))
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(url="https://example.com/f.pdf"))
|
||||
assert ei.value.code == "unsupported_content_type"
|
||||
Reference in New Issue
Block a user