mirror of
https://github.com/davidkaya/aryx.git
synced 2026-09-12 21:01:52 +02:00
Stop forwarding hook lifecycle events when a project has no configured .github/hooks/*.json commands so the UI only receives meaningful hook activity. Also expose the configured-hook state on the bundle, cover the suppression wiring with tests, and document the updated event semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
377 lines
13 KiB
C#
377 lines
13 KiB
C#
using Aryx.AgentHost.Contracts;
|
|
using Aryx.AgentHost.Services;
|
|
using GitHub.Copilot.SDK;
|
|
|
|
namespace Aryx.AgentHost.Tests;
|
|
|
|
public sealed class CopilotTurnExecutionStateTests
|
|
{
|
|
[Fact]
|
|
public void ObserveSessionEvent_McpOauthRequired_SetsActiveAgent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Pattern.Agents[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.Pattern.Agents[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_ToolExecutionStart_TracksToolNameByCallId()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Pattern.Agents[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"
|
|
}
|
|
"""));
|
|
|
|
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
|
Assert.Equal("view", toolName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Pattern.Agents[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 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.Pattern.Agents[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.Pattern.Agents[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.Pattern.Agents[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.Pattern.Agents[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.Pattern.Agents[0], CreateHookStartEvent());
|
|
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
|
|
|
Assert.Empty(state.DrainPendingEvents());
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
|
|
{
|
|
RunTurnCommandDto command = CreateCommand();
|
|
CopilotTurnExecutionState state = new(command);
|
|
|
|
state.ObserveSessionEvent(
|
|
command.Pattern.Agents[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.Pattern.Agents[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);
|
|
}
|
|
|
|
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"
|
|
}
|
|
""");
|
|
}
|
|
|
|
private static RunTurnCommandDto CreateCommand()
|
|
{
|
|
return new RunTurnCommandDto
|
|
{
|
|
RequestId = "turn-1",
|
|
SessionId = "session-1",
|
|
Pattern = new PatternDefinitionDto
|
|
{
|
|
Id = "pattern-1",
|
|
Name = "MCP OAuth Pattern",
|
|
Mode = "single",
|
|
Availability = "available",
|
|
Agents =
|
|
[
|
|
new PatternAgentDefinitionDto
|
|
{
|
|
Id = "agent-1",
|
|
Name = "Primary",
|
|
Model = "gpt-5.4",
|
|
Instructions = "Help with the request.",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
}
|