fix(a2a-client): read 0.3-shaped file parts and report parts the SDK can't read
The 1.0 SDK ignores unknown part fields, so a file part shaped {kind:file, file:{bytes|uri,
name, mimeType}} arrived as an empty part and vanished. A2AWireCompat rewrites such parts
to {raw|url, filename, mediaType} in JSON and SSE responses before the SDK reads them, and
marks any part it still can't read so the result lists it with its field names.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.Extensions.A2A;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
|
||||
|
||||
public sealed class A2AWireCompatTests : IDisposable
|
||||
{
|
||||
private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-wire", Guid.NewGuid().ToString("N"));
|
||||
|
||||
private static readonly string Skill = Convert.ToBase64String(Encoding.UTF8.GetBytes("# skill"));
|
||||
|
||||
private static string TaskJson(string parts) => $$"""
|
||||
{"id":"t-1","contextId":"c-1","status":{"state":"TASK_STATE_COMPLETED"},
|
||||
"artifacts":[{"artifactId":"response","parts":[{"kind":"text","text":"Here you go"}]},
|
||||
{"artifactId":"files","parts":[{{parts}}]}]}
|
||||
""";
|
||||
|
||||
private static IA2AClient Client(Func<HttpRequestMessage, HttpResponseMessage> respond) =>
|
||||
new A2AClient(new Uri("https://agent.example/a2a"),
|
||||
new HttpClient(new A2AWireCompat(new Fake(respond), () => NullLogger.Instance)));
|
||||
|
||||
private static HttpResponseMessage Json(string result) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent($$"""{"jsonrpc":"2.0","id":1,"result":{{result}}}""", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_V03FileParts_BecomeRawAndUrl()
|
||||
{
|
||||
var client = Client(_ => Json(TaskJson(
|
||||
"""{"kind":"file","file":{"bytes":"SKILL","name":"SKILL.md","mimeType":"text/markdown"}},""".Replace("SKILL\"", Skill + "\"") +
|
||||
"""{"kind":"file","file":{"uri":"https://cdn.example/crm.tar.gz","name":"crm.tar.gz","mimeType":"application/gzip"}}""")));
|
||||
|
||||
var task = await client.GetTaskAsync(new GetTaskRequest { Id = "t-1" });
|
||||
|
||||
var parts = task.Artifacts![1].Parts;
|
||||
Assert.Equal("# skill", Encoding.UTF8.GetString(parts[0].Raw!));
|
||||
Assert.Equal(("SKILL.md", "text/markdown"), (parts[0].Filename!, parts[0].MediaType!));
|
||||
Assert.Equal("https://cdn.example/crm.tar.gz", parts[1].Url);
|
||||
Assert.Equal("crm.tar.gz", parts[1].Filename);
|
||||
Assert.Equal("Here you go", A2AResults.FinalText(task));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownPart_ReportedInFiles_V1PartsUntouched()
|
||||
{
|
||||
var client = Client(_ => Json(TaskJson(
|
||||
"{\"raw\":\"" + Skill + "\",\"filename\":\"SKILL.md\"}," + """{"kind":"blob","blob":{"content":"x","title":"y"}}""")));
|
||||
var task = await client.GetTaskAsync(new GetTaskRequest { Id = "t-1" });
|
||||
|
||||
var files = await A2AFiles.SaveAsync(_ws, task, null, new Fake(_ => new HttpResponseMessage(HttpStatusCode.NotFound)), new A2AClientSettings(), CancellationToken.None);
|
||||
|
||||
Assert.Equal("inbox/a2a/t-1/SKILL.md", files[0].Path);
|
||||
Assert.Equal("not understood (fields: kind, blob{content, title})", files[1].Problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_SseEvents_Rewritten()
|
||||
{
|
||||
var client = Client(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"event: message\n" +
|
||||
"data: " + ("""{"jsonrpc":"2.0","id":1,"result":{"task":""" +
|
||||
TaskJson("{\"kind\":\"file\",\"file\":{\"bytes\":\"" + Skill + "\",\"name\":\"SKILL.md\"}}") + "}}").Replace("\n", "") + "\n\n",
|
||||
Encoding.UTF8, "text/event-stream"),
|
||||
});
|
||||
var events = new List<StreamResponse>();
|
||||
await foreach (var ev in client.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = "t-1" })) events.Add(ev);
|
||||
|
||||
var part = Assert.Single(events).Task!.Artifacts![1].Parts.Single();
|
||||
Assert.Equal("# skill", Encoding.UTF8.GetString(part.Raw!));
|
||||
Assert.Equal("SKILL.md", part.Filename);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewrite_NoParts_LeavesBodyAlone()
|
||||
{
|
||||
Assert.Null(A2AWireCompat.Rewrite("""{"name":"card"}""", NullLogger.Instance));
|
||||
Assert.Null(A2AWireCompat.Rewrite("""{"parts":[{"text":"hi"}]}""", NullLogger.Instance));
|
||||
}
|
||||
|
||||
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
|
||||
|
||||
private sealed class Fake(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user