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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/A2A.V0_3/Models/A2AResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ public abstract class A2AEvent(string kind)
/// <summary>
/// The 'kind' discriminator value
/// </summary>
[JsonRequired, JsonPropertyName(BaseKindDiscriminatorConverter<A2AEvent>.DiscriminatorPropertyName), JsonInclude, JsonPropertyOrder(int.MinValue)]
/// <remarks>
/// Not <c>[JsonRequired]</c>: presence of the discriminator is already enforced by
/// <see cref="BaseKindDiscriminatorConverter{TBase}.Read"/> for polymorphic deserialization
/// (through <see cref="A2AEvent"/> or <see cref="A2AResponse"/>). Requiring it here as well
/// broke deserialization of a concretely-typed <see cref="AgentMessage"/>, such as
/// <see cref="AgentTaskStatus.Message"/>, where the converter never runs and some producers
/// omit the redundant discriminator.
/// </remarks>
[JsonPropertyName(BaseKindDiscriminatorConverter<A2AEvent>.DiscriminatorPropertyName), JsonInclude, JsonPropertyOrder(int.MinValue)]
public string Kind { get; internal set; } = kind;
}

Expand Down
16 changes: 14 additions & 2 deletions src/A2A.V0_3/Models/AgentMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,24 @@ public sealed class AgentMessage() : A2AResponse(A2AEventKind.Message)
[JsonPropertyName("referenceTaskIds")]
public List<string>? ReferenceTaskIds { get; set; }

private string? _messageId;

/// <summary>
/// Identifier created by the message creator.
/// </summary>
/// <remarks>
/// Not <c>[JsonRequired]</c>: some v0.3 producers omit it (a discrepancy between the
/// <c>.proto</c> and JSON Schema definitions of v0.3). Falls back to a lazily generated id
/// rather than an empty string, so a message deserialized without one still has a usable,
/// unique identifier. Lazy so a message whose JSON already carries a messageId never
/// allocates a discarded id on construction.
/// </remarks>
[JsonPropertyName("messageId")]
[JsonRequired]
public string MessageId { get; set; } = string.Empty;
public string MessageId
{
get => _messageId ??= Guid.NewGuid().ToString();
set => _messageId = value;
}

/// <summary>
/// Identifier of task the message is related to.
Expand Down
80 changes: 80 additions & 0 deletions tests/A2A.V0_3.UnitTests/GitHubIssues/Issue396.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Text.Json;

namespace A2A.V0_3.UnitTests.GitHubIssues
{
public sealed class Issue396
{
[Fact]
public void Issue_396_Passes()
{
var json = """
{
"jsonrpc": "2.0",
"id": "1",
"result": {
"id": "63b2861e-1234-4a1a-9a3a-000000000001",
"contextId": "2c727c41-1234-4a1a-9a3a-000000000002",
"kind": "task",
"status": {
"state": "completed",
"message": {
"role": "agent",
"parts": [ { "kind": "text", "text": "Skill completed." } ]
},
"timestamp": "2026-05-17T15:59:43.401Z"
}
}
}
""";

var deserializedResponseObj = JsonSerializer.Deserialize<JsonRpcResponse>(json, A2AJsonUtilities.DefaultOptions);
Assert.NotNull(deserializedResponseObj);

var task = deserializedResponseObj.Result.Deserialize<AgentTask>(A2AJsonUtilities.DefaultOptions);
Assert.NotNull(task);

Assert.Equal("63b2861e-1234-4a1a-9a3a-000000000001", task.Id);
Assert.NotNull(task.Status.Message);

// kind and messageId were both omitted from status.message; both fall back to
// their defaults instead of failing deserialization.
Assert.Equal("message", task.Status.Message!.Kind);
Assert.False(string.IsNullOrEmpty(task.Status.Message.MessageId));
Assert.Equal(MessageRole.Agent, task.Status.Message.Role);
Assert.Single(task.Status.Message.Parts);
}

[Fact]
public void MessageId_WhenNotSet_IsLazyAndUniquePerInstance()
{
var first = new AgentMessage { Role = MessageRole.Agent };
var second = new AgentMessage { Role = MessageRole.Agent };

// Each unset instance gets its own generated id, not a shared/cached one.
Assert.False(string.IsNullOrEmpty(first.MessageId));
Assert.False(string.IsNullOrEmpty(second.MessageId));
Assert.NotEqual(first.MessageId, second.MessageId);

// Reading it twice on the same instance returns the same cached value.
Assert.Equal(first.MessageId, first.MessageId);
}

[Fact]
public void MessageId_WhenSetFromJson_IsPreservedExactly()
{
const string json = """
{
"kind": "message",
"role": "user",
"messageId": "explicit-id",
"parts": []
}
""";

var message = JsonSerializer.Deserialize<AgentMessage>(json, A2AJsonUtilities.DefaultOptions);

Assert.NotNull(message);
Assert.Equal("explicit-id", message.MessageId);
}
}
}