@@ -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