Replace the bespoke GiteaHand httpx client and tools_gitea.py wrappers with official MCP tool servers (gitea-mcp + filesystem MCP), wired into Agno via MCPTools. Switch all session functions to async (arun/acontinue_run) so MCP tools auto-connect. Delete ~1070 lines of custom Gitea code. - Create src/timmy/mcp_tools.py with MCP factories + standalone issue bridge - Wire MCPTools into agent.py tool list (Gitea + filesystem) - Switch session.py chat/chat_with_tools/continue_chat to async - Update all callers (dashboard routes, Discord vendor, CLI, thinking engine) - Add gitea_token fallback from ~/.config/gitea/token - Add MCP session cleanup to app shutdown hook - Update tool_safety.py for MCP tool names - 11 new tests, all 1417 passing, coverage 74.2% Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
7.3 KiB
Python
210 lines
7.3 KiB
Python
"""Tests for dashboard tool confirmation flow using native Agno RunOutput."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
def _mock_completed_run(content="Just a reply."):
|
|
"""Create a mock RunOutput for a completed (no tool) run."""
|
|
run = MagicMock()
|
|
run.content = content
|
|
run.status = "COMPLETED"
|
|
run.active_requirements = []
|
|
return run
|
|
|
|
|
|
def _mock_paused_run(tool_name="shell", tool_args=None, content="Sure, I can do that."):
|
|
"""Create a mock RunOutput for a paused run needing tool confirmation."""
|
|
tool_args = tool_args or {"command": "echo hello"}
|
|
|
|
te = MagicMock()
|
|
te.tool_name = tool_name
|
|
te.tool_args = tool_args
|
|
|
|
req = MagicMock()
|
|
req.needs_confirmation = True
|
|
req.tool_execution = te
|
|
|
|
run = MagicMock()
|
|
run.content = content
|
|
run.status = "PAUSED"
|
|
run.active_requirements = [req]
|
|
return run, req
|
|
|
|
|
|
def _mock_approval_item(item_id="test-approval-123"):
|
|
"""Create a mock ApprovalItem."""
|
|
item = MagicMock()
|
|
item.id = item_id
|
|
return item
|
|
|
|
|
|
# ── Chat returns tool actions ────────────────────────────────────────────────
|
|
|
|
|
|
def test_chat_with_tool_call_shows_approval_card(client):
|
|
"""When Agno pauses for tool confirmation, the response includes an approval card."""
|
|
run, _req = _mock_paused_run()
|
|
item = _mock_approval_item()
|
|
|
|
with (
|
|
patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run),
|
|
patch("timmy.approvals.create_item", return_value=item),
|
|
):
|
|
response = client.post("/agents/default/chat", data={"message": "run echo hello"})
|
|
|
|
assert response.status_code == 200
|
|
assert "APPROVE" in response.text
|
|
assert "REJECT" in response.text
|
|
assert "shell" in response.text
|
|
|
|
|
|
def test_chat_without_tool_call_has_no_approval_card(client):
|
|
"""Normal responses without tool calls should not show approval buttons."""
|
|
run = _mock_completed_run()
|
|
|
|
with patch("dashboard.routes.agents.chat_with_tools", return_value=run):
|
|
response = client.post("/agents/default/chat", data={"message": "hello"})
|
|
|
|
assert response.status_code == 200
|
|
assert "APPROVE" not in response.text
|
|
assert "REJECT" not in response.text
|
|
|
|
|
|
def test_chat_tool_card_contains_impact_badge(client):
|
|
"""Tool approval cards should show impact level (shell = high)."""
|
|
run, _req = _mock_paused_run()
|
|
item = _mock_approval_item()
|
|
|
|
with (
|
|
patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run),
|
|
patch("timmy.approvals.create_item", return_value=item),
|
|
):
|
|
response = client.post("/agents/default/chat", data={"message": "run it"})
|
|
|
|
assert "impact-high" in response.text
|
|
|
|
|
|
def test_chat_tool_card_has_htmx_approve_endpoint(client):
|
|
"""Approval card buttons should target the correct HTMX endpoints."""
|
|
run, _req = _mock_paused_run()
|
|
item = _mock_approval_item()
|
|
|
|
with (
|
|
patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run),
|
|
patch("timmy.approvals.create_item", return_value=item),
|
|
):
|
|
response = client.post("/agents/default/chat", data={"message": "run it"})
|
|
|
|
assert 'hx-post="/agents/default/tool/' in response.text
|
|
assert '/approve"' in response.text
|
|
assert '/reject"' in response.text
|
|
|
|
|
|
# ── Approve endpoint ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _create_pending_tool(client, approval_id="test-approval-123"):
|
|
"""Helper: send a chat that creates a pending tool, return the approval_id."""
|
|
run, _req = _mock_paused_run()
|
|
item = _mock_approval_item(approval_id)
|
|
|
|
with (
|
|
patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run),
|
|
patch("timmy.approvals.create_item", return_value=item),
|
|
):
|
|
response = client.post("/agents/default/chat", data={"message": "run it"})
|
|
|
|
assert 'hx-post="/agents/default/tool/' in response.text
|
|
return approval_id
|
|
|
|
|
|
def test_approve_executes_tool_and_returns_result(client):
|
|
"""Approving a tool should resume via Agno and return the result card."""
|
|
approval_id = _create_pending_tool(client)
|
|
|
|
# Mock the resume returning a completed run with tool result
|
|
result_run = MagicMock()
|
|
result_tool = MagicMock()
|
|
result_tool.tool_name = "shell"
|
|
result_tool.result = "hello\n"
|
|
result_run.tools = [result_tool]
|
|
result_run.content = "Done."
|
|
|
|
with (
|
|
patch(
|
|
"dashboard.routes.agents.continue_chat", new_callable=AsyncMock, return_value=result_run
|
|
),
|
|
patch("timmy.approvals.approve"),
|
|
):
|
|
response = client.post(f"/agents/default/tool/{approval_id}/approve")
|
|
|
|
assert response.status_code == 200
|
|
assert "hello" in response.text
|
|
|
|
|
|
def test_approve_unknown_id_returns_404(client):
|
|
"""Approving a non-existent tool action should return 404."""
|
|
response = client.post("/agents/default/tool/nonexistent-id/approve")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_approve_same_id_twice_returns_404(client):
|
|
"""Each approval can only be used once."""
|
|
approval_id = _create_pending_tool(client)
|
|
|
|
result_run = _mock_completed_run("ok")
|
|
|
|
with (
|
|
patch(
|
|
"dashboard.routes.agents.continue_chat", new_callable=AsyncMock, return_value=result_run
|
|
),
|
|
patch("timmy.approvals.approve"),
|
|
):
|
|
client.post(f"/agents/default/tool/{approval_id}/approve")
|
|
|
|
# Second attempt should 404
|
|
response = client.post(f"/agents/default/tool/{approval_id}/approve")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# ── Reject endpoint ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_reject_returns_rejected_card(client):
|
|
"""Rejecting a tool should return a rejected status card."""
|
|
approval_id = _create_pending_tool(client)
|
|
|
|
with (
|
|
patch(
|
|
"dashboard.routes.agents.continue_chat",
|
|
new_callable=AsyncMock,
|
|
return_value=_mock_completed_run(),
|
|
),
|
|
patch("timmy.approvals.reject"),
|
|
):
|
|
response = client.post(f"/agents/default/tool/{approval_id}/reject")
|
|
|
|
assert response.status_code == 200
|
|
assert "Rejected" in response.text
|
|
|
|
|
|
def test_reject_unknown_id_still_returns_200(client):
|
|
"""Rejecting a non-existent ID is idempotent (no crash)."""
|
|
with patch("timmy.approvals.reject"):
|
|
response = client.post("/agents/default/tool/nonexistent-id/reject")
|
|
assert response.status_code == 200
|
|
|
|
|
|
# ── Safe tools skip confirmation ─────────────────────────────────────────────
|
|
|
|
|
|
def test_safe_tool_does_not_show_approval(client):
|
|
"""Completed runs (safe tools auto-executed) should not show approval cards."""
|
|
run = _mock_completed_run("Here is the file content.")
|
|
|
|
with patch("dashboard.routes.agents.chat_with_tools", return_value=run):
|
|
response = client.post("/agents/default/chat", data={"message": "read the readme"})
|
|
|
|
assert "APPROVE" not in response.text
|
|
assert "REJECT" not in response.text
|