diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index 38a25d9..0c27b5b 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -166,6 +166,11 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public RunTurnToolingConfigDto? Tooling { get; init; } } +public sealed class CancelTurnCommandDto : SidecarCommandEnvelope +{ + public string TargetRequestId { get; init; } = string.Empty; +} + public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope { public string ApprovalId { get; init; } = string.Empty; @@ -232,6 +237,7 @@ public sealed class TurnCompleteEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; public IReadOnlyList Messages { get; init; } = []; + public bool Cancelled { get; init; } } public sealed class AgentActivityEventDto : SidecarEventDto diff --git a/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs index 2fdb1e0..5406c0f 100644 --- a/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs @@ -12,6 +12,7 @@ public sealed class SidecarProtocolHost private const string DescribeCapabilitiesCommandType = "describe-capabilities"; private const string ValidatePatternCommandType = "validate-pattern"; private const string RunTurnCommandType = "run-turn"; + private const string CancelTurnCommandType = "cancel-turn"; private const string ResolveApprovalCommandType = "resolve-approval"; private static readonly string[] AuthenticationErrorIndicators = @@ -34,6 +35,7 @@ public sealed class SidecarProtocolHost private readonly IReadOnlyDictionary> _commandHandlers; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly ConcurrentDictionary _inFlight = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _turnCancellations = new(StringComparer.Ordinal); public SidecarProtocolHost() : this(new PatternValidator()) @@ -58,6 +60,7 @@ public sealed class SidecarProtocolHost [DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync, [ValidatePatternCommandType] = HandleValidatePatternAsync, [RunTurnCommandType] = HandleRunTurnAsync, + [CancelTurnCommandType] = HandleCancelTurnAsync, [ResolveApprovalCommandType] = HandleResolveApprovalAsync, }; } @@ -160,21 +163,66 @@ public sealed class SidecarProtocolHost private async Task HandleRunTurnAsync(CommandContext context) { RunTurnCommandDto command = DeserializeCommand(context); - IReadOnlyList messages = await _workflowRunner.RunTurnAsync( - command, - delta => WriteAsync(context.Output, delta, context.CancellationToken), - activity => WriteAsync(context.Output, activity, context.CancellationToken), - approval => WriteAsync(context.Output, approval, context.CancellationToken), - context.CancellationToken) - .ConfigureAwait(false); - - await WriteAsync(context.Output, new TurnCompleteEventDto + using CancellationTokenSource turnCancellation = + CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken); + if (!_turnCancellations.TryAdd(context.Envelope.RequestId, turnCancellation)) { - Type = "turn-complete", - RequestId = context.Envelope.RequestId, - SessionId = command.SessionId, - Messages = messages, - }, context.CancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + $"A turn with request ID '{context.Envelope.RequestId}' is already in progress."); + } + + try + { + IReadOnlyList messages = await _workflowRunner.RunTurnAsync( + command, + delta => WriteAsync(context.Output, delta, turnCancellation.Token), + activity => WriteAsync(context.Output, activity, turnCancellation.Token), + approval => WriteAsync(context.Output, approval, turnCancellation.Token), + turnCancellation.Token) + .ConfigureAwait(false); + + await WriteTurnCompleteAsync( + context.Output, + context.Envelope.RequestId, + command.SessionId, + messages, + cancelled: false, + context.CancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (turnCancellation.IsCancellationRequested) + { + await WriteTurnCompleteAsync( + context.Output, + context.Envelope.RequestId, + command.SessionId, + [], + cancelled: true, + context.CancellationToken) + .ConfigureAwait(false); + } + finally + { + _turnCancellations.TryRemove(context.Envelope.RequestId, out _); + } + } + + private Task HandleCancelTurnAsync(CommandContext context) + { + CancelTurnCommandDto command = DeserializeCommand(context); + if (_turnCancellations.TryGetValue(command.TargetRequestId, out CancellationTokenSource? turnCancellation)) + { + try + { + turnCancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // The turn completed between lookup and cancellation. + } + } + + return Task.CompletedTask; } private async Task HandleResolveApprovalAsync(CommandContext context) @@ -200,6 +248,24 @@ public sealed class SidecarProtocolHost }, context.CancellationToken); } + private Task WriteTurnCompleteAsync( + TextWriter output, + string requestId, + string sessionId, + IReadOnlyList messages, + bool cancelled, + CancellationToken cancellationToken) + { + return WriteAsync(output, new TurnCompleteEventDto + { + Type = "turn-complete", + RequestId = requestId, + SessionId = sessionId, + Messages = messages, + Cancelled = cancelled, + }, cancellationToken); + } + private Task WriteCommandErrorAsync(CommandContext context, string message) { return WriteAsync(context.Output, new CommandErrorEventDto diff --git a/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs index c3adffa..b87d198 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -219,6 +219,7 @@ public sealed class SidecarProtocolHostTests { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString()); + Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); JsonElement[] messages = completionEvent.GetProperty("messages").EnumerateArray().ToArray(); Assert.Single(messages); Assert.Equal("Hello world", messages[0].GetProperty("content").GetString()); @@ -287,6 +288,7 @@ public sealed class SidecarProtocolHostTests completionEvent => { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); + Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); }, commandCompleteEvent => { @@ -295,6 +297,75 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands() + { + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return []; + })); + + IReadOnlyList events = await RunHostAsync( + [ + CreateRunTurnCommand(requestId: "turn-cancel"), + new CancelTurnCommandDto + { + Type = "cancel-turn", + RequestId = "cancel-command-1", + TargetRequestId = "turn-cancel", + }, + ], + host); + + JsonElement turnCompleteEvent = AssertSingleEvent(events, "turn-complete", "turn-cancel"); + Assert.Equal("session-1", turnCompleteEvent.GetProperty("sessionId").GetString()); + Assert.True(turnCompleteEvent.GetProperty("cancelled").GetBoolean()); + Assert.Empty(turnCompleteEvent.GetProperty("messages").EnumerateArray().ToArray()); + + AssertSingleEvent(events, "command-complete", "turn-cancel"); + AssertSingleEvent(events, "command-complete", "cancel-command-1"); + Assert.DoesNotContain(events, evt => evt.GetProperty("type").GetString() == "command-error"); + } + + [Fact] + public async Task CancelTurnCommand_UnknownTarget_CompletesWithoutError() + { + IReadOnlyList events = await RunHostAsync(new CancelTurnCommandDto + { + Type = "cancel-turn", + RequestId = "cancel-command-unknown", + TargetRequestId = "missing-turn", + }); + + JsonElement completionEvent = Assert.Single(events); + Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("cancel-command-unknown", completionEvent.GetProperty("requestId").GetString()); + } + + [Fact] + public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp() + { + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => [])); + + await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host); + + IReadOnlyList events = await RunHostAsync(new CancelTurnCommandDto + { + Type = "cancel-turn", + RequestId = "cancel-command-completed", + TargetRequestId = "turn-completed", + }, host); + + JsonElement completionEvent = Assert.Single(events); + Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("cancel-command-completed", completionEvent.GetProperty("requestId").GetString()); + } + [Fact] public async Task ResolveApprovalCommand_DelegatesToWorkflowRunnerAndCompletes() { @@ -369,7 +440,17 @@ public sealed class SidecarProtocolHostTests object command, SidecarProtocolHost? host = null) { - string input = JsonSerializer.Serialize(command, JsonOptions) + Environment.NewLine; + return await RunHostAsync([command], host); + } + + private static async Task> RunHostAsync( + IReadOnlyList commands, + SidecarProtocolHost? host = null) + { + string input = string.Join( + Environment.NewLine, + commands.Select(command => JsonSerializer.Serialize(command, JsonOptions))) + + Environment.NewLine; using StringReader reader = new(input); using StringWriter writer = new(); @@ -378,6 +459,16 @@ public sealed class SidecarProtocolHostTests return ParseEvents(writer.ToString()); } + private static JsonElement AssertSingleEvent( + IEnumerable events, + string eventType, + string requestId) + { + return Assert.Single(events.Where(evt => + evt.GetProperty("type").GetString() == eventType + && evt.GetProperty("requestId").GetString() == requestId)); + } + private static SidecarProtocolHost CreateHostForTests() { return new SidecarProtocolHost( @@ -474,6 +565,41 @@ public sealed class SidecarProtocolHostTests }; } + private static RunTurnCommandDto CreateRunTurnCommand( + string requestId = "turn-1", + string sessionId = "session-1") + { + return new RunTurnCommandDto + { + Type = "run-turn", + RequestId = requestId, + SessionId = sessionId, + ProjectPath = "C:\\workspace\\project", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(name: "Primary"), + ], + }, + Messages = + [ + new ChatMessageDto + { + Id = "user-1", + Role = "user", + AuthorName = "You", + Content = "Hello", + CreatedAt = "2026-01-01T00:00:00.0000000Z", + }, + ], + }; + } + private sealed class FakeWorkflowRunner : ITurnWorkflowRunner { private readonly Func<