mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
Merge commit 'fa7075fde6' into dev
This commit is contained in:
commit
b477d33cba
1421 changed files with 59474 additions and 27447 deletions
|
|
@ -1,93 +0,0 @@
|
|||
"""Tests for the shared ``web_search`` tool's citable-result adaptation.
|
||||
|
||||
The tool's network path (SearXNG + live connectors) is out of scope here; these
|
||||
cover the pure mapping from raw web results to renderable, citable documents and
|
||||
the end-to-end registration of ``WEB_RESULT`` ``[n]`` labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.citations import (
|
||||
CitationRegistry,
|
||||
CitationSourceType,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.document_render import render_web_results
|
||||
from app.agents.chat.shared.tools.web_search import (
|
||||
_to_renderable_web_documents,
|
||||
_web_source_label,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _raw_result(url: str, title: str, content: str) -> dict:
|
||||
return {
|
||||
"document": {"title": title, "metadata": {"url": url}},
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def test_web_source_label_strips_scheme_and_www() -> None:
|
||||
assert _web_source_label("https://www.example.com/path") == "Web · example.com"
|
||||
assert _web_source_label("http://news.site.org/a/b") == "Web · news.site.org"
|
||||
assert _web_source_label("") == "Web"
|
||||
|
||||
|
||||
def test_adapter_maps_each_result_to_one_web_passage() -> None:
|
||||
docs = _to_renderable_web_documents(
|
||||
[
|
||||
_raw_result("https://a.com/x", "Alpha", "alpha body"),
|
||||
_raw_result("https://b.com/y", "Beta", "beta body"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [d.title for d in docs] == ["Alpha", "Beta"]
|
||||
passages = [p for d in docs for p in d.passages]
|
||||
assert all(p.source_type is CitationSourceType.WEB_RESULT for p in passages)
|
||||
assert passages[0].locator == {"url": "https://a.com/x"}
|
||||
assert passages[0].content == "alpha body"
|
||||
|
||||
|
||||
def test_adapter_skips_results_without_url_or_content() -> None:
|
||||
docs = _to_renderable_web_documents(
|
||||
[
|
||||
_raw_result("", "No URL", "has content"),
|
||||
_raw_result("https://c.com/z", "Empty", " "),
|
||||
_raw_result("https://d.com/w", "Good", "real content"),
|
||||
]
|
||||
)
|
||||
|
||||
assert [d.title for d in docs] == ["Good"]
|
||||
|
||||
|
||||
def test_adapter_truncates_on_char_budget() -> None:
|
||||
big = "x" * 30
|
||||
docs = _to_renderable_web_documents(
|
||||
[
|
||||
_raw_result("https://a.com", "A", big),
|
||||
_raw_result("https://b.com", "B", big),
|
||||
_raw_result("https://c.com", "C", big),
|
||||
],
|
||||
max_chars=50,
|
||||
)
|
||||
|
||||
# First fits (30), second crosses 50 and stops the loop.
|
||||
assert [d.title for d in docs] == ["A"]
|
||||
|
||||
|
||||
def test_end_to_end_registers_web_results_for_citation() -> None:
|
||||
registry = CitationRegistry()
|
||||
docs = _to_renderable_web_documents(
|
||||
[_raw_result("https://example.com/a", "Example", "the answer is 42")]
|
||||
)
|
||||
|
||||
block = render_web_results(docs, registry)
|
||||
|
||||
assert block is not None
|
||||
assert "[1] the answer is 42" in block
|
||||
entry = registry.resolve(1)
|
||||
assert entry is not None
|
||||
assert entry.source_type is CitationSourceType.WEB_RESULT
|
||||
assert entry.locator == {"url": "https://example.com/a"}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
"""Tests for the ``<web_results>`` wrapper around web-result excerpt documents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.citations import (
|
||||
CitationRegistry,
|
||||
CitationSourceType,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.document_render import (
|
||||
RenderableDocument,
|
||||
RenderablePassage,
|
||||
render_web_results,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _web_doc(url: str, title: str, content: str) -> RenderableDocument:
|
||||
return RenderableDocument(
|
||||
title=title,
|
||||
source=f"Web · {url.split('//', 1)[-1].split('/', 1)[0]}",
|
||||
passages=[
|
||||
RenderablePassage(
|
||||
content=content,
|
||||
locator={"url": url},
|
||||
source_type=CitationSourceType.WEB_RESULT,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_returns_none_when_nothing_to_show() -> None:
|
||||
registry = CitationRegistry()
|
||||
|
||||
assert render_web_results([], registry) is None
|
||||
|
||||
|
||||
def test_wraps_in_web_results_container() -> None:
|
||||
registry = CitationRegistry()
|
||||
|
||||
block = render_web_results(
|
||||
[_web_doc("https://example.com/a", "Example", "the answer is 42")],
|
||||
registry,
|
||||
)
|
||||
|
||||
assert block is not None
|
||||
assert block.startswith("<web_results>")
|
||||
assert block.endswith("</web_results>")
|
||||
assert "cite a result with its [n]" in block
|
||||
assert (
|
||||
'<document title="Example" source="Web · example.com" view="excerpt">' in block
|
||||
)
|
||||
assert "[1] the answer is 42" in block
|
||||
|
||||
|
||||
def test_registers_each_result_as_web_result_with_url_locator() -> None:
|
||||
registry = CitationRegistry()
|
||||
|
||||
render_web_results(
|
||||
[
|
||||
_web_doc("https://a.com/x", "A", "alpha"),
|
||||
_web_doc("https://b.com/y", "B", "beta"),
|
||||
],
|
||||
registry,
|
||||
)
|
||||
|
||||
first = registry.resolve(1)
|
||||
second = registry.resolve(2)
|
||||
assert first is not None and second is not None
|
||||
assert first.source_type is CitationSourceType.WEB_RESULT
|
||||
assert first.locator == {"url": "https://a.com/x"}
|
||||
assert second.locator == {"url": "https://b.com/y"}
|
||||
|
||||
|
||||
def test_same_url_reuses_label_across_calls() -> None:
|
||||
registry = CitationRegistry()
|
||||
doc = _web_doc("https://example.com/a", "Example", "stable fact")
|
||||
|
||||
render_web_results([doc], registry)
|
||||
render_web_results([doc], registry)
|
||||
|
||||
assert registry.next_n == 2
|
||||
|
|
@ -25,6 +25,7 @@ from app.agents.chat.multi_agent_chat.shared.permissions.middleware.core import
|
|||
PermissionMiddleware,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||
append_today_utc,
|
||||
pack_subagent,
|
||||
)
|
||||
|
||||
|
|
@ -104,6 +105,35 @@ async def test_subagent_recovers_when_primary_llm_fails():
|
|||
assert final.content == "recovered via fallback"
|
||||
|
||||
|
||||
def test_packed_subagent_prompt_carries_todays_utc_date():
|
||||
"""Subagents must inherit the main agent's clock, not guess the date."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
|
||||
result = pack_subagent(
|
||||
name="date_test",
|
||||
description="test",
|
||||
system_prompt="be helpful",
|
||||
tools=[],
|
||||
ruleset=Ruleset(origin="date_test", rules=[]),
|
||||
dependencies={"flags": AgentFeatureFlags()},
|
||||
)
|
||||
|
||||
prompt = result.spec["system_prompt"]
|
||||
assert prompt.startswith("be helpful")
|
||||
assert f"Today (UTC): {today}" in prompt
|
||||
|
||||
|
||||
def test_append_today_utc_is_idempotent_shape():
|
||||
"""Helper appends exactly one dated line and preserves the original body."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
out = append_today_utc("body")
|
||||
assert out == f"body\n\nToday (UTC): {today}\n"
|
||||
|
||||
|
||||
def _extract_permission_mw(spec) -> PermissionMiddleware:
|
||||
"""Find the lone PermissionMiddleware in a subagent's middleware list."""
|
||||
matches = [m for m in spec["middleware"] if isinstance(m, PermissionMiddleware)]
|
||||
|
|
@ -191,25 +221,25 @@ def test_missing_user_allowlist_keeps_coded_behaviour():
|
|||
|
||||
|
||||
def test_user_allowlist_for_different_subagent_does_not_leak():
|
||||
"""User trust for ``linear`` must not affect a ``jira`` subagent compile."""
|
||||
"""User trust for one subagent must not affect a different subagent compile."""
|
||||
coded = Ruleset(
|
||||
origin="jira",
|
||||
origin="mcp_discovery",
|
||||
rules=[Rule(permission="save_issue", pattern="*", action="ask")],
|
||||
)
|
||||
linear_allowlist = Ruleset(
|
||||
origin="user_allowlist:linear",
|
||||
other_allowlist = Ruleset(
|
||||
origin="user_allowlist:knowledge_base",
|
||||
rules=[Rule(permission="save_issue", pattern="*", action="allow")],
|
||||
)
|
||||
|
||||
result = pack_subagent(
|
||||
name="jira",
|
||||
name="mcp_discovery",
|
||||
description="test",
|
||||
system_prompt="x",
|
||||
tools=[],
|
||||
ruleset=coded,
|
||||
dependencies={
|
||||
"flags": AgentFeatureFlags(),
|
||||
"user_allowlist_by_subagent": {"linear": linear_allowlist},
|
||||
"user_allowlist_by_subagent": {"knowledge_base": other_allowlist},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"""Allowlist filtering in ``_load_http_mcp_tools``.
|
||||
|
||||
Covers the fallback added after Notion renamed its MCP tools ("notion-"
|
||||
prefix) and a stale allowlist silently disabled the connector: when the
|
||||
allowlist matches zero advertised tools, load everything (HITL-gated)
|
||||
instead of nothing.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.cache import (
|
||||
CachedMCPTools,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
|
||||
_load_http_mcp_tools,
|
||||
)
|
||||
|
||||
_CACHED = CachedMCPTools(
|
||||
discovered_at=datetime.now(UTC),
|
||||
tools=[
|
||||
{"name": "notion-search", "description": "search", "input_schema": {}},
|
||||
{"name": "notion-update-page", "description": "write", "input_schema": {}},
|
||||
],
|
||||
)
|
||||
_SERVER_CONFIG = {"url": "https://example.com/mcp", "headers": {}}
|
||||
|
||||
|
||||
async def test_allowlist_match_filters_and_flags_readonly():
|
||||
tools = await _load_http_mcp_tools(
|
||||
1,
|
||||
"Notion",
|
||||
_SERVER_CONFIG,
|
||||
allowed_tools=["notion-search"],
|
||||
readonly_tools=frozenset({"notion-search"}),
|
||||
cached_tools=_CACHED,
|
||||
)
|
||||
assert [t.name for t in tools] == ["notion-search"]
|
||||
assert tools[0].metadata["hitl"] is False
|
||||
|
||||
|
||||
async def test_stale_allowlist_falls_back_to_all_tools_hitl_gated():
|
||||
tools = await _load_http_mcp_tools(
|
||||
1,
|
||||
"Notion",
|
||||
_SERVER_CONFIG,
|
||||
allowed_tools=["search", "update-page"], # server renamed everything
|
||||
readonly_tools=frozenset({"search"}),
|
||||
cached_tools=_CACHED,
|
||||
)
|
||||
assert sorted(t.name for t in tools) == ["notion-search", "notion-update-page"]
|
||||
# Renamed tools match no readonly entry -> every tool requires approval.
|
||||
assert all(t.metadata["hitl"] is True for t in tools)
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
"""Guardrails for the MCP-consolidation migration.
|
||||
|
||||
Every MCP-backed connector now routes to the single ``mcp_discovery`` subagent
|
||||
(file connectors stay native; Discord/Teams/Luma are deprecated). These tests
|
||||
pin the pieces that make that safe: connector→route mapping, any-of gating,
|
||||
legacy checkpoint aliasing, tool-name collision prefixing, the metadata-derived
|
||||
approval ruleset, and the KB indexing-deprecation sets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.tools import StructuredTool
|
||||
|
||||
from app.agents.chat.multi_agent_chat.constants import (
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS,
|
||||
LEGACY_SUBAGENT_ALIASES,
|
||||
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.builtins.mcp_discovery.tools.index import (
|
||||
NAME as MCP_DISCOVERY_NAME,
|
||||
build_ruleset,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.mcp_tools.index import (
|
||||
resolve_tool_name_collisions,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.registry import (
|
||||
SUBAGENT_BUILDERS_BY_NAME,
|
||||
get_subagents_to_exclude,
|
||||
)
|
||||
from app.services.mcp_oauth.registry import (
|
||||
DEPRECATED_INDEXING_CONNECTOR_TYPES,
|
||||
LIVE_CONNECTOR_TYPES,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# Connectors that must all funnel into ``mcp_discovery`` (not their own routes).
|
||||
_MCP_ROUTED = {
|
||||
"SLACK_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"CLICKUP_CONNECTOR",
|
||||
"AIRTABLE_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"CONFLUENCE_CONNECTOR",
|
||||
"GOOGLE_GMAIL_CONNECTOR",
|
||||
"GOOGLE_CALENDAR_CONNECTOR",
|
||||
"MCP_CONNECTOR",
|
||||
}
|
||||
|
||||
|
||||
def _tool(name: str, metadata: dict) -> StructuredTool:
|
||||
return StructuredTool.from_function(
|
||||
func=lambda: name,
|
||||
name=name,
|
||||
description=name,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_all_mcp_connectors_route_to_discovery():
|
||||
for connector_type in _MCP_ROUTED:
|
||||
assert (
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS[connector_type] == MCP_DISCOVERY_NAME
|
||||
), connector_type
|
||||
|
||||
|
||||
def test_file_connectors_keep_native_routes():
|
||||
assert (
|
||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["GOOGLE_DRIVE_CONNECTOR"]
|
||||
== "google_drive"
|
||||
)
|
||||
assert CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["DROPBOX_CONNECTOR"] == "dropbox"
|
||||
assert CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS["ONEDRIVE_CONNECTOR"] == "onedrive"
|
||||
|
||||
|
||||
def test_deprecated_connectors_have_no_route():
|
||||
for connector_type in ("DISCORD_CONNECTOR", "TEAMS_CONNECTOR", "LUMA_CONNECTOR"):
|
||||
assert connector_type not in CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS
|
||||
|
||||
|
||||
def test_discovery_gating_is_any_of():
|
||||
"""One connected app is enough to keep ``mcp_discovery``; none excludes it."""
|
||||
assert MCP_DISCOVERY_NAME not in get_subagents_to_exclude(["SLACK_CONNECTOR"])
|
||||
assert MCP_DISCOVERY_NAME in get_subagents_to_exclude([])
|
||||
# A generic user MCP server alone still unlocks it.
|
||||
assert MCP_DISCOVERY_NAME not in get_subagents_to_exclude(["MCP_CONNECTOR"])
|
||||
|
||||
|
||||
def test_discovery_gating_tokens_match_routed_connectors():
|
||||
assert SUBAGENT_TO_REQUIRED_CONNECTOR_MAP[MCP_DISCOVERY_NAME] == frozenset(
|
||||
_MCP_ROUTED
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_aliases_resolve_to_a_live_subagent():
|
||||
"""Old per-connector task names must alias onto a subagent that still exists."""
|
||||
for legacy, target in LEGACY_SUBAGENT_ALIASES.items():
|
||||
assert legacy not in SUBAGENT_BUILDERS_BY_NAME, legacy
|
||||
assert target in SUBAGENT_BUILDERS_BY_NAME, target
|
||||
|
||||
|
||||
def test_collision_only_prefixes_shared_names():
|
||||
"""A name on two connectors is prefixed; a unique name is left untouched."""
|
||||
tools = [
|
||||
_tool("search", {"mcp_connector_id": 1, "mcp_transport": "http"}),
|
||||
_tool("search", {"mcp_connector_id": 2, "mcp_transport": "http"}),
|
||||
_tool("list_bases", {"mcp_connector_id": 2, "mcp_transport": "http"}),
|
||||
]
|
||||
resolved = {
|
||||
t.name: t
|
||||
for t in resolve_tool_name_collisions(
|
||||
tools, {1: "NOTION_CONNECTOR", 2: "AIRTABLE_CONNECTOR"}
|
||||
)
|
||||
}
|
||||
|
||||
# The unique tool keeps its bare name (trusted_tools / history stay valid).
|
||||
assert "list_bases" in resolved
|
||||
# The colliding name is gone; both are prefixed with service + connector id.
|
||||
assert "search" not in resolved
|
||||
assert "notion_1_search" in resolved
|
||||
assert "airtable_2_search" in resolved
|
||||
# Original name preserved for the "Always Allow" fallback key.
|
||||
for name in ("notion_1_search", "airtable_2_search"):
|
||||
meta = resolved[name].metadata or {}
|
||||
assert meta["mcp_original_tool_name"] == "search"
|
||||
assert meta["mcp_collision_prefixed"] is True
|
||||
|
||||
|
||||
def test_collision_noop_without_collisions():
|
||||
tools = [
|
||||
_tool("a", {"mcp_connector_id": 1}),
|
||||
_tool("b", {"mcp_connector_id": 2}),
|
||||
]
|
||||
assert [t.name for t in resolve_tool_name_collisions(tools, {})] == ["a", "b"]
|
||||
|
||||
|
||||
def test_ruleset_reads_hitl_from_metadata():
|
||||
"""Read-only MCP tools ``allow``; every other MCP tool ``ask``; natives skip."""
|
||||
tools = [
|
||||
_tool("readonly_search", {"mcp_transport": "http", "hitl": False}),
|
||||
_tool("mutating_create", {"mcp_transport": "http", "hitl": True}),
|
||||
_tool("native_helper", {}), # no mcp_transport => no rule
|
||||
]
|
||||
rules = {r.permission: r.action for r in build_ruleset(tools).rules}
|
||||
assert rules == {"readonly_search": "allow", "mutating_create": "ask"}
|
||||
|
||||
|
||||
def test_indexing_deprecation_sets():
|
||||
"""Indexing-only connectors are deprecated; migrated ones are LIVE; Obsidian stays."""
|
||||
deprecated = {t.value for t in DEPRECATED_INDEXING_CONNECTOR_TYPES}
|
||||
assert deprecated == {
|
||||
"GITHUB_CONNECTOR",
|
||||
"BOOKSTACK_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
"CIRCLEBACK_CONNECTOR",
|
||||
}
|
||||
assert "OBSIDIAN_CONNECTOR" not in deprecated
|
||||
|
||||
live = {t.value for t in LIVE_CONNECTOR_TYPES}
|
||||
assert {"NOTION_CONNECTOR", "CONFLUENCE_CONNECTOR"} <= live
|
||||
|
|
@ -45,7 +45,7 @@ def test_every_subagent_has_description_md(name: str):
|
|||
[
|
||||
"core_behavior.md",
|
||||
"routing.md",
|
||||
"tools/web_search/description.md",
|
||||
"tools/task/description.md",
|
||||
],
|
||||
)
|
||||
def test_main_agent_prompt_fragments_resolve(filename: str):
|
||||
|
|
|
|||
|
|
@ -19,36 +19,32 @@ from app.agents.chat.multi_agent_chat.subagents.registry import (
|
|||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# The full specialist roster the main agent composes from: 4 builtins + 15
|
||||
# connector routes. Adding/removing a specialist is a deliberate product change
|
||||
# and must be reflected here.
|
||||
# The full specialist roster the main agent composes from after the MCP
|
||||
# migration: builtins + the three file-connector routes (Drive/Dropbox/
|
||||
# OneDrive). Every MCP-backed connector (Slack/Jira/Linear/ClickUp/Airtable/
|
||||
# Notion/Confluence/Gmail/Calendar) now lives behind the single
|
||||
# ``mcp_discovery`` route; Discord/Teams/Luma were deprecated. Adding/removing a
|
||||
# specialist is a deliberate product change and must be reflected here.
|
||||
_EXPECTED_SUBAGENTS = frozenset(
|
||||
{
|
||||
"airtable",
|
||||
"calendar",
|
||||
"clickup",
|
||||
"confluence",
|
||||
"deliverables",
|
||||
"discord",
|
||||
"dropbox",
|
||||
"gmail",
|
||||
"google_drive",
|
||||
"jira",
|
||||
"google_maps",
|
||||
"google_search",
|
||||
"knowledge_base",
|
||||
"linear",
|
||||
"luma",
|
||||
"mcp_discovery",
|
||||
"memory",
|
||||
"notion",
|
||||
"onedrive",
|
||||
"research",
|
||||
"slack",
|
||||
"teams",
|
||||
"reddit",
|
||||
"web_crawler",
|
||||
"youtube",
|
||||
}
|
||||
)
|
||||
|
||||
# Specialists that are always available regardless of connected sources, so they
|
||||
# carry no required-connector entry.
|
||||
_CONNECTORLESS = frozenset({"memory", "research"})
|
||||
_CONNECTORLESS = frozenset({"memory"})
|
||||
|
||||
|
||||
def test_registry_contains_exactly_expected_subagents():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"""Guardrail: the multi-engine ``web_search`` tool is fully retired.
|
||||
|
||||
Public web search now runs exclusively through the ``google_search`` subagent,
|
||||
and the four search connector types (Tavily/SearXNG/Linkup/Baidu) are soft-
|
||||
deprecated. This pins those invariants so a future change can't quietly bring
|
||||
the ``web_search`` tool back or un-deprecate a search connector.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.agents.chat.multi_agent_chat.main_agent.tools.index import (
|
||||
MAIN_AGENT_SURFSENSE_TOOL_NAMES,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.registry import (
|
||||
SUBAGENT_BUILDERS_BY_NAME,
|
||||
)
|
||||
from app.utils.validators import (
|
||||
DEPRECATED_CONNECTOR_TYPES,
|
||||
raise_if_connector_deprecated,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_DEPRECATED_SEARCH_TYPES = (
|
||||
"TAVILY_API",
|
||||
"SEARXNG_API",
|
||||
"LINKUP_API",
|
||||
"BAIDU_SEARCH_API",
|
||||
)
|
||||
|
||||
|
||||
def test_web_search_tool_removed_from_main_agent():
|
||||
assert "web_search" not in MAIN_AGENT_SURFSENSE_TOOL_NAMES
|
||||
|
||||
|
||||
def test_google_search_specialist_present():
|
||||
assert "google_search" in SUBAGENT_BUILDERS_BY_NAME
|
||||
|
||||
|
||||
@pytest.mark.parametrize("connector_type", _DEPRECATED_SEARCH_TYPES)
|
||||
def test_search_connectors_deprecated(connector_type):
|
||||
assert connector_type in DEPRECATED_CONNECTOR_TYPES
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
raise_if_connector_deprecated(connector_type)
|
||||
assert excinfo.value.status_code == 410
|
||||
|
|
@ -94,7 +94,7 @@ def fake_session_factory():
|
|||
class TestActionLogMiddlewareDisabled:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_flag_off(self, patch_get_flags) -> None:
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={
|
||||
"name": "make_widget",
|
||||
|
|
@ -110,7 +110,7 @@ class TestActionLogMiddlewareDisabled:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_thread_id_none(self, patch_get_flags) -> None:
|
||||
mw = ActionLogMiddleware(thread_id=None, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=None, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -126,7 +126,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
|
||||
mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
|
||||
request = _FakeRequest(
|
||||
tool_call={
|
||||
"name": "make_widget",
|
||||
|
|
@ -150,7 +150,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
assert len(captured["rows"]) == 1
|
||||
row = captured["rows"][0]
|
||||
assert row.thread_id == 42
|
||||
assert row.search_space_id == 7
|
||||
assert row.workspace_id == 7
|
||||
assert row.user_id == "u1"
|
||||
assert row.tool_name == "make_widget"
|
||||
assert row.args == {"color": "red", "size": 3}
|
||||
|
|
@ -170,7 +170,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
) -> None:
|
||||
"""``chat_turn_id`` falls back to NULL when ``runtime.config`` is absent."""
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc-1"},
|
||||
runtime=None,
|
||||
|
|
@ -190,7 +190,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
|
||||
mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {"color": "red"}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -214,7 +214,7 @@ class TestActionLogMiddlewarePersistence:
|
|||
self, patch_get_flags
|
||||
) -> None:
|
||||
"""Even if the DB write blows up, the tool's result must reach the model."""
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -250,7 +250,7 @@ class TestReverseDescriptor:
|
|||
)
|
||||
mw = ActionLogMiddleware(
|
||||
thread_id=1,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id="u",
|
||||
tool_definitions={"make_widget": tool_def},
|
||||
)
|
||||
|
|
@ -296,7 +296,7 @@ class TestReverseDescriptor:
|
|||
)
|
||||
mw = ActionLogMiddleware(
|
||||
thread_id=1,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id=None,
|
||||
tool_definitions={"make_widget": tool_def},
|
||||
)
|
||||
|
|
@ -321,7 +321,7 @@ class TestReverseDescriptor:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "unknown_tool", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -343,7 +343,7 @@ class TestActionLogDispatch:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
_captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=42, search_space_id=7, user_id="u1")
|
||||
mw = ActionLogMiddleware(thread_id=42, workspace_id=7, user_id="u1")
|
||||
request = _FakeRequest(
|
||||
tool_call={
|
||||
"name": "make_widget",
|
||||
|
|
@ -383,7 +383,7 @@ class TestActionLogDispatch:
|
|||
@pytest.mark.asyncio
|
||||
async def test_no_dispatch_when_persistence_fails(self, patch_get_flags) -> None:
|
||||
"""If commit fails the dispatch is suppressed (no row to surface)."""
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
request = _FakeRequest(
|
||||
tool_call={"name": "make_widget", "args": {}, "id": "tc1"}
|
||||
)
|
||||
|
|
@ -411,7 +411,7 @@ class TestArgsTruncation:
|
|||
self, patch_get_flags, fake_session_factory
|
||||
) -> None:
|
||||
captured, factory = fake_session_factory
|
||||
mw = ActionLogMiddleware(thread_id=1, search_space_id=1, user_id=None)
|
||||
mw = ActionLogMiddleware(thread_id=1, workspace_id=1, user_id=None)
|
||||
# Build a > 32KB string so the persisted payload triggers the truncation path.
|
||||
huge = "x" * (40 * 1024)
|
||||
request = _FakeRequest(
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class TestSpillEdit:
|
|||
assert edit.pending_spills == []
|
||||
|
||||
def test_above_trigger_clears_and_records(self) -> None:
|
||||
edit = SpillToBackendEdit(trigger=100, keep=1, path_prefix="/tool_outputs")
|
||||
edit = SpillToBackendEdit(trigger=100, keep=1)
|
||||
msgs = _build_history(4)
|
||||
edit.apply(msgs, count_tokens=_approx_count)
|
||||
|
||||
|
|
@ -102,7 +102,9 @@ class TestSpillEdit:
|
|||
assert edit.drain_pending() == []
|
||||
|
||||
def test_placeholder_format(self) -> None:
|
||||
path = "/tool_outputs/thread-1/tool-msg-0.txt"
|
||||
text = _build_spill_placeholder(path)
|
||||
assert path in text
|
||||
assert "explore" in text # mentions the recovery agent
|
||||
import uuid
|
||||
|
||||
spill_id = uuid.uuid4()
|
||||
text = _build_spill_placeholder(spill_id)
|
||||
assert f"spill_{spill_id}" in text
|
||||
assert "read_run" in text # points at the recovery tools
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ earliest -> latest:
|
|||
3. (future) user-defined rules from the Agent Permissions UI
|
||||
|
||||
Without #1 every read-only built-in (``ls``, ``read_file``, ``grep``,
|
||||
``glob``, ``web_search`` …) defaulted to ``ask`` because
|
||||
``glob`` …) defaulted to ``ask`` because
|
||||
``permissions.evaluate`` returns ``ask`` when no rule matches. That
|
||||
caused two production-painful behaviors:
|
||||
|
||||
|
|
@ -58,8 +58,6 @@ class TestReadOnlyToolsAllowed:
|
|||
"read_file",
|
||||
"grep",
|
||||
"glob",
|
||||
"web_search",
|
||||
"scrape_webpage",
|
||||
"get_connected_accounts",
|
||||
"write_todos",
|
||||
"task",
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class TestResolveMentions:
|
|||
session.execute = AsyncMock()
|
||||
result = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
mentioned_documents=None,
|
||||
)
|
||||
assert isinstance(result, ResolvedMentionSet)
|
||||
|
|
@ -134,7 +134,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
mentioned_documents=[chip],
|
||||
)
|
||||
assert len(out.mentions) == 1
|
||||
|
|
@ -170,7 +170,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=3,
|
||||
workspace_id=3,
|
||||
mentioned_documents=[chip],
|
||||
)
|
||||
assert len(out.mentions) == 1
|
||||
|
|
@ -201,7 +201,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
mentioned_documents=[chip],
|
||||
)
|
||||
assert out.mentions == []
|
||||
|
|
@ -238,7 +238,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
mentioned_documents=[chip_short, chip_long],
|
||||
)
|
||||
tokens = [tok for tok, _ in out.token_to_path]
|
||||
|
|
@ -265,7 +265,7 @@ class TestResolveMentions:
|
|||
|
||||
out = await resolve_mentions(
|
||||
session,
|
||||
search_space_id=2,
|
||||
workspace_id=2,
|
||||
mentioned_documents=None,
|
||||
mentioned_document_ids=[7],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ class TestResolveToolName:
|
|||
def test_falls_back_to_tool_call_name(self) -> None:
|
||||
request = MagicMock()
|
||||
request.tool = None
|
||||
request.tool_call = {"name": "web_search", "args": {}}
|
||||
assert _resolve_tool_name(request) == "web_search"
|
||||
request.tool_call = {"name": "create_automation", "args": {}}
|
||||
assert _resolve_tool_name(request) == "create_automation"
|
||||
|
||||
def test_unknown_when_nothing_resolves(self) -> None:
|
||||
request = MagicMock()
|
||||
|
|
@ -278,8 +278,8 @@ class TestMiddlewareIntegration:
|
|||
|
||||
request = MagicMock()
|
||||
request.tool = MagicMock()
|
||||
request.tool.name = "web_search"
|
||||
request.tool.name = "scrape_webpage"
|
||||
await mw.awrap_tool_call(request, handler)
|
||||
assert errors == ["web_search"]
|
||||
assert errors == ["scrape_webpage"]
|
||||
finally:
|
||||
ot.reload_for_tests()
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/{encoded_basename}",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
@ -169,7 +169,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/Calendar_ Happy birthday!.xml",
|
||||
)
|
||||
assert document is None
|
||||
|
|
@ -191,7 +191,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/Plain Note.xml",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
@ -217,7 +217,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf.xml",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
@ -239,7 +239,7 @@ class TestVirtualPathToDoc:
|
|||
|
||||
document = await virtual_path_to_doc(
|
||||
session,
|
||||
search_space_id=5,
|
||||
workspace_id=5,
|
||||
virtual_path=f"{DOCUMENTS_ROOT}/2025-W2.pdf",
|
||||
)
|
||||
assert document is target_doc
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class _DummyMiddleware(AgentMiddleware):
|
|||
|
||||
def _ctx() -> PluginContext:
|
||||
return PluginContext.build(
|
||||
search_space_id=1,
|
||||
workspace_id=1,
|
||||
user_id="u",
|
||||
thread_visibility="PRIVATE", # type: ignore[arg-type]
|
||||
llm=MagicMock(),
|
||||
|
|
@ -165,12 +165,12 @@ class TestPluginContext:
|
|||
def test_build_includes_required_fields(self) -> None:
|
||||
llm = MagicMock()
|
||||
ctx = PluginContext.build(
|
||||
search_space_id=42,
|
||||
workspace_id=42,
|
||||
user_id="user-1",
|
||||
thread_visibility="PRIVATE", # type: ignore[arg-type]
|
||||
llm=llm,
|
||||
)
|
||||
assert ctx["search_space_id"] == 42
|
||||
assert ctx["workspace_id"] == 42
|
||||
assert ctx["user_id"] == "user-1"
|
||||
assert ctx["llm"] is llm
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.agents.chat.multi_agent_chat.main_agent.skills.backends import (
|
|||
SKILLS_BUILTIN_PREFIX,
|
||||
SKILLS_SPACE_PREFIX,
|
||||
BuiltinSkillsBackend,
|
||||
SearchSpaceSkillsBackend,
|
||||
WorkspaceSkillsBackend,
|
||||
build_skills_backend_factory,
|
||||
default_skills_sources,
|
||||
)
|
||||
|
|
@ -176,7 +176,7 @@ class _FakeKBBackend:
|
|||
return out
|
||||
|
||||
|
||||
class TestSearchSpaceSkillsBackend:
|
||||
class TestWorkspaceSkillsBackend:
|
||||
def test_remaps_paths_when_listing(self) -> None:
|
||||
listing = [
|
||||
{"path": "/documents/_skills/policy", "is_dir": True},
|
||||
|
|
@ -184,7 +184,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
{"path": "/documents/other-folder/x.md", "is_dir": False},
|
||||
]
|
||||
kb = _FakeKBBackend(listing=listing, file_contents={})
|
||||
backend = SearchSpaceSkillsBackend(kb)
|
||||
backend = WorkspaceSkillsBackend(kb)
|
||||
infos = asyncio.run(backend.als_info("/"))
|
||||
assert kb.last_ls_path == "/documents/_skills"
|
||||
paths = [info["path"] for info in infos]
|
||||
|
|
@ -200,7 +200,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
"/documents/_skills/policy/SKILL.md": b"---\nname: policy\n---\n",
|
||||
},
|
||||
)
|
||||
backend = SearchSpaceSkillsBackend(kb)
|
||||
backend = WorkspaceSkillsBackend(kb)
|
||||
responses = asyncio.run(backend.adownload_files(["/policy/SKILL.md"]))
|
||||
assert kb.last_download_paths == ["/documents/_skills/policy/SKILL.md"]
|
||||
assert responses[0].path == "/policy/SKILL.md"
|
||||
|
|
@ -208,7 +208,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
assert responses[0].content is not None
|
||||
|
||||
def test_sync_methods_raise_not_implemented(self) -> None:
|
||||
backend = SearchSpaceSkillsBackend(_FakeKBBackend([], {}))
|
||||
backend = WorkspaceSkillsBackend(_FakeKBBackend([], {}))
|
||||
with pytest.raises(NotImplementedError):
|
||||
backend.ls_info("/")
|
||||
with pytest.raises(NotImplementedError):
|
||||
|
|
@ -221,7 +221,7 @@ class TestSearchSpaceSkillsBackend:
|
|||
],
|
||||
file_contents={},
|
||||
)
|
||||
backend = SearchSpaceSkillsBackend(kb, kb_root="/skills_admin")
|
||||
backend = WorkspaceSkillsBackend(kb, kb_root="/skills_admin")
|
||||
infos = asyncio.run(backend.als_info("/"))
|
||||
assert kb.last_ls_path == "/skills_admin"
|
||||
assert infos[0]["path"] == "/x"
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ async def test_generate_resume_defaults_to_one_page_target(monkeypatch) -> None:
|
|||
monkeypatch.setattr(resume_tool, "_compile_typst", lambda _source: b"pdf")
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: 1)
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience"})
|
||||
|
||||
assert result["status"] == "ready"
|
||||
|
|
@ -178,7 +178,7 @@ async def test_generate_resume_compresses_when_over_limit(monkeypatch) -> None:
|
|||
page_counts = iter([2, 1])
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
|
||||
|
||||
assert result["status"] == "ready"
|
||||
|
|
@ -213,7 +213,7 @@ async def test_generate_resume_returns_ready_when_target_not_met(monkeypatch) ->
|
|||
page_counts = iter([3, 3, 2])
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
|
||||
|
||||
assert result["status"] == "ready"
|
||||
|
|
@ -246,7 +246,7 @@ async def test_generate_resume_fails_when_hard_limit_exceeded(monkeypatch) -> No
|
|||
page_counts = iter([7, 6, 6])
|
||||
monkeypatch.setattr(resume_tool, "_count_pdf_pages", lambda _pdf: next(page_counts))
|
||||
|
||||
tool = resume_tool.create_generate_resume_tool(search_space_id=1, thread_id=1)
|
||||
tool = resume_tool.create_generate_resume_tool(workspace_id=1, thread_id=1)
|
||||
result = await _invoke(tool, {"user_info": "Jane Doe experience", "max_pages": 1})
|
||||
|
||||
assert result["status"] == "failed"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue