refactor(agent): extract agent+shared into standalone packages with full GRACE semantic markup

- Move agent code from backend/src/agent/ to agent/src/ss_tools/agent/
- Extract shared stdlib-only utilities to shared/src/ss_tools/shared/
- Add #region/#endregion contracts to all ~140 functions (INV_1 compliance)
- Update docker files, entrypoint, build scripts for new package layout
- Backend now imports ss_tools.shared._llm_health (no gradio/langchain deps)
- Add specs for 036-039 feature plans
This commit is contained in:
2026-07-07 15:18:24 +03:00
parent ce368429f7
commit b95df37cd5
75 changed files with 2711 additions and 1861 deletions

View File

@@ -0,0 +1,490 @@
# agent/tests/agent/test_confirmation.py
# #region Test.AgentChat.Confirmation [C:3] [TYPE Module] [SEMANTICS test,agent,hitl,confirmation,integration]
# @BRIEF Integration tests for _confirmation.py — handle_resume fast-path, LLM formatting,
# build_confirmation_contract, metadata, and title race-condition coverage.
# @RELATION BINDS_TO -> [AgentChat.Confirmation]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import json
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── Helpers ─────────────────────────────────────────────────────────
def _json_chunks(chunks: list[str]) -> list[dict]:
"""Parse all JSON chunk strings into dicts."""
return [json.loads(c) for c in chunks]
async def _collect(generator):
"""Collect all items from an async generator into a list."""
return [item async for item in generator]
def _make_fake_llm_chunk(content: str):
"""Create a fake ChatOpenAI chunk with .content."""
chunk = MagicMock()
chunk.content = content
return chunk
# ── Fixtures ────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def clear_pending():
"""Clear _pending_confirmations before each test."""
from ss_tools.agent._confirmation import _pending_confirmations
_pending_confirmations.clear()
# ═══════════════════════════════════════════════════════════════════
# build_confirmation_contract
# ═══════════════════════════════════════════════════════════════════
# #region test_build_confirmation_contract [C:2] [TYPE Class]
# @BRIEF Test build_confirmation_contract for all risk levels.
class TestBuildConfirmationContract:
def test_safe_tool_returns_read_contract(self):
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("list_environments")
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
assert c["prompt"] == "Разрешить чтение данных?"
assert c["requires_confirmation"] is True
def test_dangerous_tool_returns_write_contract(self):
from ss_tools.agent._confirmation import build_confirmation_contract
# deploy_dashboard starts with "deploy" → guarded risk level in v1 contract
c = build_confirmation_contract("deploy_dashboard")
assert c["risk"] == "write"
assert c["risk_level"] == "guarded"
assert c["prompt"] == "Подтвердить изменение данных?"
def test_guarded_tool_returns_write_contract(self):
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("start_maintenance")
assert c["risk"] == "write"
assert c["risk_level"] == "guarded"
assert c["prompt"] == "Подтвердить изменение данных?"
def test_unknown_tool_returns_unknown_contract(self):
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract("some_unknown_tool")
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
assert c["prompt"] == "Разрешить чтение данных?"
def test_none_tool_name_returns_unknown(self):
from ss_tools.agent._confirmation import build_confirmation_contract
c = build_confirmation_contract(None)
assert c["operation"] == "unknown_action"
assert c["risk"] == "read"
assert c["risk_level"] == "safe"
# #endregion test_build_confirmation_contract
# ═══════════════════════════════════════════════════════════════════
# confirmation_metadata_for_tool
# ═══════════════════════════════════════════════════════════════════
# #region test_confirmation_metadata_for_tool [C:2] [TYPE Class]
# @BRIEF Test confirmation_metadata_for_tool output shape.
class TestConfirmationMetadataForTool:
def test_includes_all_required_fields(self):
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-42", "list_environments", {"env_id": "prod"})
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-42"
assert meta["tool_name"] == "list_environments"
assert meta["tool_args"] == {"env_id": "prod"}
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
assert "intent" in meta
assert meta["intent"]["operation"] == "list_environments"
def test_empty_args_defaults_to_empty_dict(self):
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments", None)
assert meta["tool_args"] == {}
def test_no_args_passed_defaults_to_empty_dict(self):
from ss_tools.agent._confirmation import confirmation_metadata_for_tool
meta = confirmation_metadata_for_tool("conv-1", "list_environments")
assert meta["tool_args"] == {}
# #endregion test_confirmation_metadata_for_tool
# ═══════════════════════════════════════════════════════════════════
# _format_tool_output_via_llm
# ═══════════════════════════════════════════════════════════════════
# #region test_format_tool_output [C:3] [TYPE Class]
# @BRIEF Integration tests for _format_tool_output_via_llm — LLM path and fallbacks.
class TestFormatToolOutput:
@pytest.mark.asyncio
async def test_empty_output_yields_placeholder(self):
from ss_tools.agent._confirmation import _format_tool_output_via_llm
chunks = await _collect(_format_tool_output_via_llm("test_tool", ""))
data = _json_chunks(chunks)
assert len(data) == 1
assert "нет данных" in data[0]["content"]
@pytest.mark.asyncio
async def test_whitespace_only_yields_placeholder(self):
from ss_tools.agent._confirmation import _format_tool_output_via_llm
chunks = await _collect(_format_tool_output_via_llm("test_tool", " "))
data = _json_chunks(chunks)
assert len(data) == 1
assert "нет данных" in data[0]["content"]
@pytest.mark.asyncio
async def test_llm_formatting_streams_tokens(self):
"""When LLM config exists, output is streamed through ChatOpenAI."""
from ss_tools.agent._confirmation import _format_tool_output_via_llm
fake_llm = MagicMock()
fake_llm.astream = MagicMock(return_value=_make_async_iter([
_make_fake_llm_chunk("Доступно"),
_make_fake_llm_chunk(" три"),
_make_fake_llm_chunk(" окружения."),
]))
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("ss_tools.agent._confirmation.ChatOpenAI", return_value=fake_llm):
mock_cfg.return_value = {
"configured": True,
"api_key": "sk-test",
"default_model": "gpt-4o-mini",
"base_url": "https://api.test.com/v1",
}
chunks = await _collect(
_format_tool_output_via_llm("list_environments", '[{"id":"ss-dev"}]')
)
data = _json_chunks(chunks)
assert len(data) == 3
assert data[0]["content"] == "Доступно"
assert data[1]["content"] == " три"
assert data[2]["content"] == " окружения."
for d in data:
assert d["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_fallback_when_llm_unavailable(self):
"""When fetch_llm_config returns None, fall back to prettified JSON."""
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = '[{"id":"ss-dev","name":"Dev"}]'
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config", return_value=None):
chunks = await _collect(_format_tool_output_via_llm("list_environments", raw))
data = _json_chunks(chunks)
assert len(data) == 1
# Should be prettified JSON (indent=2)
assert ' "' in data[0]["content"]
assert "ss-dev" in data[0]["content"]
assert data[0]["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_fallback_when_config_not_configured(self):
"""When config exists but configured=False, fall back to prettified JSON."""
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = '{"key": "value"}'
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config",
return_value={"configured": False}):
chunks = await _collect(_format_tool_output_via_llm("some_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
assert '"key"' in data[0]["content"]
@pytest.mark.asyncio
async def test_fallback_on_llm_exception(self):
"""When ChatOpenAI raises, fall back to prettified JSON."""
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = '[{"a": 1}]'
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
patch("ss_tools.agent._confirmation.ChatOpenAI") as mock_llm_cls:
mock_cfg.return_value = {
"configured": True,
"api_key": "sk-test",
"default_model": "gpt-4o-mini",
}
mock_llm_cls.side_effect = RuntimeError("LLM connection refused")
chunks = await _collect(_format_tool_output_via_llm("test_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
assert "a" in data[0]["content"]
@pytest.mark.asyncio
async def test_non_json_output_passed_through_raw(self):
"""Non-JSON output (plain text) is yielded as-is in fallback."""
from ss_tools.agent._confirmation import _format_tool_output_via_llm
raw = "Operation completed successfully"
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config", return_value=None):
chunks = await _collect(_format_tool_output_via_llm("test_tool", raw))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["content"] == raw
# #endregion test_format_tool_output
# ═══════════════════════════════════════════════════════════════════
# #region test_handle_resume_integration [C:3] [TYPE Class]
# @BRIEF Integration tests for handle_resume — fast-path confirm/deny, error paths,
# LLM formatting integration, and title race-condition coverage.
class TestHandleResumeIntegration:
@pytest.mark.asyncio
async def test_deny_yields_cancelled(self):
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
_pending_confirmations["conv-deny"] = {
"tool_name": "list_environments",
"tool_args": {},
}
chunks = await _collect(handle_resume("conv-deny", "deny"))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["metadata"]["type"] == "confirm_resolved"
assert data[0]["metadata"]["result"] == "denied"
assert "отменена" in data[0]["content"].lower()
# Pending is popped
assert "conv-deny" not in _pending_confirmations
@pytest.mark.asyncio
async def test_confirm_executes_tool_and_formats_via_llm(self):
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev","name":"Dev"}]')
_pending_confirmations["conv-confirm"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), \
patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_fmt:
async def _fake_fmt(tool_name, output):
yield json.dumps({
"content": f"SUMMARY: {tool_name} -> {output[:20]}",
"metadata": {"type": "stream_token", "token": "X"},
})
mock_fmt.side_effect = _fake_fmt
chunks = [c async for c in handle_resume("conv-confirm", "confirm")]
return chunks
chunks = await collect()
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "confirm_resolved" in types
assert "tool_start" in types
assert "tool_end" in types
assert "stream_token" in types
# Check tool_end has the output summary
tool_end = [d for d in data if d["metadata"]["type"] == "tool_end"]
assert len(tool_end) == 1
assert tool_end[0]["metadata"]["output"]["result"].startswith('[{"id":"ss-dev"')
# Check LLM formatting chunk
stream_tokens = [d for d in data if d["metadata"]["type"] == "stream_token"]
assert len(stream_tokens) >= 1
assert "SUMMARY" in stream_tokens[0]["content"]
# Pending is popped
assert "conv-confirm" not in _pending_confirmations
@pytest.mark.asyncio
async def test_confirm_unknown_tool_yields_error(self):
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
_pending_confirmations["conv-unknown"] = {
"tool_name": "nonexistent_tool_xyz",
"tool_args": {},
}
with patch("ss_tools.agent._confirmation.find_tool", return_value=None):
chunks = await _collect(handle_resume("conv-unknown", "confirm"))
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "confirm_resolved" in types
assert "tool_start" in types
assert "tool_error" in types
assert "stream_token" not in types, "No LLM output on tool error"
error_chunk = [d for d in data if d["metadata"]["type"] == "tool_error"]
assert len(error_chunk) == 1
assert "Unknown tool" in error_chunk[0]["metadata"]["error"]
@pytest.mark.asyncio
async def test_confirm_tool_invocation_failure_yields_error(self):
from ss_tools.agent._confirmation import handle_resume, _pending_confirmations
tool = MagicMock()
tool.ainvoke = AsyncMock(side_effect=RuntimeError("API timeout"))
_pending_confirmations["conv-fail"] = {
"tool_name": "list_environments",
"tool_args": {},
}
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool):
chunks = await _collect(handle_resume("conv-fail", "confirm"))
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "tool_error" in types
assert "tool_end" not in types, "No tool_end on failure"
assert "stream_token" not in types, "No LLM output on failure"
error_chunk = [d for d in data if d["metadata"]["type"] == "tool_error"]
assert len(error_chunk) == 1
assert "API timeout" in error_chunk[0]["metadata"]["error"]
@pytest.mark.asyncio
async def test_confirm_with_no_pending_falls_to_langgraph(self):
"""When no pending confirmation exists, handle_resume should fall through
to the LangGraph checkpoint resume path."""
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-no-pending", "confirm"))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["metadata"]["type"] == "confirm_resolved"
assert data[0]["metadata"]["result"] == "confirmed"
@pytest.mark.asyncio
async def test_deny_with_no_pending_returns_denied(self):
"""When no pending confirmation exists, deny also goes through
the LangGraph checkpoint path."""
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-no-pending", "deny"))
data = _json_chunks(chunks)
assert len(data) == 1
assert data[0]["metadata"]["type"] == "confirm_resolved"
assert data[0]["metadata"]["result"] == "denied"
@pytest.mark.asyncio
async def test_confirm_streams_langgraph_events_when_no_pending(self):
"""When no pending and LangGraph agent streams events, they are forwarded."""
from ss_tools.agent._confirmation import handle_resume
mock_chunk = MagicMock()
mock_chunk.content = "Hello from checkpoint"
mock_agent = MagicMock()
mock_agent.astream_events = MagicMock(return_value=_make_async_iter([
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
{"event": "on_tool_start", "name": "some_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "some_tool", "data": {"output": "ok"}},
]))
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), \
patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
chunks = await _collect(handle_resume("conv-stream", "confirm"))
data = _json_chunks(chunks)
types = [d["metadata"]["type"] for d in data]
assert "confirm_resolved" in types
assert "stream_token" in types
assert "tool_start" in types
assert "tool_end" in types
# #endregion test_handle_resume_integration
# ═══════════════════════════════════════════════════════════════════
# Title race-condition coverage
# ═══════════════════════════════════════════════════════════════════
# #region test_title_race_condition [C:2] [TYPE Class]
# @BRIEF Verify that agent_handler captures tool_name BEFORE handle_resume pops it,
# so the conversation title is descriptive (e.g. "✅ list_environments"), not
# the fallback "HITL: confirm".
class TestTitleRaceCondition:
@pytest.mark.asyncio
async def test_tool_name_read_before_pop(self):
"""Simulate the exact flow from agent_handler: capture tool_name from
_pending_confirmations BEFORE calling handle_resume (which pops it)."""
from ss_tools.agent._confirmation import _pending_confirmations
conv_id = "title-race-test"
_pending_confirmations[conv_id] = {
"tool_name": "list_environments",
"tool_args": {},
}
# Step 1: Capture BEFORE pop (this is what the fix does)
pending_pre = _pending_confirmations.get(conv_id, {})
tool_name = pending_pre.get("tool_name", "") if pending_pre else ""
assert tool_name == "list_environments", (
"tool_name must be readable BEFORE handle_resume pops it"
)
# Step 2: Pop (simulating handle_resume's internal pop)
_pending_confirmations.pop(conv_id, None)
# Step 3: After pop, accessing _pending_confirmations would give empty
pending_post = _pending_confirmations.get(conv_id, {})
assert pending_post == {}, "After pop, pending should be gone"
# But tool_name was already captured — title should be "✅ list_environments"
title = f"{tool_name}" if tool_name else "HITL: confirm"
assert title == "✅ list_environments", (
f"Title should use captured tool_name, got: {title}"
)
@pytest.mark.asyncio
async def test_race_condition_without_fix_would_fallback(self):
"""Demonstrate the bug: if tool_name is read AFTER pop, it's lost."""
from ss_tools.agent._confirmation import _pending_confirmations
conv_id = "race-bug-test"
_pending_confirmations[conv_id] = {
"tool_name": "get_health_summary",
"tool_args": {},
}
# Pop first (bad order — this is what the old code did)
_pending_confirmations.pop(conv_id, None)
# THEN try to read tool_name (too late!)
pending_post = _pending_confirmations.get(conv_id, {})
tool_name = pending_post.get("tool_name", "") if pending_post else ""
assert tool_name == "", "tool_name should be empty after pop — this IS the bug"
title = f"{tool_name}" if tool_name else "HITL: confirm"
assert title == "HITL: confirm", "Without the fix, title falls back to generic"
# #endregion test_title_race_condition
# ── Helper for async iter ─────────────────────────────────────────
def _make_async_iter(items):
"""Create an async iterator from a list."""
class AsyncIter:
def __init__(self, items):
self._iter = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
return AsyncIter(items)
# #endregion Test.AgentChat.Confirmation

View File

@@ -0,0 +1,561 @@
# #region Test.Agent.SupersetTools [C:3] [TYPE Module] [SEMANTICS test,agent,tools,superset,sql,audit,dashboard,dataset]
# @BRIEF Integration tests for new Superset agent tools: SQL, explore DB, audit, create/copy dashboard, create dataset, format SQL.
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @RELATION BINDS_TO -> [AgentChat.ToolResolver]
# @TEST_EDGE: safe_sql -> superset_execute_sql with SELECT passes
# @TEST_EDGE: dangerous_sql -> superset_execute_sql with DROP returns error
# @TEST_EDGE: external_fail -> HTTP error returns error string
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import httpx
# ── Helpers ──
def _mock_httpx_response(status_code=200, json_data=None, text=""):
"""Build a mock httpx.Response with given status, JSON, and text."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.text = text
resp.json = MagicMock(return_value=json_data or {})
return resp
# ═══════════════════════════════════════════════════════════════════
# Tool name registration
# ═══════════════════════════════════════════════════════════════════
class TestToolRegistration:
"""Test that new tools are registered in get_all_tools() and classification sets."""
def test_all_new_tools_registered(self):
"""All 7 new tools appear in get_all_tools()."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
expected = {
"superset_execute_sql",
"superset_explore_database",
"superset_audit_permissions",
"superset_create_dashboard",
"superset_copy_dashboard",
"superset_create_dataset",
"superset_format_sql",
}
assert expected.issubset(tool_names), f"Missing: {expected - tool_names}"
@pytest.mark.skip(reason="_SAFE_AGENT_TOOLS removed during 035 refactoring — needs test rewrite for new tool registry API")
def test_tool_resolver_sets_contain_new_tools(self):
"""Classification sets in _tool_resolver.py contain the new tools."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent._tool_resolver import (
_SAFE_AGENT_TOOLS,
_GUARDED_AGENT_TOOLS,
_FAST_CONFIRM_TOOLS,
)
assert "superset_execute_sql" in _SAFE_AGENT_TOOLS
assert "superset_explore_database" in _SAFE_AGENT_TOOLS
assert "superset_audit_permissions" in _SAFE_AGENT_TOOLS
assert "superset_format_sql" in _SAFE_AGENT_TOOLS
assert "superset_create_dashboard" in _GUARDED_AGENT_TOOLS
assert "superset_copy_dashboard" in _GUARDED_AGENT_TOOLS
assert "superset_create_dataset" in _GUARDED_AGENT_TOOLS
assert "superset_explore_database" in _FAST_CONFIRM_TOOLS
assert "superset_audit_permissions" in _FAST_CONFIRM_TOOLS
assert "superset_format_sql" in _FAST_CONFIRM_TOOLS
# ═══════════════════════════════════════════════════════════════════
# Intent matching (get_tools_for_query)
# ═══════════════════════════════════════════════════════════════════
class TestIntentMatching:
"""Test get_tools_for_query matches intent keywords for new tools."""
pytestmark = pytest.mark.skip(reason="get_tools_for_query removed during 035 refactoring — needs test rewrite for new tool pipeline API")
def _get_for_query(self, query):
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_tools_for_query
return {t.name for t in get_tools_for_query(query)}
def test_sql_intent(self):
tools = self._get_for_query("run a SQL query on users")
assert "superset_execute_sql" in tools
def test_schema_intent(self):
tools = self._get_for_query("list all schemas in the database")
assert "superset_explore_database" in tools
def test_table_intent(self):
tools = self._get_for_query("show me the tables in public schema")
assert "superset_explore_database" in tools
def test_audit_intent(self):
tools = self._get_for_query("audit user permissions access rights")
assert "superset_audit_permissions" in tools
def test_create_dashboard_intent(self):
tools = self._get_for_query("create a new dashboard called Sales")
assert "superset_create_dashboard" in tools
def test_copy_dashboard_intent(self):
tools = self._get_for_query("copy dashboard 42")
assert "superset_copy_dashboard" in tools
def test_create_dataset_intent(self):
tools = self._get_for_query("create a new dataset for orders table")
assert "superset_create_dataset" in tools
def test_format_sql_intent(self):
# The intent matcher looks for exact phrase "format sql" in text
tools = self._get_for_query("please format sql for me")
assert "superset_format_sql" in tools
def test_no_match_returns_defaults(self):
tools = self._get_for_query("hello how are you")
assert "show_capabilities" in tools
assert "search_dashboards" in tools
# ═══════════════════════════════════════════════════════════════════
# superset_execute_sql
# ═══════════════════════════════════════════════════════════════════
class TestSupersetExecuteSql:
"""Test superset_execute_sql agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_safe_sql_execution(self):
"""Safe SELECT returns API result."""
mock_resp = _mock_httpx_response(200, text='{"status": "success", "data": []}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
})
assert "success" in result
@pytest.mark.asyncio
async def test_dangerous_sql_returns_error(self):
"""Dangerous SQL returns error from API (guard is server-side)."""
mock_resp = _mock_httpx_response(200, text='{"error": "Dangerous SQL rejected"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "DROP TABLE users",
})
assert "Dangerous" in result
@pytest.mark.asyncio
async def test_optional_schema(self):
"""Schema parameter is passed when provided."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from ss_tools.agent.tools import superset_execute_sql
await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
"query_schema": "public",
})
assert "schema" in mock_post.call_args[1]["params"]
@pytest.mark.asyncio
async def test_http_error(self):
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Server Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
})
assert "Error 500" in result
# ═══════════════════════════════════════════════════════════════════
# superset_explore_database
# ═══════════════════════════════════════════════════════════════════
class TestSupersetExploreDatabase:
"""Test superset_explore_database agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_list_schemas(self):
"""Action 'schemas' GETs /api/agent/superset/databases/{id}/schemas."""
mock_resp = _mock_httpx_response(200, text='["public", "analytics"]')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "schemas",
})
assert "public" in result
@pytest.mark.asyncio
async def test_list_tables(self):
"""Action 'tables' GETs /api/agent/superset/databases/{id}/tables."""
mock_resp = _mock_httpx_response(200, text="table1, table2")
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "tables",
"schema_name": "public",
})
assert "table" in result
@pytest.mark.asyncio
async def test_table_metadata(self):
"""Action 'table_metadata' GETs metadata endpoint."""
mock_resp = _mock_httpx_response(200, text='{"columns": []}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "table_metadata",
"table_name": "users",
})
assert "columns" in result
@pytest.mark.asyncio
async def test_select_star(self):
"""Action 'select_star' GETs select_star endpoint."""
mock_resp = _mock_httpx_response(200, text='"SELECT * FROM users"')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "select_star",
"table_name": "users",
})
assert "SELECT" in result
@pytest.mark.asyncio
async def test_tables_missing_schema_returns_error(self):
"""Missing schema_name for 'tables' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "tables",
})
assert "Error" in result
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_table_metadata_missing_table_name_returns_error(self):
"""Missing table_name for 'table_metadata' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "table_metadata",
})
assert "Error" in result
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_unknown_action_returns_error(self):
"""Unknown action returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from ss_tools.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "invalid_action",
})
assert "unknown action" in result.lower()
mock_get.assert_not_called()
# ═══════════════════════════════════════════════════════════════════
# superset_audit_permissions
# ═══════════════════════════════════════════════════════════════════
class TestSupersetAuditPermissions:
"""Test superset_audit_permissions agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_basic_audit(self):
"""Permissions audit GETs /api/agent/superset/audit/permissions."""
mock_resp = _mock_httpx_response(200, text='{"users": [], "total_users": 0}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_audit_permissions
result = await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
assert "total_users" in result
@pytest.mark.asyncio
async def test_audit_with_filters(self):
"""Username filter and include_admin are passed as query params."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from ss_tools.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
"username_filter": "alice",
"include_admin": True,
})
call_params = mock_get.call_args[1]["params"]
assert call_params["username_filter"] == "alice"
assert call_params["include_admin"] == "true"
@pytest.mark.asyncio
async def test_audit_without_filters(self):
"""Without filters, no additional params beyond environment_id."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from ss_tools.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
call_params = mock_get.call_args[1]["params"]
assert "username_filter" not in call_params
assert "include_admin" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_create_dashboard
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCreateDashboard:
"""Test superset_create_dashboard agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_create_dashboard(self):
"""Creates dashboard via POST to /api/agent/superset/dashboards."""
mock_resp = _mock_httpx_response(201, text='{"id": 1, "dashboard_title": "Sales"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_create_dashboard
result = await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Sales",
"slug": "sales",
"published": True,
})
assert "Sales" in result
@pytest.mark.asyncio
async def test_create_dashboard_minimal(self):
"""Minimal create with just title."""
mock_resp = _mock_httpx_response(201, text='{"id": 2}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from ss_tools.agent.tools import superset_create_dashboard
await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Minimal",
})
call_params = mock_post.call_args[1]["params"]
assert call_params["dashboard_title"] == "Minimal"
assert call_params["published"] == "false"
# ═══════════════════════════════════════════════════════════════════
# superset_copy_dashboard
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCopyDashboard:
"""Test superset_copy_dashboard agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_copy_dashboard(self):
"""Copies dashboard via POST to /api/agent/superset/dashboards/{id}/copy."""
mock_resp = _mock_httpx_response(201, text='{"id": 2, "result": "copied"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_copy_dashboard
result = await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
"dashboard_title": "Copy of Sales",
})
assert "copied" in result
@pytest.mark.asyncio
async def test_copy_dashboard_without_title(self):
"""Copy without optional title."""
mock_resp = _mock_httpx_response(201, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from ss_tools.agent.tools import superset_copy_dashboard
await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
})
call_params = mock_post.call_args[1]["params"]
assert "dashboard_title" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_create_dataset
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCreateDataset:
"""Test superset_create_dataset agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_create_dataset(self):
"""Creates dataset via POST to /api/agent/superset/datasets."""
mock_resp = _mock_httpx_response(201, text='{"id": 5, "table_name": "orders"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_create_dataset
result = await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "orders",
"database": 1,
"schema_name": "public",
})
assert "orders" in result
@pytest.mark.asyncio
async def test_create_dataset_minimal(self):
"""Minimal create without schema."""
mock_resp = _mock_httpx_response(201, text='{"id": 6}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from ss_tools.agent.tools import superset_create_dataset
await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "users",
"database": 1,
})
call_params = mock_post.call_args[1]["params"]
assert "schema_name" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_format_sql
# ═══════════════════════════════════════════════════════════════════
class TestSupersetFormatSql:
"""Test superset_format_sql agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("ss_tools.agent.tools.logger", MagicMock()), \
patch("ss_tools.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("ss_tools.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("ss_tools.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_format_sql(self):
"""Formats SQL via POST to /api/agent/superset/sqllab/format."""
mock_resp = _mock_httpx_response(200, text="SELECT\n 1\nFROM\n users\n")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "SELECT 1 FROM users",
})
assert "SELECT" in result
@pytest.mark.asyncio
async def test_format_sql_error(self):
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from ss_tools.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "INVALID",
})
assert "Error 500" in result
# ═══════════════════════════════════════════════════════════════════
# Tool Resolver inference
# ═══════════════════════════════════════════════════════════════════
class TestToolResolverInference:
"""Test _tool_resolver.py inference with new Superset tool patterns."""
pytestmark = pytest.mark.skip(reason="infer_tool_from_text removed during 035 refactoring — needs test rewrite for new resolver API")
def test_infer_from_text_sql(self):
"""SQL-related query infers superset_execute_sql."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent._tool_resolver import infer_tool_from_text
# The resolver doesn't currently have SQL inference — verify fallback
# but ensure it's extended if needed in the future
result = infer_tool_from_text("sql select query users")
# No specific SQL inference yet — returns None or base tool
# This test documents current behavior; update if resolver is extended
assert result is None or isinstance(result, str)
# #endregion Test.Agent.SupersetTools

View File

@@ -0,0 +1,129 @@
# agent/tests/agent/test_title_cleaning.py
# #region Test.Agent.Persistence.CleanTitle [C:2] [TYPE Module] [SEMANTICS test,agent,persistence,title]
# @BRIEF Unit tests for rule-based conversation title cleaning — all edge cases.
# @RELATION BINDS_TO -> [AgentChat.Persistence.CleanTitle]
# @TEST_EDGE: empty_input -> "Новый диалог"
# @TEST_EDGE: whitespace_only -> "Новый диалог"
# @TEST_EDGE: file_markers -> stripped before first marker
# @TEST_EDGE: prefetch_markers -> stripped before first marker
# @TEST_EDGE: hitl_title -> preserved as-is
# @TEST_EDGE: json_input -> "Данные: {...}"
# @TEST_EDGE: url_input -> domain extracted
# @TEST_EDGE: long_text -> truncated at 80 with word boundary
# @TEST_EDGE: code_input -> first line preserved
import pytest
from ss_tools.agent._persistence import clean_title
class TestCleanTitle:
"""Rule-based title cleaning — all edge cases from the spec matrix."""
# ── Empty / whitespace ──
def test_empty_string(self):
assert clean_title("") == "Новый диалог"
def test_whitespace_only(self):
assert clean_title(" \n \t ") == "Новый диалог"
def test_none_input(self):
assert clean_title(None) == "Новый диалог"
# ── Short / normal ──
def test_short_greeting(self):
assert clean_title("Привет") == "Привет"
def test_emoji_only(self):
assert clean_title("🔥") == "🔥"
def test_normal_russian(self):
assert clean_title("Покажи доступные дашборды") == "Покажи доступные дашборды"
def test_normal_english(self):
assert clean_title("Show me all dashboards in prod") == "Show me all dashboards in prod"
# ── File markers ──
def test_file_marker_stripped(self):
result = clean_title("Покажи дашборды\n--- Uploaded file content ---\nid,name\n1,A")
assert result == "Покажи дашборды"
def test_only_file_marker(self):
result = clean_title("--- Uploaded file content ---\nid,name\n1,A")
assert result == "Новый диалог"
# ── Pre-fetch markers ──
def test_prefetch_marker_stripped(self):
result = clean_title("Мигрируй 42\n[PRE-FETCHED DATA]\nAvailable dashboards...")
assert result == "Мигрируй 42"
def test_prefetch_end_marker_stripped(self):
result = clean_title("Поиск\n[/PRE-FETCHED DATA]\nextra")
assert result == "Поиск"
# ── HITL titles (preserved) ──
def test_hitl_confirm_preserved(self):
assert clean_title("✅ get_health_summary") == "✅ get_health_summary"
def test_hitl_deny_preserved(self):
assert clean_title("⏹️ show_capabilities") == "⏹️ show_capabilities"
# ── JSON / CSV / URL detection ──
def test_json_input(self):
result = clean_title('{"id": 1, "name": "Dashboard A"}')
assert result.startswith("Данные: ")
def test_csv_input(self):
result = clean_title("id,name,status\n1,Dashboard A,active")
# CSV under 80 chars, first line kept as-is
assert result == "id,name,status"
def test_url_input(self):
result = clean_title("https://superset.example.com/dashboard/42")
# Should extract domain
assert "superset.example.com" in result
# ── Sentence cut ──
def test_cut_at_first_sentence(self):
result = clean_title(
"Чтобы проанализировать систему, мне нужно проверить health. "
"Для начала покажи список дашбордов."
)
assert "Чтобы проанализировать систему, мне нужно проверить health." in result
assert "Для начала" not in result
def test_cut_at_exclamation(self):
result = clean_title("Срочно! Проверь статус системы.")
assert result == "Срочно!"
def test_cut_at_question(self):
result = clean_title("Как работает миграция? Нужно подробное описание.")
assert result == "Как работает миграция?"
# ── Long text truncation ──
def test_long_text_truncated(self):
long_text = "Это очень длинное сообщение про миграцию дашбордов из окружения dev в prod с заменой конфигов и фиксом кросс-фильтров"
result = clean_title(long_text)
assert len(result) <= 80
assert result.endswith("")
def test_long_text_no_spaces(self):
result = clean_title("A" * 200)
assert len(result) <= 80
# ── Code detection ──
def test_code_def(self):
result = clean_title("def migrate(): pass\n\nMore text here")
assert result == "def migrate(): pass"
def test_code_import(self):
result = clean_title("import os\nimport sys\n\nShow dashboards")
assert result == "import os"
# ── Already clean ──
def test_already_clean_title(self):
assert clean_title("CSV-файл: Анализ дашбордов") == "CSV-файл: Анализ дашбордов"
# ── Edge: no dot at end ──
def test_single_sentence_no_punctuation(self):
result = clean_title("Проверь статус системы ss-dev")
assert result == "Проверь статус системы ss-dev"
# #endregion Test.Agent.Persistence.CleanTitle

View File

@@ -0,0 +1,241 @@
# agent/tests/test_agent/test_agent_confirmation_v2.py
# #region Test.Agent.ConfirmationV2 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,confirmation,guardrails,hitl]
# @BRIEF Contract tests for confirmation_metadata_for_tool and build_confirmation_contract_v2 — three-axis risk, env resolution, permission denied.
# @RELATION BINDS_TO -> [AgentChat.Confirmation]
# @TEST_FIXTURE: deploy_prod -> guarded + prod env_context
# @TEST_FIXTURE: deploy_staging -> guarded + staging env_context
# @TEST_FIXTURE: deploy_dev -> guarded + dev env_context
# @TEST_FIXTURE: delete_any -> dangerous risk_level
# @TEST_FIXTURE: read_only -> safe risk_level
# @TEST_FIXTURE: analyst_deploy -> permission_granted=false
# @TEST_FIXTURE: env_resolution -> tool_args.env_id > target_env > None
from ss_tools.agent._confirmation import (
_resolve_env_tier,
build_confirmation_contract_v2,
confirmation_metadata_for_tool,
permission_denied_payload,
)
# ── _resolve_env_tier ───────────────────────────────────────────────
# #region test_env_resolution_from_tool_args [C:2] [TYPE Function]
def test_resolve_env_tier_from_tool_args_env_id():
"""env_id in tool_args takes highest priority."""
assert _resolve_env_tier({"env_id": "prod-01"}, None) == "prod"
assert _resolve_env_tier({"env_id": "prod-01"}, "staging") == "prod" # tool_args wins
# #endregion test_env_resolution_from_tool_args
# #region test_env_resolution_from_environment_id [C:2] [TYPE Function]
def test_resolve_env_tier_from_environment_id():
"""environment_id in tool_args (alternative key) works."""
assert _resolve_env_tier({"environment_id": "staging-v2"}, None) == "staging"
# #endregion test_env_resolution_from_environment_id
# #region test_env_resolution_from_target_env [C:2] [TYPE Function]
def test_resolve_env_tier_from_target_env():
"""target_env fallback when tool_args has no env."""
assert _resolve_env_tier({}, "ss-dev") == "dev"
assert _resolve_env_tier({"query": "test"}, "prod-v2") == "prod"
# #endregion test_env_resolution_from_target_env
# #region test_env_resolution_null_when_no_env [C:2] [TYPE Function]
def test_resolve_env_tier_null_when_no_env():
"""When neither tool_args nor target_env provides env, return None."""
assert _resolve_env_tier({}, None) is None
assert _resolve_env_tier({"dashboard_id": 42}, None) is None
# #endregion test_env_resolution_null_when_no_env
# #region test_env_resolution_ambiguous_names [C:2] [TYPE Function]
def test_resolve_env_tier_ambiguous_names():
"""Environment names containing stag/test/local/dev are correctly tiered."""
assert _resolve_env_tier({"env_id": "autotest"}, None) == "staging" # "test" in autotest
assert _resolve_env_tier({"env_id": "localhost"}, None) == "dev" # "local" in localhost
# #endregion test_env_resolution_ambiguous_names
# ── build_confirmation_contract_v2 ───────────────────────────────────
# #region test_deploy_to_prod_is_guarded_with_prod_context [C:2] [TYPE Function]
def test_deploy_to_prod_is_guarded_with_prod_context():
"""Deploy to production → guarded risk + prod env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "prod-01"}, "admin", "prod-01",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["dangerous"] is False
assert contract["env_context"] == "prod"
assert contract["permission_granted"] is True
# #endregion test_deploy_to_prod_is_guarded_with_prod_context
# #region test_deploy_to_staging_is_guarded_with_staging_context [C:2] [TYPE Function]
def test_deploy_to_staging_is_guarded_with_staging_context():
"""Deploy to staging → guarded risk + staging env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "staging-v2"}, "admin", "staging-v2",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "staging"
# #endregion test_deploy_to_staging_is_guarded_with_staging_context
# #region test_deploy_to_dev_is_guarded_with_dev_context [C:2] [TYPE Function]
def test_deploy_to_dev_is_guarded_with_dev_context():
"""Deploy to dev → guarded risk + dev env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "my-dev-env"}, "admin", "my-dev-env",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "dev"
# #endregion test_deploy_to_dev_is_guarded_with_dev_context
# #region test_delete_operation_is_dangerous [C:2] [TYPE Function]
def test_delete_operation_is_dangerous():
"""Delete-prefixed tools should be classified as dangerous."""
contract = build_confirmation_contract_v2(
"delete_dashboard", {}, "admin",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "dangerous"
assert contract["dangerous"] is True
# #endregion test_delete_operation_is_dangerous
# #region test_read_only_tool_is_safe [C:2] [TYPE Function]
def test_read_only_tool_is_safe():
"""Search/list/get tools should be classified as safe/read."""
contract = build_confirmation_contract_v2(
"search_dashboards", {"env_id": "prod"}, "admin", "prod",
)
assert contract["risk"] == "read"
assert contract["risk_level"] == "safe"
assert contract["dangerous"] is False
# #endregion test_read_only_tool_is_safe
# #region test_viewer_gets_permission_denied [C:2] [TYPE Function]
def test_viewer_permission_denied_for_write_tools():
"""Viewer role should be denied permission for write tools."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "prod"}, "viewer", "prod",
)
assert contract["permission_granted"] is False
assert contract["required_role"] == "admin"
assert contract["alternatives"] is not None
assert len(contract["alternatives"]) >= 1
# #endregion test_viewer_gets_permission_denied
# #region test_analyst_permission_denied_specific_tools [C:2] [TYPE Function]
def test_analyst_permission_denied_for_admin_tools():
"""Analyst/editor role should be denied for admin-only tools."""
for tool_name in ("deploy_dashboard", "execute_migration", "run_backup"):
contract = build_confirmation_contract_v2(tool_name, {}, "analyst")
assert contract["permission_granted"] is False, f"{tool_name} should be denied for analyst"
# #endregion test_analyst_permission_denied_specific_tools
# #region test_admin_write_tool_permission_granted [C:2] [TYPE Function]
def test_admin_write_tool_permission_granted():
"""Admin role should always have permission_granted=True for guarded tools."""
write_tools = ["deploy_dashboard", "commit_changes", "execute_migration"]
for tool_name in write_tools:
contract = build_confirmation_contract_v2(tool_name, {}, "admin")
assert contract["permission_granted"] is True, f"{tool_name} should be allowed for admin"
# #endregion test_admin_write_tool_permission_granted
# ── confirmation_metadata_for_tool ────────────────────────────────────
# #region test_metadata_for_tool_contains_required_fields [C:2] [TYPE Function]
def test_metadata_for_tool_contains_all_required_fields():
"""confirmation_metadata_for_tool should produce a complete metadata dict."""
meta = confirmation_metadata_for_tool(
"conv-123", "deploy_dashboard",
{"env_id": "prod-01"}, "admin", "prod-01",
)
required_fields = [
"type", "thread_id", "prompt", "tool_name", "tool_args",
"risk", "risk_level", "requires_confirmation",
"dangerous", "env_context",
]
for field in required_fields:
assert field in meta, f"Missing field '{field}' in metadata"
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-123"
assert meta["tool_name"] == "deploy_dashboard"
assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded"
assert meta["requires_confirmation"] is True
# #endregion test_metadata_for_tool_contains_required_fields
# ── permission_denied_payload ─────────────────────────────────────────
# #region test_permission_denied_payload_structure [C:2] [TYPE Function]
def test_permission_denied_payload_structure():
"""permission_denied_payload should produce valid JSON with correct type."""
import json
payload_str = permission_denied_payload("deploy_dashboard", "admin", "viewer")
payload = json.loads(payload_str)
assert payload["content"] == "⛔ Недостаточно прав для deploy_dashboard"
assert payload["metadata"]["type"] == "permission_denied"
assert payload["metadata"]["tool_name"] == "deploy_dashboard"
assert payload["metadata"]["required_role"] == "admin"
assert payload["metadata"]["user_role"] == "viewer"
assert payload["metadata"]["alternatives"] == []
# #endregion test_permission_denied_payload_structure
# #region test_permission_denied_payload_with_alternatives [C:2] [TYPE Function]
def test_permission_denied_payload_with_alternatives():
"""Alternatives list should be preserved in the payload."""
import json
alternatives = [{"action": "get_health_summary", "prompt": "Check system health"}]
payload_str = permission_denied_payload(
"deploy_dashboard", "admin", "viewer", alternatives,
)
payload = json.loads(payload_str)
assert payload["metadata"]["alternatives"] == alternatives
# #endregion test_permission_denied_payload_with_alternatives
# ── Unknown/null tool ─────────────────────────────────────────────────
# #region test_null_tool_name_handled [C:2] [TYPE Function]
def test_null_tool_name_handled_gracefully():
"""Null tool_name should produce 'unknown_action' fallback."""
contract = build_confirmation_contract_v2(None)
assert contract["operation"] == "unknown_action"
assert contract["risk_level"] == "safe"
# #endregion test_null_tool_name_handled
# #region test_execute_migration_is_guarded [C:2] [TYPE Function]
def test_execute_migration_is_guarded():
"""execute_migration should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("execute_migration", {})
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
# #endregion test_execute_migration_is_guarded
# #region test_commit_changes_is_guarded [C:2] [TYPE Function]
def test_commit_changes_is_guarded():
"""commit_changes should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("commit_changes", {})
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
# #endregion test_commit_changes_is_guarded
# #endregion Test.Agent.ConfirmationV2

View File

@@ -0,0 +1,218 @@
# agent/tests/test_agent/test_agent_context.py
# #region Test.Agent.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,validation,uicontext]
# @BRIEF Contract tests for UIContext validation — enum checks, size limits, field lengths, boundary values.
# @RELATION BINDS_TO -> [AgentChat.Context.Validate]
# @TEST_FIXTURE: null -> returns {}
# @TEST_FIXTURE: dashboard+id+name -> passes
# @TEST_FIXTURE: malformed_objectType -> raises UIContextValidationError
# @TEST_FIXTURE: objectName>256 -> raises
# @TEST_FIXTURE: oversized>4KB -> raises
# @TEST_FIXTURE: contextVersion!=1 -> raises
# @TEST_FIXTURE: dataset_context -> passes
# @TEST_FIXTURE: migration_context -> passes
# @TEST_FIXTURE: non_numeric_objectId -> raises
# @TEST_FIXTURE: empty_route -> passes
import pytest
from ss_tools.agent._context import UIContextValidationError, validate_uicontext
# #region test_null_payload_returns_empty [C:2] [TYPE Function]
def test_null_payload_returns_empty_dict():
"""Null payloads should safely return an empty dict."""
assert validate_uicontext(None) == {}
# #endregion test_null_payload_returns_empty
# #region test_valid_dashboard_context_passes [C:2] [TYPE Function]
def test_valid_dashboard_context_passes():
"""Full dashboard UIContext with all fields should validate cleanly."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "Energy Report",
"envId": "ss-dev",
"route": "/dashboards/42",
"contextVersion": 1,
}
result = validate_uicontext(payload)
assert result == payload
# #endregion test_valid_dashboard_context_passes
# #region test_valid_dataset_context_passes [C:2] [TYPE Function]
def test_valid_dataset_context_passes():
"""Dataset UIContext should pass validation."""
payload = {
"objectType": "dataset",
"objectId": "15",
"objectName": None,
"envId": "staging",
"route": "/datasets/15",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_valid_dataset_context_passes
# #region test_valid_migration_context_passes [C:2] [TYPE Function]
def test_valid_migration_context_passes():
"""Migration UIContext should pass validation."""
payload = {
"objectType": "migration",
"objectId": None,
"objectName": None,
"envId": None,
"route": "/migrations",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_valid_migration_context_passes
# #region test_invalid_object_type_raises [C:2] [TYPE Function]
def test_invalid_object_type_raises_validation_error():
"""Unknown objectType values should be rejected."""
payload = {
"objectType": "chart",
"objectId": "42",
"envId": "ss-dev",
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectType"):
validate_uicontext(payload)
# #endregion test_invalid_object_type_raises
# #region test_invalid_object_id_raises [C:2] [TYPE Function]
def test_non_numeric_object_id_raises():
"""ObjectId must be a numeric string or None."""
payload = {
"objectType": "dashboard",
"objectId": "abc-not-a-number",
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectId"):
validate_uicontext(payload)
# #endregion test_invalid_object_id_raises
# #region test_object_name_exceeds_limit_raises [C:2] [TYPE Function]
def test_object_name_exceeds_256_chars_raises():
"""ObjectName longer than 256 characters should be rejected."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "A" * 257,
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectName"):
validate_uicontext(payload)
# #endregion test_object_name_exceeds_limit_raises
# #region test_object_name_at_boundary_passes [C:2] [TYPE Function]
def test_object_name_at_256_chars_passes():
"""ObjectName at exactly 256 characters should pass."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "A" * 256,
"route": "/dashboards/42",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_object_name_at_boundary_passes
# #region test_invalid_context_version_raises [C:2] [TYPE Function]
def test_invalid_context_version_raises():
"""Only contextVersion=1 is currently supported."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
"contextVersion": 2,
}
with pytest.raises(UIContextValidationError, match="contextVersion"):
validate_uicontext(payload)
# #endregion test_invalid_context_version_raises
# #region test_missing_context_version_raises [C:2] [TYPE Function]
def test_missing_context_version_raises():
"""ContextVersion should always be present and equal 1."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
}
with pytest.raises(UIContextValidationError):
validate_uicontext(payload)
# #endregion test_missing_context_version_raises
# #region test_payload_exceeds_4kb_raises [C:2] [TYPE Function]
def test_payload_exceeds_4kb_raises():
"""Payloads larger than 4 KB should be rejected to prevent prompt injection."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
"contextVersion": 1,
# Create a field that pushes the serialized JSON over 4096 bytes
"padding": "X" * 4096,
}
with pytest.raises(UIContextValidationError, match="exceeds 4 KB"):
validate_uicontext(payload)
# #endregion test_payload_exceeds_4kb_raises
# #region test_route_exceeds_512_chars_raises [C:2] [TYPE Function]
def test_route_exceeds_512_chars_raises():
"""Route length should be capped at 512 characters."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/" + "a" * 512,
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="route"):
validate_uicontext(payload)
# #endregion test_route_exceeds_512_chars_raises
# #region test_env_id_none_and_string_accepted [C:2] [TYPE Function]
def test_env_id_none_and_string_accepted():
"""envId should accept None and string values."""
assert validate_uicontext({
"objectType": "dashboard", "objectId": "42",
"envId": None, "route": "/dashboards/42", "contextVersion": 1,
})["envId"] is None
assert validate_uicontext({
"objectType": "dashboard", "objectId": "42",
"envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1,
})["envId"] == "ss-dev"
# #endregion test_env_id_none_and_string_accepted
# #region test_without_object_type_passes [C:2] [TYPE Function]
def test_no_object_type_with_env_passes():
"""Context without objectType (general mode) should pass validation."""
payload = {
"objectType": None,
"objectId": None,
"envId": "ss-dev",
"route": "/settings",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_without_object_type_passes
# #endregion Test.Agent.Context

View File

@@ -0,0 +1,274 @@
# #region TestAgentChat.Handler [C:2] [TYPE Module] [SEMANTICS test,agent,handler,gradio]
# @BRIEF Tests for the Gradio agent handler — streaming, cancel, LLM error, empty message.
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
import os
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
# Set AUTH_SECRET_KEY and LLM_API_KEY for tests (match conftest)
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("ss_tools.agent.app.save_conversation", new_callable=AsyncMock):
yield
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
def _empty_agent_state():
state = MagicMock(spec_set=["next"])
state.next = ()
return state
# #region TestAgentChat.Handler.EmptyMessage [C:2] [TYPE Function] [SEMANTICS test,handler,empty]
# @BRIEF Empty message returns immediately without calling LangGraph.
# @TEST_EDGE empty_text, empty_with_files_none
@pytest.mark.anyio
async def test_handler_empty_message_returns_immediately():
"""An empty message should return immediately without calling the graph."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
# Patch create_agent to avoid OpenAI init
with patch("ss_tools.agent.langgraph_setup.create_agent"):
# Empty message
message = {"text": "", "files": None}
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
# Empty text passes auth but should not start a graph
assert len(results) == 0, "Empty message should yield no chunks"
# create_agent should NOT be called for empty messages
# (it gets called currently — future optimization)
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
# #endregion TestAgentChat.Handler.EmptyMessage
# #region TestAgentChat.Handler.AuthGraceful [C:2] [TYPE Function] [SEMANTICS test,handler,auth]
# @BRIEF Missing or invalid JWT does NOT reject — Gradio handler forwards to graph (auth at tool layer).
# @TEST_EDGE missing_auth, invalid_token
# @RATIONALE Per design, @gradio/client does not forward Authorization headers, so the Gradio handler
# does NOT enforce JWT. Missing/invalid JWT falls back to anonymous context.
# Tool-level auth is enforced via SERVICE_JWT + X-User-JWT dual identity pattern.
@pytest.mark.anyio
async def test_handler_missing_auth_continues_gracefully():
"""Missing authorization header does NOT yield UNAUTHORIZED — handler continues."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
mock_request.headers = {}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
# Patch create_agent to prevent LLM call — handler should proceed without JWT
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
"""Empty async generator — yields nothing."""
return
yield # make it a generator
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
# No UNAUTHORIZED error — handler proceeds with empty user_jwt
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject missing auth — JWT optional at Gradio layer"
@pytest.mark.anyio
async def test_handler_invalid_jwt_continues_gracefully():
"""Invalid JWT does NOT yield UNAUTHORIZED — handler continues with fallback context."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
mock_request.headers = {"authorization": "Bearer invalid-token"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, None, None):
results.append(chunk)
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
# #endregion TestAgentChat.Handler.AuthError
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
@pytest.mark.anyio
async def test_handler_yields_stream_tokens():
"""Handler yields stream_token metadata when graph emits token events."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
# Patch create_agent to return a mock that streams events
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _mock_stream(*_args, **_kwargs):
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content=" world")}}
mock_graph.astream_events = _mock_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
results.append(chunk)
assert len(results) > 0, "Should yield at least one chunk"
import json
stream_tokens = [r for r in results if json.loads(r)["metadata"]["type"] == "stream_token"]
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
# #endregion TestAgentChat.Handler.Streaming
# #region TestAgentChat.Handler.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,handler,resume]
# @BRIEF Handler detects action=confirm and resumes via Command(resume=...).
@pytest.mark.anyio
async def test_handler_resume_confirm():
"""When action='confirm', handler resumes via Command(resume=...)."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "confirm", "files": None}
# Patch create_agent in _confirmation because handle_resume (now in _confirmation.py)
# imports create_agent directly from langgraph_setup
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
results.append(chunk)
# Should yield confirm_resolved metadata
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "confirmed"
assert mock_create.call_args.kwargs["interrupt_before"] == []
# #endregion TestAgentChat.Handler.ResumeConfirm
# #region TestAgentChat.Handler.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,handler,deny]
@pytest.mark.anyio
async def test_handler_resume_deny():
"""When action='deny', handler yields confirm_resolved with denied."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "deny", "files": None}
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "deny"):
results.append(chunk)
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "denied"
# #endregion TestAgentChat.Handler.ResumeDeny
# #endregion TestAgentChat.Handler

