Skip to content

Commit b6afee8

Browse files
committed
extended integration tests coverage
1 parent 0dbf23e commit b6afee8

1 file changed

Lines changed: 147 additions & 32 deletions

File tree

‎tests/integration/endpoints/test_a2a_integration.py‎

Lines changed: 147 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,9 @@ def _jsonrpc_body(
122122
context_id: Optional[str] = None,
123123
metadata: Optional[dict[str, Any]] = None,
124124
parts: Optional[list[dict[str, Any]]] = None,
125+
method: str = "message/send",
125126
) -> dict[str, Any]:
126-
"""Build a ``message/send`` JSON-RPC request body.
127+
"""Build a JSON-RPC request body for message/send or message/stream.
127128
128129
Parameters:
129130
text: Text content for the default single text part. Ignored when
@@ -132,6 +133,7 @@ def _jsonrpc_body(
132133
metadata: Optional message metadata (e.g. ``model``, ``vector_store_ids``).
133134
parts: Optional explicit parts list, overriding the default single
134135
text part. Pass ``[]`` to build a message with no input content.
136+
method: JSON-RPC method, either "message/send" or "message/stream".
135137
"""
136138
message: dict[str, Any] = {
137139
"messageId": str(uuid.uuid4()),
@@ -145,7 +147,7 @@ def _jsonrpc_body(
145147
return {
146148
"jsonrpc": "2.0",
147149
"id": "1",
148-
"method": "message/send",
150+
"method": method,
149151
"params": {"message": message},
150152
}
151153

@@ -210,18 +212,70 @@ async def test_a2a_jsonrpc_forbidden_without_action(
210212

211213

212214
@pytest.mark.asyncio
213-
async def test_message_send_returns_failed_task_when_ogx_is_unreachable(
215+
@pytest.mark.parametrize(
216+
"method",
217+
["message/send", "message/stream"],
218+
)
219+
async def test_ogx_unreachable_handles_gracefully(
214220
mock_ogx_client: Any,
215221
test_auth: AuthTuple,
222+
method: str,
216223
) -> None:
217-
"""``message/send`` returns a failed task when OGX cannot be reached."""
224+
"""Both message/send and message/stream handle OGX unreachable gracefully."""
225+
from starlette.responses import StreamingResponse
226+
218227
mock_ogx_client.openai.list.side_effect = ApiException(status=None)
219228

220-
result = _jsonrpc_result(
221-
await _post_a2a(_jsonrpc_body("What is a pod?"), test_auth)
229+
response = await _post_a2a(
230+
_jsonrpc_body("What is a pod?", method=method), test_auth
231+
)
232+
233+
if method == "message/send":
234+
result = _jsonrpc_result(response)
235+
assert _task_state(result) == "failed"
236+
else:
237+
assert isinstance(response, StreamingResponse)
238+
239+
240+
@pytest.mark.asyncio
241+
@pytest.mark.parametrize(
242+
"method",
243+
["message/send", "message/stream"],
244+
)
245+
async def test_message_without_text_short_circuits(
246+
test_auth: AuthTuple,
247+
mocker: MockerFixture,
248+
method: str,
249+
) -> None:
250+
"""Message without text short-circuits without calling agent.
251+
252+
Note: the parts list itself can't be empty -- the a2a-sdk's own
253+
``new_task()`` raises ``ValueError`` for an empty ``parts`` list before
254+
our handler ever runs (see ``a2a.utils.task.new_task``), which surfaces
255+
as a JSON-RPC -32603 Internal Error rather than our own
256+
``input_required`` handling. To reach our ``if not user_input`` branch
257+
for real, the message must carry a non-text part (e.g. ``DataPart``) so
258+
``RequestContext.get_user_input()`` -- which only extracts ``TextPart``
259+
content -- returns an empty string while ``parts`` stays non-empty.
260+
"""
261+
from starlette.responses import StreamingResponse
262+
263+
build_agent = mocker.patch("app.endpoints.a2a.build_agent")
264+
265+
response = await _post_a2a(
266+
_jsonrpc_body(
267+
"", parts=[{"kind": "data", "data": {"foo": "bar"}}], method=method
268+
),
269+
test_auth,
222270
)
223271

224-
assert _task_state(result) == "failed"
272+
if method == "message/send":
273+
result = _jsonrpc_result(response)
274+
assert _task_state(result) == "input-required"
275+
else:
276+
assert isinstance(response, StreamingResponse)
277+
278+
build_agent.assert_not_called()
225279

226280

227281
@pytest.mark.asyncio
@@ -239,10 +293,13 @@ async def test_follow_up_reuses_one_conversation(
239293
context_id = _context_id(first)
240294
assert context_id
241295

242-
await _post_a2a(
243-
_jsonrpc_body("What is its population?", context_id=context_id),
244-
test_auth,
296+
second = _jsonrpc_result(
297+
await _post_a2a(
298+
_jsonrpc_body("What is its population?", context_id=context_id),
299+
test_auth,
300+
)
245301
)
302+
assert _context_id(second) == context_id
246303

247304
mock_ogx_client.conversations.create.assert_awaited_once()
248305
assert build_agent.call_args.args[1].conversation == "conv_" + "a" * 48
@@ -299,32 +356,90 @@ async def test_message_metadata_sets_vector_store_ids(
299356

300357

301358
@pytest.mark.asyncio
302-
async def test_message_send_without_text_returns_input_required(
303-
mock_ogx_client: Any, # pylint: disable=unused-argument
359+
async def test_a2a_health_check_returns_healthy_status() -> None:
360+
"""Health check endpoint returns expected structure and healthy status."""
361+
result = await a2a_endpoint.a2a_health_check()
362+
363+
assert result["status"] == "healthy"
364+
assert result["service"] == "lightspeed-a2a"
365+
assert "version" in result
366+
assert result["version"]
367+
assert "a2a_sdk_version" in result
368+
assert result["a2a_sdk_version"]
369+
assert "timestamp" in result
370+
assert result["timestamp"]
371+
372+
373+
@pytest.mark.asyncio
374+
@pytest.mark.parametrize(
375+
"endpoint_path",
376+
[
377+
"/.well-known/agent.json",
378+
"/.well-known/agent-card.json",
379+
],
380+
)
381+
async def test_agent_card_endpoints_return_same_card(
382+
test_auth: AuthTuple,
383+
endpoint_path: str,
384+
) -> None:
385+
"""Both agent card endpoints return identical agent card based on _AGENT_CARD_CONFIG."""
386+
agent_card = await a2a_endpoint.get_agent_card(auth=test_auth)
387+
388+
assert agent_card.name == _AGENT_CARD_CONFIG["name"]
389+
assert agent_card.description == _AGENT_CARD_CONFIG["description"]
390+
assert agent_card.protocol_version == _AGENT_CARD_CONFIG["protocolVersion"]
391+
392+
assert agent_card.provider is not None
393+
assert (
394+
agent_card.provider.organization
395+
== _AGENT_CARD_CONFIG["provider"]["organization"]
396+
)
397+
assert agent_card.provider.url == _AGENT_CARD_CONFIG["provider"]["url"]
398+
399+
assert len(agent_card.skills) == len(_AGENT_CARD_CONFIG["skills"])
400+
assert agent_card.skills[0].id == _AGENT_CARD_CONFIG["skills"][0]["id"]
401+
assert agent_card.skills[0].name == _AGENT_CARD_CONFIG["skills"][0]["name"]
402+
assert (
403+
agent_card.skills[0].description
404+
== _AGENT_CARD_CONFIG["skills"][0]["description"]
405+
)
406+
407+
assert agent_card.capabilities is not None
408+
assert (
409+
agent_card.capabilities.streaming
410+
== _AGENT_CARD_CONFIG["capabilities"]["streaming"]
411+
)
412+
assert (
413+
agent_card.capabilities.push_notifications
414+
== _AGENT_CARD_CONFIG["capabilities"]["pushNotifications"]
415+
)
416+
assert (
417+
agent_card.capabilities.state_transition_history
418+
== _AGENT_CARD_CONFIG["capabilities"]["stateTransitionHistory"]
419+
)
420+
421+
422+
@pytest.mark.asyncio
423+
async def test_message_stream_with_metadata_returns_streaming_response(
424+
mock_ogx_client: Any,
304425
test_auth: AuthTuple,
305426
mocker: MockerFixture,
306427
) -> None:
307-
"""A message with no text part short-circuits to an ``input_required`` task.
428+
"""message/stream with metadata returns StreamingResponse."""
429+
from starlette.responses import StreamingResponse
308430

309-
Note: the parts list itself can't be empty -- the a2a-sdk's own
310-
``new_task()`` raises ``ValueError`` for an empty ``parts`` list before
311-
our handler ever runs (see ``a2a.utils.task.new_task``), which surfaces
312-
as a JSON-RPC -32603 Internal Error rather than our own
313-
``input_required`` handling. To reach our ``if not user_input`` branch
314-
for real, the message must carry a non-text part (e.g. ``DataPart``) so
315-
``RequestContext.get_user_input()`` -- which only extracts ``TextPart``
316-
content -- returns an empty string while ``parts`` stays non-empty.
317-
"""
318-
build_agent = mocker.patch("app.endpoints.a2a.build_agent")
431+
mock_ogx_client.openai.list.return_value = make_openai_models_list_response(
432+
make_openai_model(model_id="together/llama3.1", provider_id="together"),
433+
)
434+
_install_agent(mocker, "Response with metadata")
319435

320-
result = _jsonrpc_result(
321-
await _post_a2a(
322-
_jsonrpc_body("", parts=[{"kind": "data", "data": {"foo": "bar"}}]),
323-
test_auth,
324-
)
436+
response = await _post_a2a(
437+
_jsonrpc_body(
438+
"What is a pod?",
439+
method="message/stream",
440+
metadata={"model": "llama3.1", "provider": "together"},
441+
),
442+
test_auth,
325443
)
326444

327-
# TaskState serializes with a hyphen on the wire ("input-required"),
328-
# unlike the Python enum member name (TaskState.input_required).
329-
assert _task_state(result) == "input-required"
330-
build_agent.assert_not_called()
445+
assert isinstance(response, StreamingResponse)

0 commit comments

Comments
 (0)