mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1099 lines
42 KiB
C#
1099 lines
42 KiB
C#
using Aryx.AgentHost.Contracts;
|
|
using Aryx.AgentHost.Services;
|
|
using GitHub.Copilot.SDK;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
namespace Aryx.AgentHost.Tests;
|
|
|
|
public sealed class CopilotTurnExecutionStateTests
|
|
{
|
|
[Fact]
|
|
public void ObserveSessionEvent_McpOauthRequired_SetsActiveAgent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
new McpOauthRequiredEvent
|
|
{
|
|
Data = new McpOauthRequiredData
|
|
{
|
|
RequestId = "oauth-request-1",
|
|
ServerName = "Example MCP",
|
|
ServerUrl = "https://example.com/mcp",
|
|
},
|
|
});
|
|
|
|
Assert.True(state.ActiveAgent.HasValue);
|
|
Assert.Equal("agent-1", state.ActiveAgent.Value.AgentId);
|
|
Assert.Equal("Primary", state.ActiveAgent.Value.AgentName);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantMessageDelta_QueuesThinkingActivity()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message_delta",
|
|
"data": {
|
|
"messageId": "msg-1",
|
|
"deltaContent": "Hello"
|
|
},
|
|
"id": "11111111-1111-1111-1111-111111111111",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
|
Assert.Equal("thinking", activity.ActivityType);
|
|
Assert.Equal("agent-1", activity.AgentId);
|
|
Assert.Equal("Primary", activity.AgentName);
|
|
Assert.True(state.TryResolveObservedAgentForMessage("msg-1", out AgentIdentity observedAgent));
|
|
Assert.Equal("agent-1", observedAgent.AgentId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
|
|
{
|
|
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
|
|
CopilotTurnExecutionState state = new(command);
|
|
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
|
|
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
|
|
|
|
state.ObserveSessionEvent(
|
|
nestedAgent,
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message_delta",
|
|
"data": {
|
|
"messageId": "msg-nested-1",
|
|
"deltaContent": "Reviewing"
|
|
},
|
|
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
|
Assert.Equal("thinking", activity.ActivityType);
|
|
Assert.Equal("agent-reviewer", activity.AgentId);
|
|
Assert.Equal("Reviewer", activity.AgentName);
|
|
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
|
|
Assert.Equal("Review Lane", activity.SubworkflowName);
|
|
Assert.True(state.ActiveAgent.HasValue);
|
|
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
|
|
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
|
|
{
|
|
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":"/src/main.ts","view_range":[10,20]}},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
|
|
|
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
|
Assert.Equal("tool-calling", toolActivity.ActivityType);
|
|
Assert.Equal("view", toolActivity.ToolName);
|
|
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
|
|
Assert.NotNull(toolActivity.ToolArguments);
|
|
Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]);
|
|
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
|
|
Assert.Equal("view", toolName);
|
|
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_ToolExecutionStart_WithoutArguments_SetsToolArgumentsToNull()
|
|
{
|
|
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"}"""));
|
|
|
|
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
|
Assert.Null(toolActivity.ToolArguments);
|
|
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
|
}
|
|
|
|
[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()
|
|
{
|
|
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":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
|
|
|
|
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
|
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
|
|
Assert.Equal("handoff_to_specialist", toolName);
|
|
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
|
}
|
|
|
|
[Fact]
|
|
public void QueueCompletedActivity_QueuesCompletedAgentActivity()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.QueueCompletedActivity(new AgentIdentity("agent-1", "Primary"));
|
|
|
|
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
|
Assert.Equal("completed", activity.ActivityType);
|
|
Assert.Equal("agent-1", activity.AgentId);
|
|
Assert.Equal("Primary", activity.AgentName);
|
|
Assert.Equal(command.RequestId, activity.RequestId);
|
|
Assert.Equal(command.SessionId, activity.SessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message",
|
|
"data": {
|
|
"messageId": "msg-2",
|
|
"content": "Let me search for that.",
|
|
"toolRequests": [
|
|
{
|
|
"toolCallId": "tool-call-1",
|
|
"name": "rg",
|
|
"arguments": {
|
|
"pattern": "identifierUri"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"id": "3f75988b-8e69-4c90-a203-6b01d1c1f90b",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
|
|
|
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
|
|
Assert.Equal("thinking", thinking.ActivityType);
|
|
|
|
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
|
Assert.Equal("session-1", reclassified.SessionId);
|
|
Assert.Equal("msg-2", reclassified.MessageId);
|
|
Assert.Equal("thinking", reclassified.NewKind);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_ToolExecutionStart_ReclassifiesLastObservedMessageOnce()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message_delta",
|
|
"data": {
|
|
"messageId": "msg-3",
|
|
"deltaContent": "Searching"
|
|
},
|
|
"id": "0b65f0e9-d0fb-417e-ab5c-7a3343d8581b",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
_ = state.DrainPendingEvents();
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "tool.execution_start",
|
|
"data": {
|
|
"toolCallId": "tool-call-1",
|
|
"toolName": "rg"
|
|
},
|
|
"id": "8f33240e-bd3f-475c-aeb6-a4b7908e47b0",
|
|
"timestamp": "2026-03-27T00:00:01Z"
|
|
}
|
|
"""));
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "tool.execution_start",
|
|
"data": {
|
|
"toolCallId": "tool-call-2",
|
|
"toolName": "view"
|
|
},
|
|
"id": "a23f9c9a-f947-4282-866d-f599451c3899",
|
|
"timestamp": "2026-03-27T00:00:02Z"
|
|
}
|
|
"""));
|
|
|
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
|
|
|
AgentActivityEventDto[] toolActivities = [.. pending.OfType<AgentActivityEventDto>().Where(activity => activity.ActivityType == "tool-calling")];
|
|
Assert.Equal(2, toolActivities.Length);
|
|
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg");
|
|
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
|
|
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
|
Assert.Equal("msg-3", reclassified.MessageId);
|
|
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? firstToolName));
|
|
Assert.Equal("rg", firstToolName);
|
|
Assert.True(state.ToolCalls.TryGetToolName("tool-call-2", out string? secondToolName));
|
|
Assert.Equal("view", secondToolName);
|
|
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
|
|
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-2"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantMessageWithoutToolRequests_DoesNotQueueMessageReclassifiedEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message",
|
|
"data": {
|
|
"messageId": "msg-4",
|
|
"content": "Final answer."
|
|
},
|
|
"id": "d07fe954-1258-4f6a-bf79-1550d6143ed0",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
Assert.Empty(state.DrainPendingEvents().OfType<MessageReclassifiedEventDto>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.reasoning_delta",
|
|
"data": {
|
|
"reasoningId": "reasoning-1",
|
|
"deltaContent": "Planning"
|
|
},
|
|
"id": "22222222-2222-2222-2222-222222222222",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
List<AgentActivityEventDto> activities = [.. state.DrainPendingEvents().OfType<AgentActivityEventDto>()];
|
|
|
|
await state.EmitThinkingIfNeeded(
|
|
new AgentIdentity("agent-1", "Primary"),
|
|
sidecarEvent =>
|
|
{
|
|
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
AgentActivityEventDto thinking = Assert.Single(activities);
|
|
Assert.Equal("thinking", thinking.ActivityType);
|
|
Assert.Equal("agent-1", thinking.AgentId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantIntent_QueuesIntentEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.intent",
|
|
"data": {
|
|
"intent": "Searching incident playbooks"
|
|
},
|
|
"id": "64cf59fe-63f0-4217-adf4-9bd6b3a80452",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
|
|
|
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
|
|
Assert.Equal("thinking", thinking.ActivityType);
|
|
|
|
AssistantIntentEventDto intent = Assert.Single(pending.OfType<AssistantIntentEventDto>());
|
|
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]
|
|
public void ObserveSessionEvent_AssistantReasoningDelta_QueuesReasoningDeltaEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.reasoning_delta",
|
|
"data": {
|
|
"reasoningId": "reasoning-2",
|
|
"deltaContent": "Searching logs."
|
|
},
|
|
"id": "bd269258-5e5d-46b6-bf3f-bd8cba793b1a",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
|
|
|
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
|
|
Assert.Equal("thinking", thinking.ActivityType);
|
|
|
|
ReasoningDeltaEventDto reasoning = Assert.Single(pending.OfType<ReasoningDeltaEventDto>());
|
|
Assert.Equal("session-1", reasoning.SessionId);
|
|
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]
|
|
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
McpOauthRequiredEventDto request = new()
|
|
{
|
|
Type = "mcp-oauth-required",
|
|
RequestId = command.RequestId,
|
|
SessionId = command.SessionId,
|
|
OauthRequestId = "oauth-request-1",
|
|
ServerName = "Example MCP",
|
|
ServerUrl = "https://example.com/mcp",
|
|
};
|
|
|
|
state.EnqueuePendingMcpOauthRequest(request);
|
|
|
|
IReadOnlyList<McpOauthRequiredEventDto> firstDrain = state.DrainPendingMcpOauthRequests();
|
|
IReadOnlyList<McpOauthRequiredEventDto> secondDrain = state.DrainPendingMcpOauthRequests();
|
|
|
|
McpOauthRequiredEventDto drained = Assert.Single(firstDrain);
|
|
Assert.Equal("oauth-request-1", drained.OauthRequestId);
|
|
Assert.Empty(secondDrain);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_SubagentStarted_QueuesSubagentEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "subagent.started",
|
|
"data": {
|
|
"toolCallId": "tool-call-1",
|
|
"agentName": "designer",
|
|
"agentDisplayName": "Designer",
|
|
"agentDescription": "Design specialist"
|
|
},
|
|
"id": "44444444-4444-4444-4444-444444444444",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
SubagentEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SubagentEventDto>());
|
|
Assert.Equal("started", evt.EventKind);
|
|
Assert.Equal("tool-call-1", evt.ToolCallId);
|
|
Assert.Equal("designer", evt.CustomAgentName);
|
|
Assert.Equal("Designer", evt.CustomAgentDisplayName);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_SkillInvoked_QueuesSkillEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "skill.invoked",
|
|
"data": {
|
|
"name": "reviewer",
|
|
"path": "C:\\skills\\reviewer\\SKILL.md",
|
|
"content": "# Reviewer",
|
|
"allowedTools": ["view"],
|
|
"pluginName": "aryx-plugin",
|
|
"pluginVersion": "1.0.0"
|
|
},
|
|
"id": "55555555-5555-5555-5555-555555555555",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
SkillInvokedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SkillInvokedEventDto>());
|
|
Assert.Equal("reviewer", evt.SkillName);
|
|
Assert.Equal(@"C:\skills\reviewer\SKILL.md", evt.Path);
|
|
Assert.Equal(["view"], evt.AllowedTools);
|
|
Assert.Equal("aryx-plugin", evt.PluginName);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_HookStart_QueuesHookLifecycleEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
|
|
|
|
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
|
Assert.Equal("start", evt.Phase);
|
|
Assert.Equal("postToolUse", evt.HookType);
|
|
Assert.Equal("hook-1", evt.HookInvocationId);
|
|
Assert.NotNull(evt.Input);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_HookEnd_QueuesHookLifecycleEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
|
|
|
|
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
|
Assert.Equal("end", evt.Phase);
|
|
Assert.Equal("postToolUse", evt.HookType);
|
|
Assert.Equal("hook-1", evt.HookInvocationId);
|
|
Assert.True(evt.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_HookLifecycleEvents_AreSuppressedWhenConfigured()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command)
|
|
{
|
|
SuppressHookLifecycleEvents = true,
|
|
};
|
|
|
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
|
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
|
|
|
|
Assert.Empty(state.DrainPendingEvents());
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantUsage_QueuesAssistantUsageEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.usage",
|
|
"data": {
|
|
"model": "gpt-5.4",
|
|
"inputTokens": 1200,
|
|
"outputTokens": 300,
|
|
"cacheReadTokens": 50,
|
|
"cacheWriteTokens": 10,
|
|
"cost": 0.42,
|
|
"duration": 8200,
|
|
"quotaSnapshots": {
|
|
"premium_interactions": {
|
|
"entitlementRequests": 50,
|
|
"usedRequests": 12,
|
|
"remainingPercentage": 76,
|
|
"overage": 0,
|
|
"overageAllowedWithExhaustedQuota": true,
|
|
"resetDate": "2026-04-01T00:00:00Z"
|
|
}
|
|
},
|
|
"copilotUsage": {
|
|
"tokenDetails": [
|
|
{
|
|
"batchSize": 1,
|
|
"costPerBatch": 1,
|
|
"tokenCount": 1500,
|
|
"tokenType": "input"
|
|
}
|
|
],
|
|
"totalNanoAiu": 1200000000
|
|
}
|
|
},
|
|
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
AssistantUsageEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<AssistantUsageEventDto>());
|
|
Assert.Equal("session-1", evt.SessionId);
|
|
Assert.Equal("agent-1", evt.AgentId);
|
|
Assert.Equal("Primary", evt.AgentName);
|
|
Assert.Equal("gpt-5.4", evt.Model);
|
|
Assert.Equal(1200, evt.InputTokens);
|
|
Assert.Equal(300, evt.OutputTokens);
|
|
Assert.Equal(0.42, evt.Cost);
|
|
Assert.Equal(8200, evt.Duration);
|
|
Assert.Equal(1200000000, evt.TotalNanoAiu);
|
|
QuotaSnapshotDto snapshot = Assert.Single(evt.QuotaSnapshots!.Values);
|
|
Assert.Equal(50, snapshot.EntitlementRequests);
|
|
Assert.Equal(12, snapshot.UsedRequests);
|
|
Assert.Equal(76, snapshot.RemainingPercentage);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "session.compaction_complete",
|
|
"data": {
|
|
"success": true,
|
|
"preCompactionTokens": 1000,
|
|
"postCompactionTokens": 400,
|
|
"messagesRemoved": 8,
|
|
"tokensRemoved": 600,
|
|
"summaryContent": "Compacted summary",
|
|
"checkpointNumber": 2,
|
|
"checkpointPath": "C:\\Users\\me\\.copilot\\session-state\\checkpoint-2.json"
|
|
},
|
|
"id": "77777777-7777-7777-7777-777777777777",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
SessionCompactionEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SessionCompactionEventDto>());
|
|
Assert.Equal("complete", evt.Phase);
|
|
Assert.True(evt.Success);
|
|
Assert.Equal(1000, evt.PreCompactionTokens);
|
|
Assert.Equal(400, evt.PostCompactionTokens);
|
|
Assert.Equal("Compacted summary", evt.SummaryContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_PendingMessagesModified_QueuesPendingMessageSignal()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "pending_messages.modified",
|
|
"data": {},
|
|
"id": "88888888-8888-8888-8888-888888888888",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
PendingMessagesModifiedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<PendingMessagesModifiedEventDto>());
|
|
Assert.Equal("session-1", evt.SessionId);
|
|
Assert.Equal("agent-1", evt.AgentId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_AssistantMessage_FinalizesTranscriptAndSuppressesLateDeltas()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
Assert.True(state.TryAppendDelta("msg-final", "Primary", "Draft response", out TranscriptSegment streamed));
|
|
Assert.False(streamed.IsFinalized);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message",
|
|
"data": {
|
|
"messageId": "msg-final",
|
|
"content": "Provider final response"
|
|
},
|
|
"id": "12121212-1212-1212-1212-121212121212",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
TurnDeltaEventDto correction = Assert.Single(state.DrainPendingEvents().OfType<TurnDeltaEventDto>());
|
|
Assert.Equal("msg-final", correction.MessageId);
|
|
Assert.Equal("Primary", correction.AuthorName);
|
|
Assert.Equal(string.Empty, correction.ContentDelta);
|
|
Assert.Equal("Provider final response", correction.Content);
|
|
|
|
Assert.False(state.TryAppendDelta("msg-final", "Primary", " late delta", out TranscriptSegment unchanged));
|
|
Assert.True(unchanged.IsFinalized);
|
|
Assert.Equal("Provider final response", unchanged.Content);
|
|
|
|
ChatMessage output = new(ChatRole.Assistant, "Workflow wording")
|
|
{
|
|
MessageId = "msg-final",
|
|
AuthorName = "assistant",
|
|
};
|
|
|
|
state.UpdateCompletedMessages([output], []);
|
|
ChatMessageDto completed = Assert.Single(state.FinalizeCompletedMessages());
|
|
Assert.Equal("msg-final", completed.Id);
|
|
Assert.Equal("Primary", completed.AuthorName);
|
|
Assert.Equal("Provider final response", completed.Content);
|
|
}
|
|
|
|
private static SessionEvent CreateHookStartEvent()
|
|
{
|
|
return SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "hook.start",
|
|
"data": {
|
|
"hookInvocationId": "hook-1",
|
|
"hookType": "postToolUse",
|
|
"input": {
|
|
"toolName": "view"
|
|
}
|
|
},
|
|
"id": "66666666-6666-6666-6666-666666666666",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
""");
|
|
}
|
|
|
|
private static SessionEvent CreateHookEndEvent()
|
|
{
|
|
return SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "hook.end",
|
|
"data": {
|
|
"hookInvocationId": "hook-1",
|
|
"hookType": "postToolUse",
|
|
"success": true,
|
|
"output": {
|
|
"status": "ok"
|
|
}
|
|
},
|
|
"id": "99999999-9999-9999-9999-999999999999",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void FinalizeCompletedMessages_TagsReclassifiedMessagesAsThinking()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
// Simulate assistant message with tool requests → triggers reclassification
|
|
state.ObserveSessionEvent(
|
|
command.Workflow.GetAgentNodes()[0],
|
|
SessionEvent.FromJson(
|
|
"""
|
|
{
|
|
"type": "assistant.message",
|
|
"data": {
|
|
"messageId": "msg-intermediate",
|
|
"content": "Let me search...",
|
|
"toolRequests": [
|
|
{
|
|
"toolCallId": "tool-call-1",
|
|
"name": "grep",
|
|
"arguments": {}
|
|
}
|
|
]
|
|
},
|
|
"id": "11111111-1111-1111-1111-111111111111",
|
|
"timestamp": "2026-03-27T00:00:00Z"
|
|
}
|
|
"""));
|
|
|
|
state.DrainPendingEvents();
|
|
|
|
// Build completed messages with a reclassified and a non-reclassified message
|
|
ChatMessage intermediateMsg = new(ChatRole.Assistant, "Let me search...");
|
|
intermediateMsg.MessageId = "msg-intermediate";
|
|
intermediateMsg.AuthorName = "Primary";
|
|
|
|
ChatMessage finalMsg = new(ChatRole.Assistant, "Here are the results.");
|
|
finalMsg.MessageId = "msg-final";
|
|
finalMsg.AuthorName = "Primary";
|
|
|
|
state.UpdateCompletedMessages([intermediateMsg, finalMsg], []);
|
|
IReadOnlyList<ChatMessageDto> messages = state.FinalizeCompletedMessages();
|
|
|
|
ChatMessageDto intermediate = Assert.Single(messages, m => m.Id == "msg-intermediate");
|
|
Assert.Equal("thinking", intermediate.MessageKind);
|
|
|
|
ChatMessageDto final_ = Assert.Single(messages, m => m.Id == "msg-final");
|
|
Assert.Null(final_.MessageKind);
|
|
}
|
|
|
|
private static RunTurnCommandDto CreateCommand()
|
|
{
|
|
return new RunTurnCommandDto
|
|
{
|
|
RequestId = "turn-1",
|
|
SessionId = "session-1",
|
|
Workflow = new WorkflowDefinitionDto
|
|
{
|
|
Id = "workflow-1",
|
|
Name = "Execution State Workflow",
|
|
Graph = new WorkflowGraphDto
|
|
{
|
|
Nodes =
|
|
[
|
|
new WorkflowNodeDto
|
|
{
|
|
Id = "agent-1",
|
|
Kind = "agent",
|
|
Label = "Primary",
|
|
Config = new WorkflowNodeConfigDto
|
|
{
|
|
Kind = "agent",
|
|
Id = "agent-1",
|
|
Name = "Primary",
|
|
Model = "gpt-5.4",
|
|
Instructions = "Help with the request.",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
Settings = new WorkflowSettingsDto
|
|
{
|
|
OrchestrationMode = "single",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
private static RunTurnCommandDto CreateCommandWithReferencedSubworkflow()
|
|
{
|
|
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
|
|
"nested-review-workflow",
|
|
[
|
|
CreateAgent("agent-reviewer", "Reviewer"),
|
|
]);
|
|
|
|
return new RunTurnCommandDto
|
|
{
|
|
RequestId = "turn-1",
|
|
SessionId = "session-1",
|
|
WorkflowLibrary = [nestedWorkflow],
|
|
Workflow = CreateWorkflow(
|
|
"workflow-parent",
|
|
[
|
|
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
|
|
]),
|
|
};
|
|
}
|
|
|
|
private static WorkflowDefinitionDto CreateWorkflow(string id, IReadOnlyList<WorkflowNodeDto> nodes)
|
|
{
|
|
return new WorkflowDefinitionDto
|
|
{
|
|
Id = id,
|
|
Name = "Execution State Workflow",
|
|
Graph = new WorkflowGraphDto
|
|
{
|
|
Nodes = [.. nodes],
|
|
},
|
|
Settings = new WorkflowSettingsDto
|
|
{
|
|
OrchestrationMode = "single",
|
|
},
|
|
};
|
|
}
|
|
|
|
private static WorkflowNodeDto CreateAgent(string id, string name)
|
|
{
|
|
return new WorkflowNodeDto
|
|
{
|
|
Id = id,
|
|
Kind = "agent",
|
|
Label = name,
|
|
Config = new WorkflowNodeConfigDto
|
|
{
|
|
Kind = "agent",
|
|
Id = id,
|
|
Name = name,
|
|
Model = "gpt-5.4",
|
|
Instructions = "Help with the request.",
|
|
},
|
|
};
|
|
}
|
|
|
|
private static WorkflowNodeDto CreateSubworkflow(
|
|
string id,
|
|
string label,
|
|
string? workflowId = null)
|
|
{
|
|
return new WorkflowNodeDto
|
|
{
|
|
Id = id,
|
|
Kind = "sub-workflow",
|
|
Label = label,
|
|
Config = new WorkflowNodeConfigDto
|
|
{
|
|
Kind = "sub-workflow",
|
|
WorkflowId = workflowId,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|