mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 18:38:35 +02:00
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>
This commit is contained in:
@@ -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<string, object?>? 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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<string, object?>? 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; }
|
||||
}
|
||||
@@ -4,5 +4,7 @@ namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IProviderEventAdapter
|
||||
{
|
||||
ProviderTurnStreamCapabilities Capabilities { get; }
|
||||
|
||||
ProviderSessionEvent? TryAdapt(object rawEvent);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<SidecarEventDto> _pendingEvents = new();
|
||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, string> _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<string, object?>? 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProviderToolExecutionCompleteEvent>(
|
||||
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<ProviderAssistantReasoningEvent>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user