View File

@@ -0,0 +1,255 @@
# agent/tests/test_agent/test_agent_tool_filter.py
# #region Test.Agent.ToolFilter [C:3] [TYPE Module] [SEMANTICS test,agent-chat,tools,filter,pipeline,rbac]
# @BRIEF Contract tests for build_tool_pipeline and enforce_tool_permission — two-layer RBAC + context affinity.
# @RELATION BINDS_TO -> [AgentChat.ToolFilter]
# @TEST_FIXTURE: null_object_type -> all RBAC-allowed tools pass
# @TEST_FIXTURE: dashboard+admin -> all dashboard tools + capabilities kept
# @TEST_FIXTURE: dashboard+analyst -> admin-only tools removed from dashboard
# @TEST_FIXTURE: dataset+admin -> all dataset tools + capabilities kept
# @TEST_FIXTURE: dataset+viewer -> admin-only tools removed from dataset
# @TEST_FIXTURE: migration+admin -> all migration tools + capabilities kept
# @TEST_FIXTURE: unknown_object_type -> graceful fallback to full list
# @TEST_FIXTURE: invocation_guard_admin -> deploy_dashboard allowed for admin
# @TEST_FIXTURE: invocation_guard_viewer -> deploy_dashboard rejected for viewer
# @TEST_FIXTURE: unknown_tool_invocation -> always allowed
# @TEST_FIXTURE: show_capabilities_always_included -> mandatory tool
# @TEST_FIXTURE: idempotent -> build_tool_pipeline never mutates input list
from types import SimpleNamespace
from ss_tools.agent._tool_filter import (
_CONTEXT_TOOL_AFFINITY,
_TOOL_PERMISSIONS,
build_tool_pipeline,
enforce_tool_permission,
)
# #region _tools_helper [C:1] [TYPE Function] [SEMANTICS test,helper]
# @BRIEF Create SimpleNamespace-based tool mimics with .name attribute for pipeline tests.
def _tools(names: list[str]) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in names]
# #endregion _tools_helper
# ── build_tool_pipeline ──────────────────────────────────────────────
# #region test_null_object_type_returns_all_rbac_allowed [C:2] [TYPE Function]
def test_null_object_type_returns_all_rbac_allowed_tools():
"""With no object_type, all tools pass except those blocked by RBAC (viewer)."""
tools = _tools(["search_dashboards", "deploy_dashboard", "show_capabilities"])
# Admin: all tools pass
result_admin = [t.name for t in build_tool_pipeline(tools, "admin", None)]
assert "deploy_dashboard" in result_admin
assert "search_dashboards" in result_admin
assert "show_capabilities" in result_admin
# Viewer: deploy_dashboard blocked by RBAC
result_viewer = [t.name for t in build_tool_pipeline(tools, "viewer", None)]
assert "deploy_dashboard" not in result_viewer
assert "search_dashboards" in result_viewer
assert "show_capabilities" in result_viewer
# #endregion test_null_object_type_returns_all_rbac_allowed
# #region test_dashboard_context_admin [C:2] [TYPE Function]
def test_dashboard_context_admin_keeps_affinity_tools():
"""Dashboard context + admin role: keep all dashboard tools + capabilities."""
tools = _tools([
"search_dashboards", "get_health_summary", "deploy_dashboard",
"run_llm_validation", "run_llm_documentation", "execute_migration",
"create_branch", "commit_changes", "show_capabilities",
# Non-dashboard tools (should be excluded)
"run_backup", "superset_execute_sql", "list_environments",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "dashboard")]
assert "search_dashboards" in result
assert "get_health_summary" in result
assert "deploy_dashboard" in result
assert "show_capabilities" in result
assert "run_backup" not in result
assert "superset_execute_sql" not in result
# #endregion test_dashboard_context_admin
# #region test_dashboard_context_viewer [C:2] [TYPE Function]
def test_dashboard_context_viewer_removes_admin_only():
"""Dashboard context + viewer: admin-only tools removed even from dashboard affinity."""
tools = _tools([
"search_dashboards", "deploy_dashboard", "execute_migration",
"commit_changes", "run_llm_validation", "show_capabilities",
])
result = [t.name for t in build_tool_pipeline(tools, "viewer", "dashboard")]
assert "search_dashboards" in result
assert "run_llm_validation" in result
assert "show_capabilities" in result
assert "deploy_dashboard" not in result
assert "execute_migration" not in result
assert "commit_changes" not in result
# #endregion test_dashboard_context_viewer
# #region test_dataset_context_admin [C:2] [TYPE Function]
def test_dataset_context_admin_keeps_dataset_tools():
"""Dataset context + admin: keep dataset affinity tools, exclude non-dataset."""
tools = _tools([
"superset_explore_database", "superset_format_sql", "superset_execute_sql",
"superset_audit_permissions", "superset_create_dataset",
"search_dashboards", "get_task_status", "list_environments",
"show_capabilities",
# Non-dataset tools
"deploy_dashboard", "run_backup", "start_maintenance",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "dataset")]
assert "superset_explore_database" in result
assert "superset_execute_sql" in result
assert "superset_format_sql" in result
assert "show_capabilities" in result
assert "deploy_dashboard" not in result
assert "run_backup" not in result
# #endregion test_dataset_context_admin
# #region test_dataset_context_viewer [C:2] [TYPE Function]
def test_dataset_context_viewer_removes_admin_tools():
"""Dataset context + viewer: admin-only tools in dataset affinity removed."""
tools = _tools([
"superset_explore_database", "superset_execute_sql",
"search_dashboards", "show_capabilities",
"start_maintenance",
])
result = [t.name for t in build_tool_pipeline(tools, "viewer", "dataset")]
assert "superset_explore_database" in result
assert "superset_execute_sql" in result
assert "show_capabilities" in result
assert "start_maintenance" not in result
# #endregion test_dataset_context_viewer
# #region test_migration_context_admin [C:2] [TYPE Function]
def test_migration_context_admin_keeps_migration_tools():
"""Migration context + admin: keep migration affinity tools, exclude others."""
tools = _tools([
"execute_migration", "search_dashboards", "get_health_summary",
"deploy_dashboard", "list_environments", "show_capabilities",
# Non-migration tools
"run_backup", "superset_execute_sql",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "migration")]
assert "execute_migration" in result
assert "search_dashboards" in result
assert "show_capabilities" in result
assert "run_backup" not in result
assert "superset_execute_sql" not in result
# #endregion test_migration_context_admin
# #region test_unknown_object_type_falls_back [C:2] [TYPE Function]
def test_unknown_object_type_falls_back_to_full_list():
"""Unknown object_type should not filter — behaves like null context."""
tools = _tools([
"search_dashboards", "deploy_dashboard", "run_backup",
"superset_execute_sql", "show_capabilities",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "unknown_type")]
# All RBAC-allowed tools pass (admin sees all)
assert "search_dashboards" in result
assert "deploy_dashboard" in result
assert "run_backup" in result
assert "superset_execute_sql" in result
assert "show_capabilities" in result
# #endregion test_unknown_object_type_falls_back
# #region test_show_capabilities_always_included [C:2] [TYPE Function]
def test_show_capabilities_always_included():
"""show_capabilities must survive all filtering stages regardless of context."""
tools = _tools(["show_capabilities"])
# Must appear in any context+role combination
for obj_type in (None, "dashboard", "dataset", "migration"):
for role in ("admin", "editor", "viewer"):
result = [t.name for t in build_tool_pipeline(tools, role, obj_type)]
assert "show_capabilities" in result, (
f"show_capabilities missing for role={role}, object_type={obj_type}"
)
# #endregion test_show_capabilities_always_included
# #region test_pipeline_is_idempotent [C:2] [TYPE Function]
def test_pipeline_does_not_mutate_input_list():
"""build_tool_pipeline must return a new list and not mutate the input."""
tools = _tools(["search_dashboards", "show_capabilities"])
original_ids = [id(t) for t in tools]
_ = build_tool_pipeline(tools, "admin", "dashboard")
# Input list unchanged
assert [id(t) for t in tools] == original_ids
assert len(tools) == 2
# #endregion test_pipeline_is_idempotent
# ── enforce_tool_permission ──────────────────────────────────────────
# #region test_invocation_guard_admin_allowed [C:2] [TYPE Function]
def test_enforce_tool_permission_admin_allowed():
"""Admin role should be allowed to invoke all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance"]
for tool_name in restricted:
assert enforce_tool_permission(tool_name, "admin") is True, (
f"Admin should be allowed to invoke '{tool_name}'"
)
# #endregion test_invocation_guard_admin_allowed
# #region test_invocation_guard_viewer_denied [C:2] [TYPE Function]
def test_enforce_tool_permission_viewer_denied():
"""Viewer role should be denied for all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance"]
for tool_name in restricted:
assert enforce_tool_permission(tool_name, "viewer") is False, (
f"Viewer should NOT be allowed to invoke '{tool_name}'"
)
# #endregion test_invocation_guard_viewer_denied
# #region test_invocation_guard_unknown_tool_allowed [C:2] [TYPE Function]
def test_enforce_tool_permission_unknown_tool_always_allowed():
"""Unknown tools (not in _TOOL_PERMISSIONS) should always be allowed."""
assert enforce_tool_permission("search_dashboards", "viewer") is True
assert enforce_tool_permission("show_capabilities", "viewer") is True
assert enforce_tool_permission("nonexistent_tool", "viewer") is True
# #endregion test_invocation_guard_unknown_tool_allowed
# #region test_context_affinity_coverage [C:2] [TYPE Function]
def test_context_affinity_maps_exist_for_all_expected_types():
"""Verify that all three known object types have affinity mappings."""
assert "dashboard" in _CONTEXT_TOOL_AFFINITY
assert "dataset" in _CONTEXT_TOOL_AFFINITY
assert "migration" in _CONTEXT_TOOL_AFFINITY
# Each mapping should have at least 3 tools
for obj_type in ("dashboard", "dataset", "migration"):
assert len(_CONTEXT_TOOL_AFFINITY[obj_type]) >= 3, (
f"Context affinity for '{obj_type}' should have ≥3 tools"
)
# #endregion test_context_affinity_coverage
# #region test_rbac_maps_exist_for_write_tools [C:2] [TYPE Function]
def test_rbac_permissions_for_write_tools():
"""Verify all admin-only tools are explicitly listed in _TOOL_PERMISSIONS."""
expected_admin_tools = [
"deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance",
]
for tool_name in expected_admin_tools:
assert tool_name in _TOOL_PERMISSIONS, (
f"'{tool_name}' should be in _TOOL_PERMISSIONS"
)
assert "admin" in _TOOL_PERMISSIONS[tool_name]
# #endregion test_rbac_permissions_for_write_tools
# #endregion Test.Agent.ToolFilter

