mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +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 abstract record ProviderSessionEvent;
|
||||||
|
|
||||||
internal sealed record ProviderAssistantMessageDeltaEvent(string MessageId) : ProviderSessionEvent;
|
internal sealed record ProviderAssistantMessageDeltaEvent(
|
||||||
|
string MessageId,
|
||||||
|
string? DeltaContent) : ProviderSessionEvent;
|
||||||
|
|
||||||
internal sealed record ProviderAssistantMessageEvent(
|
internal sealed record ProviderAssistantMessageEvent(
|
||||||
string MessageId,
|
string MessageId,
|
||||||
|
string? Content,
|
||||||
bool HasToolRequests) : ProviderSessionEvent;
|
bool HasToolRequests) : ProviderSessionEvent;
|
||||||
|
|
||||||
internal sealed record ProviderToolExecutionStartEvent(
|
internal sealed record ProviderToolExecutionStartEvent(
|
||||||
@@ -13,12 +16,35 @@ internal sealed record ProviderToolExecutionStartEvent(
|
|||||||
string ToolName,
|
string ToolName,
|
||||||
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
|
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 ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
|
||||||
|
|
||||||
internal sealed record ProviderAssistantReasoningDeltaEvent(
|
internal sealed record ProviderAssistantReasoningDeltaEvent(
|
||||||
string? ReasoningId,
|
string? ReasoningId,
|
||||||
string? DeltaContent) : ProviderSessionEvent;
|
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(
|
internal sealed record ProviderSubagentStartedEvent(
|
||||||
string? ToolCallId,
|
string? ToolCallId,
|
||||||
string? AgentName,
|
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
|
internal interface IProviderEventAdapter
|
||||||
{
|
{
|
||||||
|
ProviderTurnStreamCapabilities Capabilities { get; }
|
||||||
|
|
||||||
ProviderSessionEvent? TryAdapt(object rawEvent);
|
ProviderSessionEvent? TryAdapt(object rawEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,34 @@ namespace Aryx.AgentHost.Services;
|
|||||||
|
|
||||||
internal sealed class CopilotEventAdapter : IProviderEventAdapter
|
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)
|
public ProviderSessionEvent? TryAdapt(object rawEvent)
|
||||||
{
|
{
|
||||||
return rawEvent switch
|
return rawEvent switch
|
||||||
{
|
{
|
||||||
AssistantMessageDeltaEvent messageDelta
|
AssistantMessageDeltaEvent messageDelta
|
||||||
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
|
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
|
||||||
new ProviderAssistantMessageDeltaEvent(messageId),
|
new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent),
|
||||||
|
|
||||||
AssistantMessageEvent assistantMessage
|
AssistantMessageEvent assistantMessage
|
||||||
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
|
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
|
||||||
new ProviderAssistantMessageEvent(
|
new ProviderAssistantMessageEvent(
|
||||||
messageId,
|
messageId,
|
||||||
|
assistantMessage.Data?.Content,
|
||||||
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
|
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
|
||||||
|
|
||||||
ToolExecutionStartEvent toolExecutionStart
|
ToolExecutionStartEvent toolExecutionStart
|
||||||
@@ -27,6 +43,27 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
|
|||||||
toolName,
|
toolName,
|
||||||
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)),
|
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 =>
|
AssistantIntentEvent intentEvent =>
|
||||||
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
|
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
|
||||||
|
|
||||||
@@ -35,6 +72,17 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
|
|||||||
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
|
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
|
||||||
reasoningDelta.Data?.DeltaContent),
|
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 =>
|
SubagentStartedEvent started =>
|
||||||
new ProviderSubagentStartedEvent(
|
new ProviderSubagentStartedEvent(
|
||||||
started.Data?.ToolCallId,
|
started.Data?.ToolCallId,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
|
|||||||
CancellationTokenSource runCancellation,
|
CancellationTokenSource runCancellation,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
state.SetStreamCapabilities(_providerEventAdapter.Capabilities);
|
||||||
|
|
||||||
return await CopilotAgentBundle.CreateAsync(
|
return await CopilotAgentBundle.CreateAsync(
|
||||||
command,
|
command,
|
||||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ internal class TurnExecutionState
|
|||||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
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 readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||||
private int _fallbackMessageIndex;
|
private int _fallbackMessageIndex;
|
||||||
private string? _lastObservedMessageId;
|
private string? _lastObservedMessageId;
|
||||||
@@ -35,8 +39,19 @@ internal class TurnExecutionState
|
|||||||
|
|
||||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
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 bool SuppressHookLifecycleEvents { get; set; }
|
||||||
|
|
||||||
|
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
|
||||||
|
{
|
||||||
|
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
|
||||||
|
}
|
||||||
|
|
||||||
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
|
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
|
||||||
{
|
{
|
||||||
return AgentIdentityResolver.ResolveAgentIdentity(
|
return AgentIdentityResolver.ResolveAgentIdentity(
|
||||||
@@ -162,9 +177,22 @@ internal class TurnExecutionState
|
|||||||
|
|
||||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||||
break;
|
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:
|
case ProviderAssistantIntentEvent intentEvent:
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
QueueThinkingIfNeeded(agent);
|
QueueThinkingIfNeeded(agent);
|
||||||
|
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
|
||||||
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
|
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
|
||||||
if (assistantIntent is not null)
|
if (assistantIntent is not null)
|
||||||
{
|
{
|
||||||
@@ -174,6 +202,7 @@ internal class TurnExecutionState
|
|||||||
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
|
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
QueueThinkingIfNeeded(agent);
|
QueueThinkingIfNeeded(agent);
|
||||||
|
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
|
||||||
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
|
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
|
||||||
agent,
|
agent,
|
||||||
reasoningDelta.ReasoningId,
|
reasoningDelta.ReasoningId,
|
||||||
@@ -183,6 +212,22 @@ internal class TurnExecutionState
|
|||||||
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
||||||
}
|
}
|
||||||
break;
|
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:
|
case ProviderSubagentStartedEvent started:
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
|
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
|
||||||
@@ -291,6 +336,27 @@ internal class TurnExecutionState
|
|||||||
return pending;
|
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)
|
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||||
{
|
{
|
||||||
agent = default;
|
agent = default;
|
||||||
@@ -334,6 +400,129 @@ internal class TurnExecutionState
|
|||||||
{
|
{
|
||||||
ToolNamesByCallId[toolCallId] = toolName;
|
ToolNamesByCallId[toolCallId] = toolName;
|
||||||
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 };
|
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)
|
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
||||||
@@ -759,4 +948,9 @@ internal class TurnExecutionState
|
|||||||
AgentName = agent.AgentName,
|
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);
|
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]
|
[Fact]
|
||||||
public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools()
|
public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools()
|
||||||
{
|
{
|
||||||
@@ -366,6 +474,9 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
Assert.Equal("session-1", intent.SessionId);
|
Assert.Equal("session-1", intent.SessionId);
|
||||||
Assert.Equal("agent-1", intent.AgentId);
|
Assert.Equal("agent-1", intent.AgentId);
|
||||||
Assert.Equal("Searching incident playbooks", intent.Intent);
|
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]
|
[Fact]
|
||||||
@@ -399,6 +510,93 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
Assert.Equal("agent-1", reasoning.AgentId);
|
Assert.Equal("agent-1", reasoning.AgentId);
|
||||||
Assert.Equal("reasoning-2", reasoning.ReasoningId);
|
Assert.Equal("reasoning-2", reasoning.ReasoningId);
|
||||||
Assert.Equal("Searching logs.", reasoning.ContentDelta);
|
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]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user