forked from Rockachopa/Timmy-time-dashboard
* polish: streamline nav, extract inline styles, improve tablet UX - Restructure desktop nav from 8+ flat links + overflow dropdown into 5 grouped dropdowns (Core, Agents, Intel, System, More) matching the mobile menu structure to reduce decision fatigue - Extract all inline styles from mission_control.html and base.html notification elements into mission-control.css with semantic classes - Replace JS-built innerHTML with secure DOM construction in notification loader and chat history - Add CONNECTING state to connection indicator (amber) instead of showing OFFLINE before WebSocket connects - Add tablet breakpoint (1024px) with larger touch targets for Apple Pencil / stylus use and safe-area padding for iPad toolbar - Add active-link highlighting in desktop dropdown menus - Rename "Mission Control" page title to "System Overview" to disambiguate from the chat home page - Add "Home — Timmy Time" page title to index.html https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h * fix(security): move auth-gate credentials to environment variables Hardcoded username, password, and HMAC secret in auth-gate.py replaced with os.environ lookups. Startup now refuses to run if any variable is unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example. https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h * refactor(tooling): migrate from black+isort+bandit to ruff Replace three separate linting/formatting tools with a single ruff invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs), .pre-commit-config.yaml, and CI workflow. Fixes all ruff errors including unused imports, missing raise-from, and undefined names. Ruff config maps existing bandit skips to equivalent S-rules. https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h --------- Co-authored-by: Claude <noreply@anthropic.com>
202 lines
7.1 KiB
Python
202 lines
7.1 KiB
Python
"""Tests for dashboard tool confirmation flow using native Agno RunOutput."""
|
|
|
|
from unittest.mock import 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", 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", 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", 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", 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", 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", 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", 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
|