feat: add sidecar turn cancellation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 22:04:14 +01:00
co-authored by Copilot
parent c8bb9d6f59
commit d84b3021f2
3 changed files with 213 additions and 15 deletions
@@ -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<JsonElement> 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<JsonElement> 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<JsonElement> 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<IReadOnlyList<JsonElement>> RunHostAsync(
IReadOnlyList<object> 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<JsonElement> 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<