From 1fbfdbbac4abfb2e145dd9ad0f5a0f678bd1c49f Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 28 Mar 2026 14:05:10 +0100 Subject: [PATCH] fix: suppress noisy hook lifecycle events 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> --- ARCHITECTURE.md | 2 +- .../Services/CopilotAgentBundle.cs | 7 +- .../Services/CopilotTurnExecutionState.cs | 12 ++- .../Services/CopilotWorkflowRunner.cs | 11 +++ .../CopilotAgentBundleTests.cs | 9 ++ .../CopilotTurnExecutionStateTests.cs | 87 +++++++++++++++---- .../CopilotWorkflowRunnerTests.cs | 12 +++ 7 files changed, 118 insertions(+), 22 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 38bc909..45b5bf5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -206,7 +206,7 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt - **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined - **Skill invocation events**: emitted when an agent-side skill is triggered -- **Hook lifecycle events**: start and end of registered hooks, including Aryx-managed SDK hooks and project hook files discovered from `.github/hooks/*.json` +- **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity - **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming - **Session usage events**: current token count and context-window limit for usage-bar rendering - **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index 68d1cb1..9349262 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -13,13 +13,16 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable { private readonly List _disposables = []; - private CopilotAgentBundle(IReadOnlyList agents) + internal CopilotAgentBundle(IReadOnlyList agents, bool hasConfiguredHooks) { Agents = agents; + HasConfiguredHooks = hasConfiguredHooks; } public IReadOnlyList Agents { get; } + public bool HasConfiguredHooks { get; } + public static async Task CreateAsync( RunTurnCommandDto command, Func> onPermissionRequest, @@ -72,7 +75,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable disposables.Add(agent); } - CopilotAgentBundle bundle = new(agents); + CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty); bundle._disposables.AddRange(disposables); return bundle; } diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs index 9aba7e0..bf09373 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -28,6 +28,8 @@ internal sealed class CopilotTurnExecutionState public bool HasPendingExitPlanModeRequest { get; private set; } + public bool SuppressHookLifecycleEvents { get; set; } + public async Task EmitThinkingIfNeeded( AgentIdentity agent, Func onEvent) @@ -113,11 +115,17 @@ internal sealed class CopilotTurnExecutionState break; case HookStartEvent hookStart: ActiveAgent = agent; - _pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data)); + if (!SuppressHookLifecycleEvents) + { + _pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data)); + } break; case HookEndEvent hookEnd: ActiveAgent = agent; - _pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data)); + if (!SuppressHookLifecycleEvents) + { + _pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data)); + } break; case SessionUsageInfoEvent usageInfo: ActiveAgent = agent; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index 1bba6e8..16affc7 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -75,6 +75,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } }, runCancellation.Token); + ConfigureHookLifecycleEventSuppression(state, bundle); Workflow workflow = bundle.BuildWorkflow(command.Pattern); List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode); @@ -118,6 +119,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } } + internal static void ConfigureHookLifecycleEventSuppression( + CopilotTurnExecutionState state, + CopilotAgentBundle bundle) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(bundle); + + state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks; + } + private static async Task EmitPendingEventsAsync( CopilotTurnExecutionState state, Func onEvent) diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index 0406d07..74ac12a 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -53,6 +53,15 @@ public sealed class CopilotAgentBundleTests Assert.Equal(["glob", "view"], sessionConfig.AvailableTools); } + [Fact] + public void Constructor_StoresWhetherHooksAreConfigured() + { + CopilotAgentBundle bundle = new([], hasConfiguredHooks: true); + + Assert.True(bundle.HasConfiguredHooks); + Assert.Empty(bundle.Agents); + } + [Fact] public async Task CreateConfiguredSessionConfig_MergesInstructionsAndConvertsHandoffDeclarations() { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index 0a888de..93ff92b 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -212,23 +212,7 @@ public sealed class CopilotTurnExecutionStateTests RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); - state.ObserveSessionEvent( - command.Pattern.Agents[0], - 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" - } - """)); + state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent()); HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("start", evt.Phase); @@ -237,6 +221,36 @@ public sealed class CopilotTurnExecutionStateTests 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()); + 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() { @@ -295,6 +309,45 @@ public sealed class CopilotTurnExecutionStateTests 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 diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index e5fd180..ed236cf 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -11,6 +11,18 @@ namespace Aryx.AgentHost.Tests; public sealed class CopilotWorkflowRunnerTests { + [Fact] + public void ConfigureHookLifecycleEventSuppression_SetsStateFromBundle() + { + RunTurnCommandDto command = CreateApprovalCommand(); + CopilotTurnExecutionState state = new(command); + CopilotAgentBundle bundle = new(Array.Empty(), hasConfiguredHooks: false); + + CopilotWorkflowRunner.ConfigureHookLifecycleEventSuppression(state, bundle); + + Assert.True(state.SuppressHookLifecycleEvents); + } + [Fact] public void SelectNewOutputMessages_SkipsFullTranscriptPrefix() {