Skip to content

Commit ade341e

Browse files
Merge branch 'main' into ci/pin-actions-sha
2 parents 0d6b9b3 + e12d494 commit ade341e

4 files changed

Lines changed: 134 additions & 5 deletions

File tree

‎src/a2a/server/routes/jsonrpc_dispatcher.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,11 @@ async def handle_requests(self, request: Request) -> Response: # noqa: PLR0911,
233233
body = await request.json()
234234
if isinstance(body, dict):
235235
request_id = body.get('id')
236-
# Ensure request_id is valid for JSON-RPC response (str/int/None only)
237-
if request_id is not None and not isinstance(
238-
request_id, str | int
236+
# Ensure request_id is valid for JSON-RPC response (str/int/None only;
237+
# exclude bool since bool is a subclass of int in Python)
238+
if isinstance(request_id, bool) or (
239+
request_id is not None
240+
and not isinstance(request_id, str | int)
239241
):
240242
request_id = None
241243
logger.debug('Request body: %s', body)
@@ -257,6 +259,13 @@ async def handle_requests(self, request: Request) -> Response: # noqa: PLR0911,
257259
message="Invalid request: 'jsonrpc' must be exactly '2.0'"
258260
),
259261
)
262+
if isinstance(base_request._id, bool): # noqa: SLF001
263+
return self._generate_error_response(
264+
request_id,
265+
InvalidRequestError(
266+
message='Invalid request: id must be a string, integer, or null'
267+
),
268+
)
260269
except Exception as e:
261270
logger.exception('Failed to validate base JSON-RPC request')
262271
return self._generate_error_response(

‎src/a2a/server/tasks/database_task_store.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from google.protobuf.json_format import MessageToDict, ParseDict
2626
from google.protobuf.timestamp_pb2 import Timestamp
2727

28+
from a2a.compat.v0_3.conversions import to_compat_task_status
2829
from a2a.compat.v0_3.model_conversions import (
2930
compat_task_model_to_core,
3031
)
@@ -259,8 +260,16 @@ async def list(
259260
)
260261
if params.status:
261262
base_stmt = base_stmt.where(
262-
self.task_model.status['state'].as_string()
263-
== a2a_pb2.TaskState.Name(params.status)
263+
self.task_model.status['state']
264+
.as_string()
265+
.in_(
266+
[
267+
a2a_pb2.TaskState.Name(params.status),
268+
to_compat_task_status(
269+
a2a_pb2.TaskStatus(state=params.status)
270+
).state.value,
271+
]
272+
)
264273
)
265274
if params.HasField('status_timestamp_after'):
266275
last_updated_after = params.status_timestamp_after.ToDatetime()

‎tests/server/routes/test_jsonrpc_dispatcher.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,41 @@ def _make_send_message_request(
9494
}
9595

9696

97+
class TestJsonRpcRequestIds:
98+
"""JSON booleans are not numeric JSON-RPC request identifiers."""
99+
100+
@pytest.mark.parametrize('request_id', [True, False])
101+
def test_boolean_id_is_rejected(
102+
self, client: TestClient, mock_handler: AsyncMock, request_id: bool
103+
) -> None:
104+
request = _make_send_message_request()
105+
request['id'] = request_id
106+
107+
response = client.post('/', json=request)
108+
body = response.json()
109+
110+
assert response.status_code == 200
111+
assert body['id'] is None
112+
assert body['error']['code'] == -32600
113+
mock_handler.on_message_send.assert_not_awaited()
114+
115+
@pytest.mark.parametrize('request_id', [0, 1, '', 'request'])
116+
def test_integer_and_string_ids_are_preserved(
117+
self, client: TestClient, mock_handler: AsyncMock, request_id: int | str
118+
) -> None:
119+
request = _make_send_message_request()
120+
request['id'] = request_id
121+
122+
response = client.post('/', json=request)
123+
body = response.json()
124+
125+
assert response.status_code == 200
126+
assert body['id'] == request_id
127+
assert type(body['id']) is type(request_id)
128+
assert 'result' in body
129+
mock_handler.on_message_send.assert_awaited_once()
130+
131+
97132
class TestUnrecognizedFields:
98133
"""Unrecognized fields are ignored, for forward compatibility."""
99134

‎tests/server/tasks/test_database_task_store.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,82 @@ def capture_bound_datetimes(
407407
assert all(value.year >= 1000 for value in bound_datetimes)
408408

409409

410+
@pytest.mark.asyncio
411+
@pytest.mark.parametrize(
412+
'state, legacy_state',
413+
[
414+
(TaskState.TASK_STATE_SUBMITTED, 'submitted'),
415+
(TaskState.TASK_STATE_WORKING, 'working'),
416+
(TaskState.TASK_STATE_COMPLETED, 'completed'),
417+
(TaskState.TASK_STATE_FAILED, 'failed'),
418+
(TaskState.TASK_STATE_CANCELED, 'canceled'),
419+
(TaskState.TASK_STATE_INPUT_REQUIRED, 'input-required'),
420+
(TaskState.TASK_STATE_REJECTED, 'rejected'),
421+
(TaskState.TASK_STATE_AUTH_REQUIRED, 'auth-required'),
422+
],
423+
)
424+
async def test_list_tasks_filters_mixed_protocol_states(
425+
db_store_parameterized: DatabaseTaskStore,
426+
state: TaskState.ValueType,
427+
legacy_state: str,
428+
) -> None:
429+
store = db_store_parameterized
430+
other_context = ServerCallContext(user=SampleUser('other_user'))
431+
for owner_context in (TEST_CONTEXT, other_context):
432+
owner = store.owner_resolver(owner_context)
433+
for prefix, core_state, stored_state in (
434+
('match', state, legacy_state),
435+
('nonmatch', TaskState.TASK_STATE_UNSPECIFIED, 'unknown'),
436+
):
437+
await store.save(
438+
Task(
439+
id=f'{owner}-{prefix}-modern',
440+
context_id='mixed-context',
441+
status=TaskStatus(state=core_state),
442+
),
443+
owner_context,
444+
)
445+
async with store.async_session_maker.begin() as session:
446+
for version in (None, '0.3'):
447+
await session.execute(
448+
insert(store.task_model).values(
449+
id=f'{owner}-{prefix}-legacy-{version}',
450+
context_id='mixed-context',
451+
owner=owner,
452+
status={'state': stored_state},
453+
protocol_version=version,
454+
)
455+
)
456+
457+
owner = store.owner_resolver(TEST_CONTEXT)
458+
expected_ids = [
459+
f'{owner}-match-modern',
460+
f'{owner}-match-legacy-None',
461+
f'{owner}-match-legacy-0.3',
462+
]
463+
for params in (
464+
ListTasksRequest(),
465+
ListTasksRequest(status=TaskState.TASK_STATE_UNSPECIFIED),
466+
):
467+
unfiltered = await store.list(params, TEST_CONTEXT)
468+
assert unfiltered.total_size == 6
469+
assert {task.id for task in unfiltered.tasks} == {
470+
task_id.replace('-match-', f'-{prefix}-')
471+
for task_id in expected_ids
472+
for prefix in ('match', 'nonmatch')
473+
}
474+
475+
params = ListTasksRequest(status=state, page_size=1)
476+
for index, expected_id in enumerate(expected_ids):
477+
page = await store.list(params, TEST_CONTEXT)
478+
assert page.total_size == 3
479+
assert [task.id for task in page.tasks] == [expected_id]
480+
assert page.tasks[0].status.state == state
481+
assert page.page_size == 1
482+
assert bool(page.next_page_token) == (index < len(expected_ids) - 1)
483+
params.page_token = page.next_page_token
484+
485+
410486
@pytest.mark.asyncio
411487
@pytest.mark.parametrize(
412488
'params, expected_error_message',

0 commit comments

Comments
 (0)