From 7b9c4d140cfae2b5996954b2b48a89844dab26ae Mon Sep 17 00:00:00 2001 From: David Kaya Date: Mon, 13 Apr 2026 15:17:21 +0200 Subject: [PATCH] refactor: enrich provider turn stream model Add turn-stream capability metadata and richer normalized provider events for reasoning blocks, tool lifecycle, and turn boundaries. Track those signals in turn execution state and cover the new Copilot adapter/state behavior with tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/ProviderSessionEvent.cs | 28 ++- .../ProviderTurnStreamCapabilities.cs | 28 +++ .../Contracts/ProviderTurnStreamSnapshots.cs | 38 ++++ .../Services/IProviderEventAdapter.cs | 2 + .../Providers/Copilot/CopilotEventAdapter.cs | 50 ++++- .../Copilot/CopilotTurnRunnerSupport.cs | 2 + .../Services/TurnExecutionState.cs | 194 +++++++++++++++++ .../CopilotEventAdapterTests.cs | 77 +++++++ .../CopilotTurnExecutionStateTests.cs | 198 ++++++++++++++++++ 9 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamCapabilities.cs create mode 100644 sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamSnapshots.cs create mode 100644 sidecar/tests/Aryx.AgentHost.Tests/CopilotEventAdapterTests.cs diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProviderSessionEvent.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProviderSessionEvent.cs index b4ad9d2..49a59f8 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProviderSessionEvent.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProviderSessionEvent.cs @@ -2,10 +2,13 @@ namespace Aryx.AgentHost.Contracts; internal abstract record ProviderSessionEvent; -internal sealed record ProviderAssistantMessageDeltaEvent(string MessageId) : ProviderSessionEvent; +internal sealed record ProviderAssistantMessageDeltaEvent( + string MessageId, + string? DeltaContent) : ProviderSessionEvent; internal sealed record ProviderAssistantMessageEvent( string MessageId, + string? Content, bool HasToolRequests) : ProviderSessionEvent; internal sealed record ProviderToolExecutionStartEvent( @@ -13,12 +16,35 @@ internal sealed record ProviderToolExecutionStartEvent( string ToolName, IReadOnlyDictionary? ToolArguments) : ProviderSessionEvent; +internal sealed record ProviderToolExecutionProgressEvent( + string ToolCallId, + string? ProgressMessage) : ProviderSessionEvent; + +internal sealed record ProviderToolExecutionPartialResultEvent( + string ToolCallId, + string? PartialOutput) : ProviderSessionEvent; + +internal sealed record ProviderToolExecutionCompleteEvent( + string ToolCallId, + bool Success, + string? ResultContent, + string? DetailedResultContent, + string? Error) : ProviderSessionEvent; + internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent; internal sealed record ProviderAssistantReasoningDeltaEvent( string? ReasoningId, string? DeltaContent) : ProviderSessionEvent; +internal sealed record ProviderAssistantReasoningEvent( + string? ReasoningId, + string? Content) : ProviderSessionEvent; + +internal sealed record ProviderAssistantTurnStartEvent(string? TurnId) : ProviderSessionEvent; + +internal sealed record ProviderAssistantTurnEndEvent(string? TurnId) : ProviderSessionEvent; + internal sealed record ProviderSubagentStartedEvent( string? ToolCallId, string? AgentName, diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamCapabilities.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamCapabilities.cs new file mode 100644 index 0000000..c34108b --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamCapabilities.cs @@ -0,0 +1,28 @@ +namespace Aryx.AgentHost.Contracts; + +internal sealed record ProviderTurnStreamCapabilities +{ + public static ProviderTurnStreamCapabilities None { get; } = new(); + + public bool SupportsIntent { get; init; } + + public bool SupportsReasoningDelta { get; init; } + + public bool SupportsReasoningBlock { get; init; } + + public bool SupportsToolExecutionProgress { get; init; } + + public bool SupportsToolExecutionPartialResult { get; init; } + + public bool SupportsToolExecutionCompletion { get; init; } + + public bool SupportsSubagentLifecycle { get; init; } + + public bool SupportsHookLifecycle { get; init; } + + public bool SupportsSessionCompaction { get; init; } + + public bool SupportsPendingMessagesMutation { get; init; } + + public bool SupportsSessionTurnBoundaries { get; init; } +} diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamSnapshots.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamSnapshots.cs new file mode 100644 index 0000000..331d6df --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProviderTurnStreamSnapshots.cs @@ -0,0 +1,38 @@ +namespace Aryx.AgentHost.Contracts; + +internal enum ProviderToolExecutionStatus +{ + Running, + Completed, + Failed, +} + +internal sealed record ProviderToolExecutionSnapshot +{ + public string ToolCallId { get; init; } = string.Empty; + + public string? ToolName { get; init; } + + public IReadOnlyDictionary? ToolArguments { get; init; } + + public ProviderToolExecutionStatus Status { get; init; } + + public string? LatestProgressMessage { get; init; } + + public string PartialOutput { get; init; } = string.Empty; + + public string? ResultContent { get; init; } + + public string? DetailedResultContent { get; init; } + + public string? Error { get; init; } +} + +internal sealed record ProviderReasoningSnapshot +{ + public string ReasoningId { get; init; } = string.Empty; + + public string Content { get; init; } = string.Empty; + + public bool IsComplete { get; init; } +} diff --git a/sidecar/src/Aryx.AgentHost/Services/IProviderEventAdapter.cs b/sidecar/src/Aryx.AgentHost/Services/IProviderEventAdapter.cs index 55ceb6e..e68be42 100644 --- a/sidecar/src/Aryx.AgentHost/Services/IProviderEventAdapter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/IProviderEventAdapter.cs @@ -4,5 +4,7 @@ namespace Aryx.AgentHost.Services; internal interface IProviderEventAdapter { + ProviderTurnStreamCapabilities Capabilities { get; } + ProviderSessionEvent? TryAdapt(object rawEvent); } diff --git a/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotEventAdapter.cs b/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotEventAdapter.cs index 6a2b587..e484df7 100644 --- a/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotEventAdapter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotEventAdapter.cs @@ -5,18 +5,34 @@ namespace Aryx.AgentHost.Services; internal sealed class CopilotEventAdapter : IProviderEventAdapter { + public ProviderTurnStreamCapabilities Capabilities { get; } = new() + { + SupportsIntent = true, + SupportsReasoningDelta = true, + SupportsReasoningBlock = true, + SupportsToolExecutionProgress = true, + SupportsToolExecutionPartialResult = true, + SupportsToolExecutionCompletion = true, + SupportsSubagentLifecycle = true, + SupportsHookLifecycle = true, + SupportsSessionCompaction = true, + SupportsPendingMessagesMutation = true, + SupportsSessionTurnBoundaries = true, + }; + public ProviderSessionEvent? TryAdapt(object rawEvent) { return rawEvent switch { AssistantMessageDeltaEvent messageDelta when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId => - new ProviderAssistantMessageDeltaEvent(messageId), + new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent), AssistantMessageEvent assistantMessage when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId => new ProviderAssistantMessageEvent( messageId, + assistantMessage.Data?.Content, assistantMessage.Data?.ToolRequests is { Length: > 0 }), ToolExecutionStartEvent toolExecutionStart @@ -27,6 +43,27 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter toolName, WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)), + ToolExecutionProgressEvent toolExecutionProgress + when NormalizeRequiredString(toolExecutionProgress.Data?.ToolCallId) is { } toolCallId => + new ProviderToolExecutionProgressEvent( + toolCallId, + NormalizeOptionalString(toolExecutionProgress.Data?.ProgressMessage)), + + ToolExecutionPartialResultEvent toolExecutionPartialResult + when NormalizeRequiredString(toolExecutionPartialResult.Data?.ToolCallId) is { } toolCallId => + new ProviderToolExecutionPartialResultEvent( + toolCallId, + toolExecutionPartialResult.Data?.PartialOutput), + + ToolExecutionCompleteEvent toolExecutionComplete + when NormalizeRequiredString(toolExecutionComplete.Data?.ToolCallId) is { } toolCallId => + new ProviderToolExecutionCompleteEvent( + toolCallId, + toolExecutionComplete.Data?.Success ?? false, + NormalizeOptionalString(toolExecutionComplete.Data?.Result?.Content), + NormalizeOptionalString(toolExecutionComplete.Data?.Result?.DetailedContent), + NormalizeOptionalString(toolExecutionComplete.Data?.Error?.Message)), + AssistantIntentEvent intentEvent => new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)), @@ -35,6 +72,17 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter NormalizeOptionalString(reasoningDelta.Data?.ReasoningId), reasoningDelta.Data?.DeltaContent), + AssistantReasoningEvent reasoning => + new ProviderAssistantReasoningEvent( + NormalizeOptionalString(reasoning.Data?.ReasoningId), + reasoning.Data?.Content), + + AssistantTurnStartEvent turnStart => + new ProviderAssistantTurnStartEvent(NormalizeOptionalString(turnStart.Data?.TurnId)), + + AssistantTurnEndEvent turnEnd => + new ProviderAssistantTurnEndEvent(NormalizeOptionalString(turnEnd.Data?.TurnId)), + SubagentStartedEvent started => new ProviderSubagentStartedEvent( started.Data?.ToolCallId, diff --git a/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotTurnRunnerSupport.cs b/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotTurnRunnerSupport.cs index ce84cec..20e7931 100644 --- a/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotTurnRunnerSupport.cs +++ b/sidecar/src/Aryx.AgentHost/Services/Providers/Copilot/CopilotTurnRunnerSupport.cs @@ -20,6 +20,8 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport CancellationTokenSource runCancellation, CancellationToken cancellationToken) { + state.SetStreamCapabilities(_providerEventAdapter.Capabilities); + return await CopilotAgentBundle.CreateAsync( command, (agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync( diff --git a/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs index 5b7592c..605e358 100644 --- a/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using Aryx.AgentHost.Contracts; using Microsoft.Extensions.AI; @@ -14,6 +15,9 @@ internal class TurnExecutionState private readonly ConcurrentQueue _pendingEvents = new(); private readonly ConcurrentQueue _pendingMcpOauthRequests = new(); private readonly ConcurrentDictionary _observedAgentsByMessageId = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _toolExecutionsByCallId = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _reasoningById = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _latestIntentByAgentId = new(StringComparer.Ordinal); private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); private int _fallbackMessageIndex; private string? _lastObservedMessageId; @@ -35,8 +39,19 @@ internal class TurnExecutionState public bool HasPendingExitPlanModeRequest { get; private set; } + public ProviderTurnStreamCapabilities StreamCapabilities { get; private set; } = ProviderTurnStreamCapabilities.None; + + public string? CurrentProviderTurnId { get; private set; } + + public string? LatestCompletedProviderTurnId { get; private set; } + public bool SuppressHookLifecycleEvents { get; set; } + public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities) + { + StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); + } + public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName) { return AgentIdentityResolver.ResolveAgentIdentity( @@ -162,9 +177,22 @@ internal class TurnExecutionState QueueMessageReclassifiedIfNeeded(_lastObservedMessageId); break; + case ProviderToolExecutionProgressEvent toolExecutionProgress: + ActiveAgent = agent; + TrackToolExecutionProgress(toolExecutionProgress.ToolCallId, toolExecutionProgress.ProgressMessage); + break; + case ProviderToolExecutionPartialResultEvent toolExecutionPartialResult: + ActiveAgent = agent; + TrackToolExecutionPartialResult(toolExecutionPartialResult.ToolCallId, toolExecutionPartialResult.PartialOutput); + break; + case ProviderToolExecutionCompleteEvent toolExecutionComplete: + ActiveAgent = agent; + TrackToolExecutionComplete(toolExecutionComplete); + break; case ProviderAssistantIntentEvent intentEvent: ActiveAgent = agent; QueueThinkingIfNeeded(agent); + TrackLatestIntent(agent.AgentId, intentEvent.Intent); AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent); if (assistantIntent is not null) { @@ -174,6 +202,7 @@ internal class TurnExecutionState case ProviderAssistantReasoningDeltaEvent reasoningDelta: ActiveAgent = agent; QueueThinkingIfNeeded(agent); + TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false); ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent( agent, reasoningDelta.ReasoningId, @@ -183,6 +212,22 @@ internal class TurnExecutionState _pendingEvents.Enqueue(reasoningDeltaEvent); } break; + case ProviderAssistantReasoningEvent reasoning: + ActiveAgent = agent; + TrackReasoningContent(reasoning.ReasoningId, reasoning.Content, isComplete: true); + break; + case ProviderAssistantTurnStartEvent turnStart: + ActiveAgent = agent; + CurrentProviderTurnId = turnStart.TurnId; + break; + case ProviderAssistantTurnEndEvent turnEnd: + ActiveAgent = agent; + LatestCompletedProviderTurnId = turnEnd.TurnId; + if (string.Equals(CurrentProviderTurnId, turnEnd.TurnId, StringComparison.Ordinal)) + { + CurrentProviderTurnId = null; + } + break; case ProviderSubagentStartedEvent started: ActiveAgent = agent; _pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started)); @@ -291,6 +336,27 @@ internal class TurnExecutionState return pending; } + public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot) + { + snapshot = null; + return !string.IsNullOrWhiteSpace(toolCallId) + && _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot); + } + + public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot) + { + snapshot = null; + return !string.IsNullOrWhiteSpace(reasoningId) + && _reasoningById.TryGetValue(reasoningId, out snapshot); + } + + public bool TryGetLatestIntent(string? agentId, [NotNullWhen(true)] out string? intent) + { + intent = null; + return !string.IsNullOrWhiteSpace(agentId) + && _latestIntentByAgentId.TryGetValue(agentId, out intent); + } + public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent) { agent = default; @@ -334,6 +400,129 @@ internal class TurnExecutionState { ToolNamesByCallId[toolCallId] = toolName; ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 }; + TrackToolExecutionStart(toolCallId, toolName, toolArguments); + } + + private void TrackToolExecutionStart( + string toolCallId, + string toolName, + IReadOnlyDictionary? toolArguments) + { + _toolExecutionsByCallId.AddOrUpdate( + toolCallId, + static (id, state) => new ProviderToolExecutionSnapshot + { + ToolCallId = id, + ToolName = state.ToolName, + ToolArguments = state.ToolArguments, + Status = ProviderToolExecutionStatus.Running, + }, + static (_, existing, state) => existing with + { + ToolName = state.ToolName, + ToolArguments = state.ToolArguments, + Status = ProviderToolExecutionStatus.Running, + }, + (ToolName: toolName, ToolArguments: toolArguments)); + } + + private void TrackToolExecutionProgress(string toolCallId, string? progressMessage) + { + string? normalizedProgress = NormalizeOptionalString(progressMessage); + _toolExecutionsByCallId.AddOrUpdate( + toolCallId, + id => new ProviderToolExecutionSnapshot + { + ToolCallId = id, + Status = ProviderToolExecutionStatus.Running, + LatestProgressMessage = normalizedProgress, + }, + (_, existing) => existing with + { + Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed + ? existing.Status + : ProviderToolExecutionStatus.Running, + LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage, + }); + } + + private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput) + { + if (string.IsNullOrEmpty(partialOutput)) + { + return; + } + + _toolExecutionsByCallId.AddOrUpdate( + toolCallId, + id => new ProviderToolExecutionSnapshot + { + ToolCallId = id, + Status = ProviderToolExecutionStatus.Running, + PartialOutput = partialOutput, + }, + (_, existing) => existing with + { + Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed + ? existing.Status + : ProviderToolExecutionStatus.Running, + PartialOutput = string.Concat(existing.PartialOutput, partialOutput), + }); + } + + private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution) + { + _toolExecutionsByCallId.AddOrUpdate( + toolExecution.ToolCallId, + id => new ProviderToolExecutionSnapshot + { + ToolCallId = id, + Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed, + ResultContent = toolExecution.ResultContent, + DetailedResultContent = toolExecution.DetailedResultContent, + Error = toolExecution.Error, + }, + (_, existing) => existing with + { + Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed, + ResultContent = toolExecution.ResultContent ?? existing.ResultContent, + DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent, + Error = toolExecution.Error ?? existing.Error, + }); + } + + private void TrackLatestIntent(string agentId, string? intent) + { + string? normalizedIntent = NormalizeOptionalString(intent); + if (normalizedIntent is null) + { + return; + } + + _latestIntentByAgentId[agentId] = normalizedIntent; + } + + private void TrackReasoningContent(string? reasoningId, string? content, bool isComplete) + { + string? normalizedReasoningId = NormalizeOptionalString(reasoningId); + if (normalizedReasoningId is null || content is null) + { + return; + } + + _reasoningById.AddOrUpdate( + normalizedReasoningId, + id => new ProviderReasoningSnapshot + { + ReasoningId = id, + Content = content, + IsComplete = isComplete, + }, + (_, existing) => existing with + { + Content = isComplete ? content : string.Concat(existing.Content, content), + IsComplete = isComplete || existing.IsComplete, + }); } private void QueueMessageReclassifiedIfNeeded(string? messageId) @@ -759,4 +948,9 @@ internal class TurnExecutionState AgentName = agent.AgentName, }; } + + private static string? NormalizeOptionalString(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotEventAdapterTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotEventAdapterTests.cs new file mode 100644 index 0000000..2caf4c9 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotEventAdapterTests.cs @@ -0,0 +1,77 @@ +using Aryx.AgentHost.Contracts; +using Aryx.AgentHost.Services; +using GitHub.Copilot.SDK; + +namespace Aryx.AgentHost.Tests; + +public sealed class CopilotEventAdapterTests +{ + private static readonly CopilotEventAdapter Adapter = new(); + + [Fact] + public void Capabilities_AdvertiseRichTurnStreamSupport() + { + ProviderTurnStreamCapabilities capabilities = Adapter.Capabilities; + + Assert.True(capabilities.SupportsIntent); + Assert.True(capabilities.SupportsReasoningDelta); + Assert.True(capabilities.SupportsReasoningBlock); + Assert.True(capabilities.SupportsToolExecutionProgress); + Assert.True(capabilities.SupportsToolExecutionPartialResult); + Assert.True(capabilities.SupportsToolExecutionCompletion); + Assert.True(capabilities.SupportsSubagentLifecycle); + Assert.True(capabilities.SupportsHookLifecycle); + Assert.True(capabilities.SupportsSessionCompaction); + Assert.True(capabilities.SupportsPendingMessagesMutation); + Assert.True(capabilities.SupportsSessionTurnBoundaries); + } + + [Fact] + public void TryAdapt_ToolExecutionComplete_MapsNormalizedResult() + { + ProviderToolExecutionCompleteEvent evt = Assert.IsType( + Adapter.TryAdapt(SessionEvent.FromJson( + """ + { + "type": "tool.execution_complete", + "data": { + "toolCallId": "tool-call-1", + "success": true, + "result": { + "content": "summary", + "detailedContent": "summary\nfull" + } + }, + "id": "11111111-2222-3333-4444-555555555555", + "timestamp": "2026-03-27T00:00:00Z" + } + """))); + + Assert.Equal("tool-call-1", evt.ToolCallId); + Assert.True(evt.Success); + Assert.Equal("summary", evt.ResultContent); + Assert.Equal("summary\nfull", evt.DetailedResultContent); + Assert.Null(evt.Error); + } + + [Fact] + public void TryAdapt_AssistantReasoning_MapsCompletedReasoningBlock() + { + ProviderAssistantReasoningEvent evt = Assert.IsType( + Adapter.TryAdapt(SessionEvent.FromJson( + """ + { + "type": "assistant.reasoning", + "data": { + "reasoningId": "reasoning-1", + "content": "Planning the next step." + }, + "id": "66666666-7777-8888-9999-aaaaaaaaaaaa", + "timestamp": "2026-03-27T00:00:00Z" + } + """))); + + Assert.Equal("reasoning-1", evt.ReasoningId); + Assert.Equal("Planning the next step.", evt.Content); + } +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index f8b617f..be08f4b 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -133,6 +133,114 @@ public sealed class CopilotTurnExecutionStateTests Assert.False(hasArguments); } + [Fact] + public void ObserveSessionEvent_ToolExecutionProgress_TracksLatestProgressMessage() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}""")); + _ = state.DrainPendingEvents(); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_progress","data":{"toolCallId":"tool-call-1","progressMessage":"Scanning repository"},"id":"43333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}""")); + + Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution)); + Assert.NotNull(toolExecution); + Assert.Equal(ProviderToolExecutionStatus.Running, toolExecution.Status); + Assert.Equal("view", toolExecution.ToolName); + Assert.Equal("Scanning repository", toolExecution.LatestProgressMessage); + } + + [Fact] + public void ObserveSessionEvent_ToolExecutionPartialResult_AppendsPartialOutput() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"bash"},"id":"53333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}""")); + _ = state.DrainPendingEvents(); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"first line\n"},"id":"63333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}""")); + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"second line"},"id":"73333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:02Z"}""")); + + Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution)); + Assert.NotNull(toolExecution); + Assert.Equal("first line\nsecond line", toolExecution.PartialOutput); + } + + [Fact] + public void ObserveSessionEvent_ToolExecutionComplete_TracksFinalToolState() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"README.md"}},"id":"83333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}""")); + _ = state.DrainPendingEvents(); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """ + { + "type": "tool.execution_complete", + "data": { + "toolCallId": "tool-call-1", + "success": true, + "result": { + "content": "README excerpt", + "detailedContent": "README excerpt\nwith more detail" + } + }, + "id": "93333333-3333-3333-3333-333333333333", + "timestamp": "2026-03-27T00:00:01Z" + } + """)); + + Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution)); + Assert.NotNull(toolExecution); + Assert.Equal(ProviderToolExecutionStatus.Completed, toolExecution.Status); + Assert.Equal("view", toolExecution.ToolName); + Assert.NotNull(toolExecution.ToolArguments); + Assert.Equal("README excerpt", toolExecution.ResultContent); + Assert.Equal("README excerpt\nwith more detail", toolExecution.DetailedResultContent); + Assert.Null(toolExecution.Error); + } + + [Fact] + public void ObserveSessionEvent_ToolExecutionCompleteFailure_TracksErrorState() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """{"type":"tool.execution_complete","data":{"toolCallId":"tool-call-err","success":false,"error":{"message":"permission denied"}},"id":"a3333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}""")); + + Assert.True(state.TryGetToolExecution("tool-call-err", out ProviderToolExecutionSnapshot? toolExecution)); + Assert.NotNull(toolExecution); + Assert.Equal(ProviderToolExecutionStatus.Failed, toolExecution.Status); + Assert.Equal("permission denied", toolExecution.Error); + } + [Fact] public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools() { @@ -366,6 +474,9 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("session-1", intent.SessionId); Assert.Equal("agent-1", intent.AgentId); Assert.Equal("Searching incident playbooks", intent.Intent); + Assert.True(state.TryGetLatestIntent("agent-1", out string? latestIntent)); + Assert.NotNull(latestIntent); + Assert.Equal("Searching incident playbooks", latestIntent); } [Fact] @@ -399,6 +510,93 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("agent-1", reasoning.AgentId); Assert.Equal("reasoning-2", reasoning.ReasoningId); Assert.Equal("Searching logs.", reasoning.ContentDelta); + Assert.True(state.TryGetReasoning("reasoning-2", out ProviderReasoningSnapshot? reasoningState)); + Assert.NotNull(reasoningState); + Assert.Equal("Searching logs.", reasoningState.Content); + Assert.False(reasoningState.IsComplete); + } + + [Fact] + public void ObserveSessionEvent_AssistantReasoning_TracksCompletedReasoningBlock() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.reasoning_delta", + "data": { + "reasoningId": "reasoning-3", + "deltaContent": "Planning." + }, + "id": "cd269258-5e5d-46b6-bf3f-bd8cba793b1a", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + _ = state.DrainPendingEvents(); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.reasoning", + "data": { + "reasoningId": "reasoning-3", + "content": "Planning. Checking logs." + }, + "id": "dd269258-5e5d-46b6-bf3f-bd8cba793b1a", + "timestamp": "2026-03-27T00:00:01Z" + } + """)); + + Assert.True(state.TryGetReasoning("reasoning-3", out ProviderReasoningSnapshot? reasoning)); + Assert.NotNull(reasoning); + Assert.Equal("Planning. Checking logs.", reasoning.Content); + Assert.True(reasoning.IsComplete); + } + + [Fact] + public void ObserveSessionEvent_AssistantTurnBoundaries_TrackProviderTurnIds() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.turn_start", + "data": { + "turnId": "turn-sdk-1" + }, + "id": "ed269258-5e5d-46b6-bf3f-bd8cba793b1a", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + Assert.Equal("turn-sdk-1", state.CurrentProviderTurnId); + + state.ObserveSessionEvent( + command.Workflow.GetAgentNodes()[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.turn_end", + "data": { + "turnId": "turn-sdk-1" + }, + "id": "fd269258-5e5d-46b6-bf3f-bd8cba793b1a", + "timestamp": "2026-03-27T00:00:01Z" + } + """)); + + Assert.Null(state.CurrentProviderTurnId); + Assert.Equal("turn-sdk-1", state.LatestCompletedProviderTurnId); } [Fact]