fix(a2a-client): watches no longer hang on a silently dead task stream
Hour-long remote tasks were never delivered: a subscribe watch held one SSE stream for up to a day, the remote sent nothing while its agent worked without text, and once the quiet connection died on the way (no FIN/RST) the watch waited on it with no error and no log line. - A subscription lasts at most 5 minutes (SubscriptionSlice), then GetTask and a new subscription: a pause, not a failure. A slice ended mid-read counts as ours even when it surfaces as an I/O error. - The shared handler connects with TCP keepalive (60 s, every 15 s, 4 probes) and HTTP/2 pings. - Log lines when a watch starts or is restored, when a slice ends on a running task, when a result is delivered or kept; the Telegram host's console log carries a UTC timestamp. - action=status on a settled task ends its watch, so the result isn't delivered a second time. - A "context busy" refusal of send says nothing was sent, names the watched task in that context and that its result will arrive by itself; refusals never touch a watch.
This commit is contained in:
@@ -242,6 +242,49 @@ public class A2AToolTests
|
||||
Assert.DoesNotContain(host.UserTexts(), t => t.Contains("[A2A result", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_SettledTask_EndsItsWatch_SoTheResultIsNotDeliveredTwice()
|
||||
{
|
||||
await using var remote = await RemoteAgentServer.StartAsync();
|
||||
remote.Faux.Enqueue(FauxTurn.Say("Answer by status").WithDelay(TimeSpan.FromMilliseconds(300)));
|
||||
RemoteRewriter? rewriter = null;
|
||||
// The watch's stream stays silent (a dead connection): only status can see the task finish.
|
||||
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 },
|
||||
inner => rewriter = new RemoteRewriter(inner) { SilentSubscribes = true });
|
||||
var session = await host.CreateSessionAsync();
|
||||
var (started, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "go" });
|
||||
var taskId = Field(started, "task");
|
||||
Assert.Single(host.Registry.List(A2AClientHost.Target));
|
||||
string status;
|
||||
do
|
||||
{
|
||||
await Task.Delay(100);
|
||||
(status, _) = await host.CallToolAsync(session, new { action = "status", agent = "remote", taskId });
|
||||
} while (!status.Contains("Answer by status"));
|
||||
Assert.Empty(host.Registry.List(A2AClientHost.Target));
|
||||
Assert.Empty(PendingFile.Read(host.Workspace));
|
||||
Assert.DoesNotContain(host.UserTexts(), t => t.Contains("[A2A result", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_BusyContext_SaysWhichTaskRunsAndThatItIsStillWatched()
|
||||
{
|
||||
await using var remote = await RemoteAgentServer.StartAsync();
|
||||
remote.Faux.Enqueue(FauxTurn.Say("first").WithDelay(TimeSpan.FromSeconds(10)));
|
||||
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 });
|
||||
var session = await host.CreateSessionAsync();
|
||||
var (started, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "long job" });
|
||||
var taskId = Field(started, "task");
|
||||
var contextId = Field(started, "context");
|
||||
var (refused, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "and another", contextId });
|
||||
Assert.True(isError, refused);
|
||||
Assert.Contains($"still working on task {taskId} in context {contextId}", refused);
|
||||
Assert.Contains("still watched", refused);
|
||||
Assert.Contains("without contextId", refused);
|
||||
var watch = Assert.Single(host.Registry.List(A2AClientHost.Target)); // the refusal leaves the running task's watch alone
|
||||
Assert.Equal(taskId, watch.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_AbortedWhileWaiting_TaskWatched_AndResultArrives()
|
||||
{
|
||||
|
||||
@@ -368,6 +368,54 @@ public sealed class A2AWatchTests : IDisposable
|
||||
Assert.Equal(3, http.Subscribes); // three failed subscribes, then polling only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watch_SilentStream_SubscribesAgainEachSlice_AndDelivers()
|
||||
{
|
||||
await using var remote = await RemoteAgentServer.StartAsync();
|
||||
remote.Faux.Enqueue(FauxTurn.Say("Finished in silence").WithDelay(TimeSpan.FromMilliseconds(700)));
|
||||
WriteAgents();
|
||||
var delivery = new RecordingDelivery(_ws);
|
||||
var logger = new ListLogger();
|
||||
var http = new RemoteRewriter(remote.Handler) { SilentSubscribes = true };
|
||||
await using var registry = new A2AWatchRegistry(TimeProvider.System, http, logger)
|
||||
{
|
||||
SourceOverride = WatchSources.Subscribe, SubscriptionSlice = TimeSpan.FromMilliseconds(200),
|
||||
ReconnectDelays = [TimeSpan.FromSeconds(30)], // a failure would stall the watch past the test's wait
|
||||
};
|
||||
registry.UseDelivery(delivery);
|
||||
var settings = new A2AClientSettings();
|
||||
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
|
||||
var task = await StartRemoteTaskAsync(agent);
|
||||
var record = await registry.StartAsync("1", _ws, agent, task, settings, CancellationToken.None);
|
||||
await WaitUntilAsync(() => !delivery.Delivered.IsEmpty, timeoutMs: 10_000);
|
||||
Assert.Contains("Finished in silence", Assert.Single(delivery.Delivered).Text);
|
||||
Assert.True(http.Subscribes >= 2, $"subscribes: {http.Subscribes}"); // each slice ended, the task read, then a new stream
|
||||
Assert.Equal(WatchSources.Subscribe, record.Source); // pauses aren't failures: it never fell back to polling
|
||||
Assert.DoesNotContain(logger.Lines, l => l.Contains("failed", StringComparison.Ordinal));
|
||||
Assert.Contains(logger.Lines, l => l.Contains($"watching task {task.Id} of remote for 1 by subscribe", StringComparison.Ordinal));
|
||||
Assert.Contains(logger.Lines, l => l.Contains($"task {task.Id} of remote is still", StringComparison.Ordinal) && l.Contains("subscribing again", StringComparison.Ordinal));
|
||||
await WaitUntilAsync(() => logger.Lines.Any(l => l.Contains($"delivered the result of task {task.Id} of remote to 1", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SharedHandler_ConnectsWithTcpKeepAlive()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var accepted = listener.AcceptSocketAsync();
|
||||
await using var stream = Assert.IsType<System.Net.Sockets.NetworkStream>(
|
||||
await A2AWatchRegistry.ConnectWithKeepAliveAsync(new System.Net.DnsEndPoint("localhost", port), CancellationToken.None));
|
||||
using var server = await accepted;
|
||||
var socket = stream.Socket;
|
||||
Assert.True(socket.Connected);
|
||||
Assert.NotEqual(0, (int)socket.GetSocketOption(System.Net.Sockets.SocketOptionLevel.Socket, System.Net.Sockets.SocketOptionName.KeepAlive)!);
|
||||
Assert.Equal(60, (int)socket.GetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp, System.Net.Sockets.SocketOptionName.TcpKeepAliveTime)!);
|
||||
Assert.Equal(15, (int)socket.GetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp, System.Net.Sockets.SocketOptionName.TcpKeepAliveInterval)!);
|
||||
using var handler = A2AWatchRegistry.CreateSharedHandler();
|
||||
Assert.NotNull(handler.ConnectCallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watch_ReusesTheConnectionBetweenChecks()
|
||||
{
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Between our client and the remote: counts subscribes and card fetches, can hide the card's streaming capability
|
||||
/// (so the client polls), and can answer <c>SendMessage</c> itself with a plain message instead of a task.
|
||||
/// (so the client polls), can answer <c>SendMessage</c> itself with a plain message instead of a task, and can answer
|
||||
/// <c>SubscribeToTask</c> with a stream that never sends anything (a connection that died silently on the way).
|
||||
/// </summary>
|
||||
public sealed class RemoteRewriter(HttpMessageHandler inner) : DelegatingHandler(inner)
|
||||
{
|
||||
@@ -21,6 +22,9 @@ public sealed class RemoteRewriter(HttpMessageHandler inner) : DelegatingHandler
|
||||
/// <summary>When set, <c>SendMessage</c> is answered with a message carrying this text (the remote never sees it).</summary>
|
||||
public string? MessageReply { get; init; }
|
||||
|
||||
/// <summary>When true, every subscribe gets an open stream that stays silent until the client gives up on it.</summary>
|
||||
public volatile bool SilentSubscribes;
|
||||
|
||||
/// <summary>When true, every request fails as if the remote were down.</summary>
|
||||
public volatile bool Down;
|
||||
|
||||
@@ -30,7 +34,16 @@ public sealed class RemoteRewriter(HttpMessageHandler inner) : DelegatingHandler
|
||||
{
|
||||
if (Down) throw new HttpRequestException("remote is down");
|
||||
string? body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (body is not null && body.Contains("\"SubscribeToTask\"", StringComparison.Ordinal)) Interlocked.Increment(ref _subscribes);
|
||||
if (body is not null && body.Contains("\"SubscribeToTask\"", StringComparison.Ordinal))
|
||||
{
|
||||
Interlocked.Increment(ref _subscribes);
|
||||
if (SilentSubscribes)
|
||||
{
|
||||
var silent = new StreamContent(new SilentStream());
|
||||
silent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/event-stream");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = silent };
|
||||
}
|
||||
}
|
||||
if (MessageReply is not null && body is not null && body.Contains("\"SendMessage\"", StringComparison.Ordinal))
|
||||
{
|
||||
var id = JsonNode.Parse(body)!["id"]?.DeepClone();
|
||||
@@ -57,3 +70,25 @@ public sealed class RemoteRewriter(HttpMessageHandler inner) : DelegatingHandler
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A response body that never yields a byte; a read ends only when the reader cancels it.</summary>
|
||||
internal sealed class SilentStream : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException("async reads only");
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return 0;
|
||||
}
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user