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:
@@ -1,490 +0,0 @@
|
||||
# backend/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 src.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 src.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 src.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 src.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 src.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 src.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 src.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 src.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 src.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 src.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 src.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 src.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("src.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
|
||||
patch("src.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 src.agent._confirmation import _format_tool_output_via_llm
|
||||
|
||||
raw = '[{"id":"ss-dev","name":"Dev"}]'
|
||||
with patch("src.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 src.agent._confirmation import _format_tool_output_via_llm
|
||||
|
||||
raw = '{"key": "value"}'
|
||||
with patch("src.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 src.agent._confirmation import _format_tool_output_via_llm
|
||||
|
||||
raw = '[{"a": 1}]'
|
||||
with patch("src.agent.langgraph_setup._fetch_llm_config") as mock_cfg, \
|
||||
patch("src.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 src.agent._confirmation import _format_tool_output_via_llm
|
||||
|
||||
raw = "Operation completed successfully"
|
||||
with patch("src.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 src.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 src.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("src.agent._confirmation.find_tool", return_value=tool), \
|
||||
patch("src.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 src.agent._confirmation import handle_resume, _pending_confirmations
|
||||
|
||||
_pending_confirmations["conv-unknown"] = {
|
||||
"tool_name": "nonexistent_tool_xyz",
|
||||
"tool_args": {},
|
||||
}
|
||||
|
||||
with patch("src.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 src.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("src.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 src.agent._confirmation import handle_resume
|
||||
|
||||
mock_agent = MagicMock()
|
||||
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("src.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 src.agent._confirmation import handle_resume
|
||||
|
||||
mock_agent = MagicMock()
|
||||
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("src.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 src.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("src.agent._confirmation.create_agent", return_value=mock_agent), \
|
||||
patch("src.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 src.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 src.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
|
||||
@@ -1,561 +0,0 @@
|
||||
# #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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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 src.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 src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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 src.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 src.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 src.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 src.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 src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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 src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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("src.agent.tools.logger", MagicMock()), \
|
||||
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
|
||||
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
|
||||
patch("src.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 src.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 src.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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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
|
||||
@@ -1,129 +0,0 @@
|
||||
# backend/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 src.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
|
||||
@@ -1,241 +0,0 @@
|
||||
# backend/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 src.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
|
||||
@@ -1,218 +0,0 @@
|
||||
# backend/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 src.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
|
||||
@@ -1,274 +0,0 @@
|
||||
# #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("src.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 src.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("src.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 src.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("src.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 src.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("src.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 src.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("src.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 src.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("src.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 src.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("src.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
|
||||
@@ -1,255 +0,0 @@
|
||||
# backend/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 src.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
|
||||
@@ -1,106 +0,0 @@
|
||||
# #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
|
||||
@@ -1,682 +0,0 @@
|
||||
# #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 src.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 src.agent._persistence import extract_user_id
|
||||
|
||||
with patch("src.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 src.agent._persistence import extract_user_id
|
||||
|
||||
with patch("src.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 src.agent._persistence import extract_user_id
|
||||
|
||||
with patch("src.agent._jwt_decoder.decode_token", side_effect=Exception("bad token")):
|
||||
assert extract_user_id("bad") == "unknown"
|
||||
|
||||
def test_returns_unknown_on_empty(self):
|
||||
from src.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 src.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 src.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 src.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 src.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 src.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 src.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("src.agent._confirmation.find_tool", return_value=tool), patch("src.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 src.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 src.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 src.agent.app import agent_handler
|
||||
|
||||
# handle_resume is imported into app.py from _confirmation
|
||||
with patch("src.agent.app.handle_resume") as mock_resume:
|
||||
mock_resume.return_value = _make_async_iter([json.dumps({"content": "confirmed", "metadata": {"type": "confirm_resolved"}})])
|
||||
with patch("src.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 src.agent.app import agent_handler
|
||||
|
||||
with patch("src.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("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent),
|
||||
patch("src.agent.app.get_all_tools", return_value=[]),
|
||||
patch("src.agent.app.prefetch_dashboards", AsyncMock(return_value="Available dashboards\n- USA Births Names (id: 15)")),
|
||||
patch("src.agent.app.generate_llm_title", AsyncMock()),
|
||||
patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.agent.app.save_conversation", AsyncMock()), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.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("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.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("src.agent.app.decode_token", side_effect=JWTError("invalid")), patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.agent.app import agent_handler
|
||||
from src.agent.context import get_user_jwt
|
||||
from src.core.auth.jwt import create_access_token
|
||||
|
||||
token = create_access_token({"sub": "admin", "scopes": ["Admin"]})
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
with patch("src.agent.app.create_agent", return_value=agent), patch("src.agent.app.get_all_tools", return_value=[]), patch("src.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 src.agent._confirmation import handle_resume
|
||||
|
||||
mock_agent = MagicMock()
|
||||
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.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 src.agent._confirmation import handle_resume
|
||||
|
||||
mock_agent = MagicMock()
|
||||
with patch("src.agent._confirmation.create_agent", return_value=mock_agent), patch("src.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 src.agent._persistence import save_conversation
|
||||
|
||||
with patch("src.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 src.agent._persistence import save_conversation
|
||||
|
||||
with patch("src.agent._persistence.httpx.AsyncClient") as mock_client, patch("src.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 src.agent._persistence import save_conversation
|
||||
|
||||
with patch("src.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 src.agent._persistence import save_conversation
|
||||
|
||||
with patch("src.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 src.agent.app import create_chat_interface
|
||||
|
||||
with patch("src.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 src.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 src.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("src.agent.app.create_agent") as mock_create,
|
||||
patch("src.agent.app.get_all_tools", return_value=[]),
|
||||
patch("src.agent.app.save_conversation", AsyncMock()),
|
||||
patch("src.agent.app.log_tool_event", AsyncMock()),
|
||||
patch("src.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" / "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
|
||||
@@ -1,148 +0,0 @@
|
||||
# #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("src.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 src.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 src.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("src.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 src.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("src.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
|
||||
@@ -1,303 +0,0 @@
|
||||
# #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 src.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 src.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 src.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 src.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
|
||||
@@ -1,82 +0,0 @@
|
||||
# #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 src.agent._tool_filter import build_tool_pipeline
|
||||
from src.agent.context import set_user_role
|
||||
from src.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
|
||||
@@ -1,134 +0,0 @@
|
||||
# #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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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("src.agent.tools.logger", MagicMock()):
|
||||
from src.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 src.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 src.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("src.agent.tools.logger", MagicMock()):
|
||||
from src.agent.tools import get_all_tools
|
||||
try:
|
||||
from src.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 src.agent._tool_resolver import normalize_tool_args
|
||||
assert normalize_tool_args({"key": "value"}) == {"key": "value"}
|
||||
|
||||
def test_normalize_tool_args_handles_none(self):
|
||||
from src.agent._tool_resolver import normalize_tool_args
|
||||
assert normalize_tool_args(None) == {}
|
||||
|
||||
def test_coerce_tool_call_from_dict(self):
|
||||
from src.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 src.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 src.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
|
||||
@@ -1,446 +0,0 @@
|
||||
# #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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
import src.agent.tools as tools_mod
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.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 src.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 src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt, set_user_role
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt, set_user_role
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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 src.agent.context import set_service_jwt, set_user_jwt
|
||||
from src.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
|
||||
@@ -1,183 +0,0 @@
|
||||
# #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 src.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 src.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 src.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("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.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 src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None # Reset
|
||||
with patch("src.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 src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
ls.configure_from_api({
|
||||
"configured": True,
|
||||
"api_key": "sk-key-only",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.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 src.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("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.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 src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
ls.configure_from_api({
|
||||
"configured": True,
|
||||
"api_key": "sk-test",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.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 src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
ls.configure_from_api({
|
||||
"configured": True,
|
||||
"api_key": "sk-test",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
|
||||
patch("src.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 src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
ls.configure_from_api({
|
||||
"configured": True,
|
||||
"api_key": "sk-test",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
|
||||
patch("src.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 src.agent.langgraph_setup as ls
|
||||
ls._llm_config = None
|
||||
ls.configure_from_api({
|
||||
"configured": True,
|
||||
"api_key": "sk-test",
|
||||
})
|
||||
with patch("src.agent.langgraph_setup.ChatOpenAI"), \
|
||||
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \
|
||||
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
|
||||
patch("src.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
|
||||
@@ -1,88 +0,0 @@
|
||||
# #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 src.agent.middleware import log_tool_event
|
||||
event = {
|
||||
"event": "on_tool_start",
|
||||
"name": "migrate",
|
||||
"data": {"input": {"dashboard_id": "42"}},
|
||||
}
|
||||
with patch("src.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 src.agent.middleware import log_tool_event
|
||||
event = {
|
||||
"event": "on_tool_end",
|
||||
"name": "migrate",
|
||||
"data": {"output": "success"},
|
||||
}
|
||||
with patch("src.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 src.agent.middleware import log_tool_event
|
||||
event = {
|
||||
"event": "on_tool_error",
|
||||
"name": "migrate",
|
||||
"data": {"error": "Connection failed"},
|
||||
}
|
||||
with patch("src.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 src.agent.middleware import log_tool_event
|
||||
event = {
|
||||
"event": "on_tool_start",
|
||||
"name": "test_tool",
|
||||
"data": {"input": {}},
|
||||
}
|
||||
with patch("src.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 src.agent.middleware import log_tool_event
|
||||
event = {"event": "on_tool_start", "name": "test_tool"}
|
||||
with patch("src.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 src.agent.middleware import log_tool_event
|
||||
event = {"event": "on_custom_event", "name": "custom"}
|
||||
with patch("src.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 src.agent.middleware import log_tool_event
|
||||
long_input = "x" * 1000
|
||||
event = {
|
||||
"event": "on_tool_start",
|
||||
"name": "big_tool",
|
||||
"data": {"input": long_input},
|
||||
}
|
||||
with patch("src.agent.middleware.get_user_jwt", return_value="token"):
|
||||
await log_tool_event(event, "conv-1")
|
||||
# #endregion test_log_tool_event
|
||||
# #endregion Test.AgentChat.Middleware
|
||||
@@ -1,130 +0,0 @@
|
||||
# #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
|
||||
@@ -1,252 +0,0 @@
|
||||
# #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 sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
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 src.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 src.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 src.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 src.agent.run import _fetch_llm_config
|
||||
with patch("src.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 src.agent.run import _fetch_llm_config
|
||||
with patch("src.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 src.agent.run import _fetch_llm_config
|
||||
import time as time_module
|
||||
with patch("src.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 src.agent.run import _fetch_llm_config
|
||||
import time as time_module
|
||||
with patch("src.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 src.agent.run import _fetch_llm_config
|
||||
import time as time_module
|
||||
with patch("src.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 src.agent.run import _fetch_llm_config
|
||||
with patch("src.agent.run.httpx.get") as mock_get, \
|
||||
patch("src.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" / "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 src.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('src.agent.app.create_chat_interface') as mock_create_ci, \
|
||||
patch('src.agent.context.set_service_jwt') as mock_set_jwt, \
|
||||
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \
|
||||
patch('src.agent.langgraph_setup.init_checkpointer'), \
|
||||
patch('src.agent.run.SERVICE_JWT', svc_jwt), \
|
||||
patch('src.agent.run.GRADIO_SERVER_PORT', gradio_port), \
|
||||
patch('src.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('src.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('src.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
|
||||
@@ -1,178 +0,0 @@
|
||||
# #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 src.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
|
||||
@@ -1,73 +0,0 @@
|
||||
# #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 src.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
|
||||
@@ -1,87 +0,0 @@
|
||||
# #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 src.agent.tools import _execute_with_timeout
|
||||
|
||||
expected = {"status": "ok", "data": [1, 2, 3]}
|
||||
fast_fn = AsyncMock(return_value=expected)
|
||||
|
||||
with patch("src.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 src.agent.tools import _execute_with_timeout
|
||||
|
||||
async def slow_read():
|
||||
await asyncio.sleep(0.3)
|
||||
return "never_reached"
|
||||
|
||||
with patch("src.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 src.agent.tools import _execute_with_timeout
|
||||
|
||||
async def slow_write():
|
||||
await asyncio.sleep(0.3)
|
||||
return "never_reached"
|
||||
|
||||
with patch("src.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
|
||||
Reference in New Issue
Block a user