Skip to content

Commit db63730

Browse files
author
Santosh Kumar Radha
committed
Add quick start functional test
1 parent 19af7e9 commit db63730

1 file changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""
2+
Functional test: Quick Start workflow from README
3+
4+
This test mirrors the Quick Start documentation by:
5+
1. Creating a Python agent with a fetch_url skill and summarize reasoner
6+
2. Registering the agent with the AgentField control plane
7+
3. Executing the summarize reasoner via the control plane API
8+
4. Verifying the agent can read live content and summarize it with OpenRouter
9+
"""
10+
11+
import asyncio
12+
import os
13+
import socket
14+
import threading
15+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16+
from typing import Dict, Optional, Tuple
17+
18+
import pytest
19+
import requests
20+
import uvicorn
21+
22+
23+
AGENT_BIND_HOST = os.environ.get("TEST_AGENT_BIND_HOST", "127.0.0.1")
24+
AGENT_CALLBACK_HOST = os.environ.get("TEST_AGENT_CALLBACK_HOST", "127.0.0.1")
25+
QUICK_START_URL = os.environ.get("TEST_QUICK_START_URL")
26+
27+
EXAMPLE_DOMAIN_HTML = """<!doctype html>
28+
<html>
29+
<head>
30+
<title>Example Domain</title>
31+
<meta charset="utf-8" />
32+
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
33+
<meta name="viewport" content="width=device-width, initial-scale=1" />
34+
</head>
35+
<body>
36+
<div>
37+
<h1>Example Domain</h1>
38+
<p>This domain is for use in illustrative examples in documents. You may use this
39+
domain in literature without prior coordination or asking for permission.</p>
40+
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
41+
</div>
42+
</body>
43+
</html>
44+
"""
45+
46+
47+
def _start_example_domain_server() -> Tuple[ThreadingHTTPServer, threading.Thread, str]:
48+
"""
49+
Spin up a lightweight HTTP server that serves the Example Domain HTML used in docs.
50+
"""
51+
52+
class ExampleDomainHandler(BaseHTTPRequestHandler):
53+
def do_GET(self):
54+
self.send_response(200)
55+
self.send_header("Content-Type", "text/html; charset=utf-8")
56+
self.end_headers()
57+
self.wfile.write(EXAMPLE_DOMAIN_HTML.encode("utf-8"))
58+
59+
def log_message(self, *_):
60+
# Silence default logging noise from BaseHTTPRequestHandler
61+
return
62+
63+
server = ThreadingHTTPServer(("127.0.0.1", 0), ExampleDomainHandler)
64+
thread = threading.Thread(target=server.serve_forever, daemon=True)
65+
thread.start()
66+
host, port = server.server_address
67+
return server, thread, f"http://{host}:{port}"
68+
69+
70+
@pytest.mark.functional
71+
@pytest.mark.openrouter
72+
@pytest.mark.asyncio
73+
async def test_quick_start_documentation_flow(
74+
make_test_agent,
75+
openrouter_config,
76+
async_http_client,
77+
):
78+
"""
79+
Validate the README Quick Start instructions end-to-end.
80+
81+
This spins up the canonical agent from the docs (fetch_url + summarize),
82+
registers it with the control plane, runs a live summary request against
83+
https://example.com, and ensures the response structure matches expectations.
84+
"""
85+
content_server: Optional[ThreadingHTTPServer] = None
86+
content_thread: Optional[threading.Thread] = None
87+
88+
# Determine which URL to summarize. Default to local Example Domain server
89+
# to avoid relying on outbound internet access, but allow overriding via env.
90+
if QUICK_START_URL:
91+
target_url = QUICK_START_URL
92+
else:
93+
content_server, content_thread, target_url = _start_example_domain_server()
94+
95+
# ----------------------------------------------------------------------
96+
# Step 1: Create the Quick Start agent with OpenRouter configuration
97+
# ----------------------------------------------------------------------
98+
agent = make_test_agent(
99+
node_id="quick-start-agent",
100+
ai_config=openrouter_config,
101+
)
102+
103+
# ----------------------------------------------------------------------
104+
# Step 2: Define the fetch_url skill and summarize reasoner
105+
# ----------------------------------------------------------------------
106+
@agent.skill()
107+
def fetch_url(url: str) -> str:
108+
response = requests.get(url, timeout=10)
109+
response.raise_for_status()
110+
return response.text
111+
112+
@agent.reasoner()
113+
async def summarize(url: str) -> Dict[str, str]:
114+
"""
115+
Quick Start reasoner: fetch a URL and summarize it with OpenRouter.
116+
"""
117+
content = fetch_url(url)
118+
truncated = content[:2000] # keep context manageable for the model
119+
120+
ai_response = await agent.ai(
121+
system=(
122+
"You summarize documentation for internal verification. "
123+
"Always mention the phrase 'Example Domain' exactly once."
124+
),
125+
user=(
126+
"Summarize the following web page in no more than two sentences. "
127+
"Focus on what the site is intended for.\n"
128+
f"Content:\n{truncated}"
129+
),
130+
)
131+
summary_text = getattr(ai_response, "text", str(ai_response)).strip()
132+
133+
return {
134+
"url": url,
135+
"summary": summary_text,
136+
"content_snippet": truncated[:200],
137+
}
138+
139+
# ----------------------------------------------------------------------
140+
# Step 3: Start the agent server on a free port
141+
# ----------------------------------------------------------------------
142+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
143+
s.bind((AGENT_BIND_HOST, 0))
144+
agent_port = s.getsockname()[1]
145+
146+
agent.base_url = f"http://{AGENT_CALLBACK_HOST}:{agent_port}"
147+
148+
config = uvicorn.Config(
149+
app=agent,
150+
host=AGENT_BIND_HOST,
151+
port=agent_port,
152+
log_level="error",
153+
access_log=False,
154+
)
155+
server = uvicorn.Server(config)
156+
loop = asyncio.new_event_loop()
157+
158+
def run_server():
159+
asyncio.set_event_loop(loop)
160+
loop.run_until_complete(server.serve())
161+
162+
thread = threading.Thread(target=run_server, daemon=True)
163+
thread.start()
164+
165+
# Give uvicorn a moment to start
166+
await asyncio.sleep(2)
167+
168+
try:
169+
# ------------------------------------------------------------------
170+
# Step 4: Register agent with the control plane
171+
# ------------------------------------------------------------------
172+
await agent.agentfield_handler.register_with_agentfield_server(agent_port)
173+
agent.agentfield_server = None
174+
175+
# Wait for registration propagation
176+
await asyncio.sleep(2)
177+
178+
nodes_response = await async_http_client.get(f"/api/v1/nodes/{agent.node_id}")
179+
assert nodes_response.status_code == 200, nodes_response.text
180+
181+
node_data = nodes_response.json()
182+
assert node_data["id"] == agent.node_id
183+
assert "summarize" in [r["id"] for r in node_data.get("reasoners", [])]
184+
185+
# ------------------------------------------------------------------
186+
# Step 5: Execute the Quick Start reasoner through control plane
187+
# ------------------------------------------------------------------
188+
execution_request = {"input": {"url": target_url}}
189+
190+
execution_response = await async_http_client.post(
191+
f"/api/v1/reasoners/{agent.node_id}.summarize",
192+
json=execution_request,
193+
timeout=90.0,
194+
)
195+
196+
assert execution_response.status_code == 200, execution_response.text
197+
result_data = execution_response.json()
198+
199+
# ------------------------------------------------------------------
200+
# Step 6: Validate summary output and metadata
201+
# ------------------------------------------------------------------
202+
assert "result" in result_data
203+
result = result_data["result"]
204+
205+
assert result["url"] == target_url
206+
summary_text = result["summary"]
207+
assert summary_text, "Summary should not be empty"
208+
assert len(summary_text.split()) >= 5, "Summary should contain multiple words"
209+
210+
snippet = result.get("content_snippet", "")
211+
assert "Example Domain" in snippet, "Snippet should contain fetched page content"
212+
assert len(snippet) > 0
213+
214+
assert result_data["node_id"] == agent.node_id
215+
assert result_data["duration_ms"] > 0
216+
217+
headers = execution_response.headers
218+
assert "X-Workflow-ID" in headers or "x-workflow-id" in headers
219+
assert "X-Execution-ID" in headers or "x-execution-id" in headers
220+
221+
finally:
222+
# ------------------------------------------------------------------
223+
# Cleanup: Stop agent server
224+
# ------------------------------------------------------------------
225+
server.should_exit = True
226+
if loop.is_running():
227+
loop.call_soon_threadsafe(lambda: None)
228+
thread.join(timeout=10)
229+
230+
if content_server:
231+
content_server.shutdown()
232+
if content_thread:
233+
content_thread.join(timeout=5)

0 commit comments

Comments
 (0)