Repository navigation
fix(compat): apply v1.0 history_length semantics to v0.3 results #1340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| """A v0.3 agent cannot be asked for an empty history, so the compat transports | ||
| must apply the v1.0 `history_length` semantics to the task they return. | ||
| In v0.3 `history_length = 0` means *unlimited* (see | ||
| `tests/compat/v0_3/test_proto_utils.py::test_message_send_configuration_history_length`), | ||
| while in v1.0 it means *no history*. Forwarding `0` to a v0.3 agent therefore | ||
| returns the whole history, so each compat transport has to truncate locally. | ||
| """ | ||
|
|
||
| from unittest.mock import AsyncMock, MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
| from a2a.compat.v0_3.grpc_transport import CompatGrpcTransport | ||
| from a2a.compat.v0_3.jsonrpc_transport import CompatJsonRpcTransport | ||
| from a2a.compat.v0_3.rest_transport import CompatRestTransport | ||
| from a2a.types.a2a_pb2 import ( | ||
| AgentCard, | ||
| GetTaskRequest, | ||
| Role, | ||
| SendMessageRequest, | ||
| TaskState, | ||
| ) | ||
|
|
||
|
|
||
| URL = 'http://example.com' | ||
|
|
||
|
|
||
| def _task_dict(proto_style: bool) -> dict: | ||
| """A v0.3 task payload carrying three history messages. | ||
| `proto_style` selects the enum spelling used by the protobuf-backed | ||
| transports; the JSON-RPC transport validates against the v0.3 pydantic | ||
| models, which use the short names. | ||
| """ | ||
| role = 'ROLE_USER' if proto_style else 'user' | ||
| state = 'TASK_STATE_WORKING' if proto_style else 'working' | ||
| return { | ||
| 'id': 'task-1', | ||
| 'contextId': 'ctx-1', | ||
| 'status': {'state': state}, | ||
| 'history': [ | ||
| {'messageId': f'm{i}', 'role': role, 'parts': []} for i in range(3) | ||
| ], | ||
| } | ||
|
|
||
|
|
||
| def _rpc_ok(result: dict) -> dict: | ||
| return {'id': '0', 'jsonrpc': '2.0', 'result': result} | ||
|
|
||
|
|
||
| def _rest_transport() -> CompatRestTransport: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When working on |
||
| t = CompatRestTransport( | ||
| httpx_client=AsyncMock(), | ||
| agent_card=AgentCard(name='a', version='1'), | ||
| url=URL, | ||
| ) | ||
|
|
||
| async def _execute_request(method, path, context=None, **kwargs): | ||
| return _task_dict(proto_style=True) | ||
|
|
||
| t._execute_request = _execute_request | ||
| return t | ||
|
|
||
|
|
||
| def _jsonrpc_transport() -> CompatJsonRpcTransport: | ||
| t = CompatJsonRpcTransport( | ||
| httpx_client=AsyncMock(), | ||
| agent_card=AgentCard(name='a', version='1'), | ||
| url=URL, | ||
| ) | ||
|
|
||
| async def _send_request(payload_, context=None): | ||
| return _rpc_ok(_task_dict(proto_style=False)) | ||
|
|
||
| t._send_request = _send_request | ||
| return t | ||
|
|
||
|
|
||
| def _grpc_transport() -> CompatGrpcTransport: | ||
| import a2a.compat.v0_3.a2a_v0_3_pb2 as v03 | ||
|
|
||
| payload = _task_dict(proto_style=True) | ||
| task = v03.Task() | ||
| task.id = payload['id'] | ||
| task.context_id = payload['contextId'] | ||
| setattr(task.status, 'state', TaskState.TASK_STATE_WORKING) | ||
| for m in payload['history']: | ||
| msg = task.history.add() | ||
| msg.message_id = m['messageId'] | ||
| msg.role = Role.ROLE_USER | ||
|
|
||
| t = CompatGrpcTransport( | ||
| channel=MagicMock(), agent_card=AgentCard(name='a', version='1') | ||
| ) | ||
|
|
||
| async def _get_task(req, metadata=None): | ||
| return task | ||
|
|
||
| t.stub = MagicMock(GetTask=AsyncMock(side_effect=_get_task)) | ||
| return t | ||
|
|
||
|
|
||
| TRANSPORTS = [_rest_transport, _jsonrpc_transport, _grpc_transport] | ||
|
|
||
|
|
||
| class TestCompatGetTaskHistoryLength: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's worth adding a test for edge case |
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize('factory', TRANSPORTS) | ||
| async def test_history_length_zero_drops_history(self, factory): | ||
| t = factory() | ||
| task = await t.get_task(GetTaskRequest(id='task-1', history_length=0)) | ||
| assert len(task.history) == 0 | ||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize('factory', TRANSPORTS) | ||
| async def test_history_length_keeps_last_n(self, factory): | ||
| t = factory() | ||
| task = await t.get_task(GetTaskRequest(id='task-1', history_length=1)) | ||
| assert [m.message_id for m in task.history] == ['m2'] | ||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize('factory', TRANSPORTS) | ||
| async def test_unset_history_length_keeps_history(self, factory): | ||
| t = factory() | ||
| task = await t.get_task(GetTaskRequest(id='task-1')) | ||
| assert len(task.history) == 3 | ||
|
|
||
|
|
||
| class TestCompatSendMessageHistoryLength: | ||
| @pytest.mark.asyncio | ||
| async def test_rest_send_message_applies_history_length(self): | ||
| t = CompatRestTransport( | ||
| httpx_client=AsyncMock(), | ||
| agent_card=AgentCard(name='a', version='1'), | ||
| url=URL, | ||
| ) | ||
|
|
||
| async def _execute_request(method, path, context=None, **kwargs): | ||
| return {'task': _task_dict(proto_style=True)} | ||
|
|
||
| t._execute_request = _execute_request | ||
| req = SendMessageRequest() | ||
| req.configuration.history_length = 0 | ||
| resp = await t.send_message(req) | ||
| assert resp.HasField('task') | ||
| assert len(resp.task.history) == 0 | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_jsonrpc_send_message_applies_history_length(self): | ||
| t = CompatJsonRpcTransport( | ||
| httpx_client=AsyncMock(), | ||
| agent_card=AgentCard(name='a', version='1'), | ||
| url=URL, | ||
| ) | ||
|
|
||
| async def _send_request(payload_, context=None): | ||
| payload = _task_dict(proto_style=False) | ||
| payload['kind'] = 'task' | ||
| return _rpc_ok(payload) | ||
|
|
||
| t._send_request = _send_request | ||
| req = SendMessageRequest() | ||
| req.configuration.history_length = 0 | ||
| resp = await t.send_message(req) | ||
| assert resp.HasField('task') | ||
| assert len(resp.task.history) == 0 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please split the tests into existing
test_{grpc,jsonrpc,rest}_transport.pyinstead of creatingtest_history_length_compat.py