Skip to content

.NET: [Bug]: A2A streaming handler emits one Message per chat-client update with shared messageId and empty Parts on bookend chunks #5868

@glisti

Description

@glisti

Description

What happens

Microsoft.Agents.AI.Hosting.A2A.A2AAgentHandler.HandleNewMessageStreamingAsync enqueues one A2A Message event for every AgentResponseUpdate emitted by _hostAgent.RunStreamingAsync(...). This produces two distinct wire-protocol problems against spec-compliant A2A clients (notably the Python a2a-sdk used by the A2A Explorer):

  1. Empty Parts arrays. Many OpenAI streaming chunks have no convertible content (role-only first delta, finish-reason-only last delta, function-call-only intermediate chunks). For those updates, MessageConverter.ToParts(AgentResponseUpdate) returns []. The handler still calls CreateMessageFromUpdate and enqueues a Message with Parts = []. The A2A spec marks Message.parts as REQUIRED (google.api.field_behavior = REQUIRED on a repeated field, which per AIP-203 means "must be present and non-empty"). The generated Python pydantic models enforce this with min_items=1, so the client rejects the event with:

    Message object must have a non-empty 'parts' array.
    
  2. Duplicate messageId across consecutive events. CreateMessageFromUpdate sets MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"). With the OpenAI chat-client adapter, every chunk in a single completion shares the same chatcmpl-... id, so every emitted Message ends up with the same messageId. A2A clients that treat messageId as the unique identifier of an agent turn (e.g. dedup, replace-on-update) collapse the entire stream down to whichever single chunk wins. In practice that ends up being the empty bookend chunk, so the UI shows an empty agent reply even though the wire stream contained text deltas.

This is the A2A analogue of the AG-UI bug tracked in #3962, where MapAGUI reuses the same messageId across consecutive TOOL_CALL_RESULT events.

What I expected

Either:

  • the handler aggregates updates server-side and emits a single Message with the concatenated parts (matching the A2A "lightweight message response" pattern); or
  • each emitted Message carries a unique messageId (e.g. update.MessageId rather than update.ResponseId, or a freshly generated GUID per update), AND empty-Contents updates are skipped so spec-compliant clients don't receive parts: [].

Either resolution would interoperate with the Python a2a-sdk and other strict A2A clients.

Repro steps

  1. Register a ChatClientAgent over an OpenAI / Azure OpenAI chat client.
  2. Wire it with services.AddA2AServer("agent-name") and endpoints.MapA2AJsonRpc("agent-name", "/a2a/agent-name") (or the v0.3 compatibility shim).
  3. Advertise capabilities.streaming: true on the agent card.
  4. Connect with A2A Explorer (or any Python a2a-sdk client) and call message/stream.

Result: every SSE event the server emits is either rejected with "Message object must have a non-empty 'parts' array" or collapses by shared messageId to an empty-text message.

Workaround

Wrap the per-agent A2AServer with a custom IA2ARequestHandler that filters Message events whose Parts array is empty out of SendStreamingMessageAsync/SubscribeToTaskAsync. This silences the pydantic error but does not fix the duplicate-messageId collapse, so streaming still ends up showing a single (often empty) message. Setting Capabilities.Streaming = false on the agent card so clients pick the non-streaming message/send path is the only fully working workaround today, since HandleNewMessageAsync emits a single complete Message via response.Messages.ToParts().

Related

Code Sample

// From Microsoft.Agents.AI.Hosting.A2A.A2AAgentHandler.HandleNewMessageStreamingAsync
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken)
    .ConfigureAwait(false))
{
    var message = CreateMessageFromUpdate(contextId, update);
    await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
}

// CreateMessageFromUpdate:
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
    new()
    {
        MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),  // (1) shared across all chunks of a single OpenAI completion
        ContextId = contextId,
        Role = Role.Agent,
        Parts = update.ToParts(),                                       // (2) returns [] for role-only and finish-only chunks
        Metadata = update.AdditionalProperties?.ToA2AMetadata(),
    };

Error Messages / Stack Traces

# Python a2a-sdk pydantic validation error on receiving the SSE event:
Failed to send message: HTTP Error 400: Message object must have a non-empty 'parts' array.

# When parts validation is relaxed (e.g. our FilteringA2ARequestHandler workaround),
# the dedup-by-messageId collapse instead surfaces as a single empty agent reply:
{
  "contextId": "9be41ff144044165a506999434b41e62",
  "kind": "message",
  "messageId": "chatcmpl-DfXt5qcZZyztaUWi1lpDnnmnwq21m",
  "parts": [ { "kind": "text", "text": "" } ],
  "role": "agent"
}

Package Versions

Microsoft.Agents.AI 1.6.1-preview.260514.1, Microsoft.Agents.AI.Hosting.A2A 1.6.1-preview.260514.1, Microsoft.Agents.AI.Hosting.A2A.AspNetCore 1.6.1-preview.260514.1, A2A 1.0.0-preview2, A2A.AspNetCore 1.0.0-preview2

.NET Version

.NET 8.0

Additional Context

The agent card pattern used here advertises capabilities.streaming: true to expose the streaming endpoint to interactive clients. Disabling streaming on the card forces clients onto message/send which works correctly, but loses the streaming UX. A proper server-side fix (either aggregation or unique per-update ids + empty-parts filtering) would let agents safely advertise streaming again.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions