Skip to content

fix(a2a): make the client reconcile artifacts by identity and raise o… - #3494

Open
vvlrff wants to merge 17 commits into
mainfrom
fix/a2a-client-conformance
Open

vvlrff wants to merge 17 commits into
mainfrom
fix/a2a-client-conformance

Conversation

@vvlrff

@vvlrff vvlrff commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Why are these changes needed?

The A2A client could silently return a wrong answer, and a cancelled task had no typed error. Found by auditing ag2.a2a against the A2A v1.0 spec.

  • Polling truncated a growing answer ([Bug]: A2A polling drops text appended to an artifact after its first snapshot #3465). Every polled artifact was treated as a final one-shot chunk, so a client with streaming=False kept only the prefix it saw on its first poll. Live against OpenAI through an AG2 server: streaming returned 149 characters, polling returned 1, 2, 3, (8).
  • Text from earlier tool round trips leaked into the reply.
  • Cancelled tasks were not an error. The other terminal states raise; TASK_STATE_CANCELED fell through as a normal reply.
  • The server never closed its streamed text artifact, so no other A2A client could tell the answer was complete.
  • Reconnect lost text. Two more bugs found by the live checks, beyond the original tickets (details below).

What changed

 A2ADriveState
-  accumulated_text: str                    # += on every chunk / every poll
+  text_segments + text_slot                # one slot per text artifact
+  absorb_text(artifact_id, text, append, snapshot)
+                                           # extend (append), or replace (non-append, live or snapshot)
+  prior_text                               # what earlier tool-result drives already consumed
+  seen_calls: {(artifact_id, call_id)}     # a tool call is surfaced once, however often it is re-sent
+  closed_artifact_ids / touched_artifact_ids

 client
   _apply_artifact_update     # shared by streaming and polling
-    append every update
+    reconcile by artifact id; emit only newly arrived text
+    mixed (text + other parts) artifacts reconcile their text the same way
   _drain_stream
+    on a resubscribe snapshot: reconcile its artifacts (text written while disconnected)
   _absorb_task_artifacts (polling)
-    each snapshot = closed chunk
+    each snapshot = current full content, never marked last_chunk
+    answer carried only in status.message now also emits one chunk
   _handle_status_update
+    TASK_STATE_CANCELED -> A2ATaskCancelledError (partial text on err.task.artifacts)

 server executor
+  _ClosingTaskUpdater: sends last_chunk=True on the text artifact before the task leaves `working`
+  AgentExecutor.cancel(): uses the running task's updater, so a cancelled task closes it too

The reply is the text of the final turn only across tool round trips. Following the A2A spec, a task snapshot is the current full content of each artifact and an update without append replaces the artifact: even when the new text is shorter or empty, and even when a later turn reuses an artifact id and begins with the earlier turn's text. A snapshot that is exactly the text an earlier turn consumed is a replay and adds nothing. Each artifact appears once, in order of first appearance, text-only and mixed artifacts alike.

Beyond the tickets, both found by the live checks and both covered by tests:

  • After a dropped stream, SubscribeToTask opens with the task as it stands. The streaming path ignored that snapshot, so text produced while disconnected was lost. This is also present on main (reproduced there).
  • In polling, an answer carried only in the COMPLETED status.message produced no ModelMessageChunk, unlike streaming.

Found by review and by live checks against a non-AG2 server's conventions (reused artifact ids, mixed artifacts), all covered by tests:

  • A tool call introduced by a replacement of a mixed artifact was dropped; calls are now deduplicated by (artifact_id, call_id) instead of by "first sight of the artifact".
  • When an artifact id was reused after a tool round trip, the final answer never reached ModelMessageChunk consumers, and a continuation that began with the earlier turn's text lost that prefix.
  • AgentExecutor.cancel() bypassed the closing last_chunk=True update.

Failed / rejected / auth-required behaviour is unchanged.

Evidence

Live, gpt-4o-mini, direct provider access, real sockets:

Check Before After
Polling, count to 40 8 chars (1, 2, 3,) 149 chars, equal to streaming
Reconnect, stream cut at ~600 bytes 144–148 chars, text missing 149 chars
Polling, answer only in status.message text correct, no chunk text correct, one chunk
Cancel mid-generation no typed error A2ATaskCancelledError, partial equals received chunks

Matrix run against the AG2 server and against an independent server (@a2a-js/sdk 1.3.0 on Node, separate process, streaming a plain OpenAI call, no AG2 conventions), over JSON-RPC, REST and gRPC, streaming and polling:

  • counting in four artifact shapes (chunked, single artifact, answer only in status.message, artifact re-sent in full): answer correct, chunks equal the answer;
  • failed (real OpenAI 404), rejected and auth-required (decided by an OpenAI classifier in the server), cancel: correct typed error each time;
  • reconnect with the connection cut at 400 / 1200 / 2500 bytes (TCP proxy, so it applies to gRPC too): full answer.

A second live pass, same model, direct, real sockets, against the AG2 server and a custom executor that writes real OpenAI text into artifacts the way a non-AG2 server may (the AG2 server uses a fresh artifact id per turn, so it never exercises these). Run on the code before the review fixes and on this branch:

Check Before After
Artifact id reused after a tool round trip, new text: final answer reaches chunks not emitted (streaming and polling) emitted
Same, turn 2 begins with turn 1's text ' Done.' (prefix lost) whole text (streaming and polling)
Mixed text+image artifact later replaced by text+tool call HumanInputNotProvidedError, tool never run tool run once, reply ok (streaming and polling)
Mixed artifact growing full text full text
AG2 server: client tool round trip ok ok (tool run once)
Cancel mid-stream, 8 runs n/a A2ATaskCancelledError, one closing last_chunk=True, nothing after it, partial equals received chunks

Not checked live: Go / Java / .NET servers. Client-tool turns are an AG2 extension, so they were exercised against the AG2 server and the custom executor above, not a third-party SDK. Live scripts are not part of the suite.

Tests (uv run pytest test/a2a -q): 280 passed, 1 skipped. New ones, roughly:

growing artifact            -> full text, chunks never repeat   (streaming + polling)
artifact re-sent in full    -> replaces, does not concatenate
cancelled task              -> A2ATaskCancelledError, partial text kept   (both modes)
tool round trips            -> only the final turn's text       (both modes)
tool-only turn              -> no extra text artifact
server                      -> exactly one last_chunk, none when nothing streamed
reconnect after a gap       -> text from the resubscribe snapshot recovered
answer only in status.message -> one chunk in both modes
shortened / cleared replacement -> replaces                       (both modes)
mixed text+image artifact   -> text reconciled by identity        (both modes)
artifact id reused after a tool round trip
                            -> answer kept whole, reaches chunks  (both modes)
mixed artifact replaced by a tool call
                            -> tool runs once                     (both modes)
cancel via cancel_task      -> text artifact closed, [False, True]

Cancel racing the task's own completion

The SDK does not order AgentExecutor.cancel() against execute(). ActiveTask.cancel only checks that its consumer has not yet processed a terminal event, so a cancel can arrive after the turn has already enqueued COMPLETED. In a2a-sdk 1.2.1 through 1.3.0, if cancel() raises there, _mark_task_as_failed writes FAILED over the COMPLETED the client already received on the stream. We reported this upstream with a self-contained repro: a2aproject/a2a-python#1397.

Until the SDK fixes this, the AG2 server follows the spec itself:

  • A terminal state is never replaced. COMPLETED is "a terminal state" (§4.1.3), and a streaming client is told it is final because the stream "MUST close when the task reaches a terminal state" (§3.1.2). The turn and cancel() share one _ClosingTaskUpdater. Whichever claims a terminal state first wins, and any later transition does nothing. The base TaskUpdater raises RuntimeError instead, and that is what triggers the SDK's FAILED overwrite. AG2-side observers get lifecycle events from the same updater, so they never see the COMPLETED that lost.
  • cancel() with no turn running writes nothing. The turn may just have ended with its terminal event not yet consumed. The SDK re-reads the task after cancel() returns and closes it if it is still open.
  • CancelTask answers TaskNotCancelableError when the cancel loses. §3.1.5 is ambiguous here: "success is not guaranteed … returns its updated state" against TaskNotCancelableError for a task "already completed, failed, or canceled". The SDK answers both ways: its local path returns the COMPLETED task as a successful cancel, and its cross-replica path (_cancel_remote) raises TaskNotCancelableError. We picked the error, so a client never reads a successful cancel that cancelled nothing, and both paths now give the same answer. That question is also open in [Bug]: CancelTask racing the task's own completion overwrites COMPLETED with FAILED a2aproject/a2a-python#1397. If upstream settles it the other way, only _RequestHandler in transports/_common.py needs to change.

Covered by test/a2a/test_cancel_race.py (cancel lands after completion → stays COMPLETED; cancel with no running turn → writes nothing; turn finishing after a cancel → wire and observers see only CANCELED) and by the TaskNotCancelableError case in test_conformance.py.

Merge Danger

Door: two-way. No schema, storage or wire-format change; revert restores the old behaviour.

Blast Radius: A2A client and server users.

  • A new exception, A2ATaskCancelledError (subclass of A2ATaskTerminalError), is raised where a cancelled task used to return normally. Code that catches A2ATaskTerminalError now also sees cancellations.
  • parse_task_artifact no longer sets last_chunk=True on polled artifacts.
  • The server now sends a closing last_chunk=True update on its text artifact; clients that did not wait for it are unaffected.
  • A2ATaskCancelledError is importable from ag2.a2a.errors only, like the other task errors; it is not re-exported from ag2.a2a.
  • Polling cannot see append. A server that appends to the same artifact id across tool round trips is read per spec as that artifact's current full content, so a polling client returns both turns' text for it; streaming honours append and returns only the new text. The AG2 server is unaffected (fresh artifact id per turn).
  • CancelTask on a task that finished while the cancel was in flight now raises TaskNotCancelableError instead of returning the finished task (see above).
  • AgentExecutor now keeps a per-instance map of running tasks' updaters (removed when the turn ends) so cancel() can close the text artifact.

Related issue number

Closes #3465

Upstream: a2aproject/a2a-python#1397 (cancel racing completion overwrites COMPLETED with FAILED)

Follow-up to #3451 (fix(a2a): classify empty text artifacts by field presence)

Checks

AI assistance

  • I understand the changes in this PR and can explain them in my own words.
  • I have verified that the PR description accurately reflects the actual diff.
  • If AI assistance was used, I reviewed, tested, and validated the generated code/text before submitting.

🤖 Generated with Claude Code

…n cancel

- polling no longer truncates a growing artifact: snapshots are reconciled
  against what was already absorbed instead of being treated as final chunks
- a cancelled task raises A2ATaskCancelledError with the partial text
- the server closes the streamed text artifact with last_chunk=True
- only the final turn's text is returned across tool round trips
- reconnect recovers text written while disconnected from the task snapshot
- polling emits a chunk for an answer carried only in the completion message
@github-actions github-actions Bot added area:a2a A2A protocol support (ag2/a2a) area:docs Documentation (website/) labels Oct 24, 2026

@Lancetnik Lancetnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review by Codex. The three correctness findings below were reproduced through the public Agent.ask() API with scripted HTTPX responses, using both the PR head and its base (0796ee4).

Standards

Two nonblocking observations:

  • test/a2a/test_conformance.py:133–156 adds _ChunkingConfig/_ChunkingClient although test/AGENTS.md requires TestConfig for mocked LLM responses. TestConfig already accepts chunk events followed by a final ModelResponse; the history-dependent tool-turn double has a separate reason to exist.
  • New nested subscriber collectors in test_conformance.py:74,164,266 and test_empty_text_chunks.py:31 conflict with the root AGENTS.md ban on runtime nested functions. The testing guidelines themselves contain similar examples, so this is a low-priority consistency issue rather than a merge blocker.

Spec

Three verified correctness findings are attached inline. The most serious is a regression: reusing an open text artifact ID after a client-tool round trip returns no final answer. The shorter-replacement and mixed-artifact cases remain inconsistent with the PR's promised reconciliation behavior.

Validation: the focused changed-area tests passed (49 tests). The A2A suite passed with 261 passed and 1 skipped, excluding test_card_input_modes_anthropic.py because the existing local Anthropic SDK is too old; model-dependent tests were deselected. A2A SDK 1.2.3 was installed into a temporary directory. The three public-API reproductions still fail at the reviewed head; the reused-ID case succeeds on the base.

Standards: 2 nonblocking observations (test-double convention and runtime closures). Spec: 3 findings; worst is loss of the final answer after a tool continuation.

Comment thread ag2/a2a/client.py Outdated
artifact = update.artifact
if artifact.artifact_id in state.seen_artifact_ids:
artifact_id = artifact.artifact_id
if artifact_id in state.closed_artifact_ids or artifact_id in state.prior_artifact_ids:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Allow actual updates to an artifact reused after a tool continuation

The new prior_artifact_ids guard drops every later update for an ID seen before INPUT_REQUIRED, including an artifact that is still open and legitimately reused in the next turn. Public-API reproduction: first SendStreamingMessage emits art='pre-' (append=False, last_chunk=False), requests step(), and enters INPUT_REQUIRED; the continuation emits the same art='end' and completes. The tool executes and both requests finish, but reply.body is None instead of 'end'. The same reproduction returns 'end' on the base commit, so this is a regression. It also contradicts the promised final-turn-only result across tool round trips. Distinguish historical snapshot replay from fresh updates rather than permanently excluding all previously touched IDs.

Comment thread ag2/a2a/client.py Outdated
return text
# Not an append: the update restates the whole artifact; an older restatement changes nothing.
old = self.text_segments[slot]
if old.startswith(text):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Honor explicit full replacements that shorten the text

The PR promises that a full re-send replaces the earlier artifact, but old.startswith(text) treats every shorter prefix as stale. Sending an open artifact with 'hello world', then an explicit append=False replacement with 'hello', and completing the task returns 'hello world'. A replacement with an empty string is ignored too. A prefix comparison cannot establish that an update is older; live replacements need to update the stored artifact even when they shorten or clear its contents. Please cover shortening and clearing, in addition to the existing growing-prefix replacement test.

Comment thread ag2/a2a/client.py Outdated
state.pending_calls.append(a2a_event.call)
else:
state.closed_artifact_ids.add(artifact_id)
elif first_sight or a2a_event.append:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reconcile text in mixed artifacts as well as text-only artifacts

An artifact with a text part plus a URL/data part is classified as A2ATaskArtifactUpdate, so it reaches this fallback. After first sight, subsequent full snapshots (append=False) are silently skipped. I reproduced this through Agent.ask() with text+image-URL art='hello', then the same art='hello world': the reply remains 'hello'. Polling snapshots take this same path, so issue #3465's requirement to return the completed artifact's full text remains broken for mixed artifacts. Reconcile the textual portion by artifact identity here too, while keeping tool-call deduplication separate.

vvlrff and others added 2 commits October 26, 2026 19:54
- Let an artifact id reused after a tool round trip carry the new
  answer instead of dropping every update for an id seen before.
- Treat a non-append update as an authoritative replacement, including
  one that shortens or clears the text, and reconcile the text of
  mixed artifacts by id too.
- When the task finishes while the stream is down, read it back with
  GetTask instead of failing on the refused SubscribeToTask.
- Document the reconnect behaviour and the server's closing chunk.

@Lancetnik Lancetnik left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review generated with Codex. Re-reviewed the same head; the findings below were checked against executable reproductions. The originally reported test-collector issue is withdrawn, and the cancellation observation is now a nonblocking documentation/completeness note.

Reviewed commit b65e1f36a1acdd43bf42c440a95fe6a0be13a864 against base 024006bf553be93bb174cb786edbfd7fae3774cc. The head has not changed since the original submission of this review.

Standards

No confirmed findings. The original P3 about nested test collectors was withdrawn because the more specific test/AGENTS.md GOOD example endorses that pattern.

Spec

Three confirmed P2 regressions remain, detailed inline: an authoritative replacement can lose its prefix; the final turn’s chunk can disappear; a later mixed-artifact replacement can lose a newly introduced tool call.

The external-cancellation observation is a nonblocking P3 concerning the scope of the new documentation’s closing promise. Missing last_chunk on cancellation is pre-existing behavior, and this review does not establish it as a protocol violation.

Validation: the original unchanged PR A2A suite passed (272 passed, 1 skipped) with A2A SDK 1.2.3 and Anthropic SDK 1.15.0 in an isolated dependency directory. On re-review, the five targeted regression checks again failed on this head and passed against the base implementation with the same test harness. An additional public-API Agent.ask() check confirmed that the mixed-artifact scenario requests human input instead of executing the tool; it fails on this head and passes on the base. The real executor cancellation reproduction again confirmed the absence of a closing artifact update.

Standards: 0 confirmed findings. Spec: 3 P2 and 1 nonblocking P3; the most consequential issue is silent truncation of the final answer.

Comment thread test/a2a/test_conformance.py Outdated
"""Collect chunk events; the executor holds its remaining output until the first one lands."""
chunks: list[str] = []

@stream.where(ModelMessageChunk).subscribe

@Lancetnik Lancetnik Oct 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted correction after re-review.

Withdrawn: runtime nested test collectors

I missed the more specific test/AGENTS.md guidance, whose GOOD example explicitly uses a nested async callback and nested decorated tools inside a test. Given that test-specific example, treating this pattern as a documented-standard violation was not justified. No change is requested for this comment.

Comment thread ag2/a2a/client.py Outdated
fresh = content[len(known) :]
else:
fresh = ""
mine = content[len(prior) :] if content.startswith(prior) else content

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the full replacement when the new turn shares the previous text prefix

The existing reused-artifact scenario becomes corrupt if the continuation answer happens to start with the previous turn’s text: first emit art="pre-", request a client tool, then send art="pre-final" with append=False and complete. The reply is "final", although this update replaces the artifact with "pre-final". I reproduced this through an actual Agent tool round trip in both streaming and polling; both checks pass on the base commit. Removing any matching prior prefix conflates a cumulative replay with an authoritative new answer. Please track turn/replacement boundaries rather than infer them solely from text prefix equality.

Comment thread ag2/a2a/client.py Outdated
elif content.startswith(known):
fresh = content[len(known) :]
else:
fresh = ""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Emit the final answer chunk when an artifact ID is reused across turns

With the same tool-round-trip setup, emit art="pre-" before the tool and replace it with art="end" on continuation. The final ModelResponse contains "end", but the entire ModelMessageChunk sequence is only ["pre-"]: comparing against the previous turn’s content makes fresh empty. Consumers rendering chunk events never receive the final answer. This reproduces in streaming and polling, and the corresponding checks pass on the base commit. Newly arrived text for the current turn must not be suppressed merely because the artifact existed in an earlier turn.

Comment thread ag2/a2a/client.py Outdated
text = "".join(part.text for part in artifact.parts)
if fresh := state.absorb_text(artifact_id, text, a2a_event.append):
await context.send(ModelMessageChunk(fresh))
if (first_sight and artifact_id not in state.prior_text) or a2a_event.append:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Detect new tool calls in replacements of mixed artifacts

Send an open artifact containing text plus an image, then replace the same ID with text plus a tool-call+json part (append=False, last_chunk=True), followed by INPUT_REQUIRED. At the second update first_sight is false, so this condition drops the newly introduced tool call. I reproduced an empty pending_calls list at INPUT_REQUIRED; the outer loop consequently requests human input instead of executing the tool. The same test delivers the call on the base commit. Please deduplicate tool calls by their own identity while still discovering calls first introduced by later full artifact updates.

Comment thread ag2/a2a/executor.py Outdated
timestamp: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
if state != TaskState.TASK_STATE_WORKING and self._text_pieces and not self._closed:

@Lancetnik Lancetnik Oct 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted clarification after re-review; downgraded from P2 to a nonblocking P3.

[P3] Clarify whether artifact closing covers external cancellation

AgentExecutor.cancel() constructs a plain TaskUpdater at line 254, so the new _ClosingTaskUpdater does not participate. Running the real executor until its first text chunk, then calling cancel() and cancelling the execution coroutine in SDK order, still produces SUBMITTED → WORKING → artifact(last_chunk=False) → CANCELED, without a closing artifact update.

This is a mismatch with the new documentation’s broad promise to close text before the task leaves working. It is pre-existing cancellation behavior, however, and CANCELED already reports that execution stopped; this observation does not establish a protocol violation or a new functional regression. Either narrow the documentation to the transitions covered by this updater or cover external cancellation too. This comment should not block the PR.

@Lancetnik Lancetnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted re-review with Codex, based on executable reproductions of the changed behavior.

Reviewed c53c874379173cc354d4d42ac436b59d72ff2ce6 against base 938f671b7f511698eb2defea60439473197162e0, with particular attention to changes since b65e1f3.

The previous findings about prefix truncation, missing final chunks, newly introduced calls in mixed replacements, and external-cancellation closing are fixed in the checked scenarios. Six independent regression checks now pass, and the real executor cancellation reproduction now emits the closing artifact update.

Standards

One nonblocking P3: the remaining custom _ToolTurnsConfig / _ToolTurnsClient LLM mock conflicts with the documented TestConfig convention (inline).

Spec

One P2: the first pure tool-call chunk unconditionally closes an artifact even when last_chunk=False, causing later calls in that artifact to disappear in streaming mode (inline).

Validation: the original A2A suite passes (280 passed, 1 skipped, A2A SDK 1.2.3 and Anthropic SDK 1.15.0). An additional public Agent.ask() reproduction sends two calls as two chunks of the same artifact: streaming executes only the first on this head, whereas streaming executes both on the base commit. Polling on this head executes both.

Standards: 1 nonblocking P3 (custom LLM mock). Spec: 1 P2 (silently dropped tool call).

Comment thread ag2/a2a/client.py Outdated
elif isinstance(a2a_event, A2AToolCallArtifact):
state.pending_calls.append(a2a_event.call)
state.add_call(artifact_id, a2a_event.call)
state.closed_artifact_ids.add(artifact_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep a tool-call artifact open until its last chunk

This marks every A2AToolCallArtifact as closed regardless of last_chunk. A server can send call-0 in artifact calls with append=False, last_chunk=False, then call-1 in the same artifact with append=True, last_chunk=True, followed by INPUT_REQUIRED. The second update is discarded by the closed-ID guard, so an actual Agent.ask() executes only call-0. I reproduced both tool executions in streaming mode on the base commit; on this head streaming runs one, while polling runs both because the cumulative artifact contains two Parts and takes the general branch. seen_calls already deduplicates individual calls. Please close the artifact only when last_chunk says it is complete, and cover a growing artifact whose first chunk is a single tool-call Part.

Comment thread test/a2a/test_conformance.py Outdated
assert await _text_artifact_updates(client) == []


class _ToolTurnsConfig(ModelConfig):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, nonblocking] Use the shared TestConfig for the scripted model turns

_ToolTurnsConfig / _ToolTurnsClient mock LLM responses in agent-based tests, whereas test/AGENTS.md explicitly requires ag2.testing.TestConfig for that purpose. TestConfig now supports intermediate ModelMessageChunk events followed by ToolCallEvent or ModelResponse, as the streaming test at lines 208–209 already demonstrates. The pre-/mid-/end script can use that shared seam instead of the two custom mock classes. This is a test-convention note, not a functional blocker.

vvlrff and others added 2 commits October 27, 2026 00:17
A tool-call artifact was closed on its first update regardless of
last_chunk, so later calls streamed into the same artifact were dropped
and only the first client tool ran in streaming mode.

A resubscribe snapshot taken while the task is INPUT_REQUIRED now
surfaces its tool calls and ends the turn as input-required, instead of
hiding the calls and finishing with the partial text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@Lancetnik Lancetnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted re-review with Codex; the findings below were checked against the current code and executable public-API reproductions.

Reviewed f4059bc6298752eb0af0fc4985c41525fefde6dd against base 938f671b7f511698eb2defea60439473197162e0, including the changes since c53c874.

The previous P2 about prematurely closing a growing tool-call artifact is fixed: the independent reproduction now executes both calls in streaming and polling. The earlier prefix, chunk-delivery, and mixed-replacement reproductions also continue to pass.

Standards

The previously reported nonblocking P3 about _ToolTurnsConfig / _ToolTurnsClient instead of TestConfig remains at test/a2a/test_conformance.py:232–259. No new standards findings; no duplicate inline comment added.

Spec

One P2 remains in the newly promised cancellation behavior: a streaming response containing a canceled Task snapshot and no separate status update returns normally instead of raising A2ATaskCancelledError. This is missing coverage of the new cancellation contract, not a claim that the base branch already raised for this case. Details and the protocol reference are inline.

Validation: the unchanged A2A test suite passes (283 passed, 1 skipped, A2A SDK 1.2.3 and Anthropic SDK 1.15.0). Eight independent prior regression checks pass. An additional Agent.ask() test using the real A2A SDK and an HTTPX transport that emits a single canceled Task snapshot fails with DID NOT RAISE A2ATaskCancelledError.

Standards: 1 retained nonblocking P3 (custom LLM mock). Spec: 1 P2 (a canceled streaming task can still look like a successful reply).

Comment thread ag2/a2a/client.py Outdated
# A resubscribe opens with the task as it stands, covering what streamed while disconnected,
# including a request for input that was made while the stream was down.
status = a2a_event.task.status
input_required = status.state == TaskState.TASK_STATE_INPUT_REQUIRED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Classify cancellation carried by the Task snapshot too

This branch reads the snapshot status but only acts on INPUT_REQUIRED. If SendStreamingMessage emits a single Task whose status is TASK_STATE_CANCELED, with an artifact containing partial, and then closes, Agent.ask() returns a normal reply containing partial; state.terminal_task is never set and A2ATaskCancelledError is not raised. I reproduced this through the real SDK and public Agent.ask() path.

This stream shape is allowed by A2A §3.1.2: the initial Task may be followed by no update events, so a separate TaskStatusUpdateEvent cannot be assumed. Please apply the terminal-state classification to the snapshot status as well and preserve the Task, including its partial artifacts, on the exception. This is an uncovered case of the PR’s new cancellation contract; the base branch also returned normally for this case.

A2AServer builds a fresh LLM client per request, so a single TestConfig
replays its first turn on every client-tool continuation. Add
ag2.a2a.testing.ToolRoundsConfig, which picks the round from the history
(one Turn or TestConfig per tool round), and drop the hand-written LLM
mocks in test/a2a (StatelessScript, RecordingConfig, _ToolTurnsConfig,
_ChunkingScript, VariablesRecordingConfig).

Add ModelCall.messages so TrackingConfig can assert the full history sent
to the model. Also replace private transport imports with a2a SDK APIs and
tidy assertions per test/CLAUDE.md
@github-actions github-actions Bot added the area:core Agent runtime core: ag2/*.py, ag2/events, ag2/response, ag2/streams label Oct 27, 2026
…on a no-op

Client:
- Read the outcome from any task snapshot, polled or streamed: a task that
  arrives already finished, or a stream that closes on a final snapshot,
  now raises its cancelled/failed/rejected error or returns the completed
  task's status message. A status message on a working task is a progress
  note and stays out of the reply.
- If the stream closes before the task has settled (a cancel recorded after
  the server stopped streaming), poll get_task until it does instead of
  returning the partial text as the answer.
- A terminal error's task carries every received artifact under its own id
  rather than one merged "partial" artifact.
- A non-append update that restates the previous turn's text unchanged
  adds nothing, live or from a snapshot.

Server:
- The turn's completion and a cancel share one TaskUpdater. Whichever claims
  a terminal state first wins; a later transition is a no-op instead of a
  RuntimeError the SDK would turn into FAILED written over COMPLETED
  (a2aproject/a2a-python#1397).
- Lifecycle events are mirrored onto the AG2 stream from the updater, so a
  cancel that loses no longer emits a stray COMPLETED.
- CancelTask answers TaskNotCancelableError when the task finished some
  other way while the cancel was in flight, matching the SDK's
  cross-replica path (spec §3.1.5).
- Cancelling a task with no turn running (waiting for a client tool) leaves
  closing it to the SDK, which marks it CANCELED.
@github-actions github-actions Bot added the area:a2ui A2UI support (ag2/a2ui) label Oct 28, 2026

@Lancetnik Lancetnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review with Codex of commit 8244adb. Both inline findings were independently reproduced against this checkout.

Standards: no material violations found.

Behavior/spec: two P2 findings, covering an unchanged reconnect snapshot after a client-tool continuation and cancellation racing the closing artifact on INPUT_REQUIRED.

Validation: 309 A2A tests passed (295 in the sandbox, then the remaining 14 socket-dependent tests passed outside it); 618 tests covering A2UI and the changed testing utilities also passed. The two additional reproductions expose cases outside the passing suite.

Comment thread ag2/a2a/client.py
Comment on lines +195 to +198
elif given is None and content == prior:
reply_text = ""
else:
reply_text = content

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the current turn's suffix when a reconnect snapshot repeats already received content

This branch reintroduces text consumed by an earlier tool turn. Reproduced through the HTTPX/SSE transport: turn 1 emits artifact art = "pre-" and requests a client tool; the continuation appends "end" to the same artifact, then the connection drops. SubscribeToTask returns an unchanged snapshot with art = "pre-end", followed by COMPLETED. The append correctly sets this turn's reply to "end", but this snapshot falls through to reply_text = content, and the final reply becomes "pre-end".

This is distinct from the documented polling limitation: every append update was received and the snapshot adds no content. Please preserve the existing per-turn text when the snapshot equals the full content already received, while retaining the intended handling of actual replacements. A regression test combining a reused artifact id, a client-tool continuation, and reconnect would cover this.

Comment thread ag2/a2a/executor.py Outdated
Comment on lines +99 to +106
if self._text_pieces:
await self.add_artifact(
parts=[Part(text="")],
artifact_id=self._text_artifact_id,
append=True,
last_chunk=True,
)
await super().update_status(state, message, timestamp, metadata)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Synchronize cancellation with the nonterminal transition across the closing-artifact await

The initial ended check does not cover a cancellation that lands while INPUT_REQUIRED is awaiting its closing artifact. Reproduced via AgentExecutor.execute() using TestConfig to emit text followed by a ClientTool call and an EventQueue that delays enqueueing the closing text artifact: while requires_input() is suspended here, executor.cancel() sets the updater's terminal state to CANCELED. Releasing the enqueue then resumes the original transition at super().update_status(INPUT_REQUIRED, ...), which raises RuntimeError: Task t is already in a terminal state. That exception escapes execute(); the delayed closing artifact also arrives after CANCELED in this reproduction.

The existing tests cover competing terminal transitions, but INPUT_REQUIRED never claims ended, so it can still reach the base updater after cancellation won. Please serialize the transition/close sequence, or otherwise revalidate it after suspension, and add this race to the tests.

vvlrff and others added 3 commits October 28, 2026 23:21
…rom snapshots

- executor: cancel that lands while a non-working transition is closing the
  streamed text now waits for it under a lock, instead of ending the task
  underneath it (previously raised "already in a terminal state").
- client: after a tool round trip appends to an existing artifact, a resubscribe
  snapshot restating it no longer leaks the earlier turn's text into the reply.

@Lancetnik Lancetnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted re-review with Codex of da6a15ede9e781f8a5ea21ef10afbd5c60b60aea against base 59bba8fddd1b23a29ec50e19dd0a3a6c9845467b.

Standards

No findings. The previous TestConfig note is resolved: the custom model mocks were replaced by ToolRoundsConfig using TestConfig.

Spec

One P2 regression in the new transition lock: awaiting lifecycle subscribers while holding that lock deadlocks a subscriber that cancels the same task on INPUT_REQUIRED. Details are inline.

The previously reported canceled-snapshot case and the earlier artifact/tool-call reproductions now pass. The append/reconnect boundary is also preserved in the checked reproduction.

Validation: 930 tests passed across test/a2a, test/a2ui, test/agent/test_test_config.py, and test/test_plugin/test_invocation.py. Ten independent previous regression checks also passed. The additional lifecycle-cancellation test times out on this head with the callback waiting on asyncio.Lock.acquire(); the same test passes on both the PR base 59bba8f and the previously reviewed f4059bc. It uses a normal agent tool to install a public lifecycle subscription and calls the executor’s public cancel method; no internal object is patched.

Standards: 0 findings. Spec: 1 P2, a cancellation deadlock in a lifecycle subscriber.

Comment thread ag2/a2a/executor.py Outdated
last_chunk=True,
)
await super().update_status(state, message, timestamp, metadata)
await self._lifecycle.send(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Release the transition lock before awaiting lifecycle subscribers

_lifecycle.send() waits for user subscriber code while _transition is still held. Registering a subscriber from an ordinary tool via context.stream.where(A2ATaskStatusUpdate).subscribe(...), then having it await executor.cancel() for the same task on INPUT_REQUIRED, now deadlocks: the transition waits for the observer, while cancel() calls updater.cancel() and waits to acquire the transition lock again. The task cannot finish or complete the requested cancellation.

I reproduced the timeout using a real Agent/AgentExecutor and a recording EventQueue, without patching internals. The identical test passes on the PR base 59bba8f and on f4059bc; the failing stack here is the observer’s cancel call blocked in asyncio.Lock.acquire(). Please preserve serialization of the wire transition while invoking lifecycle subscribers outside the transition lock, and cover cancellation requested by an observer.

Lancetnik and others added 4 commits October 29, 2026 21:24
A lifecycle subscriber can await cancellation of the task whose status it
is handling. Holding the transition lock while dispatching that subscriber
makes cancellation wait on its own caller.

Keep text-artifact closing and wire status updates serialized, and release
the lock before notifying lifecycle subscribers. Cover cancellation from
INPUT_REQUIRED and the no-op cancellation of a COMPLETED task.

Validation: 932 tests passed across A2A, A2UI, and the affected testing
utilities; Ruff checks and formatting passed. Both new regression cases
failed with TimeoutError before the fix, and the original reproduction
passes after it.
Queue lifecycle notifications in wire order and drain them with one
shielded dispatcher. Reentrant transitions enqueue their notifications
instead of recursively notifying later subscribers. Account for an eager
task factory completing dispatch before task registration returns.

Serialize artifact delivery with closing transitions. Finish each queue
write and its text/client-call bookkeeping before propagating caller
cancellation: SDK legacy queues may publish to their primary consumer
before a tapped child accepts the event. Keep queued terminal lifecycle
notifications even when a producer or subscriber is canceled or fails.

Add regression coverage for multiple observers, nested cancellation,
producer cancellation, callback failures, eager dispatch, in-flight
artifacts, partial SDK queue writes, and recovery after sender cancellation.

Validation: 947 affected tests passed, including 21 cancel-race cases.
…olate observer failures

Client:
- a COMPLETED status.message that repeats the artifact text is not counted twice
- a tool call written while a stream was down is recovered from the resubscribe snapshot
- tool calls on a task that did not wait for input are logged and ignored instead of
  sent to a finished task; the INPUT_REQUIRED-only snapshot gate is removed, so
  streaming and polling treat calls the same
- rename the client's _TERMINAL_STATES to _SETTLED_STATES (it includes interrupted states)

Server:
- a failing lifecycle observer is logged, not raised, so it cannot turn a cancel or a
  completed task into FAILED
- observer subscriptions live as long as the updater can still deliver

Docs: ADR 0027 (cancel racing completion keeps the first terminal state); note the
cross-replica and parked-task limits of cancel and observers.
Human-input continuations reuse the drive state. Reset the previous
INPUT_REQUIRED finish reason and artifact closure flags before the next
response, so completed tasks cannot surface client calls and later turns
can reuse artifact ids for valid tool requests or replies.

Close request observer registrations after execution, outstanding writes,
and deferred lifecycle notifications finish. Cover registration/startup
failures and cancelled producers, and isolate observer cleanup failures
from the task's already committed terminal state.

Validation: 971 related A2A, A2UI, test-config and plugin tests passed;
regressions were reproduced before fixing and original public repros now
pass. Changed-file Ruff checks and repository mypy (255 files) passed.
Repository lint also reports two existing reimplemented-builtin errors
in unchanged ag2/events/conditions.py.

Implemented and reviewed with AI assistance.
@codecov

codecov Bot commented Oct 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.82090% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ag2/a2a/testing.py 91.42% 5 Missing and 1 partial ⚠️
ag2/a2a/client.py 95.86% 3 Missing and 2 partials ⚠️
ag2/a2a/executor.py 97.70% 1 Missing and 2 partials ⚠️
Files with missing lines Coverage Δ
ag2/a2a/errors.py 100.00% <100.00%> (ø)
ag2/a2a/mappers/events.py 92.00% <ø> (ø)
ag2/a2a/transports/_common.py 100.00% <100.00%> (ø)
ag2/a2ui/a2a/executor.py 92.25% <100.00%> (-0.20%) ⬇️
ag2/testing.py 93.81% <100.00%> (+0.06%) ⬆️
ag2/a2a/executor.py 95.96% <97.70%> (+10.24%) ⬆️
ag2/a2a/client.py 90.02% <95.86%> (+5.02%) ⬆️
ag2/a2a/testing.py 93.47% <91.42%> (-6.53%) ⬇️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:a2a A2A protocol support (ag2/a2a) area:a2ui A2UI support (ag2/a2ui) area:core Agent runtime core: ag2/*.py, ag2/events, ag2/response, ag2/streams area:docs Documentation (website/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: A2A polling drops text appended to an artifact after its first snapshot

2 participants