Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: coder-abu/a2a-python
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: main
Choose a base ref
...
head repository: a2aproject/a2a-python
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref
Checking mergeability… Don’t worry, you can still create the pull request.
  • 14 commits
  • 28 files changed
  • 14 contributors

Commits on Nov 12, 2026

  1. fix(server): fail fast when an executor produces no events (a2aprojec…

    …t#1345)
    
    ## Description
    
    With an `AgentExecutor` that returns normally without enqueueing any
    event, `message/send` hung forever on both the request coroutine and the
    ActiveTask producer/consumer tasks: `result` was `None`, and
    `ActiveTask.get_task()` awaited `_task_created` with no timeout — but
    `_task_created` is only set when a task event is processed, the task
    pre-existed at `start()`, or the producer failed. A *silent success*
    path sets none of them. Root-cause analysis and a runtime reproduction
    are in a2aproject#1330.
    
    Per the approach endorsed on the issue, this fail-fasts with
    `InternalError` when the subscribe loop finishes without a task event:
    
    - New `ActiveTask.task_created_is_set()` accessor (public wrapper over
    the internal `_task_created` event) so the handler doesn't reach into
    private state.
    - `DefaultRequestHandlerV2.on_message_send`: raise `InternalError` when
    `result is None` and `task_created_is_set()` is False, before awaiting
    `get_task()`.
    
    Follow-up (not in this PR): the legacy handler contains an equivalent
    `raise InternalError` guard (`default_request_handler.py:456`) that is
    empirically unreachable in this scenario — the flow blocks earlier in
    `consume_and_break_on_interrupt`. Making that reachable is a separate
    piece of work.
    
    - [x] Follow the CONTRIBUTING Guide
    - [x] PR title follows Conventional Commits (`fix:` → SemVer patch)
    - [x] Tests and linter pass
    - [x] Appropriate docs were updated (accessor docstring states the
    guarantee)
    
    Fixes a2aproject#1330 🦕
    
    ## Validation
    
    - Claim: a zero-event executor now fails fast with `InternalError`
    instead of hanging; an event-producing executor is unaffected.
    - Exact head: 1ed3680
    - BEFORE: `message/send` never returns (3s+ timeout in repro, issue
    a2aproject#1330). AFTER: raises `InternalError('Agent execution produced no
    events')` immediately.
    - Tests: new
    `test_on_message_send_raises_when_agent_produces_no_events`; handler +
    agent_execution suites green.
    - Environment: Python 3.10.20, macOS.
    - Limits / not tested: the optional timeout/completion-signaling
    complement discussed on the issue — the `is_set()` guard fully covers
    the unreachable-wait case; happy to add on request.
    
    Prepared with AI assistance (WorkBuddy); reviewed and verified by
    Linux2010
    
    ---------
    
    Signed-off-by: andy <[email protected]>
    Linux2010 authored Nov 12, 2026
    Configuration menu
    Copy the full SHA
    ab7e36f View commit details
    Browse the repository at this point in the history
  2. fix(compat): send no v0.3 configuration when the request has none (a2…

    …aproject#1429)
    
    ## Problem
    
    The v0.3 REST and gRPC client transports sent an empty
    `SendMessageConfiguration` for a request without one, which the server
    reads as `blocking=false`.
    
    ## Fix
    
    `ToProto.message_send_configuration` returns `None` when there is no
    configuration, so the client sends none and the server keeps its
    blocking default, like the JSON-RPC transport.
    
    ## Tests
    
    `test_compat_rest_transport_send_message_without_configuration` and
    `test_compat_grpc_transport_send_message_without_configuration` fail on
    main. `test_compat_rest_transport_send_message_with_configuration`
    checks that an explicit configuration still sends `blocking: true`. 2555
    passed.
    
    Fixes a2aproject#1428
    
    ---------
    
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    ConnorMoss02 and Iwaniukooo11 authored Nov 12, 2026
    Configuration menu
    Copy the full SHA
    01483cb View commit details
    Browse the repository at this point in the history
  3. fix(client): percent-encode ids in REST transport paths (a2aproject#1436

    )
    
    # Description
    
    A REST client that names its push notification configs, or talks to a
    server whose task ids are not plain UUIDs, can read or delete the wrong
    resource. `RestTransport` puts task ids and push config ids into the URL
    path as raw text, so an id with `#`, `?` or `%` changes the URL:
    `delete_task_push_notification_config(id='alerts#ops')` sends `DELETE
    .../pushNotificationConfigs/alerts` and deletes the config `alerts`. The
    same ids work over JSON-RPC and gRPC, which send them in the request
    body.
    
    This PR encodes each id with `urllib.parse.quote(value, safe='')` at the
    7 path sites (`get_task`, `cancel_task`, `subscribe` and the four push
    config methods). The server's router decodes the segment back, so ids
    with `#`, `?`, `%`, spaces, unicode or `:` now reach their own resource.
    Ids without reserved characters produce the same URL as before.
    
    Reproducer: a real `DefaultRequestHandler` behind `create_rest_routes`,
    served through `httpx.ASGITransport` to a real `RestTransport`. The
    client creates the configs `alerts` and `alerts#ops` on one task,
    deletes `alerts#ops`, then calls `get_task('t1?x')`.
    
    ```
    # main (28f8a62)
    configs before: ['alerts', 'alerts#ops']
    delete('alerts#ops') -> configs after: ['alerts#ops']
    get_task('t1?x') -> returned task 't1'
    
    # this branch
    configs before: ['alerts', 'alerts#ops']
    delete('alerts#ops') -> configs after: ['alerts']
    get_task('t1?x') -> TaskNotFoundError
    ```
    
    ```mermaid
    flowchart LR
        I["id = 'alerts#ops'"] --> P{RestTransport path}
        P -->|main| R1["f'.../pushNotificationConfigs/{id}'"]
        R1 --> H1["httpx: '#ops' is a fragment"]
        H1 --> S1["DELETE .../alerts: wrong config deleted"]
        P -->|this PR| R2["_quote_id(id) = 'alerts%23ops'"]
        R2 --> H2["DELETE .../alerts%23ops"]
        H2 --> S2["router decodes push_id = 'alerts#ops'"]
    ```
    
    The tests in `tests/client/transports/test_rest_client.py`
    (`TestRestTransportPathEncoding`) run the reproducer above: a real
    `DefaultRequestHandler` behind `create_rest_routes`, called through
    `httpx.ASGITransport`. One deletes `alerts#ops` and asserts that
    `alerts` is left. The other asserts that `get_task('t1?x')` raises
    `TaskNotFoundError`.
    
    ```
    $ pytest tests/client/transports/test_rest_client.py -k PathEncoding   # rest.py from main
    2 failed, 41 deselected
    $ pytest tests/client/transports/test_rest_client.py -k PathEncoding   # this branch
    2 passed, 41 deselected
    ```
    
    Run on Windows 11, Python 3.10, on b2a6aa3: `ruff check` and `ruff
    format --check` pass on the changed files; `pytest
    --ignore=tests/integration/cross_version
    --ignore=tests/integration/test_scenarios.py` gives 2431 passed, 207
    skipped, 4 xfailed. The 4 push notification e2e tests error at setup on
    Windows (spawn pickling) on `main` as well.
    
    Out of scope, noted for review:
    - An id containing `/` is encoded as `%2F`, which Starlette decodes
    before routing, so it now gets a 404 instead of reaching another route.
    Supporting `/` in ids would need a server change.
    - Ids that are exactly `.` or `..` are still collapsed by httpx
    dot-segment handling.
    - The tenant prefix in `_get_path` is left as is: it is configured by
    the operator and is not an id.
    - `src/a2a/compat/v0_3/rest_transport.py` builds paths the same way;
    happy to follow up there.
    
    - [x] Follow the [`CONTRIBUTING`
    Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
    - [x] Make your Pull Request title in the
    <https://www.conventionalcommits.org/> specification.
    - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
    from the repository root to format)
    - [ ] Appropriate docs were updated (if necessary)
    
    ---------
    
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    Sonica-B and Iwaniukooo11 authored Nov 12, 2026
    Configuration menu
    Copy the full SHA
    1bbe69c View commit details
    Browse the repository at this point in the history
  4. chore: switch codeowners to a narrower group (a2aproject#1452)

    Use google-a2a-python rather than a very wide group google-a2a-eng.
    google-a2a-eng still can approve via maintain role, however only
    google-a2a-python will now be notified.
    ishymko authored Nov 12, 2026
    Configuration menu
    Copy the full SHA
    2bddd06 View commit details
    Browse the repository at this point in the history
  5. fix(server): infer contextId from task when only taskId is provided (a…

    …2aproject#1453)
    
    ## Issue
    A message carrying only `taskId` got a freshly generated `contextId`
    instead of the one from the task it names.
     Spec 3.4.3 requires inferring it from the task.
    
    ## Change
    Both handlers now take the `contextId` from the stored task when the
    message doesn't provide one. The existing mismatch rejection is
    unchanged.
    
    ## Tests
    Added tests for v2 (blocking + streaming) and legacy, asserting the
    executor and the emitted events get the task's context.
    Updated two existing terminal-state tests whose mocked task store wasn't
    returning a real task.
    
    closes a2aproject#1411
    Iwaniukooo11 authored Nov 12, 2026
    Configuration menu
    Copy the full SHA
    2ba8e7b View commit details
    Browse the repository at this point in the history

Commits on Nov 13, 2026

  1. fix: reject decoded values that are not objects in parse_lenient (a2a…

    …project#1431)
    
    ## Problem
    
    `parse_lenient` calls `ParseDict(..., ignore_unknown_fields=True)`.
    `ParseDict` iterates the decoded value as if it were an object's keys.
    So when a decoded value is not a `dict`:
    
    - `[]` parses as an empty message
    - `None`, a number or a boolean raises a bare `TypeError` (`'NoneType'
    object is not iterable`)
    
    The REST and JSON-RPC client transports pass decoded response JSON to
    `parse_lenient`. Here is `RestTransport.get_task` against a server that
    answers `200` with each body, on main:
    
    ```
    client get_task, 200 []: returned Task(id='')
    client get_task, 200 null: TypeError: 'NoneType' object is not iterable
    ```
    
    The caller gets an empty `Task` with no error. The JSON-RPC transport
    does the same with a `"result"` of `[]` or `null`.
    
    ## Fix
    
    `parse_lenient` raises `ParseError` when a decoded value is not a
    `dict`. Raw text and bytes still go straight to `Parse`, unchanged.
    
    With the fix:
    
    ```
    client get_task, 200 []: ParseError: Expected a JSON object for Task, got list.
    client get_task, 200 null: ParseError: Expected a JSON object for Task, got NoneType.
    ```
    
    This matches `test_malformed_known_field_still_raises` from a2aproject#1354:
    unknown fields are ignored, but a malformed payload raises `ParseError`.
    
    ## Tests
    
    - `tests/utils/test_proto_utils.py`: decoded values `[]`, `None`, `0`,
    `True` raise `ParseError`. A separate test checks that a `str` goes to
    the raw-text branch (`'id'` fails JSON decoding there, not the object
    check).
    - `tests/client/transports/test_rest_client.py`: `get_task` with a body
    of `[]` or `null`.
    - `tests/client/transports/test_jsonrpc_client.py`: `get_task` with a
    `result` of `[]` or `null`.
    
    The 8 new `ParseError` cases fail on main. Two existing REST tests,
    `test_send_message_with_timeout_context` and
    `test_send_message_with_default_extensions`, never set a response body.
    The mocked `json()` returned a `MagicMock`, which only parsed because of
    this bug, so they now return `{}`.
    
    - `bash scripts/lint.sh`: success. The 3 ruff diagnostics it prints are
    in `client_factory.py` and `transports/__init__.py`, which this change
    does not touch.
    - `uv run pytest` on Windows, Python 3.10, skipping
    `tests/integration/cross_version` and
    `tests/integration/push_notifications`, which do not run on Windows
    (`os.killpg`, spawn-mode multiprocessing): 2557 passed, 207 skipped, 4
    xfailed, 1 xpassed
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    257626b View commit details
    Browse the repository at this point in the history
  2. fix(compat): serve v0.3 gRPC agent cards with a device code OAuth flow (

    a2aproject#1434)
    
    # Description
    
    The v0.3 gRPC `GetAgentCard` fails for an agent card that has an OAuth2
    scheme with a device code flow.
    
    v1.0 added `DeviceCodeOAuthFlow`, and v0.3 has no equivalent, so
    `to_compat_oauth_flows` leaves the v0.3 `OAuthFlows` with no flow set.
    `ToProto.oauth2_flows` raises `ValueError('Unknown oauth flow
    definition')` for that. `CompatGrpcHandler._handle_unary` only catches
    `A2AError`, so the call fails with `UNKNOWN`:
    
    ```python
    from a2a.compat.v0_3.conversions import to_compat_agent_card
    from a2a.compat.v0_3.proto_utils import ToProto
    from a2a.types.a2a_pb2 import (
        AgentCard, AgentInterface, DeviceCodeOAuthFlow, OAuth2SecurityScheme,
        OAuthFlows, SecurityScheme,
    )
    
    card = AgentCard(
        name='a', description='d', version='1',
        supported_interfaces=[AgentInterface(
            url='http://x', protocol_binding='GRPC', protocol_version='0.3')],
        security_schemes={'device': SecurityScheme(
            oauth2_security_scheme=OAuth2SecurityScheme(flows=OAuthFlows(
                device_code=DeviceCodeOAuthFlow(
                    device_authorization_url='https://a/device',
                    token_url='https://a/token'))))},
    )
    ToProto.agent_card(to_compat_agent_card(card))
    # ValueError: Unknown oauth flow definition
    ```
    
    The v0.3 JSON-RPC and REST bindings serve the same scheme as `"flows":
    {}`, and the public agent card does too.
    
    ## Fix
    
    - `ToProto.oauth2_flows` returns an empty `OAuthFlows` when no flow is
    set, instead of raising. gRPC clients then get the same scheme the JSON
    bindings already send.
    - `FromProto.oauth2_flows` returned a password flow with empty URLs for
    an `OAuthFlows` with no flow set, because password was its fallback
    branch. It now checks for `password` and returns an empty `OAuthFlows`
    otherwise, so the v0.3 gRPC client does not read the empty flows as a
    password flow.
    
    ## Tests
    
    - `test_get_agent_card_with_device_code_oauth_flow`
    (`test_grpc_handler.py`) serves a card with a device code scheme through
    `CompatGrpcHandler.GetAgentCard`. On main it fails with the `ValueError`
    above. With the fix the scheme comes back with empty flows.
    - `test_oauth_flows_conversion` asserted that
    `ToProto.oauth2_flows(OAuthFlows())` raises `ValueError`. It now checks
    that the empty flows convert to an empty proto and back. The `FromProto`
    half fails on main, where it returns a password flow.
    - `uv run pytest tests/compat/v0_3`: 324 passed
    - `bash scripts/lint.sh`: success. The 3 ruff diagnostics it prints are
    in `client_factory.py` and `transports/__init__.py`, which this change
    does not touch.
    - `uv run pytest` on Windows, Python 3.11, skipping
    `tests/integration/cross_version` and
    `tests/integration/push_notifications` because they need `os.killpg` and
    fork-based multiprocessing: 2549 passed, 207 skipped, 4 xfailed, 1
    xpassed (with `PYTHONUTF8=1`, which `test_samples_smoke.py` needs on a
    cp1252 console)
    
    - [x] Follow the [`CONTRIBUTING`
    Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
    - [x] Make your Pull Request title in the
    <https://www.conventionalcommits.org/> specification.
    - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
    from the repository root to format)
    - [x] Appropriate docs were updated (if necessary)
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    2ccef0d View commit details
    Browse the repository at this point in the history
  3. fix(compat): serve DELETE for v0.3 REST push notification configs (a2…

    …aproject#1442)
    
    # Description
    
    `REST03Adapter` mounts `GET`/`POST
    /v1/tasks/{id}/pushNotificationConfigs` and `GET
    /v1/tasks/{id}/pushNotificationConfigs/{push_id}`, but no `DELETE`. The
    v0.3 binding defines `DELETE
    /v1/tasks/{id}/pushNotificationConfigs/{configId}` (spec section 3.5.6,
    and the `google.api.http` option on `DeleteTaskPushNotificationConfig`
    in `a2a_v0_3.proto`), and the v0.3 JSON-RPC and gRPC adapters already
    serve the call.
    
    Without the route the request falls through to the v1.0 `/{tenant}`
    mount with tenant `v1`, so a v0.3 client gets a 400 with reason
    `VERSION_NOT_SUPPORTED` ("A2A version '0.3' is not supported by this
    handler. Expected version '1.0'.").
    
    To reproduce on main: serve a `DefaultRequestHandler` through
    `create_rest_routes(..., enable_v0_3_compat=True)`, create a push config
    over v0.3 REST, then send `DELETE
    /v1/tasks/{task_id}/pushNotificationConfigs/{config_id}`. It returns
    400. The same delete over v0.3 JSON-RPC
    (`tasks/pushNotificationConfig/delete`) and gRPC
    (`DeleteTaskPushNotificationConfig`) succeeds.
    
    This change adds `REST03Handler.delete_push_notification`, which builds
    a v0.3 `DeleteTaskPushNotificationConfigRequest` from the path and calls
    the v1.0 handler through `RequestHandler03`, and mounts it. It returns
    `{}`, the JSON form of `google.protobuf.Empty`, as the v1.0 REST
    dispatcher does.
    
    `CompatRestTransport.delete_task_push_notification_config` raised
    `NotImplementedError`, which a2aproject#1424 left in place because the server had
    no route. It now sends the same `DELETE`, so the v0.3 REST client can
    delete a config too (added after review).
    
    Tests: `test_delete_push_notification` (handler) and
    `test_delete_push_notification_config_v03` (route) fail on main and pass
    with this change. After review:
    `test_compat_rest_transport_delete_task_push_notification_config` now
    checks the request the client sends instead of expecting
    `NotImplementedError`,
    `test_compat_rest_client_deletes_push_notification_config` sends the
    delete from the client through the real v0.3 REST routes, and
    `test_delete_push_notification_config_v03_unknown_task_returns_404`
    covers `TaskNotFoundError`. The client tests fail with the old client
    code.
    - `uv run pytest tests/compat/v0_3/ -k "delete_push_notification or
    deletes_push_notification"`: 8 failed with the three compat source files
    from main, 8 passed with this change
    - `uv run pytest --cov=a2a --cov-fail-under=88` after merging current
    main: 2576 passed, 207 skipped, 4 xfailed, 1 xpassed; coverage 93.57%.
    In one of three runs
    `test_scenario_auth_required_side_channel[blocking-legacy]` failed and
    then passed on rerun; it does not touch this code.
    - `tests/integration/cross_version/client_server/test_client_server.py`
    (against `a2a-sdk` 0.3.24): 4 passed
    - `uv run ruff check` and `uv run ruff format --check`: clean; `uv run
    ty check`: no errors (3 warnings, all in files this PR does not touch)
    
    Worth checking: v0.3 routes are registered before the `/{tenant}` mount,
    so a v1.0 tenant literally named `v1` deleting at this path now reaches
    the v0.3 handler. That is already true for the existing v0.3 GET on the
    same path. Deleting an unknown config id returns 200 `{}`, as it does on
    the other transports.
    
    - [x] Follow the CONTRIBUTING Guide.
    - [x] Make your Pull Request title in the conventionalcommits.org
    specification.
    - [x] Ensure the tests and linter pass
    - [ ] Appropriate docs were updated (if necessary)
    
    Fixes a2aproject#1441
    
    ---------
    
    Co-authored-by: YusefSyed <[email protected]>
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    14e799c View commit details
    Browse the repository at this point in the history
  4. fix(server): reject out-of-range ListTasks cursor timestamps (a2aproj…

    …ect#1451)
    
    # Description
    
    Fixes a2aproject#1450.
    
    `ListTasks` with `DatabaseTaskStore` returns HTTP 500 when a
    structurally valid page token contains an out-of-range timestamp.
    Protobuf 7.36.2 raises `ValueError` from `Timestamp.FromNanoseconds()`,
    while `_after_cursor` only converts `OverflowError` into
    `InvalidParamsError`.
    
    Handle both exceptions so these tokens use the existing HTTP 400 /
    `INVALID_ARGUMENT` / `Invalid page token` response. The production
    change is one exception clause. Add four regression cases covering
    values immediately outside the timestamp range and very large
    positive/negative integers.
    
    ## Verification
    
    Tested source `main` at `2cc1dd5`, Python 3.12.16, Windows, Protobuf
    7.36.2, and SQLite/aiosqlite. Final checks used the exact dependencies
    exported from `uv.lock` with hash verification.
    
    - Before the fix, all four new SQLite cases failed with `ValueError`;
    after the fix they pass. PostgreSQL/MySQL variants skip because their
    DSNs are unset.
    - Exercised real Starlette REST routes, `DefaultRequestHandlerV2`, and
    an in-memory SQLite `DatabaseTaskStore` via `httpx.ASGITransport`,
    without mocking the conversion or store: three invalid timestamps
    changed from HTTP 500 to HTTP 400; `ts=null` and `ts=0` remained HTTP
    200. No provider requests.
    - `scripts/lint.sh`: passed Ruff check/format and ty. ty reports three
    existing unused-ignore warnings in untouched client files; no type
    errors.
    - The suite with the two Windows-incompatible integration modules below
    excluded: **2,556 passed, 215 skipped, 4 xfailed, 1 xpassed**.
    - The same suite with `--cov=src --cov-report=term-missing
    --cov-fail-under=88`: **93.57%** coverage, same test counts.
    - `git diff --check`: passed.
    
    The passing suite command (with UTF-8 enabled for sample CLI
    subprocesses):
    
    ```sh
    PYTHONUTF8=1 uv run pytest --timeout=45 -q \
      --ignore=tests/integration/cross_version/client_server/test_client_server.py \
      --ignore=tests/integration/push_notifications/test_default_push_notification_support.py
    ```
    
    ### Full-suite limitations
    
    I also ran the unfiltered suite; it was **not green on this Windows
    host**. Its initial result was 2,553 passed, 215 skipped, 4 xfailed, 1
    xpassed, 3 failed, and 8 errors. The three sample CLI failures were GBK
    console-encoding errors and passed with `PYTHONUTF8=1`.
    
    The two excluded modules fail on the unchanged production source too
    (verified by restoring the original exception clause and rerunning
    them): the cross-version harness uses POSIX `os.killpg` and `:`-joined
    `PYTHONPATH` and has short process startup/client timeouts; the
    push-notification harness cannot pickle a locally created FastAPI
    application under Windows spawn (`FastAPI.setup.<locals>.openapi`). I
    left these unrelated harnesses unchanged. Linux, PostgreSQL, and MySQL
    runs have not been performed locally.
    
    ## Checklist
    
    - [x] Followed `CONTRIBUTING.md` and read the repository's AI workflow
    documents.
    - [x] Conventional Commit title and focused bug fix with regression
    coverage.
    - [x] Ran the lint script, unfiltered tests, and the passing
    suite/coverage commands described above.
    - [x] No documentation update needed: no public API or schema change.
    
    AI disclosure: Codex investigated the code, reproduced the issue,
    prepared and reviewed this patch, and ran the checks under the account
    holder's instruction. Independent human review or reproduction is not
    claimed.
    
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    lj1166 and Iwaniukooo11 authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    f451ed7 View commit details
    Browse the repository at this point in the history
  5. fix(compat): send v0.3 REST request bodies with json_name keys (a2apr…

    …oject#1455)
    
    Fixes a2aproject#1454
    
    Drops `preserving_proto_field_name=True` from the three `MessageToDict`
    calls (send, stream, push config create), so the body uses `json_name`
    keys like the spec requires.
    
    Against a2a-go's v0.3 server, a send went from `400 "message is
    required"` to `200` with the agent's reply.
    
    The three new tests in `test_rest_transport.py` fail on main. The
    cross-version tests against a2a-sdk 0.3.24 still pass.
    
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    ConnorMoss02 and Iwaniukooo11 authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    2399993 View commit details
    Browse the repository at this point in the history
  6. fix(compat): percent-encode ids in v0.3 REST transport paths (a2aproj…

    …ect#1458)
    
    # Description
    
    `CompatRestTransport` (the v0.3 REST client) put task ids and push
    notification config ids into URL paths as raw text. An id containing
    `#`, `?` or `%` then reached a different resource: getting the push
    config `alerts#ops` returned the config `alerts`, and `get_task('t1?x')`
    returned task `t1`. a2aproject#1436 fixed this for `RestTransport`; the v0.3
    client was not covered.
    
    This PR encodes each id with `urllib.parse.quote(value, safe='')`, the
    same helper `RestTransport` uses, at the six path sites (`get_task`,
    `cancel_task`, `subscribe`, and push config create, get and list). Ids
    without reserved characters produce the same URL as before.
    
    Tests: `TestCompatRestTransportPathEncoding` in
    `tests/compat/v0_3/test_rest_transport.py` runs a real
    `DefaultRequestHandler` behind `create_rest_routes(...,
    enable_v0_3_compat=True)` through `httpx.ASGITransport`. One test
    creates `alerts` and `alerts#ops` and checks that getting `alerts#ops`
    returns that config. The other checks that `get_task('t1?x')` does not
    return task `t1`.
    
    ```
    $ pytest tests/compat/v0_3/test_rest_transport.py -k PathEncoding   # rest_transport.py from main
    2 failed, 35 deselected
    $ pytest tests/compat/v0_3/test_rest_transport.py -k PathEncoding   # this branch
    2 passed, 35 deselected
    $ pytest tests/compat tests/client
    595 passed
    ```
    
    `ruff check`, `ruff format` and `ty check` pass on the changed files.
    
    This change was written with Claude Code.
    
    - [x] Follow the [`CONTRIBUTING`
    Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
    - [x] Make your Pull Request title in the
    <https://www.conventionalcommits.org/> specification.
    - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
    from the repository root to format)
    - [x] Appropriate docs were updated (if necessary)
    
    Fixes a2aproject#1457 🦕
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    1ff86d5 View commit details
    Browse the repository at this point in the history
  7. fix(client): split SSE lines only on CR, LF and CRLF (a2aproject#1456)

    # Description
    
    `parse_sse_stream` read the stream with `response.aiter_lines()`, which
    follows `str.splitlines()` and also ends a line at U+2028, U+2029 and
    U+0085. The server writes these characters raw inside one `data:` line,
    so the client cut the event short and the payload failed to parse.
    
    As suggested in a2aproject#1432, the fix is in `parse_sse_stream`. Lines now come
    from `aiter_text()` and end only at CRLF, LF or CR, as the [SSE
    specification](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream)
    requires. A CR at the end of a chunk is held back, so a CRLF split
    across two chunks still ends one line.
    
    Tests:
    - `test_parse_sse_stream_ends_lines_only_at_cr_or_lf` feeds real
    `httpx.Response` byte chunks through the parser:
      - the three separators inside `data:`
      - a CRLF and a U+2028 byte sequence each split across chunks
      - an event with no closing blank line, which is still not dispatched
    - Transport tests that mocked `aiter_lines()` now mock `aiter_text()`.
    
    `./scripts/lint.sh` and `uv run pytest` pass. Coverage on
    `http_helpers.py` is 93%.
    
    - [x] Follow the [`CONTRIBUTING`
    Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
    - [x] Make your Pull Request title in the
    <https://www.conventionalcommits.org/> specification.
    - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
    from the repository root to format)
    - [x] Appropriate docs were updated (if necessary)
    
    Fixes a2aproject#1432 🦕
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Mateusz Iwaniuk <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    c8ccea9 View commit details
    Browse the repository at this point in the history
  8. fix(client): raise stream errors sent as plain SSE data events (a2apr…

    …oject#1444)
    
    # Description
    
    `RestTransport` only treats an SSE event named `error` as an error. A
    stream error sent as an ordinary `data:` event, with the
    `google.rpc.Status` body from section 11.6, goes to `parse_lenient`.
    That drops the unknown `error` key, so the caller gets an empty
    `StreamResponse` and no exception. Before a2aproject#1354 the strict `Parse` at
    least raised a `ParseError`.
    
    Section 11.7 only says the `data` field carries a `StreamResponse` and
    does not name an event type for errors, so SDK servers differ:
    
    - a2a-js and this SDK send `event: error`.
    - a2a-go sends a plain `data:` event, in
    [`handleStreamingRequest`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/a2asrv/rest.go#L332-L347)
    through `sseWriter.WriteData`. It does this for errors in the middle of
    a stream, and for early ones too when the request's `Accept` header
    lists no JSON type. The Python client always sends `Accept:
    text/event-stream`. a2a-go's own client reads the error from the `error`
    key of the data object
    ([`ParseStreamResponse`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/internal/rest/rest.go#L375-L400)).
    
    So a Python client streaming from an a2a-go agent reads a
    `TaskNotFoundError`, or any other mid-stream failure, as an empty event
    and keeps going.
    
    When a data event parses to no payload, `RestTransport` now checks it
    for an `error` key and raises through the same `_handle_sse_error` that
    handles `event: error`. `JsonRpcTransport` already raises for in-band
    errors the same way. Normal events are not decoded a second time. An
    event with no known payload and no `error` key is still yielded as
    before, so the forward compatibility from a2aproject#1354 stays.
    
    The repro at the end of this description (a mock server sending one
    plain `data:` error event), on main (2cc1dd5):
    
    ```
    event: None 0
    stream ended without an error
    ```
    
    On this branch:
    
    ```
    raised TaskNotFoundError: task not found: t1
    ```
    
    ```
    $ pytest tests/client/transports/test_rest_client.py   # main with the new tests
    FAILED tests/client/transports/test_rest_client.py::TestRestStreamErrors::test_stream_error_raises_mapped_error[data_event]
    1 failed, 43 passed
    $ pytest --ignore=tests/integration/cross_version   # this branch, Python 3.10, locked deps
    2558 passed, 207 skipped, 4 xfailed, 1 xpassed
    $ pytest --cov=src
    src/a2a/client/transports/rest.py   87%   (the new lines are covered)
    TOTAL   94%
    ```
    
    The `error_event` case and the unknown-event case pass on main as well.
    They pin the existing `event: error` path, which had no REST client
    test, and the lenient handling of unknown events. The cross-version
    tests are left out because their servers don't start within the 3 s
    timeout on my machine; the same happens on main. `ruff check`, `ruff
    format` and `ty check` pass, with only the three existing unused `type:
    ignore` warnings from `ty`.
    
    - [x] Follow the [`CONTRIBUTING`
    Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
    - [x] Make your Pull Request title in the
    <https://www.conventionalcommits.org/> specification.
    - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
    from the repository root to format)
    - [x] Appropriate docs were updated (if necessary)
    
    No issue exists, since the new test reproduces it.
    
    <details><summary>Repro script</summary>
    
    ```python
    import asyncio
    import json
    
    import httpx
    
    from a2a.client.transports.rest import RestTransport
    from a2a.types.a2a_pb2 import AgentCard, SubscribeToTaskRequest
    
    # The google.rpc.Status body a2a-go writes as a plain `data:` event.
    ERROR = {
        'error': {
            'code': 404,
            'status': 'NOT_FOUND',
            'message': 'task not found: t1',
            'details': [
                {
                    '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
                    'reason': 'TASK_NOT_FOUND',
                    'domain': 'a2a-protocol.org',
                }
            ],
        }
    }
    
    
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            headers={'content-type': 'text/event-stream'},
            content=f'id: 1\ndata: {json.dumps(ERROR)}\n\n'.encode(),
        )
    
    
    async def main() -> None:
        async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
            transport = RestTransport(http, AgentCard(), 'http://agent.example')
            try:
                async for event in transport.subscribe(SubscribeToTaskRequest(id='t1')):
                    print('event:', event.WhichOneof('payload'), event.ByteSize())
                print('stream ended without an error')
            except Exception as e:
                print(f'raised {type(e).__name__}: {e}')
    
    
    asyncio.run(main())
    ```
    
    </details>
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Mykyta Netipa <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    8c48ad3 View commit details
    Browse the repository at this point in the history
  9. fix(client): accept an empty body when deleting a push notification c…

    …onfig (a2aproject#1445)
    
    # Description
    
    `RestTransport.delete_task_push_notification_config` goes through
    `_execute_request`, which always calls `response.json()`. The spec
    leaves the result to the server
    ([3.1.10](https://a2a-protocol.org/latest/specification/#3110-delete-push-notification-config):
    "Confirmation of deletion (implementation-specific)"), and the SDKs
    answer differently:
    
    - this SDK's server answers 200 with `{}`;
    - a2a-js answers 204 No Content
    ([`rest_handler.ts`](https://github.com/a2aproject/a2a-js/blob/3d3ca8291e1f6b1f53293f61c12ba46c36670000/src/server/express/rest_handler.ts#L548-L559));
    - a2a-go answers 200 with no body
    ([`handleDeleteTaskPushConfig`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/a2asrv/rest.go#L536-L557)).
    
    Against a2a-js or a2a-go servers, the config is deleted, but the Python
    client then raises `A2AClientError: JSON Decode Error`. A caller that
    retries or reports the failure is acting on a delete that succeeded.
    
    The delete now sends its request, checks the status, and does not read
    the body. Error responses still go through `_handle_http_error`, so a
    404 with `TASK_NOT_FOUND` still raises `TaskNotFoundError`.
    `send_http_request` and the JSON-RPC transport are unchanged.
    
    The repro at the end of this description answers the delete the way each
    server does. On main (2cc1dd5):
    
    ```
    a2a-python 200 b'{}' -> ok
    a2a-js     204 b''   -> A2AClientError: JSON Decode Error: Expecting value: line 1 column 1 (char 0)
    a2a-go     200 b''   -> A2AClientError: JSON Decode Error: Expecting value: line 1 column 1 (char 0)
    ```
    
    On this branch, all three print `ok`.
    
    ```
    $ pytest tests/client/transports/test_rest_client.py   # src change reverted
    FAILED ...::TestTaskCallback::test_delete_task_push_notification_config_ignores_body[no_content]
    FAILED ...::TestTaskCallback::test_delete_task_push_notification_config_ignores_body[empty_200]
    2 failed, 44 passed
    $ pytest --ignore=tests/integration/cross_version   # this branch, Python 3.10, locked deps
    2560 passed, 207 skipped, 4 xfailed, 1 xpassed
    $ pytest --cov=src
    src/a2a/client/transports/rest.py   87%   (the new lines are covered)
    TOTAL   94%
    ```
    
    The new tests also cover a mapped error and a closed client on this
    path. I left the cross-version tests out because their servers don't
    start within the 3 s timeout on my machine; the same happens on main.
    `ruff check`, `ruff format` and `ty check` pass. `ty` prints only the
    three existing unused `type: ignore` warnings.
    
    a2aproject#1439 touches `send_http_request` and the transport type hints. This
    change stays inside `delete_task_push_notification_config` to keep out
    of its way, apart from one added import. a2aproject#1436 percent-encodes the ids
    in this method's path, so whichever of the two lands second has a
    one-line conflict there; I'll rebase this one if a2aproject#1436 goes first.
    
    - [x] Follow the [`CONTRIBUTING`
    Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md).
    - [x] Make your Pull Request title in the
    <https://www.conventionalcommits.org/> specification.
    - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh`
    from the repository root to format)
    - [x] Appropriate docs were updated (if necessary)
    
    No issue exists, since the new tests reproduce it.
    
    <details><summary>Repro script</summary>
    
    ```python
    import asyncio
    
    import httpx
    
    from a2a.client.transports.rest import RestTransport
    from a2a.types.a2a_pb2 import AgentCard, DeleteTaskPushNotificationConfigRequest
    
    # How each SDK's server confirms DELETE /tasks/{id}/pushNotificationConfigs/{configId}.
    SERVERS = {'a2a-python': (200, b'{}'), 'a2a-js': (204, b''), 'a2a-go': (200, b'')}
    
    
    async def main() -> None:
        for name, (status, body) in SERVERS.items():
            transport = httpx.MockTransport(lambda r, s=status, b=body: httpx.Response(s, content=b))
            async with httpx.AsyncClient(transport=transport) as http:
                client = RestTransport(http, AgentCard(), 'http://agent.example')
                try:
                    await client.delete_task_push_notification_config(
                        DeleteTaskPushNotificationConfigRequest(task_id='t1', id='cfg1')
                    )
                    print(f'{name:10} {status} {body!r:5} -> ok')
                except Exception as e:
                    print(f'{name:10} {status} {body!r:5} -> {type(e).__name__}: {e}')
    
    
    asyncio.run(main())
    ```
    
    </details>
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: mykytanetipa <[email protected]>
    3 people authored Nov 13, 2026
    Configuration menu
    Copy the full SHA
    09c9347 View commit details
    Browse the repository at this point in the history
Loading