Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions superoptix/protocols/a2a/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,29 @@ def _int_or_none(value: Any) -> int | None:
return None


def _history_length_param(value: Any, *, legacy: bool) -> int | None:
"""Read a historyLength value with the caller's spec-line semantics.

In 1.0 a zero asks for no history. The 0.3 line has no way to say "unset"
on its proto-derived wire, so a zero there means no limit (matching the
a2a-python compat layer, a2aproject/a2a-python#1311).
"""
history_length = _int_or_none(value)
if legacy and history_length == 0:
return None
return history_length


def _legacy_configuration(
configuration: Dict[str, Any] | None, *, legacy: bool
) -> Dict[str, Any]:
"""Drop a 0.3 historyLength of zero so it reads as unlimited."""
config = dict(configuration or {})
if legacy and _int_or_none(config.get("historyLength")) == 0:
config.pop("historyLength", None)
return config


def _bool_or_none(value: Any) -> bool | None:
if value is None or value == "":
return None
Expand Down Expand Up @@ -1209,7 +1232,9 @@ async def _http_send_message(
) -> Dict[str, Any]:
task = await tasks.send_message(
message=payload.get("message") or {},
configuration=payload.get("configuration") or {},
configuration=_legacy_configuration(
payload.get("configuration"), legacy=_wants_legacy(request)
),
request=request,
)
if "__a2a_message__" in task:
Expand Down Expand Up @@ -1252,7 +1277,11 @@ async def get_task(
) -> Any:
caller_key = _caller_key_from_request(request, isolate=isolate_callers)
task = await tasks.get(
task_id, history_length=historyLength, caller_key=caller_key
task_id,
history_length=_history_length_param(
historyLength, legacy=_wants_legacy(request)
),
caller_key=caller_key,
)
if not task:
return _http_error(a2a_errors.TASK_NOT_FOUND, "Task not found")
Expand Down Expand Up @@ -1338,7 +1367,9 @@ async def jsonrpc(request: Request, payload: Dict[str, Any] = Body()) -> Any:
try:
task = await tasks.send_message(
message=params.get("message") or {},
configuration=params.get("configuration") or {},
configuration=_legacy_configuration(
params.get("configuration"), legacy=legacy
),
request=request,
)
except A2AProtocolError as exc:
Expand Down Expand Up @@ -1379,7 +1410,9 @@ async def jsonrpc(request: Request, payload: Dict[str, Any] = Body()) -> Any:
caller_key = _caller_key_from_request(request, isolate=isolate_callers)
task = await tasks.get(
str(task_id),
history_length=_int_or_none(params.get("historyLength")),
history_length=_history_length_param(
params.get("historyLength"), legacy=legacy
),
caller_key=caller_key,
)
if not task:
Expand Down
56 changes: 56 additions & 0 deletions tests/test_a2a_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,62 @@ def test_a_negative_cap_is_ignored(self, client, context):
assert task["history"]


class TestLegacyHistoryLengthZero:
"""On the 0.3 line a historyLength of 0 means unlimited, not none.

Mirrors a2aproject/a2a-python#1311 so 0.3 clients see the same history
from SuperOptiX as from the reference SDK. 1.0 keeps zero as "no history".
"""

V03 = {"A2A-Version": "0.3"}

def _rpc(self, client, method, params, headers=None):
return client.post(
RPC,
json={"jsonrpc": "2.0", "id": "1", "method": method, "params": params},
headers=headers or {},
).json()

def _legacy_send(self, client, history_length=None, context_id=None):
message = {
"role": "user",
"messageId": "m-hist",
"parts": [{"kind": "text", "text": "hi"}],
}
if context_id:
message["contextId"] = context_id
params = {"message": message}
if history_length is not None:
params["configuration"] = {"historyLength": history_length}
body = self._rpc(client, "message/send", params)
assert "error" not in body, body
return body["result"]

def test_jsonrpc_send_zero_keeps_history(self, client):
task = self._legacy_send(client, history_length=0)
assert task.get("history"), task

def test_jsonrpc_get_zero_keeps_history(self, client):
task = self._legacy_send(client)
body = self._rpc(client, "tasks/get", {"id": task["id"], "historyLength": 0})
assert "error" not in body, body
assert body["result"].get("history"), body

def test_rest_get_zero_keeps_history_for_v03(self, client):
task = self._legacy_send(client)
response = client.get(
f"/tasks/{task['id']}", params={"historyLength": 0}, headers=self.V03
)
assert response.status_code == 200, response.text
assert response.json().get("history"), response.json()

def test_rest_get_zero_still_empties_history_for_v1(self, client):
task = self._legacy_send(client)
response = client.get(f"/tasks/{task['id']}", params={"historyLength": 0})
assert response.status_code == 200, response.text
assert response.json()["history"] == []


class TestSpecMandatedErrorCodes:
"""The TCK asserts the specific error, not merely that one was returned."""

Expand Down
Loading