mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
Bundled examples keep their behavior tests in backend/tests/, so the core backend workflow ran optional extension behavior on every pull request. Mark those suites with pytest.mark.bundled_extension, exclude them from the core shards and from the duration baseline, and run them in a dedicated workflow through make test-bundled-extensions. Tests of host mechanisms stay in the core shards even when an example package is the payload. The marked suites import deerflow.agents, config, runtime, knowledge and app.gateway, so the workflow triggers for backend/packages/**, backend/app/gateway/** and the marked test modules; a backend test pins those paths, the make target and both marker selections, and fails when a marked module is missing from the path list. Host/extension compatibility checks — a host API gate and authorization of contributed routes — live in test_extension_host_contracts.py so they still fail core CI. The committed duration baseline drops the 482 entries for the excluded suites. Refs #8173 Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com> Co-authored-by: willem-bd <143703838+willem-bd@users.noreply.github.com> Co-authored-by: Eilen6316 <136898293+Eilen6316@users.noreply.github.com>
507 lines
25 KiB
Python
507 lines
25 KiB
Python
"""Synthetic v0.8.3 responses; never contacts an external provider."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from deerflow.knowledge import KnowledgeRequestContext, format_result, sources_from_messages
|
|
|
|
pytestmark = pytest.mark.bundled_extension
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(monkeypatch):
|
|
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
|
|
monkeypatch.setenv("WEKNORA_TEST_KEY", "synthetic-secret")
|
|
calls = []
|
|
document = {"id": "doc-1", "knowledge_base_id": "kb-1", "title": "Synthetic guide", "parse_status": "completed", "enable_status": "enabled"}
|
|
state = {"document": document, "hits": [{"knowledge_id": "doc-1", "content": "Cedar is synthetic evidence.", "knowledge_title": "Synthetic guide", "score": 0.8}]}
|
|
|
|
def handler(request):
|
|
calls.append(request)
|
|
assert request.headers["X-API-Key"] == "synthetic-secret"
|
|
assert request.headers["X-Tenant-ID"] in {"1", "2"}
|
|
if state.get("status"):
|
|
return httpx.Response(state["status"], text="synthetic-secret upstream body")
|
|
if request.url.path == "/api/v1/knowledge-bases":
|
|
data = state.get("bases", [{"id": "kb-1", "name": "Synthetic base", "capabilities": {"wiki": True}}])
|
|
elif request.url.path == "/api/v1/knowledge-bases/kb-1/knowledge":
|
|
return httpx.Response(200, json={"success": True, "data": [state["document"]], **state.get("pagination", {"total": 1})})
|
|
elif request.url.path == "/api/v1/knowledge/doc-1":
|
|
data = state["document"]
|
|
elif request.url.path == "/api/v1/knowledgebase/kb-1/wiki/search":
|
|
return httpx.Response(200, json={"pages": state.get("pages", [])})
|
|
elif request.url.path == "/api/v1/knowledge-search":
|
|
return httpx.Response(403, json={"success": False, "error": "KB-restricted keys reject global knowledge_ids"})
|
|
elif request.url.path == "/api/v1/knowledge-bases/kb-1/hybrid-search":
|
|
data = state["hits"]
|
|
else:
|
|
return httpx.Response(404, json={"success": False})
|
|
return httpx.Response(200, json={"success": True, "data": data})
|
|
|
|
config = {"spaces": [{"base_url": "https://weknora.example", "tenant_id": "1", "api_key_env": "WEKNORA_TEST_KEY", "user_ids": ["alice"], "agent_names": ["lead_agent", "researcher"]}]}
|
|
transport = httpx.MockTransport(handler)
|
|
return WeKnoraProvider(config, transport=transport), calls, state, config, transport
|
|
|
|
|
|
async def selection(provider, *, docs=True):
|
|
context = KnowledgeRequestContext("alice", "lead_agent")
|
|
page = await provider.catalog(context=context, page=1, page_size=20, search="")
|
|
dataset_id = page.items[0].id
|
|
scope = {"version": 1, "mode": "selected", "dataset_ids": [dataset_id]}
|
|
if docs:
|
|
scope["document_filters"] = [{"dataset_id": dataset_id, "document_ids": ["doc-1"]}]
|
|
return KnowledgeRequestContext("alice", "lead_agent", scope)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_documents_search_shared_evidence(adapter):
|
|
provider, calls, _, _, _ = adapter
|
|
ctx = await selection(provider)
|
|
page = await provider.documents(ctx.scope["dataset_ids"][0], context=ctx, page=1, page_size=20, search="guide")
|
|
assert page.items[0].selectable and page.items[0].id == "doc-1"
|
|
assert calls[-1].url.params["keyword"] == "guide"
|
|
result = await provider.search_scoped("Cedar", context=ctx)
|
|
body = json.loads(next(r.content for r in calls if r.method == "POST"))
|
|
assert body["knowledge_ids"] == ["doc-1"]
|
|
assert "knowledge_base_ids" not in body
|
|
assert body["query_text"] == "Cedar"
|
|
assert body["skip_context_enrichment"] is True
|
|
assert next(r for r in calls if r.method == "POST").url.path == "/api/v1/knowledge-bases/kb-1/hybrid-search"
|
|
content, artifact = format_result(result)
|
|
sources = sources_from_messages([{"type": "tool", "content": content, "artifact": artifact}])
|
|
assert len(sources) == 1 and sources[0].provider == "weknora"
|
|
assert sources[0].excerpt == "Cedar is synthetic evidence."
|
|
assert sources[0].page is None and sources[0].url is None
|
|
assert sources_from_messages([{"type": "tool", "content": "removed", "artifact": artifact}]) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("user,agent", [("mallory", "lead_agent"), ("alice", "unbound"), ("alice", None)])
|
|
async def test_unbound_identity_cannot_make_requests(adapter, user, agent):
|
|
provider, calls, *_ = adapter
|
|
ctx = KnowledgeRequestContext(user, agent)
|
|
with pytest.raises(PermissionError):
|
|
await provider.catalog(context=ctx, page=1, page_size=20, search="")
|
|
with pytest.raises(PermissionError):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_context_search_is_not_an_authorization_bypass(adapter):
|
|
provider, calls, *_ = adapter
|
|
with pytest.raises(PermissionError):
|
|
await provider.search("Cedar")
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("scope", [{"version": 1, "mode": "disabled"}, {"mode": "selected", "dataset_ids": []}])
|
|
async def test_disabled_or_invalid_scope_never_calls_upstream(adapter, scope):
|
|
provider, calls, *_ = adapter
|
|
with pytest.raises(ValueError):
|
|
await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent", scope))
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("change", [{"knowledge_base_id": "other"}, {"id": "other"}, {"enable_status": "disabled"}, {"parse_status": "pending"}])
|
|
async def test_document_validation_precedes_search(adapter, change):
|
|
provider, calls, state, *_ = adapter
|
|
ctx = await selection(provider)
|
|
state["document"].update(change)
|
|
with pytest.raises(PermissionError):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert not any(r.method == "POST" for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unselected_result_fails_entire_request(adapter):
|
|
provider, calls, state, *_ = adapter
|
|
ctx = await selection(provider)
|
|
state["hits"].append({"knowledge_id": "other", "content": "must never escape"})
|
|
with pytest.raises(PermissionError):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert len([r for r in calls if r.method == "POST"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dataset_search_verifies_returned_document_membership(adapter):
|
|
provider, _, state, *_ = adapter
|
|
ctx = await selection(provider, docs=False)
|
|
state["document"]["knowledge_base_id"] = "other"
|
|
with pytest.raises(PermissionError):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status,error", [(401, PermissionError), (403, PermissionError), (404, FileNotFoundError), (500, RuntimeError), (302, RuntimeError)])
|
|
async def test_http_errors_are_redacted_and_never_retried(adapter, status, error):
|
|
provider, calls, state, *_ = adapter
|
|
state["status"] = status
|
|
with pytest.raises(error) as exc:
|
|
await provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
|
assert "synthetic-secret" not in str(exc.value)
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_space_ids_are_stable_bounded_and_isolated(adapter):
|
|
provider, _, _, config, transport = adapter
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
|
|
config["spaces"].append({**config["spaces"][0], "tenant_id": "2", "user_ids": ["bob"]})
|
|
multi = WeKnoraProvider(config, transport=transport)
|
|
alice = await multi.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
|
bob = await multi.catalog(context=KnowledgeRequestContext("bob", "lead_agent"), page=1, page_size=20, search="")
|
|
old = await provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
|
assert alice.items[0].id == old.items[0].id != bob.items[0].id
|
|
assert len(alice.items[0].id) <= 256
|
|
with pytest.raises(PermissionError):
|
|
await multi.search_scoped("Cedar", context=KnowledgeRequestContext("bob", "lead_agent", {"version": 1, "mode": "selected", "dataset_ids": [alice.items[0].id]}))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_scope_still_sends_explicit_targets_and_subagent_works(adapter):
|
|
provider, calls, *_ = adapter
|
|
await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "researcher"))
|
|
body = json.loads(next(r.content for r in calls if r.method == "POST"))
|
|
assert "knowledge_base_ids" not in body and "knowledge_ids" not in body
|
|
assert next(r for r in calls if r.method == "POST").url.path == "/api/v1/knowledge-bases/kb-1/hybrid-search"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_secrets_redacted_and_missing_attribution_not_invented(adapter):
|
|
provider, _, state, *_ = adapter
|
|
state["hits"][0].update(content="synthetic-secret evidence", knowledge_title="")
|
|
state["document"]["title"] = ""
|
|
result = await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent"))
|
|
assert "synthetic-secret" not in result.model_dump_json()
|
|
assert result.hits[0].source is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("bad_id", ["..", "../secret", "%2e%2e", "a/b", "x" * 257])
|
|
async def test_unsafe_native_ids_fail_before_path_request(adapter, bad_id):
|
|
provider, calls, *_ = adapter
|
|
ctx = await selection(provider)
|
|
ctx.scope["document_filters"][0]["document_ids"] = [bad_id]
|
|
with pytest.raises(ValueError):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert not any("/knowledge/" in r.url.path or r.method == "POST" for r in calls)
|
|
|
|
|
|
def test_configuration_rejects_implicit_authorization_and_credentials(adapter):
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
|
|
_, _, _, config, _ = adapter
|
|
for change in ({"tenant_id": None}, {"user_ids": []}, {"base_url": "https://user:secret@example.com"}, {"api_key_env": ""}, {"knowledge_base_ids": []}):
|
|
with pytest.raises(ValueError):
|
|
WeKnoraProvider({"spaces": [{**config["spaces"][0], **change}]})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_packaged_registration_real_tool_replay_budget_and_delegation(adapter, monkeypatch):
|
|
from types import SimpleNamespace
|
|
|
|
from langchain_core.messages import AIMessage
|
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
from langgraph.prebuilt import ToolNode
|
|
|
|
from deerflow.extensions import EXTENSION_SNAPSHOT_CONTEXT_KEY, load_extensions
|
|
from deerflow.extensions.loader import ExtensionSpec
|
|
from deerflow.knowledge import tools
|
|
from deerflow.knowledge.providers import GENERIC_KNOWLEDGE_TOOL, resolve_knowledge_provider
|
|
from deerflow.knowledge.sources import prune_sources
|
|
from deerflow.runtime.serialization import serialize_channel_values_for_api
|
|
from deerflow.tools.builtins.task_tool import _task_result_command
|
|
|
|
_, _, _, config, transport = adapter
|
|
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_weknora:install", config=config)])
|
|
assert diagnostics == []
|
|
app_config = SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(use=GENERIC_KNOWLEDGE_TOOL, model_extra={"provider": "weknora"}))
|
|
provider = resolve_knowledge_provider(app_config, extensions=loaded)
|
|
provider._transport = transport
|
|
ctx = await selection(provider)
|
|
monkeypatch.setattr(tools, "get_app_config", lambda: app_config)
|
|
builder = StateGraph(MessagesState, context_schema=dict)
|
|
builder.add_node("tools", ToolNode([tools.knowledge_search_tool]))
|
|
builder.add_edge(START, "tools")
|
|
builder.add_edge("tools", END)
|
|
result = await builder.compile().ainvoke(
|
|
{"messages": [AIMessage(content="", tool_calls=[{"id": "call", "name": "knowledge_search", "args": {"query": "Cedar"}}])]},
|
|
context={"user_id": "alice", "is_subagent": True, "agent_id": "researcher", "__knowledge_scope_execution": ctx.scope, EXTENSION_SNAPSHOT_CONTEXT_KEY: loaded},
|
|
)
|
|
message = result["messages"][-1]
|
|
sources = sources_from_messages([message])
|
|
assert len(sources) == 1 and sources[0].provider == "weknora"
|
|
persisted = json.loads(json.dumps(serialize_channel_values_for_api({"messages": [message]})))
|
|
assert sources_from_messages(persisted["messages"]) == sources
|
|
command = _task_result_command(tool_call_id="task", status="completed", result=message.content, knowledge_sources=[s.model_dump(mode="json", exclude_none=True) for s in sources])
|
|
assert sources_from_messages(command.update["messages"]) == sources
|
|
_, pruned = prune_sources("truncated", message.artifact)
|
|
assert pruned["knowledge_sources"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_key_fails_closed(adapter, monkeypatch):
|
|
provider, calls, *_ = adapter
|
|
monkeypatch.delenv("WEKNORA_TEST_KEY")
|
|
with pytest.raises(RuntimeError):
|
|
await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent"))
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowlist_and_missing_configured_base(adapter):
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
|
|
_, calls, state, config, transport = adapter
|
|
config["spaces"][0]["knowledge_base_ids"] = ["kb-1"]
|
|
provider = WeKnoraProvider(config, transport=transport)
|
|
state["bases"] = [{"id": "kb-1", "name": "Allowed"}, {"id": "kb-2", "name": "Excluded"}]
|
|
page = await provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
|
assert [item.name for item in page.items] == ["Allowed"]
|
|
state["bases"] = [{"id": "kb-2", "name": "Excluded"}]
|
|
with pytest.raises(PermissionError):
|
|
await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent"))
|
|
assert not any(r.method == "POST" for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_catalog_never_sends_unconstrained_search(adapter):
|
|
provider, calls, state, *_ = adapter
|
|
state["bases"] = None
|
|
result = await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent"))
|
|
assert result.hits == [] and not any(r.method == "POST" for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("payload", [[], {"success": False, "data": []}, {"success": True}, {"success": True, "data": "bad"}])
|
|
async def test_malformed_upstream_response_fails_closed(adapter, payload):
|
|
provider, *_ = adapter
|
|
provider._transport = httpx.MockTransport(lambda _: httpx.Response(200, json=payload))
|
|
with pytest.raises(RuntimeError):
|
|
await provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_size_limit(adapter):
|
|
provider, *_ = adapter
|
|
provider._transport = httpx.MockTransport(lambda _: httpx.Response(200, content=b"x" * (4 * 1024 * 1024 + 1)))
|
|
with pytest.raises(RuntimeError, match="size"):
|
|
await provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_document_selections_checked_before_any_search(adapter):
|
|
provider, calls, *_ = adapter
|
|
ctx = await selection(provider)
|
|
ctx.scope["document_filters"][0]["document_ids"].append("missing")
|
|
with pytest.raises(FileNotFoundError):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert not any(r.method == "POST" for r in calls)
|
|
|
|
|
|
def test_gateway_catalog_uses_authenticated_user_with_real_adapter(adapter):
|
|
from _router_auth_helpers import make_authed_test_app
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.routers import knowledge
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.extensions import ExtensionRegistry
|
|
|
|
_, calls, _, config, transport = adapter
|
|
user = User(email="weknora@example.com", password_hash="unused", system_role="user")
|
|
config["spaces"][0]["user_ids"] = [str(user.id)]
|
|
provider = WeKnoraProvider(config, transport=transport)
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("deerflow_extension_weknora:install"):
|
|
registry.knowledge_provider("weknora", provider)
|
|
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
|
|
app.state.extensions = registry.build()
|
|
app.include_router(knowledge.router)
|
|
app_config = AppConfig.model_validate(
|
|
{
|
|
"models": [],
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"knowledge_base": {"enabled": True, "scope_selection_enabled": True},
|
|
"tools": [{"name": "knowledge_search", "group": "knowledge", "use": "deerflow.knowledge.tools:knowledge_search_tool", "provider": "weknora"}],
|
|
}
|
|
)
|
|
app.dependency_overrides[get_config] = lambda: app_config
|
|
with TestClient(app) as client:
|
|
cap = client.get("/api/knowledge/capabilities?agent_name=lead_agent")
|
|
assert cap.status_code == 200 and cap.json()["scope_selection_enabled"] is True
|
|
catalog = client.get("/api/knowledge/retrieval-catalog/datasets?agent_name=lead_agent&user_id=mallory")
|
|
assert catalog.status_code == 200
|
|
dataset_id = catalog.json()["items"][0]["id"]
|
|
documents = client.get(f"/api/knowledge/retrieval-catalog/datasets/{dataset_id}/documents?agent_name=lead_agent")
|
|
assert documents.status_code == 200 and documents.json()["items"][0]["selectable"] is True
|
|
assert client.get("/api/knowledge/retrieval-catalog/datasets/unknown/documents?agent_name=lead_agent").status_code == 403
|
|
assert all(r.method == "GET" for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oversized_hit_list_does_not_cause_unbounded_detail_requests(adapter):
|
|
provider, calls, state, *_ = adapter
|
|
state["hits"] = [{"knowledge_id": f"doc-{i}", "content": "synthetic"} for i in range(101)]
|
|
with pytest.raises(RuntimeError, match="result limit"):
|
|
await provider.search_scoped("Cedar", context=KnowledgeRequestContext("alice", "lead_agent"))
|
|
assert not any("/knowledge/" in r.url.path for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("pagination", [{}, {"total": None}, {"total": "1"}, {"total": True}, {"total": -1}, {"total": 1.5}])
|
|
async def test_invalid_document_total_is_explicit_provider_error(adapter, pagination):
|
|
provider, calls, state, *_ = adapter
|
|
ctx = await selection(provider)
|
|
state["pagination"] = pagination
|
|
with pytest.raises(RuntimeError, match="Invalid WeKnora document total"):
|
|
await provider.documents(ctx.scope["dataset_ids"][0], context=ctx, page=1, page_size=20, search="")
|
|
assert not any(r.method == "POST" for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("target", ["hits", "document"])
|
|
@pytest.mark.parametrize("data", ["bad", [], ["bad"], 42])
|
|
async def test_malformed_retrieval_payloads_fail_without_retry(adapter, target, data):
|
|
provider, calls, state, *_ = adapter
|
|
ctx = await selection(provider)
|
|
state[target] = data
|
|
if target == "hits" and data == []:
|
|
assert (await provider.search_scoped("Cedar", context=ctx)).hits == []
|
|
else:
|
|
with pytest.raises(RuntimeError, match="Invalid WeKnora"):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert len([r for r in calls if r.method == "POST"]) == (1 if target == "hits" else 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrieval_response_size_limit_without_retry(adapter):
|
|
provider, calls, _, _, transport = adapter
|
|
ctx = await selection(provider, docs=False)
|
|
posts = []
|
|
|
|
async def handler(request):
|
|
if request.method == "POST":
|
|
posts.append(request)
|
|
return httpx.Response(200, content=b"x" * (4 * 1024 * 1024 + 1))
|
|
return await transport.handle_async_request(request)
|
|
|
|
provider._transport = httpx.MockTransport(handler)
|
|
with pytest.raises(RuntimeError, match="size"):
|
|
await provider.search_scoped("Cedar", context=ctx)
|
|
assert len(posts) == 1
|
|
assert not any("/knowledge/" in r.url.path for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_published_entries_use_real_wiki_evidence(adapter):
|
|
provider, calls, state, *_ = adapter
|
|
ctx = await selection(provider, docs=False)
|
|
state["pages"] = [{"knowledge_base_id": "kb-1", "slug": "cedar", "title": "Cedar entry", "status": "published", "summary": "Cedar entry evidence."}]
|
|
result = await provider.entries_search("Cedar", context=ctx)
|
|
assert result.hits[0].source.title == "Cedar entry"
|
|
assert result.hits[0].source.excerpt == "Cedar entry evidence."
|
|
assert calls[-1].url.params["q"] == "Cedar"
|
|
content, artifact = format_result(result)
|
|
assert len(sources_from_messages([{"type": "tool", "name": "knowledge_entries_search", "content": content, "artifact": artifact}])) == 1
|
|
state["pages"][0]["status"] = "draft"
|
|
assert not (await provider.entries_search("Cedar", context=ctx)).hits
|
|
state["pages"][0]["knowledge_base_id"] = "foreign"
|
|
with pytest.raises(PermissionError):
|
|
await provider.entries_search("Cedar", context=ctx)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_entries_reject_document_filter_before_network(adapter):
|
|
provider, calls, *_ = adapter
|
|
ctx = await selection(provider)
|
|
calls.clear()
|
|
with pytest.raises(ValueError, match="document"):
|
|
await provider.entries_search("Cedar", context=ctx)
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_entries_skip_non_wiki_bases_and_reject_unbound_identity(adapter):
|
|
provider, calls, state, *_ = adapter
|
|
state["bases"] = [{"id": "kb-1", "name": "Document only", "capabilities": {"wiki": False}}]
|
|
assert not (await provider.entries_search("q", context=KnowledgeRequestContext("alice", "lead_agent"))).hits
|
|
assert not any("/wiki/" in str(request.url) for request in calls)
|
|
calls.clear()
|
|
with pytest.raises(PermissionError):
|
|
await provider.entries_search("q", context=KnowledgeRequestContext("mallory", "lead_agent"))
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wiki_empty_phrase_falls_back_to_bounded_keywords_with_same_scope(adapter):
|
|
provider, calls, state, _, transport = adapter
|
|
ctx = await selection(provider, docs=False)
|
|
seen = []
|
|
page = {"knowledge_base_id": "kb-1", "slug": "cedar", "title": "青杉", "status": "published", "summary": "林岚负责青杉计划"}
|
|
|
|
async def handler(request):
|
|
if request.url.path.endswith("/wiki/search"):
|
|
seen.append(request)
|
|
return httpx.Response(200, json={"pages": [page] if request.url.params["q"] in {"青杉", "林岚"} else []})
|
|
return await transport.handle_async_request(request)
|
|
|
|
provider._transport = httpx.MockTransport(handler)
|
|
result = await provider.entries_search("青杉 计划 负责人 林岚 星河实验室 青杉", context=ctx)
|
|
assert [r.url.params["q"] for r in seen] == ["青杉 计划 负责人 林岚 星河实验室 青杉", "青杉", "计划", "负责人", "林岚"]
|
|
assert all(r.url.path == "/api/v1/knowledgebase/kb-1/wiki/search" for r in seen)
|
|
assert all(r.headers["X-API-Key"] == "synthetic-secret" for r in seen)
|
|
assert len(result.hits) == 1
|
|
assert result.hits[0].source.excerpt == "林岚负责青杉计划"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failure", ["foreign", "malformed", "http"])
|
|
async def test_wiki_keyword_fallback_preserves_fail_closed_errors(adapter, failure):
|
|
provider, _, _, _, transport = adapter
|
|
seen = []
|
|
|
|
async def handler(request):
|
|
if not request.url.path.endswith("/wiki/search"):
|
|
return await transport.handle_async_request(request)
|
|
seen.append(request.url.params["q"])
|
|
if len(seen) == 1:
|
|
return httpx.Response(200, json={"pages": []})
|
|
if failure == "http":
|
|
return httpx.Response(403)
|
|
if failure == "malformed":
|
|
return httpx.Response(200, json={"pages": "bad"})
|
|
return httpx.Response(200, json={"pages": [{"knowledge_base_id": "foreign", "status": "published"}]})
|
|
|
|
provider._transport = httpx.MockTransport(handler)
|
|
with pytest.raises((PermissionError, RuntimeError)):
|
|
await provider.entries_search("青杉 林岚", context=KnowledgeRequestContext("alice", "lead_agent"))
|
|
assert seen == ["青杉 林岚", "青杉"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wiki_nonempty_phrase_does_not_fan_out(adapter):
|
|
provider, calls, state, *_ = adapter
|
|
state["pages"] = [{"knowledge_base_id": "kb-1", "status": "published", "title": "青杉", "summary": "evidence"}]
|
|
assert (await provider.entries_search("青杉 林岚", context=KnowledgeRequestContext("alice", "lead_agent"))).hits
|
|
assert len([r for r in calls if r.url.path.endswith("/wiki/search")]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("query", "expected"), [("青杉", ["青杉"]), ("a b", ["a b"]), ("青杉 青杉 林岚", ["青杉 青杉 林岚", "青杉", "林岚"])])
|
|
async def test_wiki_empty_fallback_never_repeats_queries_or_sends_short_terms(adapter, query, expected):
|
|
provider, calls, *_ = adapter
|
|
assert not (await provider.entries_search(query, context=KnowledgeRequestContext("alice", "lead_agent"))).hits
|
|
assert [r.url.params["q"] for r in calls if r.url.path.endswith("/wiki/search")] == expected
|