144 lines
5.2 KiB
Python
144 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import app
|
|
from core.auth.models import CurrentUser
|
|
from v1.agent.dependencies import get_agent_service
|
|
from v1.agent.schemas import RunAgentInput
|
|
from v1.users.dependencies import get_current_user
|
|
|
|
|
|
class FakeAgentServiceWithInterrupt:
|
|
async def stream_run(self, input_data: RunAgentInput):
|
|
yield 'data: {"type": "RUN_STARTED", "runId": "' + input_data.runId + '"}\n\n'
|
|
yield 'data: {"type": "TEXT_MESSAGE_START", "messageId": "m1"}\n\n'
|
|
yield 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Let me navigate"}\n\n'
|
|
yield 'data: {"type": "TOOL_CALL", "toolName": "ui.navigate_to", "args": {"path": "/home"}}\n\n'
|
|
yield (
|
|
'data: {"type": "RUN_FINISHED", "runId": "'
|
|
+ input_data.runId
|
|
+ '", "outcome": "interrupt", "interrupt": {"id": "int-1", "reason": "frontend_tool", "payload": {"toolName": "ui.navigate_to", "args": {"path": "/home"}}}}\n\n'
|
|
)
|
|
|
|
async def stream_resume(self, run_id: str, input_data: RunAgentInput):
|
|
if input_data.resume and input_data.resume.get("interruptId") == "int-1":
|
|
payload = input_data.resume.get("payload", {})
|
|
yield 'data: {"type": "RUN_STARTED", "runId": "' + run_id + '"}\n\n'
|
|
yield (
|
|
'data: {"type": "TOOL_RESULT", "toolName": "ui.navigate_to", "result": '
|
|
+ str(payload.get("result", {}))
|
|
+ "}\n\n"
|
|
)
|
|
yield 'data: {"type": "TEXT_MESSAGE_START", "messageId": "m2"}\n\n'
|
|
yield 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Navigation completed"}\n\n'
|
|
yield 'data: {"type": "RUN_FINISHED", "runId": "' + run_id + '"}\n\n'
|
|
else:
|
|
yield (
|
|
'data: {"type": "RUN_FINISHED", "runId": "'
|
|
+ run_id
|
|
+ '", "outcome": "error"}\n\n'
|
|
)
|
|
|
|
|
|
def _get_test_user() -> CurrentUser:
|
|
return CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001"))
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
app.dependency_overrides[get_current_user] = _get_test_user
|
|
app.dependency_overrides[get_agent_service] = (
|
|
lambda: FakeAgentServiceWithInterrupt()
|
|
)
|
|
yield TestClient(app)
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
class TestInterruptResumeFlow:
|
|
def test_frontend_tool_interrupt_then_resume_with_result(self, client: TestClient):
|
|
payload = {
|
|
"threadId": "t1",
|
|
"runId": "r1",
|
|
"state": {},
|
|
"messages": [{"role": "user", "content": "Navigate to home"}],
|
|
"tools": [{"name": "ui.navigate_to", "execution_target": "frontend"}],
|
|
"context": [],
|
|
"forwardedProps": {},
|
|
}
|
|
response = client.post("/api/v1/agent/runs", json=payload)
|
|
assert response.status_code == 200
|
|
|
|
events = response.text.split("\n\n")
|
|
interrupt_event = [e for e in events if '"outcome": "interrupt"' in e][0]
|
|
assert '"id": "int-1"' in interrupt_event
|
|
assert '"reason": "frontend_tool"' in interrupt_event
|
|
|
|
resume_payload = {
|
|
"threadId": "t1",
|
|
"runId": "r1",
|
|
"state": {},
|
|
"messages": [],
|
|
"tools": [],
|
|
"context": [],
|
|
"forwardedProps": {},
|
|
"resume": {
|
|
"interruptId": "int-1",
|
|
"payload": {"result": {"success": True}},
|
|
},
|
|
}
|
|
resume_response = client.post(
|
|
"/api/v1/agent/runs/r1/resume", json=resume_payload
|
|
)
|
|
assert resume_response.status_code == 200
|
|
|
|
resume_events = resume_response.text.split("\n\n")
|
|
tool_result_event = [e for e in resume_events if '"type": "TOOL_RESULT"' in e][
|
|
0
|
|
]
|
|
assert '"toolName": "ui.navigate_to"' in tool_result_event
|
|
assert (
|
|
"'success': True" in tool_result_event
|
|
or '"success": true' in tool_result_event.lower()
|
|
)
|
|
|
|
def test_backend_tool_approval_rejected(self, client: TestClient):
|
|
payload = {
|
|
"threadId": "t2",
|
|
"runId": "r2",
|
|
"state": {},
|
|
"messages": [{"role": "user", "content": "Transfer funds"}],
|
|
"tools": [
|
|
{
|
|
"name": "srv.transfer_funds",
|
|
"execution_target": "backend",
|
|
"requires_approval": True,
|
|
}
|
|
],
|
|
"context": [],
|
|
"forwardedProps": {},
|
|
}
|
|
response = client.post("/api/v1/agent/runs", json=payload)
|
|
assert response.status_code == 200
|
|
|
|
resume_payload = {
|
|
"threadId": "t2",
|
|
"runId": "r2",
|
|
"state": {},
|
|
"messages": [],
|
|
"tools": [],
|
|
"context": [],
|
|
"forwardedProps": {},
|
|
"resume": {
|
|
"interruptId": "int-1",
|
|
"payload": {"decision": "rejected", "reason": "User denied"},
|
|
},
|
|
}
|
|
resume_response = client.post(
|
|
"/api/v1/agent/runs/r2/resume", json=resume_payload
|
|
)
|
|
assert resume_response.status_code == 200
|