View File

@@ -0,0 +1,106 @@
# #region TestAgentChat.ApiFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,api]
# @BRIEF Materialize API fixtures from JSON — verify fixture structure matches expected API contracts.
# @RELATION BINDS_TO -> [AgentChat.Api.Conversations]
import json
from pathlib import Path
import pytest
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "api"
# #region TestAgentChat.ApiFixtures.Load [C:2] [TYPE Function] [SEMANTICS test,fixture,load]
# @BRIEF All 9 API fixture files load without parse errors.
def test_all_api_fixtures_load():
"""Verify all API fixture files exist and load as valid JSON."""
expected_fixtures = [
"conversations_list_valid.json",
"conversations_list_empty.json",
"conversations_list_missing_auth.json",
"conversations_list_invalid_page.json",
"conversations_list_external_fail.json",
"history_valid.json",
"history_not_found.json",
"service_token_valid.json",
"service_token_invalid_secret.json",
]
for name in expected_fixtures:
path = FIXTURES_DIR / name
assert path.exists(), f"Fixture not found: {name}"
with open(path) as f:
data = json.load(f)
assert "fixture_id" in data, f"{name}: missing fixture_id"
assert "verifies" in data, f"{name}: missing verifies"
assert "input" in data, f"{name}: missing input"
assert "expected" in data, f"{name}: missing expected"
# #endregion TestAgentChat.ApiFixtures.Load
# #region TestAgentChat.ApiFixtures.ConversationsList [C:2] [TYPE Function] [SEMANTICS test,fixture,conversations]
# @BRIEF Conversations list fixtures have correct structure: valid returns 200+items, empty returns 200+[].
def test_conversations_list_valid():
"""FX_AgentChat.Conversations.ListValid → 200 with items array."""
with open(FIXTURES_DIR / "conversations_list_valid.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListValid"
assert fixture["expected"]["status"] == 200
assert "items" in fixture["expected"]["body"]
assert fixture["input"]["method"] == "GET"
def test_conversations_list_empty():
"""FX_AgentChat.Conversations.ListEmpty → 200 with empty items."""
with open(FIXTURES_DIR / "conversations_list_empty.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListEmpty"
assert fixture["expected"]["status"] == 200
assert fixture["expected"]["body"]["items"] == []
def test_conversations_list_missing_auth():
"""FX_AgentChat.Conversations.ListMissingAuth → 401."""
with open(FIXTURES_DIR / "conversations_list_missing_auth.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListMissingAuth"
assert fixture["expected"]["status"] == 401
# #endregion TestAgentChat.ApiFixtures.ConversationsList
# #region TestAgentChat.ApiFixtures.History [C:2] [TYPE Function] [SEMANTICS test,fixture,history]
# @BRIEF History fixtures have correct structure.
def test_history_valid():
"""FX_AgentChat.History.Valid → 200 with messages."""
with open(FIXTURES_DIR / "history_valid.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.History.Valid"
assert fixture["expected"]["status"] == 200
assert "items" in fixture["expected"]["body"]
def test_history_not_found():
"""FX_AgentChat.History.NotFound → 404."""
with open(FIXTURES_DIR / "history_not_found.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.History.NotFound"
assert fixture["expected"]["status"] == 404
# #endregion TestAgentChat.ApiFixtures.History
# #region TestAgentChat.ApiFixtures.ServiceToken [C:2] [TYPE Function] [SEMANTICS test,fixture,service-token]
# @BRIEF Service token fixtures.
def test_service_token_valid():
"""FX_AgentChat.ServiceToken.Valid → 200 with token."""
with open(FIXTURES_DIR / "service_token_valid.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.Valid"
assert fixture["expected"]["status"] == 200
def test_service_token_invalid():
"""FX_AgentChat.ServiceToken.InvalidSecret → 401."""
with open(FIXTURES_DIR / "service_token_invalid_secret.json") as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.InvalidSecret"
assert fixture["expected"]["status"] == 401
# #endregion TestAgentChat.ApiFixtures.ServiceToken
# #endregion TestAgentChat.ApiFixtures

View File

@@ -0,0 +1,690 @@
# #region Test.AgentChat.GradioApp [C:3] [TYPE Module] [SEMANTICS test,agent,gradio,chat]
# @BRIEF Tests for agent/app.py — agent_handler, handle_resume, extract_user_id, save_conversation, create_chat_interface, health.
# @RELATION BINDS_TO -> [AgentChat.GradioApp]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_async_iter(items):
"""Create an async iterator from a list."""
class AsyncIter:
def __init__(self, items):
self._iter = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration from None
return AsyncIter(items)
def _skip_pipeline(results: list[str]) -> list[str]:
"""Filter out pipeline_result events emitted before the agent stream loop."""
return [r for r in results if json.loads(r).get("metadata", {}).get("type") != "pipeline_result"]
def _make_agent_mock(stream_events=None, raise_on_call=False):
"""Create a properly mocked agent for async iteration."""
agent = MagicMock()
if raise_on_call:
agent.astream_events = MagicMock(side_effect=stream_events)
elif stream_events is not None:
agent.astream_events = MagicMock(return_value=_make_async_iter(stream_events))
else:
agent.astream_events = MagicMock(return_value=_make_async_iter([]))
# aget_state must be awaitable — handler calls `await agent.aget_state(config)` after stream loop
state_mock = MagicMock(spec_set=["next"])
state_mock.next = ()
agent.aget_state = AsyncMock(return_value=state_mock)
return agent
@pytest.fixture(autouse=True)
def clear_locks():
"""Clear _user_locks before each test."""
from ss_tools.agent.app import _user_locks
_user_locks.clear()
@pytest.fixture
def mock_request():
req = MagicMock()
req.headers = {"authorization": ""}
return req
# #region test_extract_user_id [C:2] [TYPE Function]
# @BRIEF Test extract_user_id for various JWT payloads.
class TestExtractUserId:
def test_extracts_sub(self):
from ss_tools.agent._persistence import extract_user_id
with patch("ss_tools.agent._jwt_decoder.decode_token", return_value={"sub": "user-1"}):
assert extract_user_id("fake-jwt") == "user-1"
def test_extracts_user_id_fallback(self):
from ss_tools.agent._persistence import extract_user_id
with patch("ss_tools.agent._jwt_decoder.decode_token", return_value={"user_id": "user-2"}):
assert extract_user_id("fake-jwt") == "user-2"
def test_returns_unknown_on_exception(self):
from ss_tools.agent._persistence import extract_user_id
with patch("ss_tools.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
assert extract_user_id("bad") == "unknown"
def test_returns_unknown_on_empty(self):
from ss_tools.agent._persistence import extract_user_id
assert extract_user_id("") == "unknown"
# #endregion test_extract_user_id
# #region test_confirmation_metadata [C:2] [TYPE Function]
# @BRIEF Test backend HITL confirmation contract exposed to the frontend.
class TestConfirmationMetadata:
def test_extracts_tool_call_and_read_contract(self):
from ss_tools.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [{"name": "list_environments", "args": {"env_id": "prod"}}]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
meta = confirmation_metadata("conv-1", state, "Какие есть окружения?")
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-1"
assert meta["prompt"] == "Разрешить чтение данных?"
assert meta["tool_name"] == "list_environments"
assert meta["tool_args"] == {"env_id": "prod"}
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
assert meta["requires_confirmation"] is True
assert meta["intent"]["operation"] == "list_environments"
def test_infers_tool_from_text_when_state_only_has_graph_node(self):
from ss_tools.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
state.next = ("tools",)
meta = confirmation_metadata("conv-2", state, "Покажи окружения")
# extract_tool_call_from_state no longer infers from text — LLM handles intent.
# State has no tool_calls, and "tools" node is in _GRAPH_NODE_NAMES → filtered.
assert meta["tool_name"] == "unknown_action"
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
def test_write_contract_for_guarded_tool(self):
from ss_tools.agent._confirmation import confirmation_metadata
msg = MagicMock()
msg.tool_calls = [
{
"name": "start_maintenance",
"args": {"environment_id": "prod", "tables": ["sales"]},
}
]
state = MagicMock()
state.values = {"messages": [msg]}
state.next = ("tools",)
meta = confirmation_metadata("conv-3", state, "Запусти maintenance")
assert meta["tool_name"] == "start_maintenance"
assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded"
assert meta["prompt"] == "⚠️ Изменение данных в PRODUCTION! Подтвердите действие."
# v2 fields — permission check runs against user_role in state; test defaults to "viewer"
# start_maintenance requires "admin", so permission_granted will be False here
assert meta["dangerous"] is False
assert meta["env_context"] == "prod"
assert meta["permission_granted"] is False
assert meta["required_role"] == "admin"
def test_unknown_contract_does_not_expose_graph_node_as_tool(self):
from ss_tools.agent._confirmation import confirmation_metadata
state = MagicMock()
state.values = {"messages": []}
state.next = ("tools",)
meta = confirmation_metadata("conv-4", state, "Непонятное действие")
assert meta["tool_name"] == "unknown_action"
assert meta["risk"] == "read"
assert meta["risk_level"] == "safe"
def test_fast_resume_deny_closes_without_langgraph(self):
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
_pending_confirmations["conv-fast-deny"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
return [chunk async for chunk in handle_resume("conv-fast-deny", "deny")]
chunks = asyncio.run(collect())
data = json.loads(chunks[0])
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
def test_fast_resume_confirm_executes_tool_directly(self):
from ss_tools.agent._confirmation import _pending_confirmations, handle_resume
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
_pending_confirmations["conv-fast-confirm"] = {
"tool_name": "list_environments",
"tool_args": {},
}
async def collect():
with patch("ss_tools.agent._confirmation.find_tool", return_value=tool), patch("ss_tools.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*_args, **_kwargs):
return
yield # pragma: no cover — async generator requires at least one yield
mock_format.side_effect = _empty_format
return [chunk async for chunk in handle_resume("conv-fast-confirm", "confirm")]
chunks = asyncio.run(collect())
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
assert metadata_types[:3] == ["confirm_resolved", "tool_start", "tool_end"]
# #endregion test_confirmation_metadata
# #region test_agent_handler [C:2] [TYPE Function]
# @BRIEF Test agent_handler for various scenarios.
class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from ss_tools.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "CONCURRENT_SEND"
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from ss_tools.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["code"] == "FILE_TOO_LARGE"
@pytest.mark.asyncio
async def test_handles_hitl_confirm(self, mock_request):
from ss_tools.agent.app import agent_handler
# handle_resume is imported into app.py from _confirmation
with patch("ss_tools.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})])
with patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("confirm", [], mock_request, "conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["type"] == "confirm_resolved"
@pytest.mark.asyncio
async def test_handles_hitl_deny(self, mock_request):
from ss_tools.agent.app import agent_handler
with patch("ss_tools.agent.app.handle_resume") as mock_resume:
mock_resume.return_value = _make_async_iter([json.dumps({"content": "denied", "metadata": {"type": "confirm_resolved", "result": "denied"}})])
with patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("deny", [], mock_request, "conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
@pytest.mark.asyncio
async def test_normal_send_yields_tokens(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "Hello"
mock_event_stream = [
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
{"event": "on_chain_end", "interrupt": {}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
token_data = json.loads(filtered[0])
assert token_data["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
async def test_normal_send_hides_runtime_context_from_saved_history(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_chunk = MagicMock()
mock_chunk.content = "ok"
agent = _make_agent_mock(
[
{"event": "on_chat_model_stream", "data": {"chunk": mock_chunk}},
]
)
save_mock = AsyncMock()
with (
patch("ss_tools.agent.app.create_agent", return_value=agent),
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
patch("ss_tools.agent.app.generate_llm_title", AsyncMock()),
patch("ss_tools.agent.app.save_conversation", save_mock),
):
results = [
r
async for r in agent_handler(
"Запусти обслуживание дашборда USA на 15 минут",
[],
mock_request,
"conv-runtime",
None,
None,
None,
"ss-dev",
)
]
# pipeline_result + stream_token = 2 events
assert len(results) == 2
messages_arg = agent.astream_events.call_args.args[0]["messages"]
llm_text = messages_arg[0].content
assert "[RUNTIME CONTEXT]" in llm_text
assert "Current datetime:" in llm_text
assert "Available dashboards" in llm_text
save_mock.assert_called_once()
assert save_mock.call_args.args[1] == "Запусти обслуживание дашборда USA на 15 минут"
@pytest.mark.asyncio
async def test_tool_events_yielded(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_start", "name": "test_tool", "data": {"input": {}}},
{"event": "on_tool_end", "name": "test_tool", "data": {"output": "done"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()), patch("ss_tools.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 2
assert json.loads(filtered[0])["metadata"]["type"] == "tool_start"
assert json.loads(filtered[1])["metadata"]["type"] == "tool_end"
@pytest.mark.asyncio
async def test_tool_error_event(self, mock_request):
from ss_tools.agent.app import agent_handler
mock_event_stream = [
{"event": "on_tool_error", "name": "test_tool", "data": {"error": "something failed"}},
]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()), patch("ss_tools.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert json.loads(filtered[0])["metadata"]["type"] == "tool_error"
@pytest.mark.asyncio
async def test_output_parser_exception_retry(self, mock_request):
from langchain_core.exceptions import OutputParserException
from ss_tools.agent.app import agent_handler
mock_stream_after = _make_async_iter([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}])
call_count = [0]
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
if call_count[0] == 1:
raise OutputParserException("bad output")
return mock_stream_after
agent = MagicMock()
agent.astream_events = mock_astream
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@pytest.mark.asyncio
async def test_output_parser_exception_final_failure(self, mock_request):
from langchain_core.exceptions import OutputParserException
from ss_tools.agent.app import agent_handler
call_count = [0]
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
raise OutputParserException(f"bad {call_count[0]}")
agent = MagicMock()
agent.astream_events = mock_astream
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_MALFORMED_OUTPUT"
@pytest.mark.asyncio
async def test_non_llm_error_saves_conversation(self, mock_request):
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("API connection error")
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
# Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limit_error_yields_code(self, mock_request):
"""RateLimitError (429) yields LLM_RATE_LIMITED, not guardrails card."""
from openai import RateLimitError
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RateLimitError(
"429 quota exceeded",
response=MagicMock(status_code=429),
body={"error": {"message": "quota exceeded"}},
)
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_RATE_LIMITED"
assert "Превышен лимит" in data["content"]
# save_conversation should be called even on error
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_generic_exception_with_llm_keyword_yields_error_not_guardrails(self, mock_request):
"""Generic exception containing 'quota' keyword yields error, not confirmation."""
from ss_tools.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("All codex accounts reached configured quota threshold")
agent = MagicMock()
agent.astream_events = mock_astream
# Even though aget_state may return a truthy 'next', the error
# contains LLM keywords and should skip the HITL recovery path.
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
save_mock = AsyncMock()
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
# Must be PROCESSING_ERROR (not confirm_required)
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_jwt_passes_gracefully(self):
from jose import JWTError
from ss_tools.agent.app import agent_handler
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.decode_token", side_effect=JWTError("invalid")), patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@pytest.mark.asyncio
async def test_valid_user_jwt_with_audience_is_kept_for_tool_auth(self, mock_request):
from ss_tools.agent.app import agent_handler
from ss_tools.agent.context import get_user_jwt
# Create a test JWT using the same jose library the agent uses
from datetime import datetime, timedelta, timezone
from jose import jwt as jose_jwt
import os
secret = os.getenv("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests")
token = jose_jwt.encode(
{"sub": "admin", "scopes": ["Admin"], "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
secret,
algorithm="HS256",
)
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
agent = _make_agent_mock(mock_event_stream)
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert get_user_jwt() == token
# #endregion test_agent_handler
# #region test_handle_resume [C:2] [TYPE Function]
# @BRIEF Test handle_resume LangGraph checkpoint resume (no pending confirmation).
class TestHandleResume:
@pytest.mark.asyncio
async def test_confirm_checkpoint_resume(self):
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "confirm")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "confirmed"
@pytest.mark.asyncio
async def test_deny_checkpoint_resume(self):
from ss_tools.agent._confirmation import handle_resume
mock_agent = MagicMock()
with patch("ss_tools.agent._confirmation.create_agent", return_value=mock_agent), patch("ss_tools.agent._confirmation.get_all_tools", return_value=[]):
results = [r async for r in handle_resume("conv-1", "deny")]
assert len(results) == 1
data = json.loads(results[0])
assert data["metadata"]["result"] == "denied"
# #endregion test_handle_resume
# #region test_save_conversation [C:2] [TYPE Function]
# @BRIEF Test save_conversation with various scenarios.
class TestSaveConversation:
@pytest.mark.asyncio
async def test_save_success(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "test message", "user-1")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_with_service_jwt(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client, patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
await save_conversation("conv-1", "hello", "admin")
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
@pytest.mark.asyncio
async def test_save_failure_logged(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
# Should not raise
await save_conversation("conv-1", "msg", "u1")
@pytest.mark.asyncio
async def test_save_empty_title(self):
from ss_tools.agent._persistence import save_conversation
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = client_instance
await save_conversation("conv-1", " ", "user-1")
call_kwargs = client_instance.post.call_args[1]
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
assert call_kwargs["json"]["title"] == "Новый диалог"
# #endregion test_save_conversation
# #region test_create_chat_interface [C:2] [TYPE Function]
# @BRIEF Test create_chat_interface returns a gr.ChatInterface.
class TestCreateChatInterface:
def test_returns_chat_interface(self):
from ss_tools.agent.app import create_chat_interface
with patch("ss_tools.agent.app.gr.ChatInterface") as mock_ci:
result = create_chat_interface()
assert result is mock_ci.return_value
# #endregion test_create_chat_interface
# #region test_health [C:2] [TYPE Function]
# @BRIEF Test health endpoint returns status ok.
class TestHealth:
@pytest.mark.asyncio
async def test_health_returns_ok(self):
from ss_tools.agent.app import health
result = await health()
assert result["status"] == "ok"
# #endregion test_health
# #region test_file_upload_parsing [C:2] [TYPE Function]
# @BRIEF Test file upload branch — parse_upload called for valid small files.
class TestFileUploadParsing:
@pytest.mark.asyncio
async def test_handler_parses_uploaded_file(self, mock_request, tmp_path):
"""Valid small file triggers parse_upload and continues to stream."""
from ss_tools.agent.app import agent_handler
test_file = tmp_path / "test.txt"
test_file.write_text("upload content for analysis")
message = {"text": "analyze", "files": [str(test_file)]}
with (
patch("ss_tools.agent.app.create_agent") as mock_create,
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
patch("ss_tools.agent.app.log_tool_event", AsyncMock()),
patch("ss_tools.agent.app._persist_chat_file", return_value="chat_uploads/test.txt"),
):
agent = _make_agent_mock([{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}}])
mock_create.return_value = agent
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
# Now: file_uploaded, pipeline_result, stream_token (3 events)
assert len(results) >= 3
# First result is file upload metadata, third (after pipeline) is stream token
file_data = json.loads(results[0])
assert file_data["metadata"]["type"] == "file_uploaded"
token_data = json.loads(results[2])
assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing
# #region test_app_main_block [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block in app.py.
class TestAppMainBlock:
def test_app_main_block(self):
"""if __name__ == '__main__' creates ChatInterface and launches."""
import importlib.util
from pathlib import Path
app_path = Path(__file__).parent.parent.parent / "src" / "ss_tools" / "agent" / "app.py"
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
mock_demo = MagicMock()
with patch("gradio.ChatInterface") as mock_ci:
mock_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
mock_demo.launch.assert_called_once()
# #endregion test_app_main_block
# #endregion Test.AgentChat.GradioApp

View File

@@ -0,0 +1,148 @@
# #region TestAgentChat.Confirmations [C:3] [TYPE Module] [SEMANTICS test,agent,confirmation,hitl]
# @BRIEF Supplementary HITL confirmation flow tests — concurrent send, unknown action, model edge cases.
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
# @TEST_EDGE: concurrent_send -> handler yields CONCURRENT_SEND error
# @TEST_EDGE: confirm_without_conversation_id -> handler still processes confirm
# @NOTE Resume confirm/deny handler tests are in test_agent_handler.py (test_handler_resume_confirm,
# test_handler_resume_deny). Model-level state machine tests are in AgentChatModel.test.ts.
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
import jwt
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
@pytest.fixture(autouse=True)
def mock_save_conversation():
with patch("ss_tools.agent.app.save_conversation", new_callable=AsyncMock):
yield
# #region TestAgentChat.Confirmations.Concurrent [C:2] [TYPE Function] [SEMANTICS test,confirmation,concurrent]
# @BRIEF Concurrent send lock prevents multiple simultaneous sends from same user.
@pytest.mark.asyncio
async def test_concurrent_send_lock():
"""Handler rejects concurrent sends from same user with CONCURRENT_SEND error."""
from ss_tools.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no JWT and no user_id_str are provided
_user_locks["admin"] = True
history: list = []
mock_request = MagicMock()
mock_request.headers = {}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
results.append(chunk)
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["code"] == "CONCURRENT_SEND"
# Clean up
_user_locks.pop("admin", None)
# #endregion TestAgentChat.Confirmations.Concurrent
# #region TestAgentChat.Confirmations.UnknownAction [C:2] [TYPE Function] [SEMANTICS test,confirmation,unknown]
# @BRIEF Non-confirm/deny action values are treated as normal messages.
@pytest.mark.asyncio
async def test_handler_unknown_action_treated_as_normal():
"""Handler with unknown action string proceeds as normal message send."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "hello", "files": None}
with patch("ss_tools.agent.app.create_agent") as mock_create:
mock_graph = MagicMock()
async def _mock_stream(*_args, **_kwargs):
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
mock_graph.astream_events = _mock_stream
state = MagicMock(spec_set=["next"])
state.next = ()
mock_graph.aget_state = AsyncMock(return_value=state)
mock_create.return_value = mock_graph
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "unknown_action"):
results.append(chunk)
# Should produce stream tokens, not confirm_resolved
assert len(results) > 0
import json
meta_types = [json.loads(r)["metadata"]["type"] for r in results]
assert "stream_token" in meta_types
assert "confirm_resolved" not in meta_types
# #endregion TestAgentChat.Confirmations.UnknownAction
# #region TestAgentChat.Confirmations.ExpiredState [C:2] [TYPE Function] [SEMANTICS test,confirmation,expired]
# @BRIEF Stale confirmations — checkpoint no longer available.
@pytest.mark.asyncio
async def test_handler_confirm_no_graph():
"""Handler with action='confirm' but create_agent raises handles it gracefully."""
from ss_tools.agent.app import agent_handler
history: list = []
mock_request = MagicMock()
token = _make_test_jwt()
mock_request.headers = {"authorization": f"Bearer {token}"}
mock_request.client.host = "127.0.0.1"
message = {"text": "confirm", "files": None}
with patch("ss_tools.agent._confirmation.create_agent") as mock_create:
mock_create.side_effect = Exception("Graph creation failed")
try:
results = []
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
results.append(chunk)
# Exception may propagate or be caught — either is acceptable
except Exception:
pass
# #endregion TestAgentChat.Confirmations.ExpiredState
# #endregion TestAgentChat.Confirmations

View File

@@ -0,0 +1,303 @@
# #region TestAgentChat.DocumentParser [C:2] [TYPE Module] [SEMANTICS test,agent,document,parser]
# @BRIEF Tests for document parser — PDF, XLSX, unsupported formats, empty files, errors.
# @RELATION BINDS_TO -> [AgentChat.Document.Parser]
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from ss_tools.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError
# #region TestAgentChat.DocumentParser.PDF [C:2] [TYPE Function] [SEMANTICS test,parser,pdf]
# @BRIEF PDF parsing: extracts text from valid PDF, handles empty and encrypted PDFs gracefully.
def test_parse_pdf_valid():
"""Parse a valid PDF and verify text extraction."""
# Create a minimal valid PDF
pdf_content = (
b"%PDF-1.4\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
b"4 0 obj<</Length 44>>stream\n"
b"BT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\n"
b"endstream\nendobj\n"
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"xref\n"
b"0 6\n"
b"trailer<</Size 6/Root 1 0 R>>\n"
b"startxref\n"
b"169\n"
b"%%EOF"
)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(pdf_content)
tmp_path = f.name
try:
result = parse_pdf(tmp_path)
assert isinstance(result, str)
assert "Hello" in result
finally:
os.unlink(tmp_path)
def test_parse_pdf_empty():
"""Empty/non-existent PDF file raises ParseError."""
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"")
tmp_path = f.name
try:
with pytest.raises(ParseError):
parse_pdf(tmp_path)
finally:
os.unlink(tmp_path)
def test_parse_pdf_nonexistent():
"""Non-existent PDF file raises ParseError."""
with pytest.raises((ParseError, FileNotFoundError)):
parse_pdf("/tmp/nonexistent_file_12345.pdf")
# #endregion TestAgentChat.DocumentParser.PDF
# #region TestAgentChat.DocumentParser.XLSX [C:2] [TYPE Function] [SEMANTICS test,parser,xlsx]
# @BRIEF XLSX parsing: extracts sheet names and cell data.
def test_parse_xlsx_valid():
"""Parse a valid XLSX and verify sheet+cell extraction."""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sheet1"
ws["A1"] = "Name"
ws["B1"] = "Value"
ws["A2"] = "Test"
ws["B2"] = 42
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
wb.save(f.name)
tmp_path = f.name
try:
result = parse_xlsx(tmp_path)
assert isinstance(result, str)
assert "Sheet1" in result
assert "Name" in result
assert "Value" in result
assert "Test" in result
assert "42" in result
finally:
os.unlink(tmp_path)
def test_parse_xlsx_empty_sheet():
"""XLSX with empty sheet returns headers but no data rows."""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "EmptySheet"
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
wb.save(f.name)
tmp_path = f.name
try:
result = parse_xlsx(tmp_path)
assert isinstance(result, str)
assert "EmptySheet" in result
finally:
os.unlink(tmp_path)
def test_parse_xlsx_not_excel():
"""Non-XLSX file raises ParseError."""
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
f.write(b"not an excel file")
tmp_path = f.name
try:
with pytest.raises(ParseError):
parse_xlsx(tmp_path)
finally:
os.unlink(tmp_path)
# #endregion TestAgentChat.DocumentParser.XLSX
# #region TestAgentChat.DocumentParser.ParseUpload [C:2] [TYPE Function] [SEMANTICS test,parser,upload]
# @BRIEF parse_upload dispatches to correct parser based on extension. Unsupported → error.
def test_parse_upload_txt():
"""Parse a .txt file returns its content."""
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
f.write("Hello, world!")
tmp_path = f.name
try:
result = parse_upload(tmp_path)
assert "Hello" in result
finally:
os.unlink(tmp_path)
def test_parse_upload_json():
"""Parse a .json file returns its text."""
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
f.write('{"key": "value"}')
tmp_path = f.name
try:
result = parse_upload(tmp_path)
assert "key" in result
finally:
os.unlink(tmp_path)
def test_parse_upload_unsupported():
"""Unsupported format raises ParseError."""
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
f.write(b"binary")
tmp_path = f.name
try:
with pytest.raises(ParseError, match="Unsupported format"):
parse_upload(tmp_path)
finally:
os.unlink(tmp_path)
def test_parse_upload_dict():
"""parse_upload accepts dict with name+path (Gradio file format)."""
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
f.write("Dict test")
tmp_path = f.name
try:
result = parse_upload({"name": "test.txt", "path": tmp_path})
assert "Dict test" in result
finally:
os.unlink(tmp_path)
def test_parse_upload_file_path_fallback():
"""parse_upload falls back to file_path key if path is missing."""
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
f.write("Fallback key test")
tmp_path = f.name
try:
result = parse_upload({"name": "test.txt", "file_path": tmp_path})
assert "Fallback" in result
finally:
os.unlink(tmp_path)
# #endregion TestAgentChat.DocumentParser.ParseUpload
# #region TestAgentChat.DocumentParser.ImportErrors [C:2] [TYPE Function] [SEMANTICS test,parser,import,error]
# @BRIEF Test ImportError paths in parse_pdf and parse_xlsx.
def test_parse_pdf_import_error():
"""pdfplumber import fails → ParseError."""
import builtins
real_import = builtins.__import__
def raise_on_pdfplumber(name, globals=None, locals=None, fromlist=(), level=0):
if name == 'pdfplumber':
raise ImportError(f"No module named pdfplumber", name=name)
return real_import(name, globals, locals, fromlist, level)
with patch('builtins.__import__', side_effect=raise_on_pdfplumber):
with pytest.raises(ParseError, match="pdfplumber not installed"):
parse_pdf("/tmp/dummy.pdf")
def test_parse_xlsx_import_error():
"""openpyxl import fails → ParseError."""
import builtins
real_import = builtins.__import__
def raise_on_openpyxl(name, globals=None, locals=None, fromlist=(), level=0):
if name == 'openpyxl':
raise ImportError(f"No module named openpyxl", name=name)
return real_import(name, globals, locals, fromlist, level)
with patch('builtins.__import__', side_effect=raise_on_openpyxl):
with pytest.raises(ParseError, match="openpyxl not installed"):
parse_xlsx("/tmp/dummy.xlsx")
# #endregion TestAgentChat.DocumentParser.ImportErrors
# #region TestAgentChat.DocumentParser.PyPDF2Fallback [C:2] [TYPE Function] [SEMANTICS test,parser,pypdf2,fallback]
# @BRIEF When pdfplumber fails, PyPDF2 is used as fallback path.
def test_parse_pdf_pypdf2_fallback():
"""pdfplumber failure triggers PyPDF2 fallback."""
# Build mock PyPDF2 module in sys.modules
mock_pypdf2 = MagicMock()
mock_reader = MagicMock()
mock_page = MagicMock()
mock_page.extract_text.return_value = "Fallback PyPDF2 text"
mock_reader.pages = [mock_page]
mock_pypdf2.PdfReader = MagicMock(return_value=mock_reader)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"dummy pdf content")
tmp_path = f.name
try:
# Patch pdfplumber.open directly (the real module) — NOT document_parser.pdfplumber
# because pdfplumber is imported inside the function body, not at module level.
with patch('pdfplumber.open', side_effect=Exception("pdfplumber error")), \
patch.dict('sys.modules', {'PyPDF2': mock_pypdf2}):
result = parse_pdf(tmp_path)
assert isinstance(result, str)
assert "PyPDF2" in result
mock_pypdf2.PdfReader.assert_called_once()
finally:
os.unlink(tmp_path)
# #endregion TestAgentChat.DocumentParser.PyPDF2Fallback
# #region TestAgentChat.DocumentParser.ParseUploadBranches [C:2] [TYPE Function] [SEMANTICS test,parser,upload,branches]
# @BRIEF Test parse_upload dispatches to correct parser for PDF, XLSX, XLS.
def test_parse_upload_pdf_branch():
"""parse_upload with .pdf dispatches to parse_pdf."""
import ss_tools.agent.document_parser as dp
with patch.object(dp, 'parse_pdf', return_value="pdf result") as mock_parse:
result = parse_upload({"name": "report.pdf", "path": "/tmp/report.pdf"})
assert result == "pdf result"
mock_parse.assert_called_once_with("/tmp/report.pdf")
def test_parse_upload_xlsx_branch():
"""parse_upload with .xlsx dispatches to parse_xlsx."""
import ss_tools.agent.document_parser as dp
with patch.object(dp, 'parse_xlsx', return_value="xlsx result") as mock_parse:
result = parse_upload({"name": "data.xlsx", "path": "/tmp/data.xlsx"})
assert result == "xlsx result"
mock_parse.assert_called_once_with("/tmp/data.xlsx")
def test_parse_upload_xls_branch():
"""parse_upload with .xls (legacy) also dispatches to parse_xlsx."""
import ss_tools.agent.document_parser as dp
with patch.object(dp, 'parse_xlsx', return_value="xls result") as mock_parse:
result = parse_upload({"name": "legacy.xls", "path": "/tmp/legacy.xls"})
assert result == "xls result"
mock_parse.assert_called_once_with("/tmp/legacy.xls")
# #endregion TestAgentChat.DocumentParser.ParseUploadBranches
# #endregion TestAgentChat.DocumentParser

View File

@@ -0,0 +1,82 @@
# #region Test.Agent.Feature035 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,tools,rbac]
# @BRIEF Contract tests for feature 035 context filtering, invocation RBAC, and tool response summarisation.
# @RELATION BINDS_TO -> [AgentChat.ToolFilter]
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @TEST_EDGE: dashboard_context_admin -> Dashboard affinity keeps dashboard tools plus mandatory capabilities.
# @TEST_EDGE: dashboard_context_viewer -> RBAC removes admin-only dashboard tools.
# @TEST_EDGE: invocation_guard_denied -> Mutating tool rejects before HTTP side effect.
import pytest
from types import SimpleNamespace
from ss_tools.agent._tool_filter import build_tool_pipeline
from ss_tools.agent.context import set_user_role
from ss_tools.agent.tools import _guard_tool_permission, _summarise_response
def _tools(names: list[str]) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in names]
# #region test_dashboard_context_filters_tools [C:2] [TYPE Function]
# @BRIEF Dashboard context keeps only dashboard-affinity tools and mandatory capabilities.
def test_dashboard_context_filters_tools():
tools = _tools([
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"superset_execute_sql",
"run_backup",
"show_capabilities",
])
result = [tool.name for tool in build_tool_pipeline(tools, "admin", "dashboard")]
assert result == [
"search_dashboards",
"get_health_summary",
"deploy_dashboard",
"show_capabilities",
]
# #endregion test_dashboard_context_filters_tools
# #region test_dashboard_context_viewer_removes_admin_tools [C:2] [TYPE Function]
# @BRIEF Viewer role removes admin-only tools even when they are dashboard-affinity tools.
def test_dashboard_context_viewer_removes_admin_tools():
tools = _tools([
"search_dashboards",
"deploy_dashboard",
"execute_migration",
"show_capabilities",
])
result = [tool.name for tool in build_tool_pipeline(tools, "viewer", "dashboard")]
assert result == ["search_dashboards", "show_capabilities"]
# #endregion test_dashboard_context_viewer_removes_admin_tools
# #region test_invocation_guard_blocks_mutating_tool [C:2] [TYPE Function]
# @BRIEF Invocation guard rejects admin-only tools for non-admin role before side effects.
def test_invocation_guard_blocks_mutating_tool():
set_user_role("viewer")
with pytest.raises(PermissionError, match="PERMISSION_DENIED:deploy_dashboard:admin:viewer"):
_guard_tool_permission("deploy_dashboard")
# #endregion test_invocation_guard_blocks_mutating_tool
# #region test_summarise_response_preserves_json_array_shape [C:2] [TYPE Function]
# @BRIEF Large JSON arrays are summarised as top-N plus total count, not cut mid-structure.
def test_summarise_response_preserves_json_array_shape():
text = "[" + ",".join(f'{{"id":{idx},"name":"dashboard-{idx}"}}' for idx in range(20)) + "]"
summary = _summarise_response(text, limit=100)
assert summary.startswith("Found 20 items:")
assert "dashboard-0" in summary
assert "15 more items" in summary
# #endregion test_summarise_response_preserves_json_array_shape
# #endregion Test.Agent.Feature035

View File

@@ -0,0 +1,134 @@
# #region Test.IntentKeyword.Edges [C:2] [TYPE Module] [SEMANTICS test,metadata,invariants]
# @BRIEF Metadata invariant tests for agent tools — tool catalog consistency, docstring coverage.
# Keyword matching tests removed (LLM handles intent detection via LangGraph tool-calling).
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @TEST_EDGE: all_tools_registered -> get_all_tools() returns consistent list
# @TEST_EDGE: docstrings_present -> every tool has a non-empty docstring
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch
# ═══════════════════════════════════════════════════════════════════
# A — Tool catalog consistency
# ═══════════════════════════════════════════════════════════════════
class TestToolCatalog:
"""Verify tool catalog is internally consistent."""
def test_get_all_tools_returns_all_24(self):
"""get_all_tools() returns at least 24 tools."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
# Core tools must be present
assert "show_capabilities" in tool_names
assert "search_dashboards" in tool_names
assert "get_health_summary" in tool_names
assert "start_maintenance" in tool_names
def test_tool_names_are_unique(self):
"""No duplicate tool names in get_all_tools()."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
names = [t.name for t in tools]
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
def test_every_tool_has_docstring(self):
"""Every tool MUST have a docstring — enforced by LangChain but verified here."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
for t in get_all_tools():
desc = (t.description or "").strip()
assert desc, f"Tool '{t.name}' has empty description. LangChain @tool requires a docstring."
# ═══════════════════════════════════════════════════════════════════
# B — Embedding router metadata
# ═══════════════════════════════════════════════════════════════════
class TestEmbeddingMetadata:
"""Verify embedding router is consistent with tool catalog."""
def test_embedding_top_k_returns_empty_for_empty_query(self):
"""Empty query → empty result."""
try:
from ss_tools.agent._embedding_router import embedding_top_k
except ImportError:
pytest.skip("_embedding_router module not importable")
assert embedding_top_k("") == []
def test_embedding_is_available_returns_bool(self):
"""embedding_is_available returns bool, does not raise."""
try:
from ss_tools.agent._embedding_router import embedding_is_available
except ImportError:
pytest.skip("_embedding_router module not importable")
result = embedding_is_available()
assert isinstance(result, bool)
def test_get_descriptions_matches_all_tools(self):
"""_get_descriptions() covers every tool in get_all_tools() 1:1."""
with patch("ss_tools.agent.tools.logger", MagicMock()):
from ss_tools.agent.tools import get_all_tools
try:
from ss_tools.agent._embedding_router import _get_descriptions
except ImportError:
pytest.skip("_embedding_router module not importable")
descs, names = _get_descriptions()
tool_names = {t.name for t in get_all_tools()}
assert set(names) == tool_names, (
f"Mismatch: descriptions={set(names) - tool_names}, tools={tool_names - set(names)}"
)
assert len(descs) == len(tool_names), f"Expected {len(tool_names)} descriptions, got {len(descs)}"
# ═══════════════════════════════════════════════════════════════════
# C — Tool resolver utilities (kept functions)
# ═══════════════════════════════════════════════════════════════════
class TestToolResolverUtils:
"""Utility functions in _tool_resolver still work."""
def test_normalize_tool_args_handles_dict(self):
from ss_tools.agent._tool_resolver import normalize_tool_args
assert normalize_tool_args({"key": "value"}) == {"key": "value"}
def test_normalize_tool_args_handles_none(self):
from ss_tools.agent._tool_resolver import normalize_tool_args
assert normalize_tool_args(None) == {}
def test_coerce_tool_call_from_dict(self):
from ss_tools.agent._tool_resolver import coerce_tool_call
name, args = coerce_tool_call({"name": "test_tool", "args": {"x": 1}})
assert name == "test_tool"
assert args == {"x": 1}
def test_extract_tool_call_from_state_empty(self):
from ss_tools.agent._tool_resolver import extract_tool_call_from_state
from unittest.mock import MagicMock
state = MagicMock()
state.values = {"messages": []}
state.next = None
name, args = extract_tool_call_from_state(state)
assert name is None
assert args == {}
def test_known_agent_tool_names(self):
from ss_tools.agent._tool_resolver import known_agent_tool_names
names = known_agent_tool_names()
assert "show_capabilities" in names
assert "search_dashboards" in names
assert len(names) >= 24
# #endregion Test.IntentKeyword.Edges

View File

@@ -0,0 +1,446 @@
# #region TestAgentChat.Tools [C:3] [TYPE Module] [SEMANTICS test,agent,tools,langchain]
# @BRIEF Tests for LangChain @tool functions — dual-identity auth, HTTP calls, tool wrapping.
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @TEST_EDGE: tool_rest_call -> tool calls FastAPI with dual-identity headers
# @TEST_EDGE: tool_http_failure -> tool returns error JSON gracefully
# @TEST_EDGE: get_all_tools -> returns expected tool list
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, Mock, patch
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
os.environ["FASTAPI_URL"] = "http://test-backend:8000"
os.environ["SERVICE_JWT"] = "test-service-jwt"
os.environ["OPENAI_API_KEY"] = "sk-test-key"
@pytest.fixture
def anyio_backend():
return "asyncio"
# #region TestAgentChat.Tools.DualAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth]
# @BRIEF Dual-identity auth headers built from ContextVar and env vars.
@pytest.mark.anyio
async def test_tool_dual_auth_headers():
"""Tools should build auth headers from ContextVar when set."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
# Set JWTs in context
set_user_jwt("user-jwt-token")
set_service_jwt("service-jwt-token")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
await search_dashboards.ainvoke({"query": "test"})
# Verify the HTTP request included dual-identity headers
call_kwargs = mock_instance.get.call_args
assert call_kwargs is not None, "HTTP GET should have been called"
_, kwargs = call_kwargs
headers = kwargs.get("headers", {})
assert "Authorization" in headers, "Should include Authorization header"
assert headers["Authorization"] == "Bearer service-jwt-token"
assert headers["X-User-JWT"] == "user-jwt-token"
# #endregion TestAgentChat.Tools.DualAuth
# #region TestAgentChat.Tools.FallbackAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth,fallback]
# @BRIEF Dual-identity auth falls back to env var when ContextVar is not set.
@pytest.mark.anyio
async def test_tool_auth_fallback_to_env():
"""Tools should fall back to SERVICE_JWT env var when ContextVar is empty."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
import ss_tools.agent.tools as tools_mod
from ss_tools.agent.tools import search_dashboards
# Clear ContextVars
set_user_jwt("")
set_service_jwt("")
os.environ["SERVICE_JWT"] = "env-service-token"
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
# Since tool uses os.getenv at call time, the env var will be read
await search_dashboards.ainvoke({"query": "test"})
call_kwargs = mock_instance.get.call_args
assert call_kwargs is not None
_, kwargs = call_kwargs
headers = kwargs.get("headers", {})
# Should use env var
assert "Authorization" in headers
# #endregion TestAgentChat.Tools.FallbackAuth
# #region TestAgentChat.Tools.HttpFailure [C:2] [TYPE Function] [SEMANTICS test,tools,failure]
# @BRIEF Tool handles HTTP failure gracefully (returns error text, not exception).
@pytest.mark.anyio
async def test_tool_http_exception_handling():
"""Tool should propagate HTTP exception as error text."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("test-jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.side_effect = Exception("Connection refused")
# Should propagate the exception (caller handles error)
with pytest.raises((Exception,)):
await search_dashboards.ainvoke({"query": "test"})
# #endregion TestAgentChat.Tools.HttpFailure
# #region TestAgentChat.Tools.GetAll [C:2] [TYPE Function] [SEMANTICS test,tools,registry]
# @BRIEF get_all_tools returns the expected list of tool functions.
def test_get_all_tools_returns_expected_list():
"""get_all_tools() should return search_dashboards, get_health_summary, etc."""
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = [t.name for t in tools]
expected = {
"show_capabilities",
"search_dashboards",
"get_health_summary",
"list_environments",
"get_task_status",
"list_llm_providers",
"get_llm_status",
"create_branch",
"commit_changes",
"deploy_dashboard",
"execute_migration",
"run_backup",
"run_llm_validation",
"run_llm_documentation",
"list_maintenance_events",
"start_maintenance",
"end_maintenance",
}
assert expected.issubset(set(tool_names))
def test_get_all_tools_args_schema():
"""Tools with args_schema should expose required fields."""
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
search_tool = next(t for t in tools if t.name == "search_dashboards")
health_tool = next(t for t in tools if t.name == "get_health_summary")
assert search_tool.args_schema is not None, "search_dashboards should have args_schema"
schema_fields = search_tool.args_schema.model_fields
assert "query" in schema_fields, "search_dashboards should have 'query' field"
assert schema_fields["query"].is_required(), "query should be required"
assert health_tool.args_schema is not None, "get_health_summary should have args_schema"
assert "env_id" in health_tool.args_schema.model_fields
# ═══════════════════════════════════════════════════════════════════
# Tool get_all — full catalog (replaces get_tools_for_query)
# ═══════════════════════════════════════════════════════════════════
def test_get_all_tools_returns_24_tools():
"""get_all_tools() returns full catalog — all 24 tools registered."""
from ss_tools.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
# Core tools (always present)
assert "show_capabilities" in tool_names
assert "search_dashboards" in tool_names
assert "get_health_summary" in tool_names
# Regression: minimum count — must have all 24
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
# #endregion TestAgentChat.Tools.GetAll
# #region TestAgentChat.Tools.ToolContracts [C:2] [TYPE Function] [SEMANTICS test,tools,contract]
# @BRIEF Tool contracts match @POST and @PRE declared in contracts/modules.md.
@pytest.mark.anyio
async def test_search_dashboards_correct_url():
"""search_dashboards calls GET /api/dashboards with query params."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
status_code=200,
text='{"dashboards": [], "total": 0}',
)
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards" in url
# #endregion TestAgentChat.Tools.ToolContracts
# #region TestAgentChat.Tools.HealthSummary [C:2] [TYPE Function] [SEMANTICS test,tools,health]
# @BRIEF get_health_summary calls the correct FastAPI endpoint.
@pytest.mark.anyio
async def test_get_health_summary_calls_correct_url():
"""get_health_summary should call GET /api/health/summary."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import get_health_summary
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '{"status": "ok"}'
await get_health_summary.ainvoke({"env_id": "ss-dev"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/health/summary" in url
assert kwargs.get("params") == {"environment_id": "ss-dev"}
# #endregion TestAgentChat.Tools.HealthSummary
# #region TestAgentChat.Tools.ListEnvironments [C:2] [TYPE Function] [SEMANTICS test,tools,environments]
# @BRIEF list_environments calls the correct FastAPI endpoint.
@pytest.mark.anyio
async def test_list_environments_calls_correct_url():
"""list_environments should call GET /api/settings/environments."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '["prod", "dev"]'
await list_environments.ainvoke({})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/settings/environments" in url
@pytest.mark.anyio
async def test_list_environments_redacts_sensitive_fields():
"""list_environments must not expose backend secrets to chat output."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
result = await list_environments.ainvoke({})
assert "secret-pass" not in result
assert "secret-key" not in result
assert "secret-token" not in result
assert result.count("[redacted]") == 3
# #endregion TestAgentChat.Tools.ListEnvironments
# #region TestAgentChat.Tools.TaskStatus [C:2] [TYPE Function] [SEMANTICS test,tools,task]
# @BRIEF get_task_status calls the correct FastAPI endpoint with task_id.
@pytest.mark.anyio
async def test_get_task_status_calls_correct_url():
"""get_task_status should call GET /api/tasks/{task_id}."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import get_task_status
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = '{"status": "running"}'
await get_task_status.ainvoke({"task_id": "task-123"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/tasks/task-123" in url
# #endregion TestAgentChat.Tools.TaskStatus
@pytest.mark.anyio
async def test_run_backup_posts_task_payload():
"""run_backup should create a superset-backup task through /api/tasks."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt, set_user_role
from ss_tools.agent.tools import run_backup
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
set_user_role("admin")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.post.return_value = Mock(status_code=201, text='{"id": "task-1"}')
await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10})
call_args = mock_instance.post.call_args
assert call_args is not None
args, kwargs = call_args
assert "api/tasks" in args[0]
assert kwargs["json"] == {
"plugin_id": "superset-backup",
"params": {"environment_id": "prod", "dashboard_ids": [10]},
}
@pytest.mark.anyio
async def test_deploy_dashboard_posts_git_endpoint():
"""deploy_dashboard should call the native Git deploy API."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt, set_user_role
from ss_tools.agent.tools import deploy_dashboard
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
set_user_role("admin")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.post.return_value = Mock(status_code=200, text='{"status": "success"}')
await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"})
call_args = mock_instance.post.call_args
assert call_args is not None
args, kwargs = call_args
assert "api/git/repositories/42/deploy" in args[0]
assert kwargs["json"] == {"environment_id": "prod"}
# #region TestAgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS test,tools,auth,headers]
# @BRIEF _dual_auth_headers builds proper headers from ContextVars.
def test_dual_auth_headers_with_both_jwts():
"""_dual_auth_headers uses service auth plus user identity when both are set."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("user-token")
headers = _dual_auth_headers()
assert headers.get("Authorization") == "Bearer svc-token"
assert headers.get("X-User-JWT") == "user-token"
def test_dual_auth_headers_no_user_jwt():
"""_dual_auth_headers falls back to service Authorization when no user JWT."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("")
headers = _dual_auth_headers()
assert headers.get("Authorization") == "Bearer svc-token"
def test_dual_auth_headers_no_jwts():
"""_dual_auth_headers falls back to _SERVICE_JWT when context vars are empty."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import _dual_auth_headers
set_service_jwt("")
set_user_jwt("")
headers = _dual_auth_headers()
# Context vars are empty, _SERVICE_JWT is module-level constant from _config
# (set at import time based on os.environ)
assert "Authorization" in headers
assert headers["Authorization"].startswith("Bearer ")
# #endregion TestAgentChat.Tools.DualAuthHeaders
# #endregion TestAgentChat.Tools

View File

@@ -0,0 +1,183 @@
# #region Test.AgentChat.LangGraph.Setup [C:3] [TYPE Module] [SEMANTICS test,agent,langgraph,setup]
# @BRIEF Tests for agent/langgraph_setup.py — configure_from_api, create_agent.
# @RELATION BINDS_TO -> [AgentChat.LangGraph.Setup]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def anyio_backend():
return "asyncio"
# #region test_configure_from_api [C:2] [TYPE Function]
# @BRIEF Test configure_from_api updates global config.
class TestConfigureFromApi:
def test_sets_llm_config(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({"configured": True, "api_key": "sk-test", "default_model": "gpt-4o"})
assert ls._llm_config is not None
assert ls._llm_config["configured"] is True
# Reset for other tests
ls.configure_from_api(None)
ls._llm_config = None
def test_overwrites_previous_config(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({"configured": True, "api_key": "sk-1"})
ls.configure_from_api({"configured": False})
assert ls._llm_config["configured"] is False
ls._llm_config = None
# #endregion test_configure_from_api
# #region test_create_agent [C:2] [TYPE Function]
# @BRIEF Test create_agent with various LLM config states.
class TestCreateAgent:
@pytest.mark.anyio
async def test_creates_agent_with_api_config(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
ls.configure_from_api({
"configured": True,
"api_key": "sk-api-config",
"base_url": "https://custom.api.com/v1",
"default_model": "gpt-4o-mini",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
result = await ls.create_agent([MagicMock()])
assert result is mock_create.return_value
call_kwargs = mock_llm.call_args[1]
assert call_kwargs["api_key"] == "sk-api-config"
assert call_kwargs["base_url"] == "https://custom.api.com/v1"
assert call_kwargs["model"] == "gpt-4o-mini"
ls._llm_config = None
@pytest.mark.anyio
async def test_raises_error_when_no_llm_configured(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None # Reset
with patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)):
with pytest.raises(RuntimeError, match="No LLM provider configured in backend"):
await ls.create_agent([])
ls._llm_config = None
@pytest.mark.anyio
async def test_creates_agent_with_partial_api_config(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-key-only",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
result = await ls.create_agent([])
assert result is mock_create.return_value
call_kwargs = mock_llm.call_args[1]
assert call_kwargs["api_key"] == "sk-key-only"
assert call_kwargs["base_url"] is None
assert call_kwargs["model"] is None
ls._llm_config = None
@pytest.mark.anyio
async def test_uses_inmemory_saver(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
"base_url": "",
"default_model": "gpt-4o-mini",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
await ls.create_agent([])
call_kwargs = mock_create.call_args[1]
from langgraph.checkpoint.memory import InMemorySaver
assert isinstance(call_kwargs["checkpointer"], InMemorySaver)
ls._llm_config = None
@pytest.mark.anyio
async def test_uses_empty_interrupt_list_by_default(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == []
ls._llm_config = None
@pytest.mark.anyio
async def test_confirm_tools_env_interrupts_before_tools_node(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("ss_tools.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
ls._llm_config = None
@pytest.mark.anyio
async def test_uses_env_configured_interrupt_nodes(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("ss_tools.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"):
mock_create.return_value = MagicMock()
await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
ls._llm_config = None
@pytest.mark.anyio
async def test_interrupt_override_bypasses_env_guardrail(self):
import ss_tools.agent.langgraph_setup as ls
ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("ss_tools.agent.langgraph_setup.ChatOpenAI"), \
patch("ss_tools.agent.langgraph_setup.create_react_agent") as mock_create, \
patch("ss_tools.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("ss_tools.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_create.return_value = MagicMock()
await ls.create_agent([], interrupt_before=[])
assert mock_create.call_args[1]["interrupt_before"] == []
ls._llm_config = None
# #endregion test_create_agent
# #endregion Test.AgentChat.LangGraph.Setup

View File

@@ -0,0 +1,88 @@
# #region Test.AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS test,agent,middleware,audit]
# @BRIEF Tests for agent/middleware.py — log_tool_event.
# @RELATION BINDS_TO -> [AgentChat.Middleware]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import MagicMock, patch
import pytest
# #region test_log_tool_event [C:2] [TYPE Function]
# @BRIEF Test log_tool_event for various event types.
class TestLogToolEvent:
@pytest.mark.asyncio
async def test_logs_tool_start(self):
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "migrate",
"data": {"input": {"dashboard_id": "42"}},
}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
# No exception = success
@pytest.mark.asyncio
async def test_logs_tool_end(self):
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_end",
"name": "migrate",
"data": {"output": "success"},
}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_logs_tool_error(self):
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_error",
"name": "migrate",
"data": {"error": "Connection failed"},
}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="user-token"):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_logs_without_user_jwt(self):
from ss_tools.agent.middleware import log_tool_event
event = {
"event": "on_tool_start",
"name": "test_tool",
"data": {"input": {}},
}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_handles_missing_data_key(self):
from ss_tools.agent.middleware import log_tool_event
event = {"event": "on_tool_start", "name": "test_tool"}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_handles_unknown_event_kind(self):
from ss_tools.agent.middleware import log_tool_event
event = {"event": "on_custom_event", "name": "custom"}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value=None):
await log_tool_event(event, "conv-1")
@pytest.mark.asyncio
async def test_truncates_long_input(self):
from ss_tools.agent.middleware import log_tool_event
long_input = "x" * 1000
event = {
"event": "on_tool_start",
"name": "big_tool",
"data": {"input": long_input},
}
with patch("ss_tools.agent.middleware.get_user_jwt", return_value="token"):
await log_tool_event(event, "conv-1")
# #endregion test_log_tool_event
# #endregion Test.AgentChat.Middleware

View File

@@ -0,0 +1,130 @@
# #region TestAgentChat.ModelFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,model]
# @BRIEF Materialize model fixtures from JSON — verify fixture structure matches expected.
# @RELATION BINDS_TO -> [AgentChat.Model]
import json
from pathlib import Path
import pytest
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "model"
# #region TestAgentChat.ModelFixtures.SendMessageValid [C:2] [TYPE Function] [SEMANTICS test,fixture,send]
# @BRIEF FX_AgentChat.Model.SendMessage.Valid — verify fixture structure.
def test_fixture_send_message_valid():
"""Load send_message_valid fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "send_message_valid.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.SendMessage.Valid"
assert fixture["verifies"] == "AgentChat.Model"
assert fixture["invariant"] == "StreamingStateGatesInput"
assert fixture["input"]["action"] == "sendMessage"
assert fixture["input"]["state_before"]["streamingState"] == "idle"
assert fixture["expected"]["state_after"]["streamingState"] == "streaming"
assert fixture["expected"]["state_after"]["isInputLocked"] is True
assert fixture["expected"]["state_after"]["error"] is None
# #endregion TestAgentChat.ModelFixtures.SendMessageValid
# #region TestAgentChat.ModelFixtures.CancelGeneration [C:2] [TYPE Function] [SEMANTICS test,fixture,cancel]
# @BRIEF FX_AgentChat.Model.CancelGeneration — verify cancel fixture structure.
def test_fixture_cancel_generation():
"""Load cancel_generation fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "cancel_generation.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.CancelGeneration"
assert fixture["edge"] == "cancel_during_streaming"
assert fixture["input"]["action"] == "cancelGeneration"
expected = fixture["expected"]["state_after"]
assert expected["streamingState"] == "idle"
assert expected["isInputLocked"] is False
assert "partialText" in expected
# #endregion TestAgentChat.ModelFixtures.CancelGeneration
# #region TestAgentChat.ModelFixtures.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,fixture,confirm]
# @BRIEF FX_AgentChat.Model.ResumeConfirm — verify resume confirm fixture.
def test_fixture_resume_confirm():
"""Load resume_confirm fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "resume_confirm.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeConfirm"
assert fixture["edge"] == "confirm_from_awaiting"
assert fixture["input"]["action"] == "resumeConfirm"
assert fixture["input"]["args"] == ["confirm"]
expected = fixture["expected"]["state_after"]
assert expected["streamingState"] == "streaming"
assert expected["pendingThreadId"] is None
# #endregion TestAgentChat.ModelFixtures.ResumeConfirm
# #region TestAgentChat.ModelFixtures.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,fixture,deny]
# @BRIEF FX_AgentChat.Model.ResumeDeny — verify resume deny fixture.
def test_fixture_resume_deny():
"""Load resume_deny fixture and verify structural integrity."""
fixture_path = FIXTURES_DIR / "resume_deny.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeDeny"
assert fixture["edge"] == "deny_from_awaiting"
assert fixture["input"]["action"] == "resumeConfirm"
assert fixture["input"]["args"] == ["deny"]
expected = fixture["expected"]["state_after"]
assert expected["streamingState"] == "idle"
assert expected["pendingThreadId"] is None
# #endregion TestAgentChat.ModelFixtures.ResumeDeny
# #region TestAgentChat.ModelFixtures.RejectedWebSocket [C:2] [TYPE Function] [SEMANTICS test,fixture,rejected]
# @BRIEF FX_AgentChat.Model.RejectedPath — verify no WebSocket resurrection.
def test_fixture_rejected_websocket():
"""Verify no WebSocket resurrection in AgentChatModel."""
fixture_path = FIXTURES_DIR / "rejected_websocket.json"
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
with open(fixture_path) as f:
fixture = json.load(f)
assert fixture["fixture_id"] == "FX_AgentChat.Model.RejectedPath"
assert fixture["invariant"] == "NoWebSocketImports"
assert fixture["edge"] == "rejected_path"
assert "No 'WebSocket'" in fixture["expected"]["assertions"][0]
# Read the actual model source
model_path = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "src" / "lib" / "models" / "AgentChatModel.svelte.ts"
source = model_path.read_text()
# Verify no WebSocket imports (not just the word — it's in comments as @REJECTED)
assertions = fixture["expected"]["assertions"]
for assertion in assertions:
if "WebSocket" in assertion:
# Check for actual WebSocket import (import WebSocket, from ... import ... WebSocket)
for line in source.split("\n"):
stripped = line.strip()
if stripped.startswith("import") and "WebSocket" in stripped:
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
if stripped.startswith("from") and "WebSocket" in stripped:
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
if "tabRole" in assertion:
assert "tabRole" not in source, f"FAIL: {assertion}"
if "follower_notify" in assertion:
assert "follower_notify" not in source, f"FAIL: {assertion}"
if "takeoverSession" in assertion:
assert "takeoverSession" not in source, f"FAIL: {assertion}"
# #endregion TestAgentChat.ModelFixtures.RejectedWebSocket
# #endregion TestAgentChat.ModelFixtures

View File

@@ -0,0 +1,248 @@
# #region Test.AgentChat.Run [C:3] [TYPE Module] [SEMANTICS test,agent,run,entrypoint]
# @BRIEF Tests for agent/run.py — _find_free_port and _fetch_llm_config.
# @RELATION BINDS_TO -> [AgentChat.Run]
from pathlib import Path
import socket
from unittest.mock import MagicMock, patch
import pytest
# #region test_find_free_port [C:2] [TYPE Function]
# @BRIEF Test _find_free_port for port scanning behavior.
class TestFindFreePort:
def test_returns_free_port(self):
from ss_tools.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
result = _find_free_port(8000, 10)
assert result == 8000
mock_instance.bind.assert_called_once_with(("", 8000))
def test_skips_busy_ports(self):
from ss_tools.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
# Ports 8000-8002 busy, 8003 free
mock_instance.bind.side_effect = [
OSError("Address in use"), # 8000
OSError("Address in use"), # 8001
OSError("Address in use"), # 8002
None, # 8003 — success
]
result = _find_free_port(8000, 10)
assert result == 8003
assert mock_instance.bind.call_count == 4
def test_raises_when_all_busy(self):
from ss_tools.agent.run import _find_free_port
with patch("socket.socket") as mock_socket:
mock_instance = MagicMock()
mock_socket.return_value.__enter__.return_value = mock_instance
mock_instance.bind.side_effect = OSError("Address in use")
with pytest.raises(OSError, match="No free port found"):
_find_free_port(8000, 3)
assert mock_instance.bind.call_count == 3
# #endregion test_find_free_port
# #region test_fetch_llm_config [C:2] [TYPE Function]
# @BRIEF Test _fetch_llm_config with retry and fallback behavior.
class TestFetchLlmConfig:
def test_returns_config_on_success(self):
from ss_tools.agent.run import _fetch_llm_config
with patch("ss_tools.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True, "provider_type": "openai", "default_model": "gpt-4o"}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is not None
assert result["configured"] is True
def test_returns_none_when_not_configured(self):
from ss_tools.agent.run import _fetch_llm_config
with patch("ss_tools.agent.run.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"configured": False, "reason": "no provider"}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is None
def test_retries_on_failure(self):
from ss_tools.agent.run import _fetch_llm_config
import time as time_module
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = Exception("Connection refused")
result = _fetch_llm_config()
assert result is None
assert mock_get.call_count == 6
def test_retries_then_returns_config(self):
from ss_tools.agent.run import _fetch_llm_config
import time as time_module
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_get.side_effect = [
Exception("Timeout"), # Attempt 1
Exception("Timeout"), # Attempt 2
MagicMock(json=lambda: {"configured": True, "provider_type": "openai"}), # Attempt 3
]
result = _fetch_llm_config()
assert result is not None
assert result["configured"] is True
def test_returns_none_after_max_retries_with_http_error(self):
from ss_tools.agent.run import _fetch_llm_config
import time as time_module
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch.object(time_module, "sleep") as mock_sleep:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is None
assert mock_get.call_count == 6
def test_uses_service_token_header(self):
from ss_tools.agent.run import _fetch_llm_config
with patch("ss_tools.agent.run.httpx.get") as mock_get, \
patch("ss_tools.agent.run.SERVICE_JWT", "test-token"):
mock_response = MagicMock()
mock_response.json.return_value = {"configured": True}
mock_get.return_value = mock_response
result = _fetch_llm_config()
assert result is not None
# Verify Authorization header was sent
call_kwargs = mock_get.call_args[1]
assert call_kwargs["headers"].get("Authorization") == "Bearer test-token"
# #endregion test_fetch_llm_config
# #region test_main_block [C:2] [TYPE Function]
# @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError.
class TestMainBlock:
"""Test the if __name__ == '__main__' entry point block via importlib.util fresh module."""
def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False,
port_bind_sequence=None, port_always_fail=False):
"""Execute run.py as __main__ with given mocking configuration."""
import importlib
import importlib.util
import os
from pathlib import Path
run_path = Path(__file__).parent.parent.parent / "src" / "ss_tools" / "agent" / "run.py"
spec = importlib.util.spec_from_file_location("__main__", str(run_path))
# Apply env overrides
env_overrides = env_overrides or {}
for k, v in env_overrides.items():
monkeypatch.setenv(k, v)
# Reload _config to pick up env var changes (module is cached otherwise)
import ss_tools.agent._config as agent_config
importlib.reload(agent_config)
svc_jwt = env_overrides.get("SERVICE_JWT", os.environ.get("SERVICE_JWT", ""))
gradio_port = int(env_overrides.get("GRADIO_SERVER_PORT", os.environ.get("GRADIO_SERVER_PORT", "7860")))
gradio_fallback = env_overrides.get("GRADIO_ALLOW_PORT_FALLBACK", os.environ.get("GRADIO_ALLOW_PORT_FALLBACK", "false")).lower() in ("1", "true", "yes")
with patch('httpx.get') as mock_httpx_get, \
patch('socket.socket') as mock_socket_cls, \
patch('asyncio.run') as mock_asyncio_run, \
patch('ss_tools.agent.app.create_chat_interface') as mock_create_ci, \
patch('ss_tools.agent.context.set_service_jwt') as mock_set_jwt, \
patch('ss_tools.agent.langgraph_setup.configure_from_api') as mock_configure, \
patch('ss_tools.agent.langgraph_setup.init_checkpointer'), \
patch('ss_tools.agent.run.SERVICE_JWT', svc_jwt), \
patch('ss_tools.agent.run.GRADIO_SERVER_PORT', gradio_port), \
patch('ss_tools.agent.run.GRADIO_ALLOW_PORT_FALLBACK', gradio_fallback):
mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
# httpx for _fetch_llm_config
mock_resp = MagicMock()
if llm_configured:
mock_resp.json.return_value = {
"configured": True, "provider_type": "openai",
"default_model": "gpt-4o", "api_key": "sk-test",
}
else:
mock_resp.json.return_value = {"configured": False}
mock_httpx_get.return_value = mock_resp
# socket for _find_free_port
mock_sock = MagicMock()
mock_sock.__enter__.return_value = mock_sock
mock_socket_cls.return_value = mock_sock
if port_always_fail:
mock_sock.bind.side_effect = OSError("all ports busy")
elif port_bind_sequence is not None:
mock_sock.bind.side_effect = port_bind_sequence
else:
mock_sock.bind.side_effect = [None] # first bind succeeds
mock_demo = MagicMock()
mock_create_ci.return_value = mock_demo
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return {
'set_jwt': mock_set_jwt,
'configure': mock_configure,
'demo': mock_demo,
}
def test_main_block_basic(self, monkeypatch):
"""Main block with default env, no SERVICE_JWT, no LLM config."""
# Ensure SERVICE_JWT is NOT set (some tests leak it via os.environ)
monkeypatch.delenv("SERVICE_JWT", raising=False)
result = self._run_as_main(monkeypatch,
env_overrides={"GRADIO_SERVER_PORT": "27860"})
result['set_jwt'].assert_not_called()
result['configure'].assert_not_called()
result['demo'].launch.assert_called_once()
def test_main_block_with_service_jwt(self, monkeypatch):
"""Main block sets service JWT via ContextVar."""
result = self._run_as_main(monkeypatch, env_overrides={
"SERVICE_JWT": "test-service-token",
"GRADIO_SERVER_PORT": "27861",
})
result['set_jwt'].assert_called_once_with("test-service-token")
def test_main_block_with_llm_config(self, monkeypatch):
"""Main block calls configure_from_api when LLM config is active."""
result = self._run_as_main(monkeypatch,
env_overrides={"GRADIO_SERVER_PORT": "27862"},
llm_configured=True)
result['configure'].assert_called_once()
def test_main_block_port_fallback(self, monkeypatch):
"""Port in use triggers fallback warning (logged but continues)."""
# Ports 27863, 27864 busy → 27865 free
with patch('ss_tools.agent.run.logger') as mock_logger:
result = self._run_as_main(monkeypatch,
env_overrides={
"GRADIO_SERVER_PORT": "27863",
"GRADIO_ALLOW_PORT_FALLBACK": "true",
},
port_bind_sequence=[OSError("in use"), OSError("in use"), None])
result['demo'].launch.assert_called_once()
def test_main_block_port_oserror(self, monkeypatch):
"""OSError during port finding raises in main block."""
with patch('ss_tools.agent.run.logger') as mock_logger:
with pytest.raises(OSError):
self._run_as_main(monkeypatch,
env_overrides={
"GRADIO_SERVER_PORT": "27866",
"GRADIO_ALLOW_PORT_FALLBACK": "true",
},
port_always_fail=True)
# #endregion test_main_block
# #endregion Test.AgentChat.Run

View File

@@ -0,0 +1,178 @@
# #region Test.AgentChat.ToolRetry [C:3] [TYPE Module] [SEMANTICS test,agent,tools,retry]
# @BRIEF Contract tests for _retry_read_tool — fixed-delay retry on transient errors.
# @RELATION BINDS_TO -> [AgentChat.Tools.Retry]
# @TEST_EDGE: first_attempt_502 -> Auto-retries once, succeeds.
# @TEST_EDGE: both_attempts_502 -> Raises original error.
# @TEST_EDGE: write_tool_502 -> No retry, raises immediately.
# @TEST_EDGE: connect_error -> Retries on ConnectError too.
# @TEST_EDGE: read_timeout -> Retries on ReadTimeout too.
import pytest
from unittest.mock import AsyncMock, patch
import httpx
from ss_tools.agent.tools import (
_execute_with_timeout,
_retry_read_tool,
drain_tool_retry_events,
start_tool_retry_event_buffer,
)
# ── Shared fixtures ──────────────────────────────────────────────────
def _make_502_error() -> httpx.HTTPStatusError:
"""Build a synthetic 502 HTTPStatusError for consistent test usage."""
request = httpx.Request("GET", "http://test.local/api/test")
response = httpx.Response(502, request=request)
return httpx.HTTPStatusError("Bad Gateway", request=request, response=response)
def _make_connect_error() -> httpx.ConnectError:
"""Build a synthetic ConnectError for transient-connectivity tests."""
return httpx.ConnectError("Connection refused")
def _make_read_timeout() -> httpx.ReadTimeout:
"""Build a synthetic ReadTimeout for transient-timeout tests."""
return httpx.ReadTimeout("Read timed out")
# ── Tests ────────────────────────────────────────────────────────────
class TestRetryReadTool:
"""Contract tests for _retry_read_tool — the fixed-delay retry wrapper."""
# #region test_first_attempt_502_retries_once [C:2] [TYPE Function]
# @BRIEF First attempt raises 502 → retries once → second attempt succeeds.
async def test_first_attempt_502_retries_once(self):
"""Prove @TEST_EDGE first_attempt_502: one retry + 1s delay → success."""
expected = {"status": "ok", "data": [1, 2, 3]}
error_502 = _make_502_error()
mock_fn = AsyncMock(side_effect=[error_502, expected])
start_tool_retry_event_buffer()
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
result = await _retry_read_tool("read_dashboards", mock_fn)
assert result == expected
assert mock_fn.call_count == 2
mock_sleep.assert_awaited_once_with(1)
assert drain_tool_retry_events() == [{
"content": "🔁 read_dashboards retry 1/2",
"metadata": {
"type": "tool_retry",
"tool": "read_dashboards",
"attempt": 1,
"max_attempts": 2,
},
}]
# #endregion test_first_attempt_502_retries_once
# #region test_both_attempts_502_raises [C:2] [TYPE Function]
# @BRIEF Both attempts raise 502 → exhaust retries → raises original error.
async def test_both_attempts_502_raises(self):
"""Prove @TEST_EDGE both_attempts_502: max 2 attempts, then raise."""
error_502 = _make_502_error()
mock_fn = AsyncMock(side_effect=[error_502, error_502])
with patch("asyncio.sleep", new_callable=AsyncMock), pytest.raises(httpx.HTTPStatusError) as exc_info:
await _retry_read_tool("read_dashboards", mock_fn)
assert exc_info.value is error_502
assert mock_fn.call_count == 2
# #endregion test_both_attempts_502_raises
# #region test_connect_error_retried [C:2] [TYPE Function]
# @BRIEF ConnectError is also retried — not just HTTP status errors.
async def test_connect_error_retried(self):
"""Prove ConnectError triggers the retry path."""
conn_err = _make_connect_error()
expected = "recovered_after_connect_error"
mock_fn = AsyncMock(side_effect=[conn_err, expected])
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await _retry_read_tool("read_some_tool", mock_fn)
assert result == expected
assert mock_fn.call_count == 2
# #endregion test_connect_error_retried
# #region test_read_timeout_retried [C:2] [TYPE Function]
# @BRIEF ReadTimeout is also retried — transient I/O timeouts are recoverable.
async def test_read_timeout_retried(self):
"""Prove ReadTimeout triggers the retry path."""
timeout_err = _make_read_timeout()
expected = "recovered_after_timeout"
mock_fn = AsyncMock(side_effect=[timeout_err, expected])
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await _retry_read_tool("read_big_dataset", mock_fn)
assert result == expected
assert mock_fn.call_count == 2
# #endregion test_read_timeout_retried
# #region test_retry_skips_delay_on_success [C:2] [TYPE Function]
# @BRIEF When first attempt succeeds, no sleep occurs at all.
async def test_retry_skips_delay_on_success(self):
"""Prove that the happy path never sleeps — sleep is only for retries."""
expected = {"result": "immediate"}
mock_fn = AsyncMock(return_value=expected)
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
result = await _retry_read_tool("fast_tool", mock_fn)
assert result == expected
assert mock_fn.call_count == 1
mock_sleep.assert_not_awaited()
# #endregion test_retry_skips_delay_on_success
# #region test_non_http_error_not_retried [C:2] [TYPE Function]
# @BRIEF Non-HTTP errors (e.g. ValueError) propagate immediately — no retry.
async def test_non_http_error_not_retried(self):
"""Prove that only the three specific httpx exception types are retried."""
non_http_err = ValueError("something broken in business logic")
mock_fn = AsyncMock(side_effect=non_http_err)
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, pytest.raises(ValueError) as exc_info:
await _retry_read_tool("broken_tool", mock_fn)
assert exc_info.value is non_http_err
assert mock_fn.call_count == 1
mock_sleep.assert_not_awaited()
# #endregion test_non_http_error_not_retried
class TestWriteToolNoRetry:
"""Prove that write tools bypass _retry_read_tool entirely."""
# #region test_write_tool_502_no_retry [C:2] [TYPE Function]
# @BRIEF Write tool (is_write=True) gets 502 → no retry, raises immediately.
async def test_write_tool_502_raises_immediately(self):
"""Prove @TEST_EDGE write_tool_502: _execute_with_timeout does NOT retry writes.
_post() calls _execute_with_timeout with is_write=True and the raw _request
function — never _retry_read_tool. This test ensures that layer propagates
errors immediately without any retry loop.
"""
error_502 = _make_502_error()
write_op = AsyncMock(side_effect=error_502)
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, pytest.raises(httpx.HTTPStatusError) as exc_info:
await _execute_with_timeout(
"create_dashboard",
write_op,
is_write=True,
timeout_s=5,
)
assert exc_info.value is error_502
assert write_op.call_count == 1
# Critical invariant: no sleep = no retry loop entered
mock_sleep.assert_not_awaited()
# #endregion test_write_tool_502_no_retry
# #endregion Test.AgentChat.ToolRetry

View File

@@ -0,0 +1,73 @@
# #region Test.AgentChat.ToolSummarise [C:3] [TYPE Module] [SEMANTICS test,agent,tools,summarise]
# @BRIEF Contract tests for _summarise_response — structured truncation.
# @RELATION BINDS_TO -> [AgentChat.Tools.Summarise]
# @TEST_EDGE: json_array_50_items -> Large JSON arrays summarised as top-5 + count.
# @TEST_EDGE: short_text_passthrough -> Text ≤ limit returned unchanged.
# @TEST_EDGE: json_object_keys_sample -> Large JSON objects summarised as keys + sample.
# @TEST_EDGE: non_json_sentence_boundary -> Non-JSON text truncated at sentence boundary.
import json
from ss_tools.agent.tools import _summarise_response
# #region test_summarise_json_array_50_items [C:2] [TYPE Function]
# @BRIEF JSON array with 50 items → top-5 summary with remaining count.
def test_summarise_json_array_50_items():
"""Large JSON arrays summarise with top-5 items and remaining count."""
items = [{"id": i, "name": f"item-{i}"} for i in range(50)]
text = json.dumps(items)
summary = _summarise_response(text, limit=200)
assert summary.startswith("Found 50 items:")
assert "item-0" in summary
assert "item-4" in summary
assert "... and 45 more items." in summary
# #endregion test_summarise_json_array_50_items
# #region test_summarise_short_text_passthrough [C:2] [TYPE Function]
# @BRIEF Text ≤ limit is returned unchanged (no truncation, no JSON parse overhead visible).
def test_summarise_short_text_passthrough():
"""Text within limit is returned unchanged."""
text = "This is a short response."
result = _summarise_response(text, limit=100)
assert result == text
# #endregion test_summarise_short_text_passthrough
# #region test_summarise_json_object_keys_sample [C:2] [TYPE Function]
# @BRIEF Large JSON object → key list + sample values.
def test_summarise_json_object_keys_sample():
"""Large JSON objects are summarised with keys and sample values."""
data = {f"key_{i}": f"value_{i}" * 50 for i in range(20)}
text = json.dumps(data)
summary = _summarise_response(text, limit=100)
assert summary.startswith("Result keys: ")
assert "Sample: " in summary
# #endregion test_summarise_json_object_keys_sample
# #region test_summarise_non_json_sentence_boundary [C:2] [TYPE Function]
# @BRIEF Non-JSON long text truncated at last sentence boundary before limit.
def test_summarise_non_json_sentence_boundary():
"""Non-JSON text truncates at last sentence boundary with trailing ellipsis."""
text = ("This is sentence one. " * 100)
summary = _summarise_response(text, limit=500)
# Must end with ellipsis
assert summary.endswith("...")
# Must be shorter than or equal to limit + 3 (ellipsis)
assert len(summary) <= 500
# Must retain at least one sentence boundary before the ellipsis
assert ". " in summary[:-3]
# #endregion test_summarise_non_json_sentence_boundary
# #endregion Test.AgentChat.ToolSummarise

View File

@@ -0,0 +1,87 @@
# #region Test.AgentChat.ToolTimeout [C:3] [TYPE Module] [SEMANTICS test,agent,tools,timeout]
# @BRIEF Contract tests for _execute_with_timeout — configurable timeout wrapper.
# @RELATION BINDS_TO -> [AgentChat.Tools.Timeout]
# @TEST_EDGE: complete_under_timeout -> Tool completes normally, returns result
# @TEST_EDGE: read_tool_timeout -> Read tool exceeds timeout, raises TimeoutError
# @TEST_EDGE: write_tool_timeout -> Write tool exceeds timeout, raises TimeoutError
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
# ── Tests ───────────────────────────────────────────────────────────
# #region test_completes_under_timeout [C:2] [TYPE Function]
# @BRIEF GIVEN a tool that returns quickly WHEN _execute_with_timeout is called with a 30s timeout THEN the result is returned normally.
@pytest.mark.asyncio
async def test_completes_under_timeout():
"""Tool returns its result before the timeout expires."""
from ss_tools.agent.tools import _execute_with_timeout
expected = {"status": "ok", "data": [1, 2, 3]}
fast_fn = AsyncMock(return_value=expected)
with patch("ss_tools.agent.tools.logger.explore") as mock_explore:
result = await _execute_with_timeout("fast_tool", fast_fn, is_write=False, timeout_s=30)
assert result == expected
fast_fn.assert_called_once()
mock_explore.assert_not_called()
# #endregion test_completes_under_timeout
# #region test_read_tool_timeout [C:2] [TYPE Function]
# @BRIEF GIVEN a read tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised and logger.explore is invoked.
@pytest.mark.asyncio
async def test_read_tool_timeout():
"""Read tool that sleeps longer than timeout_s must raise TimeoutError."""
from ss_tools.agent.tools import _execute_with_timeout
async def slow_read():
await asyncio.sleep(0.3)
return "never_reached"
with patch("ss_tools.agent.tools.logger.explore") as mock_explore:
with pytest.raises(TimeoutError):
await _execute_with_timeout("slow_read", slow_read, is_write=False, timeout_s=0.05)
mock_explore.assert_called_once()
call_args = mock_explore.call_args
assert call_args[0][0] == "Tool timeout"
payload = call_args[1]["payload"]
assert payload["tool"] == "slow_read"
assert payload["timeout_s"] == 0.05
assert payload["is_write"] is False
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
# #endregion test_read_tool_timeout
# #region test_write_tool_timeout [C:2] [TYPE Function]
# @BRIEF GIVEN a write tool that exceeds the timeout WHEN _execute_with_timeout is called THEN TimeoutError is raised with is_write=True logged.
@pytest.mark.asyncio
async def test_write_tool_timeout():
"""Write tool that sleeps longer than timeout_s must raise TimeoutError."""
from ss_tools.agent.tools import _execute_with_timeout
async def slow_write():
await asyncio.sleep(0.3)
return "never_reached"
with patch("ss_tools.agent.tools.logger.explore") as mock_explore:
with pytest.raises(TimeoutError):
await _execute_with_timeout("slow_write", slow_write, is_write=True, timeout_s=0.05)
mock_explore.assert_called_once()
call_args = mock_explore.call_args
assert call_args[0][0] == "Tool timeout"
payload = call_args[1]["payload"]
assert payload["tool"] == "slow_write"
assert payload["timeout_s"] == 0.05
assert payload["is_write"] is True
assert call_args[1]["extra"]["src"] == "AgentChat.Tools.Timeout"
# #endregion test_write_tool_timeout
# #endregion Test.AgentChat.ToolTimeout