diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index f995ffa23..065b64ecf 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -745,6 +745,10 @@ public sealed class CopilotUserResponse [JsonPropertyName("restricted_telemetry")] public bool? RestrictedTelemetry { get; set; } + /// Gets or sets the te value. + [JsonPropertyName("te")] + public bool? Te { get; set; } + /// Gets or sets the token_based_billing value. [JsonPropertyName("token_based_billing")] public bool? TokenBasedBilling { get; set; } @@ -11571,6 +11575,113 @@ public sealed class LlmInferenceHttpRequestChunkRequest public string RequestId { get; set; } = string.Empty; } +/// Client environment metadata describing the process that produced a telemetry event. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryClientInfo +{ + /// Copilot CLI version string. + [JsonPropertyName("cli_version")] + public string CliVersion { get; set; } = string.Empty; + + /// Name of the client application. + [JsonPropertyName("client_name")] + public string? ClientName { get; set; } + + /// Type of client. + [JsonPropertyName("client_type")] + public string? ClientType { get; set; } + + /// Copilot subscription plan, when known. + [JsonPropertyName("copilot_plan")] + public string? CopilotPlan { get; set; } + + /// Stable machine identifier for the device. + [JsonPropertyName("dev_device_id")] + public string? DevDeviceId { get; set; } + + /// Whether the user is a GitHub/Microsoft staff member. + [JsonPropertyName("is_staff")] + public bool? IsStaff { get; set; } + + /// Node.js runtime version string. + [JsonPropertyName("node_version")] + public string NodeVersion { get; set; } = string.Empty; + + /// Operating system architecture (e.g. arm64, x64). + [JsonPropertyName("os_arch")] + public string OsArch { get; set; } = string.Empty; + + /// Operating system platform (e.g. darwin, linux, win32). + [JsonPropertyName("os_platform")] + public string OsPlatform { get; set; } = string.Empty; + + /// Operating system version string. + [JsonPropertyName("os_version")] + public string OsVersion { get; set; } = string.Empty; +} + +/// A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryEvent +{ + /// Client environment metadata. + [JsonPropertyName("client")] + public GitHubTelemetryClientInfo? Client { get; set; } + + /// Copilot tracking ID for user-level attribution. + [JsonPropertyName("copilot_tracking_id")] + public string? CopilotTrackingId { get; set; } + + /// Timestamp when the event was created (ISO 8601 format). + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + /// Experiment assignment context. + [JsonPropertyName("exp_assignment_context")] + public string? ExpAssignmentContext { get; set; } + + /// Feature flags enabled for this session, as a map from flag to value. + [JsonPropertyName("features")] + public IDictionary? Features { get; set; } + + /// Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + [JsonPropertyName("kind")] + public string Kind { get; set; } = string.Empty; + + /// Numeric metrics as a map from key to value. + [JsonPropertyName("metrics")] + public IDictionary Metrics { get => field ??= new Dictionary(); set; } + + /// Reference to the model call that produced this event. + [JsonPropertyName("model_call_id")] + public string? ModelCallId { get; set; } + + /// String-valued properties as a map from key to value. + [JsonPropertyName("properties")] + public IDictionary Properties { get => field ??= new Dictionary(); set; } + + /// Session identifier the event belongs to. + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } +} + +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryNotification +{ + /// The telemetry event, in the runtime's native GitHub-shaped telemetry format. + [JsonPropertyName("event")] + public GitHubTelemetryEvent Event { get => field ??= new(); set; } + + /// Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + [JsonPropertyName("restricted")] + public bool Restricted { get; set; } + + /// Session the telemetry event belongs to. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Resolved Anthropic adaptive-thinking capability for a model. [Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -21966,11 +22077,24 @@ public interface ILlmInferenceHandler Task HttpRequestChunkAsync(LlmInferenceHttpRequestChunkRequest request, CancellationToken cancellationToken = default); } +/// Handles `gitHubTelemetry` client global API methods. +[Experimental(Diagnostics.Experimental)] +public interface IGitHubTelemetryHandler +{ + /// Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session. + /// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. + /// The to monitor for cancellation requests. The default is . + Task EventAsync(GitHubTelemetryNotification request, CancellationToken cancellationToken = default); +} + /// Provides all client global API handler groups for a connection. public sealed class ClientGlobalApiHandlers { /// Optional handler for LlmInference client global API methods. public ILlmInferenceHandler? LlmInference { get; set; } + + /// Optional handler for GitHubTelemetry client global API methods. + public IGitHubTelemetryHandler? GitHubTelemetry { get; set; } } /// Registers client global API handlers on a JSON-RPC connection. @@ -21994,6 +22118,11 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH var handler = handlers.LlmInference ?? throw new InvalidOperationException("No llmInference client-global handler registered"); return await handler.HttpRequestChunkAsync(request, cancellationToken); }), singleObjectParam: true); + rpc.SetLocalRpcMethod("gitHubTelemetry.event", (Func)(async (request, cancellationToken) => + { + var handler = handlers.GitHubTelemetry ?? throw new InvalidOperationException("No gitHubTelemetry client-global handler registered"); + await handler.EventAsync(request, cancellationToken); + }), singleObjectParam: true); } } @@ -22399,6 +22528,9 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(FolderTrustAddParams))] [JsonSerializable(typeof(FolderTrustCheckParams))] [JsonSerializable(typeof(FolderTrustCheckResult))] +[JsonSerializable(typeof(GitHubTelemetryClientInfo))] +[JsonSerializable(typeof(GitHubTelemetryEvent))] +[JsonSerializable(typeof(GitHubTelemetryNotification))] [JsonSerializable(typeof(HandlePendingToolCallRequest))] [JsonSerializable(typeof(HandlePendingToolCallResult))] [JsonSerializable(typeof(HistoryAbortManualCompactionResult))] diff --git a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs index e7fd50dd9..b9366c134 100644 --- a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs +++ b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs @@ -2,63 +2,394 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using Microsoft.Extensions.AI; using Xunit; using Xunit.Abstractions; namespace GitHub.Copilot.Test.E2E; +/// +/// E2E coverage for every handler exposed on : +/// OnPreToolUse, OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, +/// OnSessionStart, OnSessionEnd, OnErrorOccurred. Output-shape behavior +/// (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / +/// modifiedResult / sessionSummary) is asserted alongside hook invocation. If a +/// new handler is added to SessionHooks, add a corresponding test here. +/// public class HookLifecycleAndOutputE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "hooks_extended", output) { - private const string UnsupportedSdkHooksMessage = "SDK hook callbacks are no longer supported"; + private static readonly string[] ValidErrorContexts = ["model_call", "tool_execution", "system", "user_input"]; - private async Task AssertUnsupportedHooksAsync(SessionHooks hooks) + [Fact] + public async Task Should_Invoke_OnSessionStart_Hook_On_New_Session() { - var ex = await Assert.ThrowsAnyAsync(() => CreateSessionAsync(new SessionConfig + var sessionStartInputs = new List(); + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig { - OnPermissionRequest = PermissionHandler.ApproveAll, - Hooks = hooks, - })); - Assert.Contains(UnsupportedSdkHooksMessage, ex.ToString(), StringComparison.Ordinal); + Hooks = new SessionHooks + { + OnSessionStart = (input, invocation) => + { + sessionStartInputs.Add(input); + Assert.Equal(session!.SessionId, invocation.SessionId); + return Task.FromResult(null); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi" }); + + Assert.NotEmpty(sessionStartInputs); + Assert.Equal("new", sessionStartInputs[0].Source); + Assert.True(sessionStartInputs[0].Timestamp > DateTimeOffset.UnixEpoch); + Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].WorkingDirectory)); + + await session.DisposeAsync(); } - public static IEnumerable HookCases => - [ - [new SessionHooks + [Fact] + public async Task Should_Invoke_OnUserPromptSubmitted_Hook_When_Sending_A_Message() + { + var userPromptInputs = new List(); + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig { - OnUserPromptSubmitted = (_, _) => Task.FromResult(new UserPromptSubmittedHookOutput { ModifiedPrompt = "not used" }), - }], - [new SessionHooks + Hooks = new SessionHooks + { + OnUserPromptSubmitted = (input, invocation) => + { + userPromptInputs.Add(input); + Assert.Equal(session!.SessionId, invocation.SessionId); + return Task.FromResult(null); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello" }); + + Assert.NotEmpty(userPromptInputs); + Assert.Contains("Say hello", userPromptInputs[0].Prompt); + Assert.True(userPromptInputs[0].Timestamp > DateTimeOffset.UnixEpoch); + Assert.False(string.IsNullOrEmpty(userPromptInputs[0].WorkingDirectory)); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Invoke_OnSessionEnd_Hook_When_Session_Is_Disconnected() + { + var sessionEndInputs = new List(); + var sessionEndHookInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig { - OnSessionStart = (_, _) => Task.FromResult(new SessionStartHookOutput { AdditionalContext = "not used" }), - }], - [new SessionHooks + Hooks = new SessionHooks + { + OnSessionEnd = (input, invocation) => + { + sessionEndInputs.Add(input); + sessionEndHookInvoked.TrySetResult(input); + Assert.Equal(session!.SessionId, invocation.SessionId); + return Task.FromResult(null); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi" }); + + await session.DisposeAsync(); + + await sessionEndHookInvoked.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.NotEmpty(sessionEndInputs); + } + + [Fact] + public async Task Should_Invoke_OnErrorOccurred_Hook_When_Error_Occurs() + { + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig { - OnSessionEnd = (_, _) => Task.FromResult(new SessionEndHookOutput { SessionSummary = "not used" }), - }], - [new SessionHooks + Hooks = new SessionHooks + { + OnErrorOccurred = (input, invocation) => + { + Assert.Equal(session!.SessionId, invocation.SessionId); + Assert.True(input.Timestamp > DateTimeOffset.UnixEpoch); + Assert.False(string.IsNullOrEmpty(input.WorkingDirectory)); + Assert.False(string.IsNullOrEmpty(input.Error)); + Assert.Contains(input.ErrorContext, ValidErrorContexts); + return Task.FromResult(null); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi" }); + + // OnErrorOccurred is dispatched by the runtime for actual errors. In a normal + // session it may not fire — this test verifies the hook is properly wired and + // that the session works correctly with it registered. If the hook *did* fire, + // the assertions above would have run. + Assert.False(string.IsNullOrEmpty(session.SessionId)); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Invoke_UserPromptSubmitted_Hook_And_Modify_Prompt() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig { - OnErrorOccurred = (_, _) => Task.FromResult(new ErrorOccurredHookOutput { ErrorHandling = "skip" }), - }], - [new SessionHooks + Hooks = new SessionHooks + { + OnUserPromptSubmitted = (input, invocation) => + { + inputs.Add(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + return Task.FromResult(new UserPromptSubmittedHookOutput + { + ModifiedPrompt = "Reply with exactly: HOOKED_PROMPT", + }); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say something else" }); + + Assert.NotEmpty(inputs); + Assert.Contains("Say something else", inputs[0].Prompt); + Assert.Contains("HOOKED_PROMPT", response?.Data.Content ?? string.Empty); + } + + [Fact] + public async Task Should_Invoke_SessionStart_Hook() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig { - OnPreToolUse = (_, _) => Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), - }], - [new SessionHooks + Hooks = new SessionHooks + { + OnSessionStart = (input, invocation) => + { + inputs.Add(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + return Task.FromResult(new SessionStartHookOutput + { + AdditionalContext = "Session start hook context.", + }); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi" }); + + Assert.NotEmpty(inputs); + Assert.Equal("new", inputs[0].Source); + Assert.False(string.IsNullOrEmpty(inputs[0].WorkingDirectory)); + } + + [Fact] + public async Task Should_Invoke_SessionEnd_Hook() + { + var inputs = new List(); + var hookInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var session = await CreateSessionAsync(new SessionConfig { - OnPostToolUse = (_, _) => Task.FromResult(new PostToolUseHookOutput { SuppressOutput = false }), - }], - [new SessionHooks + Hooks = new SessionHooks + { + OnSessionEnd = (input, invocation) => + { + inputs.Add(input); + hookInvoked.TrySetResult(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + return Task.FromResult(new SessionEndHookOutput + { + SessionSummary = "session ended", + }); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say bye" }); + await session.DisposeAsync(); + await hookInvoked.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.NotEmpty(inputs); + } + + [Fact] + public async Task Should_Register_ErrorOccurred_Hook() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig { - OnPostToolUse = (_, _) => Task.FromResult(null), - OnPostToolUseFailure = (_, _) => Task.FromResult(new PostToolUseFailureHookOutput { AdditionalContext = "not used" }), - }], - ]; - - [Theory] - [MemberData(nameof(HookCases))] - public async Task Rejects_SDK_Callback_Hooks(SessionHooks hooks) + Hooks = new SessionHooks + { + OnErrorOccurred = (input, invocation) => + { + inputs.Add(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + return Task.FromResult(new ErrorOccurredHookOutput + { + ErrorHandling = "skip", + }); + }, + }, + }); + + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Say hi", + }); + + // OnErrorOccurred is dispatched only by genuine runtime errors (e.g. provider + // failures, internal exceptions). A normal turn cannot deterministically trigger + // one, so this test is **registration-only**: it verifies the SDK accepts the hook, + // wires it through to the runtime via session.create, and that the lambda above is + // not invoked inappropriately during a healthy turn. End-to-end coverage of an + // actually-fired ErrorOccurred event would require a fault injection point that + // does not exist in the public surface today. + Assert.Empty(inputs); + Assert.NotNull(session.SessionId); + } + + [Fact] + public async Task Should_Allow_PreToolUse_To_Return_ModifiedArgs_And_SuppressOutput() { - await AssertUnsupportedHooksAsync(hooks); + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = + [ + AIFunctionFactory.Create( + (string value) => value, + "echo_value", + "Echoes the supplied value") + ], + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + { + inputs.Add(input); + if (input.ToolName != "echo_value") + { + return Task.FromResult(new PreToolUseHookOutput + { + PermissionDecision = "allow", + }); + } + + return Task.FromResult(new PreToolUseHookOutput + { + PermissionDecision = "allow", + ModifiedArgs = new Dictionary { ["value"] = "modified by hook" }, + SuppressOutput = false, + }); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Call echo_value with value 'original', then reply with the result.", + }); + + Assert.NotEmpty(inputs); + Assert.Contains(inputs, input => input.ToolName == "echo_value"); + Assert.Contains("modified by hook", response?.Data.Content ?? string.Empty); + } + + [Fact] + public async Task Should_Allow_PostToolUse_To_Return_ModifiedResult() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Hooks = new SessionHooks + { + OnPostToolUse = (input, invocation) => + { + inputs.Add(input); + if (input.ToolName != "view") + { + return Task.FromResult(null); + } + + return Task.FromResult(new PostToolUseHookOutput + { + ModifiedResult = new ToolResultObject + { + TextResultForLlm = "modified by post hook", + ResultType = "success", + ToolTelemetry = new Dictionary(), + }, + SuppressOutput = false, + }); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Call the view tool to read the current directory, then reply done.", + }); + + Assert.Contains(inputs, input => input.ToolName == "view"); + Assert.Contains("done", (response?.Data.Content ?? string.Empty).ToLowerInvariant()); + } + + [Fact(Skip = "Fails with 1.0.64-0 runtime: built-in tools are not available when hooks restrict availableTools, so the failure path cannot be exercised. Follow up with runtime team.")] + public async Task Should_Invoke_PostToolUseFailure_Hook_For_Failed_Tool_Result() + { + var failureInputs = new List(); + var postToolUseInputs = new List(); + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = ["report_intent"], + Hooks = new SessionHooks + { + OnPostToolUse = (input, invocation) => + { + postToolUseInputs.Add(input); + return Task.FromResult(null); + }, + OnPostToolUseFailure = (input, invocation) => + { + failureInputs.Add(input); + Assert.Equal(session!.SessionId, invocation.SessionId); + return Task.FromResult(new PostToolUseFailureHookOutput + { + AdditionalContext = "HOOK_FAILURE_GUIDANCE_APPLIED", + }); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Call the view tool with path 'missing.txt'. If it fails, use the hook guidance to answer.", + }); + + Assert.Empty(postToolUseInputs); + var input = Assert.Single(failureInputs); + Assert.Equal("view", input.ToolName); + Assert.Contains("does not exist", input.Error); + Assert.NotNull(input.ToolArgs); + Assert.True(input.Timestamp > DateTimeOffset.UnixEpoch); + Assert.False(string.IsNullOrEmpty(input.WorkingDirectory)); + Assert.Contains("HOOK_FAILURE_GUIDANCE_APPLIED", response?.Data.Content ?? string.Empty); + + var exchanges = await WaitForExchangesAsync(2); + var toolMessage = exchanges[^1].Request.Messages.Single(message => message.Role == "tool"); + Assert.Contains("does not exist", toolMessage.StringContent); + Assert.Contains( + exchanges[^1].Request.Messages, + message => (message.StringContent ?? string.Empty).Contains("HOOK_FAILURE_GUIDANCE_APPLIED", StringComparison.Ordinal)); } } diff --git a/dotnet/test/E2E/HooksE2ETests.cs b/dotnet/test/E2E/HooksE2ETests.cs index 0b0ad37e6..0d9155fbc 100644 --- a/dotnet/test/E2E/HooksE2ETests.cs +++ b/dotnet/test/E2E/HooksE2ETests.cs @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; @@ -9,43 +10,162 @@ namespace GitHub.Copilot.Test.E2E; public class HooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "hooks", output) { - private const string UnsupportedSdkHooksMessage = "SDK hook callbacks are no longer supported"; - - private async Task AssertUnsupportedHooksAsync(SessionHooks hooks) + [Fact] + public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() { - var ex = await Assert.ThrowsAnyAsync(() => CreateSessionAsync(new SessionConfig + var preToolUseInputs = new List(); + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, - Hooks = hooks, - })); - Assert.Contains(UnsupportedSdkHooksMessage, ex.ToString(), StringComparison.Ordinal); + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + { + preToolUseInputs.Add(input); + Assert.Equal(session!.SessionId, invocation.SessionId); + return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + } + } + }); + + // Create a file for the model to read + await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "hello.txt"), "Hello from the test!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of hello.txt and tell me what it says" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Should have received at least one preToolUse hook call + Assert.NotEmpty(preToolUseInputs); + + // Should have received the tool name + Assert.Contains(preToolUseInputs, i => !string.IsNullOrEmpty(i.ToolName)); } - public static IEnumerable HookCases => - [ - [new SessionHooks + [Fact] + public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() + { + var postToolUseInputs = new List(); + CopilotSession? session = null; + session = await CreateSessionAsync(new SessionConfig { - OnPreToolUse = (_, _) => Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), - }], - [new SessionHooks + OnPermissionRequest = PermissionHandler.ApproveAll, + Hooks = new SessionHooks + { + OnPostToolUse = (input, invocation) => + { + postToolUseInputs.Add(input); + Assert.Equal(session!.SessionId, invocation.SessionId); + return Task.FromResult(null); + } + } + }); + + // Create a file for the model to read + await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "world.txt"), "World from the test!"); + + await session.SendAsync(new MessageOptions { - OnPostToolUse = (_, _) => Task.FromResult(null), - }], - [new SessionHooks + Prompt = "Read the contents of world.txt and tell me what it says" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Should have received at least one postToolUse hook call + Assert.NotEmpty(postToolUseInputs); + + // Should have received the tool name and result + Assert.Contains(postToolUseInputs, i => !string.IsNullOrEmpty(i.ToolName)); + Assert.Contains(postToolUseInputs, i => i.ToolResult != null); + } + + [Fact] + public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single_Tool_Call() + { + var preToolUseInputs = new List(); + var postToolUseInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig { - OnPreToolUse = (_, _) => Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "deny" }), - }], - [new SessionHooks + OnPermissionRequest = PermissionHandler.ApproveAll, + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + { + preToolUseInputs.Add(input); + return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + }, + OnPostToolUse = (input, invocation) => + { + postToolUseInputs.Add(input); + return Task.FromResult(null); + } + } + }); + + await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "both.txt"), "Testing both hooks!"); + + await session.SendAsync(new MessageOptions { - OnPreToolUse = (_, _) => Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), - OnPostToolUse = (_, _) => Task.FromResult(null), - }], - ]; + Prompt = "Read the contents of both.txt" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Both hooks should have been called + Assert.NotEmpty(preToolUseInputs); + Assert.NotEmpty(postToolUseInputs); - [Theory] - [MemberData(nameof(HookCases))] - public async Task Rejects_SDK_Callback_Hooks(SessionHooks hooks) + // The same tool should appear in both + var preToolNames = preToolUseInputs.Select(i => i.ToolName).Where(n => !string.IsNullOrEmpty(n)).ToHashSet(); + var postToolNames = postToolUseInputs.Select(i => i.ToolName).Where(n => !string.IsNullOrEmpty(n)).ToHashSet(); + Assert.True(preToolNames.Overlaps(postToolNames), "Expected the same tool to appear in both pre and post hooks"); + } + + [Fact] + public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() { - await AssertUnsupportedHooksAsync(hooks); + var preToolUseInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + { + preToolUseInputs.Add(input); + // Deny all tool calls + return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "deny" }); + } + } + }); + + // Create a file + var originalContent = "Original content that should not be modified"; + await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "protected.txt"), originalContent); + + await session.SendAsync(new MessageOptions + { + Prompt = "Edit protected.txt and replace 'Original' with 'Modified'" + }); + + var response = await TestHelper.GetFinalAssistantMessageAsync(session); + + // The hook should have been called + Assert.NotEmpty(preToolUseInputs); + + // The response should be defined + Assert.NotNull(response); + + // Strengthen: verify the actual deny behavior — the protected file was NOT + // modified by the runtime even though the LLM tried to edit it. The pre-tool-use + // hook denial blocks tool execution before it can mutate state. + var actualContent = await File.ReadAllTextAsync(Path.Join(Ctx.WorkDir, "protected.txt")); + Assert.Equal(originalContent, actualContent); } } diff --git a/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs b/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs index 37b41e970..02f860614 100644 --- a/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs +++ b/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs @@ -2,27 +2,163 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.Text.Json; using Xunit; using Xunit.Abstractions; namespace GitHub.Copilot.Test.E2E; +/// +/// E2E tests for the preMcpToolCall hook, verifying meta manipulation scenarios: +/// setting meta, replacing meta, and removing meta. +/// public class PreMcpToolCallHookE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "pre_mcp_tool_call_hook", output) { - private const string UnsupportedSdkHooksMessage = "SDK hook callbacks are no longer supported"; + private static string FindMetaEchoTestHarnessDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Join(dir.FullName, "test", "harness", "test-mcp-meta-echo-server.mjs"); + if (File.Exists(candidate)) + return Path.GetDirectoryName(candidate)!; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find test/harness/test-mcp-meta-echo-server.mjs"); + } + + private static Dictionary CreateMetaEchoMcpConfig(string testHarnessDir) => new() + { + ["meta-echo"] = new McpStdioServerConfig + { + Command = "node", + Args = [Path.Join(testHarnessDir, "test-mcp-meta-echo-server.mjs")], + WorkingDirectory = testHarnessDir, + Tools = ["*"] + } + }; + + [Fact] + public async Task Should_Set_Meta_Via_PreMcpToolCall_Hook() + { + var testHarnessDir = FindMetaEchoTestHarnessDir(); + var hookInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateMetaEchoMcpConfig(testHarnessDir), + Hooks = new SessionHooks + { + OnPreMcpToolCall = (input, invocation) => + { + hookInputs.Add(input); + using var doc = JsonDocument.Parse("""{"injected":"by-hook","source":"test"}"""); + return Task.FromResult(new PreMcpToolCallHookOutput + { + MetaToUse = doc.RootElement.Clone() + }); + }, + }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result." + }); + + Assert.NotNull(message); + Assert.Contains("injected", message!.Data.Content); + Assert.Contains("by-hook", message.Data.Content); + + Assert.NotEmpty(hookInputs); + Assert.Equal("meta-echo", hookInputs[0].ServerName); + Assert.Equal("echo_meta", hookInputs[0].ToolName); + Assert.False(string.IsNullOrEmpty(hookInputs[0].WorkingDirectory)); + Assert.True(hookInputs[0].Timestamp > DateTimeOffset.UnixEpoch); + + await session.DisposeAsync(); + } [Fact] - public async Task Rejects_SDK_PreMcpToolCall_Callback_Hooks() + public async Task Should_Replace_Meta_Via_PreMcpToolCall_Hook() { - var ex = await Assert.ThrowsAnyAsync(() => CreateSessionAsync(new SessionConfig + var testHarnessDir = FindMetaEchoTestHarnessDir(); + var hookInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig { + McpServers = CreateMetaEchoMcpConfig(testHarnessDir), + Hooks = new SessionHooks + { + OnPreMcpToolCall = (input, invocation) => + { + hookInputs.Add(input); + // Completely replace: ignore input.Meta entirely + using var doc = JsonDocument.Parse("""{"completely":"replaced"}"""); + return Task.FromResult(new PreMcpToolCallHookOutput + { + MetaToUse = doc.RootElement.Clone() + }); + }, + }, OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result." + }); + + Assert.NotNull(message); + Assert.Contains("completely", message!.Data.Content); + Assert.Contains("replaced", message.Data.Content); + + Assert.NotEmpty(hookInputs); + Assert.Equal("meta-echo", hookInputs[0].ServerName); + Assert.Equal("echo_meta", hookInputs[0].ToolName); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Remove_Meta_Via_PreMcpToolCall_Hook() + { + var testHarnessDir = FindMetaEchoTestHarnessDir(); + var hookInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateMetaEchoMcpConfig(testHarnessDir), Hooks = new SessionHooks { - OnPreMcpToolCall = (_, _) => Task.FromResult(new PreMcpToolCallHookOutput()), + OnPreMcpToolCall = (input, invocation) => + { + hookInputs.Add(input); + // Return output with null MetaToUse to signal removal + return Task.FromResult(new PreMcpToolCallHookOutput + { + MetaToUse = null + }); + }, }, - })); - Assert.Contains(UnsupportedSdkHooksMessage, ex.ToString(), StringComparison.Ordinal); + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result." + }); + + Assert.NotNull(message); + Assert.Contains("\"meta\":null", message!.Data.Content); + Assert.Contains("test-remove", message.Data.Content); + + Assert.NotEmpty(hookInputs); + Assert.Equal("meta-echo", hookInputs[0].ServerName); + Assert.Equal("echo_meta", hookInputs[0].ToolName); + + await session.DisposeAsync(); } } diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs index 4bef6a95b..23ba8ed3a 100644 --- a/dotnet/test/E2E/SubagentHooksE2ETests.cs +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -2,6 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.Collections.Concurrent; +using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; @@ -10,20 +12,62 @@ namespace GitHub.Copilot.Test.E2E; public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "subagent_hooks", output) { - private const string UnsupportedSdkHooksMessage = "SDK hook callbacks are no longer supported"; - [Fact] - public async Task Rejects_SDK_Callback_Hooks_For_Sub_Agent_Hook_Propagation() + public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls() { - var ex = await Assert.ThrowsAnyAsync(() => CreateSessionAsync(new SessionConfig + var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>(); + + // Create a client with the session-based subagents feature flag + var env = new Dictionary(Ctx.GetEnvironment()); + env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true"; + var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + + var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { - OnPreToolUse = (_, _) => Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), - OnPostToolUse = (_, _) => Task.FromResult(null), + OnPreToolUse = (input, invocation) => + { + hookLog.Add(("pre", input.ToolName, input.SessionId)); + return Task.FromResult(new PreToolUseHookOutput + { + PermissionDecision = "allow" + }); + }, + OnPostToolUse = (input, invocation) => + { + hookLog.Add(("post", input.ToolName, input.SessionId)); + return Task.FromResult(null); + }, }, - })); - Assert.Contains(UnsupportedSdkHooksMessage, ex.ToString(), StringComparison.Ordinal); + }); + + // Create a file for the sub-agent to read + await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "subagent-test.txt"), "Hello from subagent test!"); + + await session.SendAndWaitAsync( + new MessageOptions + { + Prompt = "Use the task tool to spawn an explore agent that reads the file " + + "subagent-test.txt in the current directory and reports its contents. " + + "You must use the task tool." + }, + timeout: TimeSpan.FromSeconds(120)); + + var log = hookLog.ToArray(); + + // Parent tool hooks fire for "task" + var taskPre = log.Where(h => h.Kind == "pre" && h.ToolName == "task").ToArray(); + Assert.True(taskPre.Length >= 1, "preToolUse should fire for the parent's 'task' tool call"); + + // Sub-agent tool hooks fire for "view" + var viewPre = log.Where(h => h.Kind == "pre" && h.ToolName == "view").ToArray(); + var viewPost = log.Where(h => h.Kind == "post" && h.ToolName == "view").ToArray(); + Assert.True(viewPre.Length > 0, "preToolUse should fire for the sub-agent's 'view' tool call"); + Assert.True(viewPost.Length > 0, "postToolUse should fire for the sub-agent's 'view' tool call"); + + // input.SessionId distinguishes parent from sub-agent + Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId); } } diff --git a/go/internal/e2e/hooks_e2e_test.go b/go/internal/e2e/hooks_e2e_test.go index faf55efa3..5e392fa89 100644 --- a/go/internal/e2e/hooks_e2e_test.go +++ b/go/internal/e2e/hooks_e2e_test.go @@ -1,68 +1,273 @@ package e2e import ( - "strings" + "os" + "path/filepath" + "sync" "testing" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" ) -const unsupportedSDKHooksMessage = "SDK hook callbacks are no longer supported" - -func assertUnsupportedHooks(t *testing.T, client *copilot.Client, hooks *copilot.SessionHooks) { - t.Helper() - - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Hooks: hooks, - }) - if err == nil { - if session != nil { - _ = session.Disconnect() - } - t.Fatal("expected SDK callback hooks to be rejected") - } - if !strings.Contains(err.Error(), unsupportedSDKHooksMessage) { - t.Fatalf("expected unsupported hooks error, got %v", err) - } -} - func TestHooksE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() t.Cleanup(func() { client.ForceStop() }) - cases := map[string]*copilot.SessionHooks{ - "preToolUse": { - OnPreToolUse: func(copilot.PreToolUseHookInput, copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { - return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil - }, - }, - "postToolUse": { - OnPostToolUse: func(copilot.PostToolUseHookInput, copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { - return nil, nil + t.Run("should invoke preToolUse hook when model runs a tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var preToolUseInputs []copilot.PreToolUseHookInput + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + mu.Lock() + preToolUseInputs = append(preToolUseInputs, input) + mu.Unlock() + + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, }, - }, - "preToolUse denial": { - OnPreToolUse: func(copilot.PreToolUseHookInput, copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { - return &copilot.PreToolUseHookOutput{PermissionDecision: "deny"}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Create a file for the model to read + testFile := filepath.Join(ctx.WorkDir, "hello.txt") + err = os.WriteFile(testFile, []byte("Hello from the test!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of hello.txt and tell me what it says", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(preToolUseInputs) == 0 { + t.Error("Expected at least one preToolUse hook call") + } + + hasToolName := false + for _, input := range preToolUseInputs { + if input.ToolName != "" { + hasToolName = true + break + } + } + if !hasToolName { + t.Error("Expected at least one input with a tool name") + } + }) + + t.Run("should invoke postToolUse hook after model runs a tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var postToolUseInputs []copilot.PostToolUseHookInput + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + mu.Lock() + postToolUseInputs = append(postToolUseInputs, input) + mu.Unlock() + + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + + return nil, nil + }, }, - }, - "combined preToolUse and postToolUse": { - OnPreToolUse: func(copilot.PreToolUseHookInput, copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { - return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Create a file for the model to read + testFile := filepath.Join(ctx.WorkDir, "world.txt") + err = os.WriteFile(testFile, []byte("World from the test!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of world.txt and tell me what it says", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(postToolUseInputs) == 0 { + t.Error("Expected at least one postToolUse hook call") + } + + hasToolName := false + hasResult := false + for _, input := range postToolUseInputs { + if input.ToolName != "" { + hasToolName = true + } + if input.ToolResult != nil { + hasResult = true + } + } + if !hasToolName { + t.Error("Expected at least one input with a tool name") + } + if !hasResult { + t.Error("Expected at least one input with a tool result") + } + }) + + t.Run("should invoke both preToolUse and postToolUse hooks for a single tool call", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var preToolUseInputs []copilot.PreToolUseHookInput + var postToolUseInputs []copilot.PostToolUseHookInput + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + mu.Lock() + preToolUseInputs = append(preToolUseInputs, input) + mu.Unlock() + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + mu.Lock() + postToolUseInputs = append(postToolUseInputs, input) + mu.Unlock() + return nil, nil + }, }, - OnPostToolUse: func(copilot.PostToolUseHookInput, copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { - return nil, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "both.txt") + err = os.WriteFile(testFile, []byte("Testing both hooks!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of both.txt", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(preToolUseInputs) == 0 { + t.Error("Expected at least one preToolUse hook call") + } + if len(postToolUseInputs) == 0 { + t.Error("Expected at least one postToolUse hook call") + } + + // Check that the same tool appears in both + preToolNames := make(map[string]bool) + for _, input := range preToolUseInputs { + if input.ToolName != "" { + preToolNames[input.ToolName] = true + } + } + + foundCommon := false + for _, input := range postToolUseInputs { + if preToolNames[input.ToolName] { + foundCommon = true + break + } + } + if !foundCommon { + t.Error("Expected the same tool to appear in both pre and post hooks") + } + }) + + t.Run("should deny tool execution when preToolUse returns deny", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var preToolUseInputs []copilot.PreToolUseHookInput + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + mu.Lock() + preToolUseInputs = append(preToolUseInputs, input) + mu.Unlock() + // Deny all tool calls + return &copilot.PreToolUseHookOutput{PermissionDecision: "deny"}, nil + }, }, - }, - } + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Create a file + originalContent := "Original content that should not be modified" + testFile := filepath.Join(ctx.WorkDir, "protected.txt") + err = os.WriteFile(testFile, []byte(originalContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } - for name, hooks := range cases { - t.Run("rejects SDK callback hook "+name, func(t *testing.T) { - ctx.ConfigureForTest(t) - assertUnsupportedHooks(t, client, hooks) + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Edit protected.txt and replace 'Original' with 'Modified'", }) - } + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(preToolUseInputs) == 0 { + t.Error("Expected at least one preToolUse hook call") + } + + // The response should be defined + if response == nil { + t.Error("Expected non-nil response") + } + + // Strengthen: verify the actual deny behavior — the protected file was NOT + // modified by the runtime even though the LLM tried to edit it. The + // pre-tool-use hook denial blocks tool execution before it can mutate state. + actualContent, readErr := os.ReadFile(testFile) + if readErr != nil { + t.Fatalf("Failed to read protected.txt: %v", readErr) + } + if string(actualContent) != originalContent { + t.Errorf("protected.txt should be unchanged after deny; got: %q", string(actualContent)) + } + }) } diff --git a/go/internal/e2e/hooks_extended_e2e_test.go b/go/internal/e2e/hooks_extended_e2e_test.go index 137e79636..f53dd13f6 100644 --- a/go/internal/e2e/hooks_extended_e2e_test.go +++ b/go/internal/e2e/hooks_extended_e2e_test.go @@ -1,81 +1,419 @@ package e2e import ( + "fmt" "strings" + "sync" "testing" + "time" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" ) -func assertUnsupportedExtendedHooks(t *testing.T, client *copilot.Client, hooks *copilot.SessionHooks) { - t.Helper() - - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Hooks: hooks, - }) - if err == nil { - if session != nil { - _ = session.Disconnect() - } - t.Fatal("expected SDK callback hooks to be rejected") - } - if !strings.Contains(err.Error(), unsupportedSDKHooksMessage) { - t.Fatalf("expected unsupported hooks error, got %v", err) - } -} - +// Mirrors dotnet/test/HookLifecycleAndOutputTests.cs (snapshot category "hooks_extended"). +// +// Covers each handler exposed on copilot.SessionHooks: OnPreToolUse, +// OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, OnSessionStart, +// OnSessionEnd, OnErrorOccurred. Output-shape behavior (modifiedPrompt / +// additionalContext / errorHandling / modifiedArgs / modifiedResult / +// sessionSummary) is asserted alongside hook invocation. If a new handler is +// added to SessionHooks, add a corresponding test here. func TestHooksExtendedE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() t.Cleanup(func() { client.ForceStop() }) - cases := map[string]*copilot.SessionHooks{ - "userPromptSubmitted": { - OnUserPromptSubmitted: func(copilot.UserPromptSubmittedHookInput, copilot.HookInvocation) (*copilot.UserPromptSubmittedHookOutput, error) { - return &copilot.UserPromptSubmittedHookOutput{ModifiedPrompt: "not used"}, nil - }, - }, - "sessionStart": { - OnSessionStart: func(copilot.SessionStartHookInput, copilot.HookInvocation) (*copilot.SessionStartHookOutput, error) { - return &copilot.SessionStartHookOutput{AdditionalContext: "not used"}, nil + t.Run("should invoke userPromptSubmitted hook and modify prompt", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.UserPromptSubmittedHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnUserPromptSubmitted: func(input copilot.UserPromptSubmittedHookInput, invocation copilot.HookInvocation) (*copilot.UserPromptSubmittedHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + return &copilot.UserPromptSubmittedHookOutput{ + ModifiedPrompt: "Reply with exactly: HOOKED_PROMPT", + }, nil + }, }, - }, - "sessionEnd": { - OnSessionEnd: func(copilot.SessionEndHookInput, copilot.HookInvocation) (*copilot.SessionEndHookOutput, error) { - return &copilot.SessionEndHookOutput{SessionSummary: "not used"}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say something else"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one userPromptSubmitted hook invocation") + } + if !strings.Contains(inputs[0].Prompt, "Say something else") { + t.Errorf("Expected hook input prompt to contain original prompt, got %q", inputs[0].Prompt) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || !strings.Contains(assistantMessage.Content, "HOOKED_PROMPT") { + t.Errorf("Expected response to contain 'HOOKED_PROMPT', got %v", response.Data) + } + }) + + t.Run("should invoke sessionStart hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.SessionStartHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnSessionStart: func(input copilot.SessionStartHookInput, invocation copilot.HookInvocation) (*copilot.SessionStartHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + return &copilot.SessionStartHookOutput{ + AdditionalContext: "Session start hook context.", + }, nil + }, }, - }, - "errorOccurred": { - OnErrorOccurred: func(copilot.ErrorOccurredHookInput, copilot.HookInvocation) (*copilot.ErrorOccurredHookOutput, error) { - return &copilot.ErrorOccurredHookOutput{ErrorHandling: "skip"}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say hi"}); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected sessionStart hook to be invoked at least once") + } + if inputs[0].Source != "new" { + t.Errorf("Expected source 'new', got %q", inputs[0].Source) + } + if inputs[0].WorkingDirectory == "" { + t.Error("Expected non-empty cwd in sessionStart hook input") + } + }) + + t.Run("should invoke sessionEnd hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.SessionEndHookInput + invocations = make(chan copilot.SessionEndHookInput, 4) + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnSessionEnd: func(input copilot.SessionEndHookInput, invocation copilot.HookInvocation) (*copilot.SessionEndHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + select { + case invocations <- input: + default: + } + return &copilot.SessionEndHookOutput{ + SessionSummary: "session ended", + }, nil + }, }, - }, - "preToolUse output": { - OnPreToolUse: func(copilot.PreToolUseHookInput, copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { - return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say bye"}); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if err := session.Disconnect(); err != nil { + t.Fatalf("Failed to disconnect session: %v", err) + } + + select { + case <-invocations: + case <-time.After(10 * time.Second): + t.Fatal("Timed out waiting for sessionEnd hook invocation") + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected sessionEnd hook to be invoked at least once") + } + }) + + t.Run("should register errorOccurred hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.ErrorOccurredHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnErrorOccurred: func(input copilot.ErrorOccurredHookInput, invocation copilot.HookInvocation) (*copilot.ErrorOccurredHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + return &copilot.ErrorOccurredHookOutput{ErrorHandling: "skip"}, nil + }, }, - }, - "postToolUse output": { - OnPostToolUse: func(copilot.PostToolUseHookInput, copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { - return &copilot.PostToolUseHookOutput{SuppressOutput: false}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say hi"}); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // OnErrorOccurred is dispatched only by genuine runtime errors (e.g. provider + // failures, internal exceptions). A normal turn cannot deterministically trigger + // one, so this is a registration-only test: the SDK must accept the hook and not + // invoke it inappropriately during a healthy turn. + mu.Lock() + got := len(inputs) + mu.Unlock() + if got != 0 { + t.Errorf("Expected errorOccurred hook to not fire on a healthy turn, got %d invocations", got) + } + if session.SessionID == "" { + t.Error("Expected session id to be set") + } + }) + + t.Run("should allow preToolUse to return modifiedArgs and suppressOutput", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type EchoParams struct { + Value string `json:"value" jsonschema:"Value to echo"` + } + echoTool := copilot.DefineTool("echo_value", "Echoes the supplied value", + func(params EchoParams, inv copilot.ToolInvocation) (string, error) { + return params.Value, nil + }) + + var ( + mu sync.Mutex + inputs []copilot.PreToolUseHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{echoTool}, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if input.ToolName != "echo_value" { + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + } + return &copilot.PreToolUseHookOutput{ + PermissionDecision: "allow", + ModifiedArgs: map[string]any{"value": "modified by hook"}, + SuppressOutput: false, + }, nil + }, }, - }, - "postToolUseFailure output": { - OnPostToolUse: func(copilot.PostToolUseHookInput, copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { - return nil, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Call echo_value with value 'original', then reply with the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected preToolUse hook to be invoked at least once") + } + hadEchoInput := false + for _, input := range inputs { + if input.ToolName == "echo_value" { + hadEchoInput = true + break + } + } + if !hadEchoInput { + t.Errorf("Expected at least one preToolUse invocation for echo_value, got %+v", inputs) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || !strings.Contains(assistantMessage.Content, "modified by hook") { + t.Errorf("Expected response to contain 'modified by hook', got %v", response.Data) + } + }) + + t.Run("should allow postToolUse to return modifiedResult", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.PostToolUseHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if input.ToolName != "view" { + return nil, nil + } + return &copilot.PostToolUseHookOutput{ + ModifiedResult: copilot.ToolResult{ + TextResultForLLM: "modified by post hook", + ResultType: "success", + ToolTelemetry: map[string]any{}, + }, + SuppressOutput: false, + }, nil + }, }, - OnPostToolUseFailure: func(copilot.PostToolUseFailureHookInput, copilot.HookInvocation) (*copilot.PostToolUseFailureHookOutput, error) { - return &copilot.PostToolUseFailureHookOutput{AdditionalContext: "not used"}, nil + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Call the view tool to read the current directory, then reply done.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + hadView := false + for _, input := range inputs { + if input.ToolName == "view" { + hadView = true + break + } + } + if !hadView { + t.Errorf("Expected at least one postToolUse invocation for view, got %+v", inputs) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || !strings.Contains(strings.ToLower(assistantMessage.Content), "done") { + t.Errorf("Expected response content to contain 'done', got %v", response.Data) + } + }) + + t.Run("should invoke postToolUseFailure hook for failed tool result", func(t *testing.T) { + t.Skip("Fails with 1.0.64-0 runtime: built-in tools are not available when " + + "hooks restrict availableTools, so the failure path cannot be exercised. " + + "Follow up with runtime team.") + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + failureInputs []copilot.PostToolUseFailureHookInput + postToolUseInputs []copilot.PostToolUseHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: []string{"report_intent"}, + Hooks: &copilot.SessionHooks{ + OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + mu.Lock() + postToolUseInputs = append(postToolUseInputs, input) + mu.Unlock() + return nil, nil + }, + OnPostToolUseFailure: func(input copilot.PostToolUseFailureHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseFailureHookOutput, error) { + mu.Lock() + failureInputs = append(failureInputs, input) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + return &copilot.PostToolUseFailureHookOutput{ + AdditionalContext: "HOOK_FAILURE_GUIDANCE_APPLIED", + }, nil + }, }, - }, - } + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } - for name, hooks := range cases { - t.Run("rejects SDK callback hook "+name, func(t *testing.T) { - ctx.ConfigureForTest(t) - assertUnsupportedExtendedHooks(t, client, hooks) + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Call the view tool with path 'missing.txt'. If it fails, use the hook guidance to answer.", }) - } + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(postToolUseInputs) != 0 { + t.Fatalf("Expected postToolUse not to fire for failed result, got %+v", postToolUseInputs) + } + if len(failureInputs) != 1 { + t.Fatalf("Expected one postToolUseFailure input, got %+v", failureInputs) + } + input := failureInputs[0] + if input.ToolName != "view" { + t.Errorf("Expected tool name view, got %q", input.ToolName) + } + if !strings.Contains(input.Error, "does not exist") { + t.Errorf("Expected missing-tool error, got %q", input.Error) + } + if !strings.Contains(fmt.Sprint(input.ToolArgs), "missing.txt") { + t.Errorf("Expected tool args to contain missing.txt, got %+v", input.ToolArgs) + } + if input.WorkingDirectory == "" { + t.Error("Expected working directory to be populated") + } + if input.Timestamp.IsZero() { + t.Error("Expected timestamp to be populated") + } + if assistantMessage, ok := response.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(assistantMessage.Content, "HOOK_FAILURE_GUIDANCE_APPLIED") { + t.Errorf("Expected response to contain hook guidance, got %v", response.Data) + } + }) } diff --git a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go index d432d2aad..184727092 100644 --- a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go +++ b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go @@ -1,7 +1,9 @@ package e2e import ( + "path/filepath" "strings" + "sync" "testing" copilot "github.com/github/copilot-sdk/go" @@ -13,25 +15,193 @@ func TestPreMCPToolCallHookE2E(t *testing.T) { client := ctx.NewClient() t.Cleanup(func() { client.ForceStop() }) - t.Run("rejects SDK preMcpToolCall callback hooks", func(t *testing.T) { + testHarnessDir, _ := filepath.Abs("../../../test/harness") + metaEchoServer := filepath.Join(testHarnessDir, "test-mcp-meta-echo-server.mjs") + + metaEchoConfig := func() map[string]copilot.MCPServerConfig { + return map[string]copilot.MCPServerConfig{ + "meta-echo": copilot.MCPStdioServerConfig{ + Command: "node", + Args: []string{metaEchoServer}, + WorkingDirectory: testHarnessDir, + Tools: []string{"*"}, + }, + } + } + + t.Run("should set meta via preMcpToolCall hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.PreMCPToolCallHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: metaEchoConfig(), + Hooks: &copilot.SessionHooks{ + OnPreMCPToolCall: func(input copilot.PreMCPToolCallHookInput, invocation copilot.HookInvocation) (*copilot.PreMCPToolCallHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + return &copilot.PreMCPToolCallHookOutput{ + MetaToUse: map[string]any{ + "injected": "by-hook", + "source": "test", + }, + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok { + t.Fatalf("Expected assistant message data, got %T", response.Data) + } + if !strings.Contains(assistantMessage.Content, "injected") || !strings.Contains(assistantMessage.Content, "by-hook") { + t.Errorf("Expected response to contain 'injected' and 'by-hook', got %q", assistantMessage.Content) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one preMcpToolCall hook invocation") + } + if inputs[0].ServerName != "meta-echo" { + t.Errorf("Expected serverName 'meta-echo', got %q", inputs[0].ServerName) + } + if inputs[0].ToolName != "echo_meta" { + t.Errorf("Expected toolName 'echo_meta', got %q", inputs[0].ToolName) + } + if inputs[0].WorkingDirectory == "" { + t.Error("Expected non-empty workingDirectory") + } + if inputs[0].Timestamp.IsZero() { + t.Error("Expected non-zero timestamp") + } + }) + + t.Run("should replace meta via preMcpToolCall hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.PreMCPToolCallHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: metaEchoConfig(), + Hooks: &copilot.SessionHooks{ + OnPreMCPToolCall: func(input copilot.PreMCPToolCallHookInput, invocation copilot.HookInvocation) (*copilot.PreMCPToolCallHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + return &copilot.PreMCPToolCallHookOutput{ + MetaToUse: map[string]any{ + "completely": "replaced", + }, + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok { + t.Fatalf("Expected assistant message data, got %T", response.Data) + } + if !strings.Contains(assistantMessage.Content, "completely") || !strings.Contains(assistantMessage.Content, "replaced") { + t.Errorf("Expected response to contain 'completely' and 'replaced', got %q", assistantMessage.Content) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one preMcpToolCall hook invocation") + } + if inputs[0].ServerName != "meta-echo" { + t.Errorf("Expected serverName 'meta-echo', got %q", inputs[0].ServerName) + } + if inputs[0].ToolName != "echo_meta" { + t.Errorf("Expected toolName 'echo_meta', got %q", inputs[0].ToolName) + } + }) + + t.Run("should remove meta via preMcpToolCall hook", func(t *testing.T) { ctx.ConfigureForTest(t) + var ( + mu sync.Mutex + inputs []copilot.PreMCPToolCallHookInput + ) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: metaEchoConfig(), Hooks: &copilot.SessionHooks{ - OnPreMCPToolCall: func(copilot.PreMCPToolCallHookInput, copilot.HookInvocation) (*copilot.PreMCPToolCallHookOutput, error) { - return &copilot.PreMCPToolCallHookOutput{MetaToUse: map[string]any{"injected": "by-hook"}}, nil + OnPreMCPToolCall: func(input copilot.PreMCPToolCallHookInput, invocation copilot.HookInvocation) (*copilot.PreMCPToolCallHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + return &copilot.PreMCPToolCallHookOutput{ + MetaToUse: nil, + }, nil }, }, }) - if err == nil { - if session != nil { - _ = session.Disconnect() - } - t.Fatal("expected SDK callback hooks to be rejected") - } - if !strings.Contains(err.Error(), unsupportedSDKHooksMessage) { - t.Fatalf("expected unsupported hooks error, got %v", err) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok { + t.Fatalf("Expected assistant message data, got %T", response.Data) + } + if !strings.Contains(assistantMessage.Content, `"meta":null`) { + t.Errorf("Expected response to contain '\"meta\":null', got %q", assistantMessage.Content) + } + if !strings.Contains(assistantMessage.Content, "test-remove") { + t.Errorf("Expected response to contain 'test-remove', got %q", assistantMessage.Content) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one preMcpToolCall hook invocation") + } + if inputs[0].ServerName != "meta-echo" { + t.Errorf("Expected serverName 'meta-echo', got %q", inputs[0].ServerName) + } + if inputs[0].ToolName != "echo_meta" { + t.Errorf("Expected toolName 'echo_meta', got %q", inputs[0].ToolName) } }) } diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go index 09f2b8f35..c632b1e60 100644 --- a/go/internal/e2e/subagent_hooks_e2e_test.go +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -1,7 +1,9 @@ package e2e import ( - "strings" + "os" + "path/filepath" + "sync" "testing" copilot "github.com/github/copilot-sdk/go" @@ -10,31 +12,93 @@ import ( func TestSubagentHooksE2E(t *testing.T) { ctx := testharness.NewTestContext(t) - client := ctx.NewClient() + client := ctx.NewClient(func(o *copilot.ClientOptions) { + o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + }) t.Cleanup(func() { client.ForceStop() }) - t.Run("rejects SDK callback hooks for sub-agent hook propagation", func(t *testing.T) { + t.Run("should invoke preToolUse and postToolUse hooks for sub-agent tool calls", func(t *testing.T) { ctx.ConfigureForTest(t) + type hookEntry struct { + kind string + toolName string + sessionID string + } + var hookLog []hookEntry + var mu sync.Mutex + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ - OnPreToolUse: func(copilot.PreToolUseHookInput, copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + mu.Lock() + hookLog = append(hookLog, hookEntry{kind: "pre", toolName: input.ToolName, sessionID: input.SessionID}) + mu.Unlock() return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil }, - OnPostToolUse: func(copilot.PostToolUseHookInput, copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + mu.Lock() + hookLog = append(hookLog, hookEntry{kind: "post", toolName: input.ToolName, sessionID: input.SessionID}) + mu.Unlock() return nil, nil }, }, }) - if err == nil { - if session != nil { - _ = session.Disconnect() + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Create a file for the sub-agent to read + testFile := filepath.Join(ctx.WorkDir, "subagent-test.txt") + if err := os.WriteFile(testFile, []byte("Hello from subagent test!"), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + // Parent tool hooks fire for "task" + var taskPre *hookEntry + for i := range hookLog { + if hookLog[i].kind == "pre" && hookLog[i].toolName == "task" { + taskPre = &hookLog[i] + break } - t.Fatal("expected SDK callback hooks to be rejected") } - if !strings.Contains(err.Error(), unsupportedSDKHooksMessage) { - t.Fatalf("expected unsupported hooks error, got %v", err) + if taskPre == nil { + t.Fatal("preToolUse should fire for the parent's 'task' tool call") + return + } + + // Sub-agent tool hooks fire for "view" + var viewPre, viewPost []hookEntry + for _, h := range hookLog { + if h.toolName == "view" { + if h.kind == "pre" { + viewPre = append(viewPre, h) + } else { + viewPost = append(viewPost, h) + } + } + } + if len(viewPre) == 0 { + t.Fatal("preToolUse should fire for the sub-agent's 'view' tool call") + } + if len(viewPost) == 0 { + t.Fatal("postToolUse should fire for the sub-agent's 'view' tool call") + } + + // input.SessionID distinguishes parent from sub-agent + if viewPre[0].sessionID == taskPre.sessionID { + t.Error("Sub-agent tool hooks should have a different sessionId than parent tool hooks") } }) } diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index f0cec5ee4..03cec0dc3 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1393,6 +1393,7 @@ type CopilotUserResponse struct { // Schema for the `CopilotUserResponseQuotaSnapshots` type. QuotaSnapshots *CopilotUserResponseQuotaSnapshots `json:"quota_snapshots,omitempty"` RestrictedTelemetry *bool `json:"restricted_telemetry,omitempty"` + Te *bool `json:"te,omitempty"` TokenBasedBilling *bool `json:"token_based_billing,omitempty"` } @@ -2012,6 +2013,81 @@ type GitHubRepoRef struct { Owner string `json:"owner"` } +// Client environment metadata describing the process that produced a telemetry event. +// Experimental: GitHubTelemetryClientInfo is part of an experimental API and may change or +// be removed. +type GitHubTelemetryClientInfo struct { + // Name of the client application. + ClientName *string `json:"client_name,omitempty"` + // Type of client. + ClientType *string `json:"client_type,omitempty"` + // Copilot CLI version string. + CLIVersion string `json:"cli_version"` + // Copilot subscription plan, when known. + CopilotPlan *string `json:"copilot_plan,omitempty"` + // Stable machine identifier for the device. + DevDeviceID *string `json:"dev_device_id,omitempty"` + // Whether the user is a GitHub/Microsoft staff member. + IsStaff *bool `json:"is_staff,omitempty"` + // Node.js runtime version string. + NodeVersion string `json:"node_version"` + // Operating system architecture (e.g. arm64, x64). + OsArch string `json:"os_arch"` + // Operating system platform (e.g. darwin, linux, win32). + OsPlatform string `json:"os_platform"` + // Operating system version string. + OsVersion string `json:"os_version"` +} + +// A single telemetry event in the runtime's native GitHub-shaped telemetry format, +// forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing +// GitHubTelemetryNotification distinguishes standard from restricted events; the payload +// shape is identical for both. +// Experimental: GitHubTelemetryEvent is part of an experimental API and may change or be +// removed. +type GitHubTelemetryEvent struct { + // Client environment metadata. + Client *GitHubTelemetryClientInfo `json:"client,omitempty"` + // Copilot tracking ID for user-level attribution. + CopilotTrackingID *string `json:"copilot_tracking_id,omitempty"` + // Timestamp when the event was created (ISO 8601 format). + CreatedAt *string `json:"created_at,omitempty"` + // Experiment assignment context. + ExpAssignmentContext *string `json:"exp_assignment_context,omitempty"` + // Feature flags enabled for this session, as a map from flag to value. + Features map[string]string `json:"features,omitzero"` + // Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + Kind string `json:"kind"` + // Numeric metrics as a map from key to value. + Metrics map[string]float64 `json:"metrics"` + // Reference to the model call that produced this event. + ModelCallID *string `json:"model_call_id,omitempty"` + // String-valued properties as a map from key to value. + Properties map[string]string `json:"properties"` + // Session identifier the event belongs to. + SessionID *string `json:"session_id,omitempty"` +} + +// Experimental: GitHubTelemetryEventResult is part of an experimental API and may change or +// be removed. +type GitHubTelemetryEventResult struct { +} + +// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the +// runtime forwards to a host connection that opted into telemetry forwarding for the +// session. +// Experimental: GitHubTelemetryNotification is part of an experimental API and may change +// or be removed. +type GitHubTelemetryNotification struct { + // The telemetry event, in the runtime's native GitHub-shaped telemetry format. + Event GitHubTelemetryEvent `json:"event"` + // Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route + // restricted events to first-party Microsoft stores only. + Restricted bool `json:"restricted"` + // Session the telemetry event belongs to. + SessionID string `json:"sessionId"` +} + // Pending external tool call request ID, with the tool result or an error describing why it // failed. // Experimental: HandlePendingToolCallRequest is part of an experimental API and may change @@ -2295,6 +2371,8 @@ type InstructionSource struct { type LlmInferenceHeaders map[string][]string // A request body chunk or cancellation signal. +// Experimental: LlmInferenceHTTPRequestChunkRequest is part of an experimental API and may +// change or be removed. type LlmInferenceHTTPRequestChunkRequest struct { // When true, `data` is base64-encoded bytes. When absent or false, `data` is UTF-8 text. Binary *bool `json:"binary,omitempty"` @@ -2315,10 +2393,14 @@ type LlmInferenceHTTPRequestChunkRequest struct { // Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as // fire-and-forget. +// Experimental: LlmInferenceHTTPRequestChunkResult is part of an experimental API and may +// change or be removed. type LlmInferenceHTTPRequestChunkResult struct { } // The head of an outbound model-layer HTTP request. +// Experimental: LlmInferenceHTTPRequestStartRequest is part of an experimental API and may +// change or be removed. type LlmInferenceHTTPRequestStartRequest struct { Headers map[string][]string `json:"headers"` // HTTP method, e.g. GET, POST. @@ -2345,6 +2427,8 @@ type LlmInferenceHTTPRequestStartRequest struct { // Acknowledgement. Returning successfully simply means the SDK accepted the start frame; it // does not imply the request will succeed. +// Experimental: LlmInferenceHTTPRequestStartResult is part of an experimental API and may +// change or be removed. type LlmInferenceHTTPRequestStartResult struct { } @@ -10501,6 +10585,8 @@ const ( // distinguishes text from binary frames. The SDK consumer uses this to decide whether to // service the request with an HTTP client or a WebSocket client. It is the one piece of // request metadata the consumer cannot reliably infer from the URL or headers alone. +// Experimental: LlmInferenceHTTPRequestStartTransport is part of an experimental API and +// may change or be removed. type LlmInferenceHTTPRequestStartTransport string const ( @@ -18624,6 +18710,20 @@ func RegisterClientSessionAPIHandlers(client *jsonrpc2.Client, getHandlers func( }) } +// Experimental: GitHubTelemetryHandler contains experimental APIs that may change or be +// removed. +type GitHubTelemetryHandler interface { + // Event forwards a single GitHub telemetry event to a host connection that opted into + // telemetry forwarding for the session. + // + // RPC method: gitHubTelemetry.event. + // + // Parameters: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry + // event the runtime forwards to a host connection that opted into telemetry forwarding for + // the session. + Event(request *GitHubTelemetryNotification) (*GitHubTelemetryEventResult, error) +} + // Experimental: LlmInferenceHandler contains experimental APIs that may change or be // removed. type LlmInferenceHandler interface { @@ -18660,7 +18760,8 @@ type LlmInferenceHandler interface { // Unlike client-session handlers these carry no implicit session id dispatch // key; a single set of handlers serves the entire connection. type ClientGlobalAPIHandlers struct { - LlmInference LlmInferenceHandler + GitHubTelemetry GitHubTelemetryHandler + LlmInference LlmInferenceHandler } func clientGlobalHandlerError(err error) *jsonrpc2.Error { @@ -18677,6 +18778,24 @@ func clientGlobalHandlerError(err error) *jsonrpc2.Error { // RegisterClientGlobalAPIHandlers registers handlers for server-to-client client-global API // calls. func RegisterClientGlobalAPIHandlers(client *jsonrpc2.Client, handlers *ClientGlobalAPIHandlers) { + client.SetRequestHandler("gitHubTelemetry.event", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request GitHubTelemetryNotification + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + if handlers == nil || handlers.GitHubTelemetry == nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: "No gitHubTelemetry client-global handler registered"} + } + result, err := handlers.GitHubTelemetry.Event(&request) + if err != nil { + return nil, clientGlobalHandlerError(err) + } + raw, err := json.Marshal(result) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)} + } + return raw, nil + }) client.SetRequestHandler("llmInference.httpRequestChunk", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { var request LlmInferenceHTTPRequestChunkRequest if err := json.Unmarshal(params, &request); err != nil { diff --git a/java/pom.xml b/java/pom.xml index d5f3fa54b..35f66287f 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -86,7 +86,7 @@ DO NOT EDIT MANUALLY. Updated by the update-copilot-dependency workflow. --> - ^1.0.66 + ^1.0.67 diff --git a/java/scripts/codegen/java.ts b/java/scripts/codegen/java.ts index c30836f7a..ac65c8003 100644 --- a/java/scripts/codegen/java.ts +++ b/java/scripts/codegen/java.ts @@ -1315,6 +1315,7 @@ async function generateRpcTypes(schemaPath: string): Promise { server?: Record; session?: Record; clientSession?: Record; + clientGlobal?: Record; definitions?: Record; }; @@ -1343,18 +1344,28 @@ async function generateRpcTypes(schemaPath: string): Promise { if (schema.server) sections.push(["server", schema.server]); if (schema.session) sections.push(["session", schema.session]); if (schema.clientSession) sections.push(["clientSession", schema.clientSession]); + if (schema.clientGlobal) sections.push(["clientGlobal", schema.clientGlobal]); const generatedClasses = new Map(); const allFiles: string[] = []; - for (const [, sectionNode] of sections) { + for (const [sectionName, sectionNode] of sections) { const methods = collectRpcMethods(sectionNode); for (const [, method] of methods) { const className = rpcMethodToClassName(method.rpcMethod); // Generate params class — resolve $ref if params is a reference let paramsSchema = method.params as JSONSchema7 | null; - if (paramsSchema?.$ref) paramsSchema = resolveRef(paramsSchema) as JSONSchema7; + const paramsRefName = extractRefName(paramsSchema); + if (paramsRefName && sectionName === "clientGlobal") { + const resolvedParamsSchema = resolveRef(paramsSchema ?? undefined); + if (resolvedParamsSchema?.type === "object" && resolvedParamsSchema.properties) { + pendingStandaloneTypes.set(paramsRefName, resolvedParamsSchema); + } + paramsSchema = null; + } else if (paramsSchema?.$ref) { + paramsSchema = resolveRef(paramsSchema) as JSONSchema7; + } if (paramsSchema && typeof paramsSchema === "object" && paramsSchema.properties) { const paramsClassName = `${className}Params`; if (!generatedClasses.has(paramsClassName)) { @@ -1368,7 +1379,11 @@ async function generateRpcTypes(schemaPath: string): Promise { const resultRefName = extractRefName(resultSchema); if (resultSchema?.$ref) resultSchema = resolveRef(resultSchema) as JSONSchema7; if (resultSchema && typeof resultSchema === "object") { - if (resultSchema.properties && Object.keys(resultSchema.properties).length > 0) { + if ( + resultSchema.properties && + (Object.keys(resultSchema.properties).length > 0 || + (resultRefName && sectionName === "clientGlobal")) + ) { // Object with properties → generate a record class const resultClassName = `${className}Result`; if (!generatedClasses.has(resultClassName)) { diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index 20e659152..27689da9a 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "json-schema": "^0.4.0", "tsx": "^4.22.4" } @@ -428,9 +428,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66.tgz", - "integrity": "sha512-m3+3FLSgum90xN4+eiwnLvdrDvM+oZzur5DfhOH88duNDKBcLQvKQY9fG/I1l1t8a1iBwjpgtRpsBwykE8k3Zw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.67.tgz", + "integrity": "sha512-5YEY9LNXBT9Q8uShjCdYcornJJJhGtdIzSYla2+pjfXYpHsDVibqYubzYjfgffOUKFChyzOpH7n/868+t56iIg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -439,20 +439,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.66", - "@github/copilot-darwin-x64": "1.0.66", - "@github/copilot-linux-arm64": "1.0.66", - "@github/copilot-linux-x64": "1.0.66", - "@github/copilot-linuxmusl-arm64": "1.0.66", - "@github/copilot-linuxmusl-x64": "1.0.66", - "@github/copilot-win32-arm64": "1.0.66", - "@github/copilot-win32-x64": "1.0.66" + "@github/copilot-darwin-arm64": "1.0.67", + "@github/copilot-darwin-x64": "1.0.67", + "@github/copilot-linux-arm64": "1.0.67", + "@github/copilot-linux-x64": "1.0.67", + "@github/copilot-linuxmusl-arm64": "1.0.67", + "@github/copilot-linuxmusl-x64": "1.0.67", + "@github/copilot-win32-arm64": "1.0.67", + "@github/copilot-win32-x64": "1.0.67" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66.tgz", - "integrity": "sha512-cJPXE2rWSjR+B8GRBUUd0k9PM4euWRUe3xgHoJqi9o/jJjtRYn6DZMrmFt9xgjoYWf0WZOyrlDgedqO1V+zDAg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.67.tgz", + "integrity": "sha512-CO3mpgFXcN6e7ZsSmjMkt1AKxMfb1+mjdn3yrf2DRnnWIURSK9kGvw+E+E1+YE37D1MBiUn/VOBmhRad5+vl0A==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66.tgz", - "integrity": "sha512-44mpx2ZcRFHDx4B9xlrL5OQyTgaD/Hn+bAkeStXgcG8UkkfYSsRtLhnaxqUEQrtIEiVQrw++XWvUO0AscRrX+w==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.67.tgz", + "integrity": "sha512-M20Hpn3bOJRkVwAIVRK4ZlX66AqtmGfXZRxZBRFQC045QIwcfmVUP45sTSgXDb4uHWeK0cZgdTdniHwKGtMplw==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66.tgz", - "integrity": "sha512-uXtTs/rYjk6kacNs/T0s/lxn0JBvAgu78pBoZeWpU5APhICkPy9kC+lNAzLYoZujVVDOHT05IoeifHppFpQ8+w==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.67.tgz", + "integrity": "sha512-b4ePtFBow+Ior+aVLKA1hHxhR5wF+ql5CD7TSg/NHGYgc1kwD+3a9uKSENy05J5Lit/G/DZ9C6JwowvvdMWSKg==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66.tgz", - "integrity": "sha512-tXn3OuJCx/YEDNgYg8mdOGSFiIjmLJtTEyZ/VoEA86ffUIPxrunc0wnapEFk2zOW1unwdJeBuVIkzlB3RS1/eA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.67.tgz", + "integrity": "sha512-4ynZyfKnWAdvEPAFDDBIz1wpFttcOTJu4Y8Mlz5oXCBA0NM/rwr8K4l7Adp8UzwbfmdrMJ9y+zivqRBMDbPInA==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66.tgz", - "integrity": "sha512-sHRag7W5CG0kbbX3j9v9cUmIafk/0N8MGGr2knvPeIHtxwZQYYjx397gT1nN6xagLWt5mvchkYybfQFCyCBaxg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.67.tgz", + "integrity": "sha512-IjezxBU8fYUr/b5hEiniXqzwoOrJ4egrQSBbG96M+roLTqd9txP0MgxZtcRtKV7phRIdIGE109wwrn4H6hSqmA==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66.tgz", - "integrity": "sha512-bdIgHOaVZlvsF/4awzMxsby6T+4k7aWe9HZr+sr+qU8tuG19jwi/1LXGB6tKdlFeFgY78yX0lR+ywByVJc5loA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.67.tgz", + "integrity": "sha512-Zy/rbja1lnhzDoNfn051H0EybCseCvjvH7WmbcHCayjXUjzXeKF6OmAt4hvqFZH87ttT3KbKtQ8/6oDUhhM2YQ==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66.tgz", - "integrity": "sha512-T7FGONCVWIPjjAxp22cu4WKqNogq56FknHGAvj7Ryn5ZoanFAR3vXXlXDsYnDKLBcshjRYGxocl2UnmRTMxgvg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.67.tgz", + "integrity": "sha512-O3VFRS5v9NXRP8o+N1SvcFbBqECDzZP7XQBeBj2Vcrma80gdJc5GQub/w2mwmr1w5UbwgzJkRasm0Ec/jxbcoA==", "cpu": [ "arm64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66.tgz", - "integrity": "sha512-eroxRUSJZOJCk0luLyX6A1qqGIWs8p4w0EjZFhCzvdFvJ0abIovGyt3R/gN9DeyJM8Qs7ROPGvqevUlXh6DhCg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.67.tgz", + "integrity": "sha512-td5tQ/nve5dB7RPvNglBZwa/6DJqiOBgacXXa1GpYcohqpCzoI8gONNkeaeyr6oF4iu5wXJ9krUNr6QXL4yB5Q==", "cpu": [ "x64" ], diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index 601f5cc17..57de91c91 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "json-schema": "^0.4.0", "tsx": "^4.22.4" } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryClientInfo.java b/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryClientInfo.java new file mode 100644 index 000000000..7d7a1eaf7 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryClientInfo.java @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Client environment metadata describing the process that produced a telemetry event. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubTelemetryClientInfo( + /** Copilot CLI version string. */ + @JsonProperty("cli_version") String cliVersion, + /** Operating system platform (e.g. darwin, linux, win32). */ + @JsonProperty("os_platform") String osPlatform, + /** Operating system version string. */ + @JsonProperty("os_version") String osVersion, + /** Operating system architecture (e.g. arm64, x64). */ + @JsonProperty("os_arch") String osArch, + /** Node.js runtime version string. */ + @JsonProperty("node_version") String nodeVersion, + /** Copilot subscription plan, when known. */ + @JsonProperty("copilot_plan") String copilotPlan, + /** Type of client. */ + @JsonProperty("client_type") String clientType, + /** Name of the client application. */ + @JsonProperty("client_name") String clientName, + /** Whether the user is a GitHub/Microsoft staff member. */ + @JsonProperty("is_staff") Boolean isStaff, + /** Stable machine identifier for the device. */ + @JsonProperty("dev_device_id") String devDeviceId +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryEvent.java b/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryEvent.java new file mode 100644 index 000000000..efbf920b4 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryEvent.java @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubTelemetryEvent( + /** Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). */ + @JsonProperty("kind") String kind, + /** Timestamp when the event was created (ISO 8601 format). */ + @JsonProperty("created_at") String createdAt, + /** Reference to the model call that produced this event. */ + @JsonProperty("model_call_id") String modelCallId, + /** String-valued properties as a map from key to value. */ + @JsonProperty("properties") Map properties, + /** Numeric metrics as a map from key to value. */ + @JsonProperty("metrics") Map metrics, + /** Experiment assignment context. */ + @JsonProperty("exp_assignment_context") String expAssignmentContext, + /** Feature flags enabled for this session, as a map from flag to value. */ + @JsonProperty("features") Map features, + /** Session identifier the event belongs to. */ + @JsonProperty("session_id") String sessionId, + /** Copilot tracking ID for user-level attribution. */ + @JsonProperty("copilot_tracking_id") String copilotTrackingId, + /** Client environment metadata. */ + @JsonProperty("client") GitHubTelemetryClientInfo client +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryNotification.java b/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryNotification.java new file mode 100644 index 000000000..fddcdc70b --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/GitHubTelemetryNotification.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubTelemetryNotification( + /** Session the telemetry event belongs to. */ + @JsonProperty("sessionId") String sessionId, + /** Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. */ + @JsonProperty("restricted") Boolean restricted, + /** The telemetry event, in the runtime's native GitHub-shaped telemetry format. */ + @JsonProperty("event") GitHubTelemetryEvent event +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestChunkRequest.java b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestChunkRequest.java new file mode 100644 index 000000000..292033f92 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestChunkRequest.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * A request body chunk or cancellation signal. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record LlmInferenceHttpRequestChunkRequest( + /** Matches the requestId from the originating httpRequestStart frame. */ + @JsonProperty("requestId") String requestId, + /** Body byte range. UTF-8 text when `binary` is absent or false; base64-encoded bytes when `binary` is true. May be empty. */ + @JsonProperty("data") String data, + /** When true, `data` is base64-encoded bytes. When absent or false, `data` is UTF-8 text. */ + @JsonProperty("binary") Boolean binary, + /** When true, this is the final body chunk for the request. The SDK may rely on having received an end-marked chunk before treating the request body as complete. */ + @JsonProperty("end") Boolean end, + /** When true, the runtime is cancelling the in-flight request (e.g. upstream consumer aborted). `data` is ignored. Implies end-of-request. */ + @JsonProperty("cancel") Boolean cancel, + /** Optional human-readable reason for the cancellation, propagated for logging. */ + @JsonProperty("cancelReason") String cancelReason +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestChunkResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestChunkResult.java new file mode 100644 index 000000000..f866fa70c --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestChunkResult.java @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record LlmInferenceHttpRequestChunkResult() { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartRequest.java b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartRequest.java new file mode 100644 index 000000000..4c23404d3 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartRequest.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * The head of an outbound model-layer HTTP request. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record LlmInferenceHttpRequestStartRequest( + /** Opaque runtime-minted id, unique per in-flight request. The SDK uses this to correlate httpRequestChunk frames and to address its httpResponseStart / httpResponseChunk replies back to the runtime. */ + @JsonProperty("requestId") String requestId, + /** Id of the runtime session that triggered this request, when one is in scope. Absent for requests issued outside any session (e.g. startup model-catalog or capability resolution). This is a payload field — not a dispatch key — because the client-global API is registered process-wide rather than per session. */ + @JsonProperty("sessionId") String sessionId, + /** HTTP method, e.g. GET, POST. */ + @JsonProperty("method") String method, + /** Absolute request URL. */ + @JsonProperty("url") String url, + @JsonProperty("headers") Map> headers, + /** Transport the runtime would otherwise use for this request. `http` (the default when absent) covers plain HTTP and SSE responses; `websocket` indicates a full-duplex message channel where each body chunk maps to one WebSocket message and the `binary` flag distinguishes text from binary frames. The SDK consumer uses this to decide whether to service the request with an HTTP client or a WebSocket client. It is the one piece of request metadata the consumer cannot reliably infer from the URL or headers alone. */ + @JsonProperty("transport") LlmInferenceHttpRequestStartTransport transport +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartResult.java new file mode 100644 index 000000000..28016dcb8 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartResult.java @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Acknowledgement. Returning successfully simply means the SDK accepted the start frame; it does not imply the request will succeed. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record LlmInferenceHttpRequestStartResult() { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartTransport.java b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartTransport.java new file mode 100644 index 000000000..1b5aa9a2d --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/LlmInferenceHttpRequestStartTransport.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import javax.annotation.processing.Generated; + +/** + * Transport the runtime would otherwise use for this request. `http` (the default when absent) covers plain HTTP and SSE responses; `websocket` indicates a full-duplex message channel where each body chunk maps to one WebSocket message and the `binary` flag distinguishes text from binary frames. The SDK consumer uses this to decide whether to service the request with an HTTP client or a WebSocket client. It is the one piece of request metadata the consumer cannot reliably infer from the URL or headers alone. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum LlmInferenceHttpRequestStartTransport { + /** The {@code http} variant. */ + HTTP("http"), + /** The {@code websocket} variant. */ + WEBSOCKET("websocket"); + + private final String value; + LlmInferenceHttpRequestStartTransport(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static LlmInferenceHttpRequestStartTransport fromValue(String value) { + for (LlmInferenceHttpRequestStartTransport v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown LlmInferenceHttpRequestStartTransport value: " + value); + } +} diff --git a/java/src/test/java/com/github/copilot/ExecutorWiringTest.java b/java/src/test/java/com/github/copilot/ExecutorWiringTest.java index 63bba3884..78764db0f 100644 --- a/java/src/test/java/com/github/copilot/ExecutorWiringTest.java +++ b/java/src/test/java/com/github/copilot/ExecutorWiringTest.java @@ -27,7 +27,9 @@ import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.PermissionRequestResult; import com.github.copilot.rpc.PermissionRequestResultKind; +import com.github.copilot.rpc.PreToolUseHookOutput; import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.SessionHooks; import com.github.copilot.rpc.ToolDefinition; import com.github.copilot.rpc.UserInputResponse; @@ -263,6 +265,48 @@ void testUserInputDispatchUsesProvidedExecutor() throws Exception { } } + /** + * Verifies that hooks dispatch routes through the provided executor. + * + *

+ * When the LLM triggers a hook, the {@code RpcHandlerDispatcher} calls + * {@code CompletableFuture.runAsync(...)} to dispatch the hooks handler. This + * test asserts that dispatch goes through the caller-supplied executor. + *

+ * + * @see Snapshot: hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool + */ + @Test + void testHooksDispatchUsesProvidedExecutor() throws Exception { + ctx.configureForTest("hooks", "invoke_pre_tool_use_hook_when_model_runs_a_tool"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse( + (input, invocation) -> CompletableFuture.completedFuture(PreToolUseHookOutput.allow()))); + + try (CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor))) { + CopilotSession session = client.createSession(config).get(); + + Path testFile = ctx.getWorkDir().resolve("hello.txt"); + Files.writeString(testFile, "Hello from the test!"); + + int beforeSend = trackingExecutor.getTaskCount(); + + session.sendAndWait( + new MessageOptions().setPrompt("Read the contents of hello.txt and tell me what it says")) + .get(60, TimeUnit.SECONDS); + + assertTrue(trackingExecutor.getTaskCount() > beforeSend, + "Expected the tracking executor to have been invoked for hooks dispatch, " + + "but task count did not increase after sendAndWait. " + + "RpcHandlerDispatcher is not routing hooks runAsync through the provided executor."); + + session.close(); + } + } + /** * Verifies that {@code CopilotClient.stop()} routes session closure through the * provided executor. diff --git a/java/src/test/java/com/github/copilot/HooksTest.java b/java/src/test/java/com/github/copilot/HooksTest.java index 8e3a35be3..329883581 100644 --- a/java/src/test/java/com/github/copilot/HooksTest.java +++ b/java/src/test/java/com/github/copilot/HooksTest.java @@ -4,28 +4,44 @@ package com.github.copilot; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; -import java.util.List; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; -import com.github.copilot.rpc.PostToolUseHookOutput; +import com.github.copilot.rpc.PostToolUseHookInput; +import com.github.copilot.rpc.PreToolUseHookInput; import com.github.copilot.rpc.PreToolUseHookOutput; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; -/** Tests for SDK callback hook behavior with the native runtime. */ +/** + * Tests for hooks functionality (pre-tool-use and post-tool-use hooks). + * + *

+ * These tests use the shared CapiProxy infrastructure for deterministic API + * response replay. Snapshots are stored in test/snapshots/hooks/. + *

+ * + *

+ * Note: Tests for userPromptSubmitted, sessionStart, and sessionEnd hooks are + * not included as they are not tested in the reference implementation .NET or + * Node.js SDKs and require test harness updates to properly invoke these hooks. + *

+ */ public class HooksTest { - private static final String UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported"; - private static E2ETestContext ctx; @BeforeAll @@ -40,31 +56,173 @@ static void teardown() throws Exception { } } + /** + * Verifies that pre-tool-use hook is invoked when model runs a tool. + * + * @see Snapshot: hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool + */ + @Test + void testInvokePreToolUseHookWhenModelRunsATool() throws Exception { + ctx.configureForTest("hooks", "invoke_pre_tool_use_hook_when_model_runs_a_tool"); + + var preToolUseInputs = new ArrayList(); + final String[] sessionIdHolder = new String[1]; + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + preToolUseInputs.add(input); + assertEquals(sessionIdHolder[0], invocation.getSessionId()); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + sessionIdHolder[0] = session.getSessionId(); + + // Create a file for the model to read + Path testFile = ctx.getWorkDir().resolve("hello.txt"); + Files.writeString(testFile, "Hello from the test!"); + + session.sendAndWait( + new MessageOptions().setPrompt("Read the contents of hello.txt and tell me what it says")) + .get(60, TimeUnit.SECONDS); + + // Should have received at least one preToolUse hook call + assertFalse(preToolUseInputs.isEmpty(), "Should have received preToolUse hook calls"); + + // Should have received the tool name + assertTrue(preToolUseInputs.stream().anyMatch(i -> i.getToolName() != null && !i.getToolName().isEmpty()), + "Should have received tool name in preToolUse hook"); + } + } + + /** + * Verifies that post-tool-use hook is invoked after model runs a tool. + * + * @see Snapshot: hooks/invoke_post_tool_use_hook_after_model_runs_a_tool + */ + @Test + void testInvokePostToolUseHookAfterModelRunsATool() throws Exception { + ctx.configureForTest("hooks", "invoke_post_tool_use_hook_after_model_runs_a_tool"); + + var postToolUseInputs = new ArrayList(); + final String[] sessionIdHolder = new String[1]; + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPostToolUse((input, invocation) -> { + postToolUseInputs.add(input); + assertEquals(sessionIdHolder[0], invocation.getSessionId()); + return CompletableFuture.completedFuture(null); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + sessionIdHolder[0] = session.getSessionId(); + + // Create a file for the model to read + Path testFile = ctx.getWorkDir().resolve("world.txt"); + Files.writeString(testFile, "World from the test!"); + + session.sendAndWait( + new MessageOptions().setPrompt("Read the contents of world.txt and tell me what it says")) + .get(60, TimeUnit.SECONDS); + + // Should have received at least one postToolUse hook call + assertFalse(postToolUseInputs.isEmpty(), "Should have received postToolUse hook calls"); + + // Should have received the tool name and result + assertTrue(postToolUseInputs.stream().anyMatch(i -> i.getToolName() != null && !i.getToolName().isEmpty()), + "Should have received tool name in postToolUse hook"); + assertTrue(postToolUseInputs.stream().anyMatch(i -> i.getToolResult() != null), + "Should have received tool result in postToolUse hook"); + } + } + + /** + * Verifies that both hooks are invoked for a single tool call. + * + * @see Snapshot: hooks/invoke_both_hooks_for_single_tool_call + */ + @Test + void testInvokeBothHooksForSingleToolCall() throws Exception { + ctx.configureForTest("hooks", "invoke_both_hooks_for_single_tool_call"); + + var preToolUseInputs = new ArrayList(); + var postToolUseInputs = new ArrayList(); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + preToolUseInputs.add(input); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + }).setOnPostToolUse((input, invocation) -> { + postToolUseInputs.add(input); + return CompletableFuture.completedFuture(null); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + + // Create a file for the model to read + Path testFile = ctx.getWorkDir().resolve("both.txt"); + Files.writeString(testFile, "Testing both hooks!"); + + session.sendAndWait(new MessageOptions().setPrompt("Read the contents of both.txt")).get(60, + TimeUnit.SECONDS); + + // Both hooks should have been called + assertFalse(preToolUseInputs.isEmpty(), "Should have received preToolUse hook calls"); + assertFalse(postToolUseInputs.isEmpty(), "Should have received postToolUse hook calls"); + + // The same tool should appear in both + Set preToolNames = preToolUseInputs.stream().map(PreToolUseHookInput::getToolName) + .filter(n -> n != null && !n.isEmpty()).collect(Collectors.toSet()); + Set postToolNames = postToolUseInputs.stream().map(PostToolUseHookInput::getToolName) + .filter(n -> n != null && !n.isEmpty()).collect(Collectors.toSet()); + + // Check if there's any overlap + boolean hasOverlap = preToolNames.stream().anyMatch(postToolNames::contains); + assertTrue(hasOverlap, "Expected the same tool to appear in both pre and post hooks"); + } + } + + /** + * Verifies that tool execution is denied when pre-tool-use returns deny. + * + * @see Snapshot: hooks/deny_tool_execution_when_pre_tool_use_returns_deny + */ @Test - void testRejectsSdkCallbackHooks() throws Exception { - ctx.initializeProxy(); - - var hookCases = List.of( - new SessionHooks().setOnPreToolUse( - (input, invocation) -> CompletableFuture.completedFuture(PreToolUseHookOutput.allow())), - new SessionHooks().setOnPostToolUse( - (input, invocation) -> CompletableFuture.completedFuture((PostToolUseHookOutput) null)), - new SessionHooks().setOnPreToolUse( - (input, invocation) -> CompletableFuture.completedFuture(PreToolUseHookOutput.deny())), - new SessionHooks() - .setOnPreToolUse( - (input, invocation) -> CompletableFuture.completedFuture(PreToolUseHookOutput.allow())) - .setOnPostToolUse((input, invocation) -> CompletableFuture - .completedFuture((PostToolUseHookOutput) null))); + void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception { + ctx.configureForTest("hooks", "deny_tool_execution_when_pre_tool_use_returns_deny"); + + var preToolUseInputs = new ArrayList(); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + preToolUseInputs.add(input); + // Deny all tool calls + return CompletableFuture.completedFuture(PreToolUseHookOutput.deny()); + })); try (CopilotClient client = ctx.createClient()) { - for (SessionHooks hooks : hookCases) { - var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setHooks(hooks); + CopilotSession session = client.createSession(config).get(); + + // Create a file + Path testFile = ctx.getWorkDir().resolve("protected.txt"); + String originalContent = "Original content that should not be modified"; + Files.writeString(testFile, originalContent); + + var response = session + .sendAndWait( + new MessageOptions().setPrompt("Edit protected.txt and replace 'Original' with 'Modified'")) + .get(60, TimeUnit.SECONDS); + + // The hook should have been called + assertFalse(preToolUseInputs.isEmpty(), "Should have received preToolUse hook calls"); + + // The response should be defined + assertNotNull(response, "Response should not be null"); - var ex = assertThrows(ExecutionException.class, () -> client.createSession(config).get()); - assertTrue(ex.toString().contains(UNSUPPORTED_SDK_HOOKS_MESSAGE), - () -> "Expected unsupported hooks error, got: " + ex); - } + assertEquals(originalContent, Files.readString(testFile), "Denied preToolUse hook should block file edits"); } } } diff --git a/java/src/test/java/com/github/copilot/PreMcpToolCallHookTest.java b/java/src/test/java/com/github/copilot/PreMcpToolCallHookTest.java index 4104d1d22..392e2cc75 100644 --- a/java/src/test/java/com/github/copilot/PreMcpToolCallHookTest.java +++ b/java/src/test/java/com/github/copilot/PreMcpToolCallHookTest.java @@ -4,28 +4,42 @@ package com.github.copilot; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.AssistantMessageEvent; +import com.github.copilot.rpc.McpServerConfig; +import com.github.copilot.rpc.McpStdioServerConfig; +import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.PreMcpToolCallHookInput; import com.github.copilot.rpc.PreMcpToolCallHookOutput; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; /** - * Tests for SDK preMcpToolCall callback hook behavior with the native runtime. + * Tests for preMcpToolCall hook functionality. + * + *

+ * These tests use the shared CapiProxy infrastructure for deterministic API + * response replay. Snapshots are stored in + * test/snapshots/pre_mcp_tool_call_hook/. + *

*/ public class PreMcpToolCallHookTest { - private static final String UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported"; - + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); private static E2ETestContext ctx; @BeforeAll @@ -40,19 +54,139 @@ static void teardown() throws Exception { } } + private McpStdioServerConfig createMetaEchoServer() { + var harnessDir = ctx.getRepoRoot().resolve("test").resolve("harness"); + return new McpStdioServerConfig().setCommand("node") + .setArgs(List.of(harnessDir.resolve("test-mcp-meta-echo-server.mjs").toString())) + .setWorkingDirectory(harnessDir.toString()).setTools(List.of("*")); + } + + /** + * Verifies that preMcpToolCall hook can set metadata on the MCP request. + * + * @see Snapshot: pre_mcp_tool_call_hook/should_set_meta_via_premcptoolcall_hook + */ + @Test + void testShouldSetMetaViaPreMcpToolCallHook() throws Exception { + ctx.configureForTest("pre_mcp_tool_call_hook", "should_set_meta_via_premcptoolcall_hook"); + + var hookInputs = new java.util.ArrayList(); + var mcpServers = new HashMap(); + mcpServers.put("meta-echo", createMetaEchoServer()); + + var hooks = new SessionHooks().setOnPreMcpToolCall((input, invocation) -> { + hookInputs.add(input); + JsonNode metaNode = MAPPER.valueToTree(Map.of("injected", "by-hook", "source", "test")); + return CompletableFuture.completedFuture(PreMcpToolCallHookOutput.withMeta(metaNode)); + }); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setMcpServers(mcpServers).setHooks(hooks) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt( + "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertFalse(hookInputs.isEmpty(), "Should have received preMcpToolCall hook calls"); + + // Verify hook input fields + PreMcpToolCallHookInput hookInput = hookInputs.get(0); + assertEquals("meta-echo", hookInput.getServerName()); + assertNotNull(hookInput.getToolName()); + assertNotNull(hookInput.getCwd()); + assertTrue(hookInput.getTimestamp() > 0); + + // Verify the response contains the injected metadata + String content = response.getData().content(); + assertTrue(content.contains("injected"), "Response should contain injected metadata: " + content); + assertTrue(content.contains("by-hook"), "Response should contain injected metadata: " + content); + + session.close(); + } + } + + /** + * Verifies that preMcpToolCall hook can replace existing metadata. + * + * @see Snapshot: + * pre_mcp_tool_call_hook/should_replace_meta_via_premcptoolcall_hook + */ @Test - void testRejectsSdkPreMcpToolCallCallbackHooks() throws Exception { - ctx.initializeProxy(); + void testShouldReplaceMetaViaPreMcpToolCallHook() throws Exception { + ctx.configureForTest("pre_mcp_tool_call_hook", "should_replace_meta_via_premcptoolcall_hook"); - var hooks = new SessionHooks().setOnPreMcpToolCall( - (input, invocation) -> CompletableFuture.completedFuture(PreMcpToolCallHookOutput.removeMeta())); + var hookInputs = new java.util.ArrayList(); + var mcpServers = new HashMap(); + mcpServers.put("meta-echo", createMetaEchoServer()); + + var hooks = new SessionHooks().setOnPreMcpToolCall((input, invocation) -> { + hookInputs.add(input); + JsonNode metaNode = MAPPER.valueToTree(Map.of("completely", "replaced")); + return CompletableFuture.completedFuture(PreMcpToolCallHookOutput.withMeta(metaNode)); + }); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setMcpServers(mcpServers).setHooks(hooks) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt( + "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertFalse(hookInputs.isEmpty(), "Should have received preMcpToolCall hook calls"); + assertEquals("meta-echo", hookInputs.get(0).getServerName()); + assertEquals("echo_meta", hookInputs.get(0).getToolName()); + + // Verify the response contains the replaced metadata + String content = response.getData().content(); + assertTrue(content.contains("completely"), "Response should contain replaced metadata: " + content); + assertTrue(content.contains("replaced"), "Response should contain replaced metadata: " + content); + + session.close(); + } + } + + /** + * Verifies that preMcpToolCall hook can remove metadata from the MCP request. + * + * @see Snapshot: + * pre_mcp_tool_call_hook/should_remove_meta_via_premcptoolcall_hook + */ + @Test + void testShouldRemoveMetaViaPreMcpToolCallHook() throws Exception { + ctx.configureForTest("pre_mcp_tool_call_hook", "should_remove_meta_via_premcptoolcall_hook"); + + var hookInputs = new java.util.ArrayList(); + var mcpServers = new HashMap(); + mcpServers.put("meta-echo", createMetaEchoServer()); + + var hooks = new SessionHooks().setOnPreMcpToolCall((input, invocation) -> { + hookInputs.add(input); + return CompletableFuture.completedFuture(PreMcpToolCallHookOutput.removeMeta()); + }); try (CopilotClient client = ctx.createClient()) { - var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setHooks(hooks); + CopilotSession session = client.createSession(new SessionConfig().setMcpServers(mcpServers).setHooks(hooks) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt( + "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertFalse(hookInputs.isEmpty(), "Should have received preMcpToolCall hook calls"); + assertEquals("meta-echo", hookInputs.get(0).getServerName()); + assertEquals("echo_meta", hookInputs.get(0).getToolName()); + + String content = response.getData().content(); + assertTrue(content.contains("\"meta\":null") || content.contains("\"meta\": null"), + "Response should contain removed metadata: " + content); + assertTrue(content.contains("test-remove"), "Response should contain tool value: " + content); - var ex = assertThrows(ExecutionException.class, () -> client.createSession(config).get()); - assertTrue(ex.toString().contains(UNSUPPORTED_SDK_HOOKS_MESSAGE), - () -> "Expected unsupported hooks error, got: " + ex); + session.close(); } } } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index d902fbc1d..8ad03e8e7 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -699,9 +699,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66.tgz", - "integrity": "sha512-m3+3FLSgum90xN4+eiwnLvdrDvM+oZzur5DfhOH88duNDKBcLQvKQY9fG/I1l1t8a1iBwjpgtRpsBwykE8k3Zw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.67.tgz", + "integrity": "sha512-5YEY9LNXBT9Q8uShjCdYcornJJJhGtdIzSYla2+pjfXYpHsDVibqYubzYjfgffOUKFChyzOpH7n/868+t56iIg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -710,20 +710,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.66", - "@github/copilot-darwin-x64": "1.0.66", - "@github/copilot-linux-arm64": "1.0.66", - "@github/copilot-linux-x64": "1.0.66", - "@github/copilot-linuxmusl-arm64": "1.0.66", - "@github/copilot-linuxmusl-x64": "1.0.66", - "@github/copilot-win32-arm64": "1.0.66", - "@github/copilot-win32-x64": "1.0.66" + "@github/copilot-darwin-arm64": "1.0.67", + "@github/copilot-darwin-x64": "1.0.67", + "@github/copilot-linux-arm64": "1.0.67", + "@github/copilot-linux-x64": "1.0.67", + "@github/copilot-linuxmusl-arm64": "1.0.67", + "@github/copilot-linuxmusl-x64": "1.0.67", + "@github/copilot-win32-arm64": "1.0.67", + "@github/copilot-win32-x64": "1.0.67" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66.tgz", - "integrity": "sha512-cJPXE2rWSjR+B8GRBUUd0k9PM4euWRUe3xgHoJqi9o/jJjtRYn6DZMrmFt9xgjoYWf0WZOyrlDgedqO1V+zDAg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.67.tgz", + "integrity": "sha512-CO3mpgFXcN6e7ZsSmjMkt1AKxMfb1+mjdn3yrf2DRnnWIURSK9kGvw+E+E1+YE37D1MBiUn/VOBmhRad5+vl0A==", "cpu": [ "arm64" ], @@ -737,9 +737,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66.tgz", - "integrity": "sha512-44mpx2ZcRFHDx4B9xlrL5OQyTgaD/Hn+bAkeStXgcG8UkkfYSsRtLhnaxqUEQrtIEiVQrw++XWvUO0AscRrX+w==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.67.tgz", + "integrity": "sha512-M20Hpn3bOJRkVwAIVRK4ZlX66AqtmGfXZRxZBRFQC045QIwcfmVUP45sTSgXDb4uHWeK0cZgdTdniHwKGtMplw==", "cpu": [ "x64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66.tgz", - "integrity": "sha512-uXtTs/rYjk6kacNs/T0s/lxn0JBvAgu78pBoZeWpU5APhICkPy9kC+lNAzLYoZujVVDOHT05IoeifHppFpQ8+w==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.67.tgz", + "integrity": "sha512-b4ePtFBow+Ior+aVLKA1hHxhR5wF+ql5CD7TSg/NHGYgc1kwD+3a9uKSENy05J5Lit/G/DZ9C6JwowvvdMWSKg==", "cpu": [ "arm64" ], @@ -769,9 +769,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66.tgz", - "integrity": "sha512-tXn3OuJCx/YEDNgYg8mdOGSFiIjmLJtTEyZ/VoEA86ffUIPxrunc0wnapEFk2zOW1unwdJeBuVIkzlB3RS1/eA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.67.tgz", + "integrity": "sha512-4ynZyfKnWAdvEPAFDDBIz1wpFttcOTJu4Y8Mlz5oXCBA0NM/rwr8K4l7Adp8UzwbfmdrMJ9y+zivqRBMDbPInA==", "cpu": [ "x64" ], @@ -785,9 +785,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66.tgz", - "integrity": "sha512-sHRag7W5CG0kbbX3j9v9cUmIafk/0N8MGGr2knvPeIHtxwZQYYjx397gT1nN6xagLWt5mvchkYybfQFCyCBaxg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.67.tgz", + "integrity": "sha512-IjezxBU8fYUr/b5hEiniXqzwoOrJ4egrQSBbG96M+roLTqd9txP0MgxZtcRtKV7phRIdIGE109wwrn4H6hSqmA==", "cpu": [ "arm64" ], @@ -801,9 +801,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66.tgz", - "integrity": "sha512-bdIgHOaVZlvsF/4awzMxsby6T+4k7aWe9HZr+sr+qU8tuG19jwi/1LXGB6tKdlFeFgY78yX0lR+ywByVJc5loA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.67.tgz", + "integrity": "sha512-Zy/rbja1lnhzDoNfn051H0EybCseCvjvH7WmbcHCayjXUjzXeKF6OmAt4hvqFZH87ttT3KbKtQ8/6oDUhhM2YQ==", "cpu": [ "x64" ], @@ -817,9 +817,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66.tgz", - "integrity": "sha512-T7FGONCVWIPjjAxp22cu4WKqNogq56FknHGAvj7Ryn5ZoanFAR3vXXlXDsYnDKLBcshjRYGxocl2UnmRTMxgvg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.67.tgz", + "integrity": "sha512-O3VFRS5v9NXRP8o+N1SvcFbBqECDzZP7XQBeBj2Vcrma80gdJc5GQub/w2mwmr1w5UbwgzJkRasm0Ec/jxbcoA==", "cpu": [ "arm64" ], @@ -833,9 +833,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66.tgz", - "integrity": "sha512-eroxRUSJZOJCk0luLyX6A1qqGIWs8p4w0EjZFhCzvdFvJ0abIovGyt3R/gN9DeyJM8Qs7ROPGvqevUlXh6DhCg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.67.tgz", + "integrity": "sha512-td5tQ/nve5dB7RPvNglBZwa/6DJqiOBgacXXa1GpYcohqpCzoI8gONNkeaeyr6oF4iu5wXJ9krUNr6QXL4yB5Q==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 928acf24e..06c34b77f 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 527e3bb8e..9d24dafe9 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index f3188d41d..79991dae9 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -2046,6 +2046,7 @@ export interface CopilotUserResponse { quota_snapshots?: CopilotUserResponseQuotaSnapshots; restricted_telemetry?: boolean; is_staff?: boolean; + te?: boolean; token_based_billing?: boolean; can_upgrade_plan?: boolean; quota_reset_date_utc?: string; @@ -4271,6 +4272,125 @@ export interface FolderTrustCheckResult { */ trusted: boolean; } +/** + * Client environment metadata describing the process that produced a telemetry event. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryClientInfo". + */ +/** @experimental */ +export interface GitHubTelemetryClientInfo { + /** + * Copilot CLI version string. + */ + cli_version: string; + /** + * Operating system platform (e.g. darwin, linux, win32). + */ + os_platform: string; + /** + * Operating system version string. + */ + os_version: string; + /** + * Operating system architecture (e.g. arm64, x64). + */ + os_arch: string; + /** + * Node.js runtime version string. + */ + node_version: string; + /** + * Copilot subscription plan, when known. + */ + copilot_plan?: string; + /** + * Type of client. + */ + client_type?: string; + /** + * Name of the client application. + */ + client_name?: string; + /** + * Whether the user is a GitHub/Microsoft staff member. + */ + is_staff?: boolean; + /** + * Stable machine identifier for the device. + */ + dev_device_id?: string; +} +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryEvent". + */ +/** @experimental */ +export interface GitHubTelemetryEvent { + /** + * Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + */ + kind: string; + /** + * Timestamp when the event was created (ISO 8601 format). + */ + created_at?: string; + /** + * Reference to the model call that produced this event. + */ + model_call_id?: string; + /** + * String-valued properties as a map from key to value. + */ + properties: { + [k: string]: string | undefined; + }; + /** + * Numeric metrics as a map from key to value. + */ + metrics: { + [k: string]: number | undefined; + }; + /** + * Experiment assignment context. + */ + exp_assignment_context?: string; + /** + * Feature flags enabled for this session, as a map from flag to value. + */ + features?: { + [k: string]: string | undefined; + }; + /** + * Session identifier the event belongs to. + */ + session_id?: string; + /** + * Copilot tracking ID for user-level attribution. + */ + copilot_tracking_id?: string; + client?: GitHubTelemetryClientInfo; +} +/** + * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryNotification". + */ +/** @experimental */ +export interface GitHubTelemetryNotification { + /** + * Session the telemetry event belongs to. + */ + sessionId: string; + /** + * Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + */ + restricted: boolean; + event: GitHubTelemetryEvent; +} /** * Pending external tool call request ID, with the tool result or an error describing why it failed. * @@ -16993,9 +17113,21 @@ export interface LlmInferenceHandler { httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest): Promise; } +/** Handler for `gitHubTelemetry` client global API methods. */ +/** @experimental */ +export interface GitHubTelemetryHandler { + /** + * Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session. + * + * @param params Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. + */ + event(params: GitHubTelemetryNotification): Promise; +} + /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { llmInference?: LlmInferenceHandler; + gitHubTelemetry?: GitHubTelemetryHandler; } /** @@ -17019,4 +17151,9 @@ export function registerClientGlobalApiHandlers( if (!handler) throw new Error("No llmInference client-global handler registered"); return handler.httpRequestChunk(params); }); + connection.onRequest("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { + const handler = handlers.gitHubTelemetry; + if (!handler) throw new Error("No gitHubTelemetry client-global handler registered"); + return handler.event(params); + }); } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index d02bafbbf..07cd079df 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -10,7 +10,7 @@ import { type ModelInfo, } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; -import { defaultJoinSessionPermissionHandler, type SessionHooks } from "../src/types.js"; +import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead @@ -2649,18 +2649,19 @@ describe("CopilotClient", () => { // corresponding SessionHooks handler. These tests guard against // regressions like the one fixed for postToolUseFailure (issue #1220). - function createHookTestSession(hooks: SessionHooks): CopilotSession { - const session = new CopilotSession("session-hooks-test", {} as any); - session.registerHooks(hooks); - return session; - } - it("dispatches postToolUseFailure to onPostToolUseFailure handler", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + const received: { input: any; invocation: any }[] = []; - const session = createHookTestSession({ - onPostToolUseFailure: async (input, invocation) => { - received.push({ input, invocation }); - return { additionalContext: "failure observed" }; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPostToolUseFailure: async (input, invocation) => { + received.push({ input, invocation }); + return { additionalContext: "failure observed" }; + }, }, }); @@ -2690,12 +2691,19 @@ describe("CopilotClient", () => { }); it("does not fall back to onPostToolUse for postToolUseFailure events", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + const postUseCalls: string[] = []; - const session = createHookTestSession({ - // Only onPostToolUse registered; postToolUseFailure events - // must not be routed here. - onPostToolUse: async (input) => { - postUseCalls.push(input.toolName); + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + // Only onPostToolUse registered; postToolUseFailure events + // must not be routed here. + onPostToolUse: async (input) => { + postUseCalls.push(input.toolName); + }, }, }); @@ -2712,14 +2720,21 @@ describe("CopilotClient", () => { }); it("dispatches postToolUse and postToolUseFailure to their respective handlers", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + const postCalls: string[] = []; const failureCalls: string[] = []; - const session = createHookTestSession({ - onPostToolUse: async (input) => { - postCalls.push(input.toolName); - }, - onPostToolUseFailure: async (input) => { - failureCalls.push(input.toolName); + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPostToolUse: async (input) => { + postCalls.push(input.toolName); + }, + onPostToolUseFailure: async (input) => { + failureCalls.push(input.toolName); + }, }, }); @@ -2746,23 +2761,29 @@ describe("CopilotClient", () => { }); it("routes hooks.invoke JSON-RPC requests to the SessionHooks handler", async () => { - // Validates the full JSON-RPC entry point used by legacy runtimes: + // Validates the full JSON-RPC entry point used by the CLI: // CopilotClient.handleHooksInvoke({sessionId, hookType, input}) // → CopilotSession._handleHooksInvoke(hookType, input) // → SessionHooks.onPostToolUseFailure(normalizedInput, {sessionId}) // - // This guards the wire-format contract: the hookType string "postToolUseFailure" and the + // This guards the wire-format contract that the bundled Copilot + // CLI relies on: the hookType string "postToolUseFailure" and the // input shape `{toolName, toolArgs, error, timestamp, cwd}`. // The SDK maps that to public `{..., timestamp: Date, workingDirectory}`. - const received: { input: any; invocation: any }[] = []; const client = new CopilotClient(); - const session = createHookTestSession({ - onPostToolUseFailure: async (input, invocation) => { - received.push({ input, invocation }); - return { additionalContext: "context from failure hook" }; + await client.start(); + onTestFinished(() => client.forceStop()); + + const received: { input: any; invocation: any }[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPostToolUseFailure: async (input, invocation) => { + received.push({ input, invocation }); + return { additionalContext: "context from failure hook" }; + }, }, }); - (client as any).sessions.set(session.sessionId, session); const failureInput = { toolName: "shell", diff --git a/nodejs/test/e2e/hooks.e2e.test.ts b/nodejs/test/e2e/hooks.e2e.test.ts index ae9e265e3..4fce7d2ac 100644 --- a/nodejs/test/e2e/hooks.e2e.test.ts +++ b/nodejs/test/e2e/hooks.e2e.test.ts @@ -2,39 +2,163 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import { readFile, writeFile } from "fs/promises"; +import { join } from "path"; import { describe, expect, it } from "vitest"; +import type { + PreToolUseHookInput, + PreToolUseHookOutput, + PostToolUseHookInput, + PostToolUseHookOutput, +} from "../../src/index.js"; import { approveAll } from "../../src/index.js"; -import type { SessionHooks } from "../../src/types.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -const UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported"; - describe("Session hooks", async () => { - const { copilotClient: client } = await createSdkTestContext(); - - async function expectUnsupportedHooks(hooks: SessionHooks) { - await expect( - client.createSession({ - onPermissionRequest: approveAll, - hooks, - }) - ).rejects.toThrow(UNSUPPORTED_SDK_HOOKS_MESSAGE); - } - - const hookCases: Array<[string, SessionHooks]> = [ - ["preToolUse", { onPreToolUse: () => ({ permissionDecision: "allow" }) }], - ["postToolUse", { onPostToolUse: () => undefined }], - ["preToolUse denial", { onPreToolUse: () => ({ permissionDecision: "deny" }) }], - [ - "combined preToolUse and postToolUse", - { - onPreToolUse: () => ({ permissionDecision: "allow" }), - onPostToolUse: () => undefined, + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should invoke preToolUse hook when model runs a tool", async () => { + const preToolUseInputs: PreToolUseHookInput[] = []; + const invocationSessionIds: string[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPreToolUse: async (input, invocation) => { + preToolUseInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + // Allow the tool to run + return { permissionDecision: "allow" } as PreToolUseHookOutput; + }, + }, + }); + + // Create a file for the model to read + await writeFile(join(workDir, "hello.txt"), "Hello from the test!"); + + await session.sendAndWait({ + prompt: "Read the contents of hello.txt and tell me what it says", + }); + + // Should have received at least one preToolUse hook call + expect(preToolUseInputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + + // Should have received the tool name + expect(preToolUseInputs.some((input) => input.toolName)).toBe(true); + + await session.disconnect(); + }); + + it("should invoke postToolUse hook after model runs a tool", async () => { + const postToolUseInputs: PostToolUseHookInput[] = []; + const invocationSessionIds: string[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPostToolUse: async (input, invocation) => { + postToolUseInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + return null as PostToolUseHookOutput; + }, }, - ], - ]; + }); + + // Create a file for the model to read + await writeFile(join(workDir, "world.txt"), "World from the test!"); + + await session.sendAndWait({ + prompt: "Read the contents of world.txt and tell me what it says", + }); + + // Should have received at least one postToolUse hook call + expect(postToolUseInputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + + // Should have received the tool name and result + expect(postToolUseInputs.some((input) => input.toolName)).toBe(true); + expect(postToolUseInputs.some((input) => input.toolResult !== undefined)).toBe(true); + + await session.disconnect(); + }); + + it("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { + const preToolUseInputs: PreToolUseHookInput[] = []; + const postToolUseInputs: PostToolUseHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPreToolUse: async (input) => { + preToolUseInputs.push(input); + return { permissionDecision: "allow" } as PreToolUseHookOutput; + }, + onPostToolUse: async (input) => { + postToolUseInputs.push(input); + return null as PostToolUseHookOutput; + }, + }, + }); + + await writeFile(join(workDir, "both.txt"), "Testing both hooks!"); + + await session.sendAndWait({ + prompt: "Read the contents of both.txt", + }); + + // Both hooks should have been called + expect(preToolUseInputs.length).toBeGreaterThan(0); + expect(postToolUseInputs.length).toBeGreaterThan(0); + + // The same tool should appear in both + const preToolNames = preToolUseInputs.map((i) => i.toolName); + const postToolNames = postToolUseInputs.map((i) => i.toolName); + const commonTool = preToolNames.find((name) => postToolNames.includes(name)); + expect(commonTool).toBeDefined(); + + await session.disconnect(); + }); + + it("should deny tool execution when preToolUse returns deny", async () => { + const preToolUseInputs: PreToolUseHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPreToolUse: async (input) => { + preToolUseInputs.push(input); + // Deny all tool calls + return { permissionDecision: "deny" } as PreToolUseHookOutput; + }, + }, + }); + + // Create a file + const originalContent = "Original content that should not be modified"; + await writeFile(join(workDir, "protected.txt"), originalContent); + + const response = await session.sendAndWait({ + prompt: "Edit protected.txt and replace 'Original' with 'Modified'", + }); + + // The hook should have been called + expect(preToolUseInputs.length).toBeGreaterThan(0); + + // The response should indicate the tool was denied (behavior may vary) + // At minimum, we verify the hook was invoked + expect(response).toBeDefined(); + + // Strengthen: verify the actual deny behavior — the protected file was NOT + // modified by the runtime even though the LLM tried to edit it. The + // pre-tool-use hook denial blocks tool execution before it can mutate state. + const actualContent = await readFile(join(workDir, "protected.txt"), "utf-8"); + expect(actualContent).toBe(originalContent); - it.each(hookCases)("rejects SDK callback hook %s", async (_name, hooks) => { - await expectUnsupportedHooks(hooks); + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 61e6b0ed2..82e1812f1 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -3,41 +3,370 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from "vitest"; -import { approveAll } from "../../src/index.js"; -import type { SessionHooks } from "../../src/types.js"; +import { z } from "zod"; +import { approveAll, defineTool } from "../../src/index.js"; +import type { + ErrorOccurredHookInput, + PostToolUseFailureHookInput, + PostToolUseHookInput, + PreToolUseHookInput, + SessionEndHookInput, + SessionStartHookInput, + UserPromptSubmittedHookInput, +} from "../../src/types.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -const UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported"; - describe("Extended session hooks", async () => { const { copilotClient: client } = await createSdkTestContext(); - async function expectUnsupportedHooks(hooks: SessionHooks) { - await expect( - client.createSession({ - onPermissionRequest: approveAll, - hooks, - }) - ).rejects.toThrow(UNSUPPORTED_SDK_HOOKS_MESSAGE); - } - - const hookCases: Array<[string, SessionHooks]> = [ - ["userPromptSubmitted", { onUserPromptSubmitted: () => undefined }], - ["sessionStart", { onSessionStart: () => undefined }], - ["sessionEnd", { onSessionEnd: () => undefined }], - ["errorOccurred", { onErrorOccurred: () => undefined }], - ["preToolUse output", { onPreToolUse: () => ({ permissionDecision: "allow" }) }], - ["postToolUse output", { onPostToolUse: () => ({ suppressOutput: false }) }], - [ - "postToolUseFailure output", - { - onPostToolUse: () => undefined, - onPostToolUseFailure: () => ({ additionalContext: "not used" }), - }, - ], - ]; - - it.each(hookCases)("rejects SDK callback hook %s", async (_name, hooks) => { - await expectUnsupportedHooks(hooks); + it("should invoke onSessionStart hook on new session", async () => { + const sessionStartInputs: SessionStartHookInput[] = []; + const invocationSessionIds: string[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionStart: async (input, invocation) => { + sessionStartInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hi", + }); + + expect(sessionStartInputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + expect(sessionStartInputs[0].source).toBe("new"); + expect(sessionStartInputs[0].timestamp).toBeInstanceOf(Date); + expect(sessionStartInputs[0].workingDirectory).toBeDefined(); + + await session.disconnect(); + }); + + it("should invoke onUserPromptSubmitted hook when sending a message", async () => { + const userPromptInputs: UserPromptSubmittedHookInput[] = []; + const invocationSessionIds: string[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptSubmitted: async (input, invocation) => { + userPromptInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hello", + }); + + expect(userPromptInputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + expect(userPromptInputs[0].prompt).toContain("Say hello"); + expect(userPromptInputs[0].timestamp).toBeInstanceOf(Date); + expect(userPromptInputs[0].workingDirectory).toBeDefined(); + + await session.disconnect(); + }); + + it("should invoke onSessionEnd hook when session is disconnected", async () => { + const sessionEndInputs: SessionEndHookInput[] = []; + const invocationSessionIds: string[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionEnd: async (input, invocation) => { + sessionEndInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hi", + }); + + await session.disconnect(); + + // Wait briefly for async hook + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(sessionEndInputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + }); + + it("should invoke onErrorOccurred hook when error occurs", async () => { + const errorInputs: ErrorOccurredHookInput[] = []; + const invocationSessionIds: string[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onErrorOccurred: async (input, invocation) => { + errorInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + expect(input.timestamp).toBeInstanceOf(Date); + expect(input.workingDirectory).toBeDefined(); + expect(input.error).toBeDefined(); + expect(["model_call", "tool_execution", "system", "user_input"]).toContain( + input.errorContext + ); + expect(typeof input.recoverable).toBe("boolean"); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hi", + }); + + // onErrorOccurred is dispatched by the runtime for actual errors (model failures, system errors). + // In a normal session it may not fire. Verify the hook is properly wired by checking + // that the session works correctly with the hook registered. + expect(session.sessionId).toBeDefined(); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + + await session.disconnect(); + }); + + it("should invoke userPromptSubmitted hook and modify prompt", async () => { + const inputs: UserPromptSubmittedHookInput[] = []; + const invocationSessionIds: string[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptSubmitted: async (input, invocation) => { + inputs.push(input); + invocationSessionIds.push(invocation.sessionId); + return { modifiedPrompt: "Reply with exactly: HOOKED_PROMPT" }; + }, + }, + }); + + const response = await session.sendAndWait({ prompt: "Say something else" }); + + expect(inputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + expect(inputs[0].prompt).toContain("Say something else"); + expect(response?.data.content ?? "").toContain("HOOKED_PROMPT"); + + await session.disconnect(); + }); + + it("should invoke sessionStart hook", async () => { + const inputs: SessionStartHookInput[] = []; + const invocationSessionIds: string[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionStart: async (input, invocation) => { + inputs.push(input); + invocationSessionIds.push(invocation.sessionId); + return { additionalContext: "Session start hook context." }; + }, + }, + }); + + await session.sendAndWait({ prompt: "Say hi" }); + + expect(inputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + expect(inputs[0].source).toBe("new"); + expect(inputs[0].workingDirectory).toBeTruthy(); + + await session.disconnect(); + }); + + it("should invoke sessionEnd hook", async () => { + const inputs: SessionEndHookInput[] = []; + const invocationSessionIds: string[] = []; + let resolveHook!: (value: SessionEndHookInput) => void; + const hookInvoked = new Promise((resolve) => { + resolveHook = resolve; + }); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionEnd: async (input, invocation) => { + inputs.push(input); + invocationSessionIds.push(invocation.sessionId); + resolveHook(input); + return { sessionSummary: "session ended" }; + }, + }, + }); + + await session.sendAndWait({ prompt: "Say bye" }); + await session.disconnect(); + + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + hookInvoked, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Timeout: onSessionEnd")), 10_000); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + + expect(inputs.length).toBeGreaterThan(0); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + }); + + it("should register erroroccurred hook", async () => { + const inputs: ErrorOccurredHookInput[] = []; + const invocationSessionIds: string[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onErrorOccurred: async (input, invocation) => { + inputs.push(input); + invocationSessionIds.push(invocation.sessionId); + return { errorHandling: "skip" }; + }, + }, + }); + + await session.sendAndWait({ prompt: "Say hi" }); + + // OnErrorOccurred is dispatched only by genuine runtime errors. A normal turn + // cannot deterministically trigger one; this test is registration-only. + expect(inputs.length).toBe(0); + expect(invocationSessionIds).toHaveLength(0); + expect(session.sessionId).toBeTruthy(); + + await session.disconnect(); + }); + + it("should allow preToolUse to return modifiedArgs and suppressOutput", async () => { + const inputs: PreToolUseHookInput[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("echo_value", { + description: "Echoes the supplied value", + parameters: z.object({ value: z.string() }), + handler: ({ value }) => value, + }), + ], + hooks: { + onPreToolUse: async (input) => { + inputs.push(input); + if (input.toolName !== "echo_value") { + return { permissionDecision: "allow" }; + } + return { + permissionDecision: "allow", + modifiedArgs: { value: "modified by hook" }, + suppressOutput: false, + }; + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "Call echo_value with value 'original', then reply with the result.", + }); + + expect(inputs.length).toBeGreaterThan(0); + expect(inputs.some((input) => input.toolName === "echo_value")).toBe(true); + expect(response?.data.content ?? "").toContain("modified by hook"); + + await session.disconnect(); + }); + + it("should allow postToolUse to return modifiedResult", async () => { + const inputs: PostToolUseHookInput[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPostToolUse: async (input) => { + inputs.push(input); + if (input.toolName !== "view") { + return undefined; + } + return { + modifiedResult: { + textResultForLlm: "modified by post hook", + resultType: "success", + toolTelemetry: {}, + }, + suppressOutput: false, + }; + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "Call the view tool to read the current directory, then reply done.", + }); + + expect(inputs.some((input) => input.toolName === "view")).toBe(true); + expect(response?.data.content?.toLowerCase()).toContain("done"); + + await session.disconnect(); + }); + + it.skip("should invoke postToolUseFailure hook for failed tool result", async () => { + // TODO: This test fails with 1.0.64-0 runtime due to built-in tools not being + // available when hooks are configured. Runtime returns "Tool 'view' does not exist. + // Available tools: report_intent" even though view is a built-in and availableTools + // wasn't specified. Follow up with runtime team. + const failureInputs: PostToolUseFailureHookInput[] = []; + const postToolUseInputs: PostToolUseHookInput[] = []; + const invocationSessionIds: string[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPostToolUse: async (input) => { + postToolUseInputs.push(input); + }, + onPostToolUseFailure: async (input, invocation) => { + failureInputs.push(input); + invocationSessionIds.push(invocation.sessionId); + return { additionalContext: "HOOK_FAILURE_GUIDANCE_APPLIED" }; + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "Call the view tool with path 'missing.txt'. If it fails, use the hook guidance to answer.", + }); + + expect(postToolUseInputs).toHaveLength(0); + expect(failureInputs).toHaveLength(1); + expect(invocationSessionIds.every((sessionId) => sessionId === session.sessionId)).toBe( + true + ); + expect(failureInputs[0].toolName).toBe("view"); + expect(failureInputs[0].error).toContain("does not exist"); + expect((failureInputs[0].toolArgs as { path?: string }).path).toContain("missing.txt"); + expect(failureInputs[0].timestamp).toBeInstanceOf(Date); + expect(failureInputs[0].workingDirectory).toBeTruthy(); + expect(response?.data.content ?? "").toContain("HOOK_FAILURE_GUIDANCE_APPLIED"); + + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts index 2ca34b716..571113239 100644 --- a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts +++ b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts @@ -2,26 +2,131 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; import { approveAll } from "../../src/index.js"; -import type { SessionHooks } from "../../src/types.js"; +import type { MCPStdioServerConfig, PreMcpToolCallHookInput } from "../../src/types.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -const UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_MCP_META_ECHO_SERVER = resolve( + __dirname, + "../../../test/harness/test-mcp-meta-echo-server.mjs" +); +const TEST_HARNESS_DIR = dirname(TEST_MCP_META_ECHO_SERVER); describe("pre_mcp_tool_call_hook", async () => { const { copilotClient: client } = await createSdkTestContext(); - it("rejects SDK preMcpToolCall callback hooks", async () => { - const hooks: SessionHooks = { - onPreMcpToolCall: () => ({ metaToUse: { injected: "by-hook" } }), - }; - - await expect( - client.createSession({ - onPermissionRequest: approveAll, - hooks, - }) - ).rejects.toThrow(UNSUPPORTED_SDK_HOOKS_MESSAGE); + it("should set meta via preMcpToolCall hook", async () => { + const hookInputs: PreMcpToolCallHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + "meta-echo": { + command: "node", + args: [TEST_MCP_META_ECHO_SERVER], + workingDirectory: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + }, + hooks: { + onPreMcpToolCall: async (input, _invocation) => { + hookInputs.push(input); + return { metaToUse: { injected: "by-hook", source: "test" } }; + }, + }, + }); + + const message = await session.sendAndWait({ + prompt: "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.", + }); + + expect(message).not.toBeNull(); + expect(message!.data.content).toContain("injected"); + expect(message!.data.content).toContain("by-hook"); + + expect(hookInputs.length).toBeGreaterThan(0); + expect(hookInputs[0].serverName).toBe("meta-echo"); + expect(hookInputs[0].toolName).toBe("echo_meta"); + expect(hookInputs[0].workingDirectory).toBeDefined(); + expect(hookInputs[0].timestamp).toBeInstanceOf(Date); + + await session.disconnect(); + }); + + it("should replace meta via preMcpToolCall hook", async () => { + const hookInputs: PreMcpToolCallHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + "meta-echo": { + command: "node", + args: [TEST_MCP_META_ECHO_SERVER], + workingDirectory: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + }, + hooks: { + onPreMcpToolCall: async (input, _invocation) => { + hookInputs.push(input); + return { metaToUse: { completely: "replaced" } }; + }, + }, + }); + + const message = await session.sendAndWait({ + prompt: "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.", + }); + + expect(message).not.toBeNull(); + expect(message!.data.content).toContain("completely"); + expect(message!.data.content).toContain("replaced"); + + expect(hookInputs.length).toBeGreaterThan(0); + expect(hookInputs[0].serverName).toBe("meta-echo"); + expect(hookInputs[0].toolName).toBe("echo_meta"); + + await session.disconnect(); + }); + + it("should remove meta via preMcpToolCall hook", async () => { + const hookInputs: PreMcpToolCallHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + "meta-echo": { + command: "node", + args: [TEST_MCP_META_ECHO_SERVER], + workingDirectory: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + }, + hooks: { + onPreMcpToolCall: async (input, _invocation) => { + hookInputs.push(input); + return { metaToUse: null }; + }, + }, + }); + + const message = await session.sendAndWait({ + prompt: "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.", + }); + + expect(message).not.toBeNull(); + expect(message!.data.content).toContain('"meta":null'); + expect(message!.data.content).toContain("test-remove"); + + expect(hookInputs.length).toBeGreaterThan(0); + expect(hookInputs[0].serverName).toBe("meta-echo"); + expect(hookInputs[0].toolName).toBe("echo_meta"); + + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts index bfb7a4339..ac0a694dc 100644 --- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -2,27 +2,80 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import { writeFile } from "fs/promises"; +import { join } from "path"; import { describe, expect, it } from "vitest"; +import type { + PreToolUseHookInput, + PreToolUseHookOutput, + PostToolUseHookInput, + PostToolUseHookOutput, +} from "../../src/index.js"; import { approveAll } from "../../src/index.js"; -import type { SessionHooks } from "../../src/types.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; - -const UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported"; +import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; describe("Subagent hooks", async () => { - const { copilotClient: client } = await createSdkTestContext(); - - it("rejects SDK callback hooks for sub-agent hook propagation", async () => { - const hooks: SessionHooks = { - onPreToolUse: () => ({ permissionDecision: "allow" }), - onPostToolUse: () => undefined, - }; - - await expect( - client.createSession({ - onPermissionRequest: approveAll, - hooks, - }) - ).rejects.toThrow(UNSUPPORTED_SDK_HOOKS_MESSAGE); + // For snapshot recording (non-CI), use RECORD_GH_TOKEN if available + const recordToken = !isCI ? process.env.RECORD_GH_TOKEN : undefined; + const { copilotClient: client, workDir } = await createSdkTestContext({ + copilotClientOptions: { + ...(recordToken ? { gitHubToken: recordToken } : {}), + env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, + }, }); + + it("should invoke preToolUse and postToolUse hooks for sub-agent tool calls", async () => { + const hookLog: { kind: "pre" | "post"; toolName: string; sessionId: string }[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onPreToolUse: async (input: PreToolUseHookInput) => { + hookLog.push({ + kind: "pre", + toolName: input.toolName, + sessionId: input.sessionId, + }); + return { permissionDecision: "allow" } as PreToolUseHookOutput; + }, + onPostToolUse: async (input: PostToolUseHookInput) => { + hookLog.push({ + kind: "post", + toolName: input.toolName, + sessionId: input.sessionId, + }); + return null as PostToolUseHookOutput; + }, + }, + }); + + // Create a file for the sub-agent to read + await writeFile(join(workDir, "subagent-test.txt"), "Hello from subagent test!"); + + await session.sendAndWait({ + prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", + }); + + // Parent tool hooks fire for "task" + const taskPre = hookLog.find((h) => h.kind === "pre" && h.toolName === "task"); + expect(taskPre, "preToolUse should fire for the parent's 'task' tool call").toBeDefined(); + + // Sub-agent tool hooks fire for "view" + const viewPre = hookLog.filter((h) => h.kind === "pre" && h.toolName === "view"); + const viewPost = hookLog.filter((h) => h.kind === "post" && h.toolName === "view"); + expect( + viewPre.length, + "preToolUse should fire for the sub-agent's 'view' tool call" + ).toBeGreaterThan(0); + expect( + viewPost.length, + "postToolUse should fire for the sub-agent's 'view' tool call" + ).toBeGreaterThan(0); + + // input.sessionId distinguishes parent from sub-agent: parent tools and + // sub-agent tools carry different sessionIds + expect(viewPre[0].sessionId).not.toBe(taskPre!.sessionId); + + await session.disconnect(); + }, 120_000); }); diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index ba9c4c310..89b724ce9 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1902,6 +1902,77 @@ def to_dict(self) -> dict: class GhCLIAuthInfoType(Enum): GH_CLI = "gh-cli" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryClientInfo: + """Client environment metadata describing the process that produced a telemetry event. + + Client environment metadata. + """ + cli_version: str + """Copilot CLI version string.""" + + node_version: str + """Node.js runtime version string.""" + + os_arch: str + """Operating system architecture (e.g. arm64, x64).""" + + os_platform: str + """Operating system platform (e.g. darwin, linux, win32).""" + + os_version: str + """Operating system version string.""" + + client_name: str | None = None + """Name of the client application.""" + + client_type: str | None = None + """Type of client.""" + + copilot_plan: str | None = None + """Copilot subscription plan, when known.""" + + dev_device_id: str | None = None + """Stable machine identifier for the device.""" + + is_staff: bool | None = None + """Whether the user is a GitHub/Microsoft staff member.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryClientInfo': + assert isinstance(obj, dict) + cli_version = from_str(obj.get("cli_version")) + node_version = from_str(obj.get("node_version")) + os_arch = from_str(obj.get("os_arch")) + os_platform = from_str(obj.get("os_platform")) + os_version = from_str(obj.get("os_version")) + client_name = from_union([from_str, from_none], obj.get("client_name")) + client_type = from_union([from_str, from_none], obj.get("client_type")) + copilot_plan = from_union([from_str, from_none], obj.get("copilot_plan")) + dev_device_id = from_union([from_str, from_none], obj.get("dev_device_id")) + is_staff = from_union([from_bool, from_none], obj.get("is_staff")) + return GitHubTelemetryClientInfo(cli_version, node_version, os_arch, os_platform, os_version, client_name, client_type, copilot_plan, dev_device_id, is_staff) + + def to_dict(self) -> dict: + result: dict = {} + result["cli_version"] = from_str(self.cli_version) + result["node_version"] = from_str(self.node_version) + result["os_arch"] = from_str(self.os_arch) + result["os_platform"] = from_str(self.os_platform) + result["os_version"] = from_str(self.os_version) + if self.client_name is not None: + result["client_name"] = from_union([from_str, from_none], self.client_name) + if self.client_type is not None: + result["client_type"] = from_union([from_str, from_none], self.client_type) + if self.copilot_plan is not None: + result["copilot_plan"] = from_union([from_str, from_none], self.copilot_plan) + if self.dev_device_id is not None: + result["dev_device_id"] = from_union([from_str, from_none], self.dev_device_id) + if self.is_staff is not None: + result["is_staff"] = from_union([from_bool, from_none], self.is_staff) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class HandlePendingToolCallResult: @@ -16857,6 +16928,7 @@ class CopilotUserResponse: """Schema for the `CopilotUserResponseQuotaSnapshots` type.""" restricted_telemetry: bool | None = None + te: bool | None = None token_based_billing: bool | None = None @staticmethod @@ -16886,8 +16958,9 @@ def from_dict(obj: Any) -> 'CopilotUserResponse': quota_reset_date_utc = from_union([from_str, from_none], obj.get("quota_reset_date_utc")) quota_snapshots = from_union([lambda x: from_dict(lambda x: from_union([CopilotUserResponseQuotaSnapshots.from_dict, from_none], x), x), from_none], obj.get("quota_snapshots")) restricted_telemetry = from_union([from_bool, from_none], obj.get("restricted_telemetry")) + te = from_union([from_bool, from_none], obj.get("te")) token_based_billing = from_union([from_bool, from_none], obj.get("token_based_billing")) - return CopilotUserResponse(access_type_sku, analytics_tracking_id, assigned_date, can_signup_for_limited, can_upgrade_plan, chat_enabled, cli_remote_control_enabled, cloud_session_storage_enabled, codex_agent_enabled, copilot_plan, copilotignore_enabled, endpoints, is_mcp_enabled, is_staff, limited_user_quotas, limited_user_reset_date, login, monthly_quotas, organization_list, organization_login_list, quota_reset_date, quota_reset_date_utc, quota_snapshots, restricted_telemetry, token_based_billing) + return CopilotUserResponse(access_type_sku, analytics_tracking_id, assigned_date, can_signup_for_limited, can_upgrade_plan, chat_enabled, cli_remote_control_enabled, cloud_session_storage_enabled, codex_agent_enabled, copilot_plan, copilotignore_enabled, endpoints, is_mcp_enabled, is_staff, limited_user_quotas, limited_user_reset_date, login, monthly_quotas, organization_list, organization_login_list, quota_reset_date, quota_reset_date_utc, quota_snapshots, restricted_telemetry, te, token_based_billing) def to_dict(self) -> dict: result: dict = {} @@ -16939,6 +17012,8 @@ def to_dict(self) -> dict: result["quota_snapshots"] = from_union([lambda x: from_dict(lambda x: from_union([lambda x: to_class(CopilotUserResponseQuotaSnapshots, x), from_none], x), x), from_none], self.quota_snapshots) if self.restricted_telemetry is not None: result["restricted_telemetry"] = from_union([from_bool, from_none], self.restricted_telemetry) + if self.te is not None: + result["te"] = from_union([from_bool, from_none], self.te) if self.token_based_billing is not None: result["token_based_billing"] = from_union([from_bool, from_none], self.token_based_billing) return result @@ -21499,6 +21574,114 @@ def to_dict(self) -> dict: result["namespacedName"] = from_union([from_str, from_none], self.namespaced_name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryEvent: + """A single telemetry event in the runtime's native GitHub-shaped telemetry format, + forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing + GitHubTelemetryNotification distinguishes standard from restricted events; the payload + shape is identical for both. + + The telemetry event, in the runtime's native GitHub-shaped telemetry format. + """ + kind: str + """Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed).""" + + metrics: dict[str, float] + """Numeric metrics as a map from key to value.""" + + properties: dict[str, str] + """String-valued properties as a map from key to value.""" + + client: GitHubTelemetryClientInfo | None = None + """Client environment metadata.""" + + copilot_tracking_id: str | None = None + """Copilot tracking ID for user-level attribution.""" + + created_at: str | None = None + """Timestamp when the event was created (ISO 8601 format).""" + + exp_assignment_context: str | None = None + """Experiment assignment context.""" + + features: dict[str, str] | None = None + """Feature flags enabled for this session, as a map from flag to value.""" + + model_call_id: str | None = None + """Reference to the model call that produced this event.""" + + session_id: str | None = None + """Session identifier the event belongs to.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryEvent': + assert isinstance(obj, dict) + kind = from_str(obj.get("kind")) + metrics = from_dict(from_float, obj.get("metrics")) + properties = from_dict(from_str, obj.get("properties")) + client = from_union([GitHubTelemetryClientInfo.from_dict, from_none], obj.get("client")) + copilot_tracking_id = from_union([from_str, from_none], obj.get("copilot_tracking_id")) + created_at = from_union([from_str, from_none], obj.get("created_at")) + exp_assignment_context = from_union([from_str, from_none], obj.get("exp_assignment_context")) + features = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("features")) + model_call_id = from_union([from_str, from_none], obj.get("model_call_id")) + session_id = from_union([from_str, from_none], obj.get("session_id")) + return GitHubTelemetryEvent(kind, metrics, properties, client, copilot_tracking_id, created_at, exp_assignment_context, features, model_call_id, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = from_str(self.kind) + result["metrics"] = from_dict(to_float, self.metrics) + result["properties"] = from_dict(from_str, self.properties) + if self.client is not None: + result["client"] = from_union([lambda x: to_class(GitHubTelemetryClientInfo, x), from_none], self.client) + if self.copilot_tracking_id is not None: + result["copilot_tracking_id"] = from_union([from_str, from_none], self.copilot_tracking_id) + if self.created_at is not None: + result["created_at"] = from_union([from_str, from_none], self.created_at) + if self.exp_assignment_context is not None: + result["exp_assignment_context"] = from_union([from_str, from_none], self.exp_assignment_context) + if self.features is not None: + result["features"] = from_union([lambda x: from_dict(from_str, x), from_none], self.features) + if self.model_call_id is not None: + result["model_call_id"] = from_union([from_str, from_none], self.model_call_id) + if self.session_id is not None: + result["session_id"] = from_union([from_str, from_none], self.session_id) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryNotification: + """Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the + runtime forwards to a host connection that opted into telemetry forwarding for the + session. + """ + event: GitHubTelemetryEvent + """The telemetry event, in the runtime's native GitHub-shaped telemetry format.""" + + restricted: bool + """Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route + restricted events to first-party Microsoft stores only. + """ + session_id: str + """Session the telemetry event belongs to.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryNotification': + assert isinstance(obj, dict) + event = GitHubTelemetryEvent.from_dict(obj.get("event")) + restricted = from_bool(obj.get("restricted")) + session_id = from_str(obj.get("sessionId")) + return GitHubTelemetryNotification(event, restricted, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["event"] = to_class(GitHubTelemetryEvent, self.event) + result["restricted"] = from_bool(self.restricted) + result["sessionId"] = from_str(self.session_id) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPExecuteSamplingParams: @@ -22310,6 +22493,9 @@ class RPC: folder_trust_check_params: FolderTrustCheckParams folder_trust_check_result: FolderTrustCheckResult gh_cli_auth_info: GhCLIAuthInfo + git_hub_telemetry_client_info: GitHubTelemetryClientInfo + git_hub_telemetry_event: GitHubTelemetryEvent + git_hub_telemetry_notification: GitHubTelemetryNotification handle_pending_tool_call_request: HandlePendingToolCallRequest handle_pending_tool_call_result: HandlePendingToolCallResult history_abort_manual_compaction_result: HistoryAbortManualCompactionResult @@ -23114,6 +23300,9 @@ def from_dict(obj: Any) -> 'RPC': folder_trust_check_params = FolderTrustCheckParams.from_dict(obj.get("FolderTrustCheckParams")) folder_trust_check_result = FolderTrustCheckResult.from_dict(obj.get("FolderTrustCheckResult")) gh_cli_auth_info = GhCLIAuthInfo.from_dict(obj.get("GhCliAuthInfo")) + git_hub_telemetry_client_info = GitHubTelemetryClientInfo.from_dict(obj.get("GitHubTelemetryClientInfo")) + git_hub_telemetry_event = GitHubTelemetryEvent.from_dict(obj.get("GitHubTelemetryEvent")) + git_hub_telemetry_notification = GitHubTelemetryNotification.from_dict(obj.get("GitHubTelemetryNotification")) handle_pending_tool_call_request = HandlePendingToolCallRequest.from_dict(obj.get("HandlePendingToolCallRequest")) handle_pending_tool_call_result = HandlePendingToolCallResult.from_dict(obj.get("HandlePendingToolCallResult")) history_abort_manual_compaction_result = HistoryAbortManualCompactionResult.from_dict(obj.get("HistoryAbortManualCompactionResult")) @@ -23780,7 +23969,7 @@ def from_dict(obj: Any) -> 'RPC': subagent_settings = from_union([SubagentSettings.from_dict, from_none], obj.get("SubagentSettings")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, poll_spawned_sessions_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, poll_spawned_sessions_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -23918,6 +24107,9 @@ def to_dict(self) -> dict: result["FolderTrustCheckParams"] = to_class(FolderTrustCheckParams, self.folder_trust_check_params) result["FolderTrustCheckResult"] = to_class(FolderTrustCheckResult, self.folder_trust_check_result) result["GhCliAuthInfo"] = to_class(GhCLIAuthInfo, self.gh_cli_auth_info) + result["GitHubTelemetryClientInfo"] = to_class(GitHubTelemetryClientInfo, self.git_hub_telemetry_client_info) + result["GitHubTelemetryEvent"] = to_class(GitHubTelemetryEvent, self.git_hub_telemetry_event) + result["GitHubTelemetryNotification"] = to_class(GitHubTelemetryNotification, self.git_hub_telemetry_notification) result["HandlePendingToolCallRequest"] = to_class(HandlePendingToolCallRequest, self.handle_pending_tool_call_request) result["HandlePendingToolCallResult"] = to_class(HandlePendingToolCallResult, self.handle_pending_tool_call_result) result["HistoryAbortManualCompactionResult"] = to_class(HistoryAbortManualCompactionResult, self.history_abort_manual_compaction_result) @@ -26869,9 +27061,16 @@ async def http_request_chunk(self, params: LlmInferenceHTTPRequestChunkRequest) "Delivers a body byte range (or a cancellation signal) for a request previously announced via httpRequestStart, correlated by requestId. The runtime fires at least one chunk per request — when there is no body, a single chunk with empty data and end=true. Mid-stream the runtime may send a chunk with cancel=true to abort the request; the SDK then stops issuing httpResponseChunk frames and may emit a terminal httpResponseChunk with error set.\n\nArgs:\n params: A request body chunk or cancellation signal.\n\nReturns:\n Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget." pass +# Experimental: this API group is experimental and may change or be removed. +class GitHubTelemetryHandler(Protocol): + async def event(self, params: GitHubTelemetryNotification) -> None: + "Forwards a single GitHub telemetry event to a host connection that opted into telemetry forwarding for the session.\n\nArgs:\n params: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session." + pass + @dataclass class ClientGlobalApiHandlers: llm_inference: LlmInferenceHandler | None = None + git_hub_telemetry: GitHubTelemetryHandler | None = None def register_client_global_api_handlers( client: "JsonRpcClient", @@ -26897,6 +27096,13 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: result = await handler.http_request_chunk(request) return result.to_dict() client.set_request_handler("llmInference.httpRequestChunk", handle_llm_inference_http_request_chunk) + async def handle_git_hub_telemetry_event(params: dict) -> dict | None: + request = GitHubTelemetryNotification.from_dict(params) + handler = handlers.git_hub_telemetry + if handler is None: raise RuntimeError("No git_hub_telemetry client-global handler registered") + await handler.event(request) + return None + client.set_request_handler("gitHubTelemetry.event", handle_git_hub_telemetry_event) __all__ = [ "APIKeyAuthInfo", @@ -27062,6 +27268,10 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: "GhCLIAuthInfo", "GhCLIAuthInfoType", "GitHubAuthApi", + "GitHubTelemetryClientInfo", + "GitHubTelemetryEvent", + "GitHubTelemetryHandler", + "GitHubTelemetryNotification", "HMACAuthInfo", "HMACAuthInfoType", "HandlePendingToolCallRequest", diff --git a/python/e2e/test_hooks_e2e.py b/python/e2e/test_hooks_e2e.py index b9fec80d7..d9a67cf03 100644 --- a/python/e2e/test_hooks_e2e.py +++ b/python/e2e/test_hooks_e2e.py @@ -1,45 +1,159 @@ -"""E2E coverage for SDK callback hook rejection.""" +""" +Tests for session hooks functionality +""" + +import os import pytest from copilot.session import PermissionHandler from .testharness import E2ETestContext +from .testharness.helper import write_file pytestmark = pytest.mark.asyncio(loop_scope="module") -UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported" +class TestHooks: + async def test_should_invoke_pretooluse_hook_when_model_runs_a_tool(self, ctx: E2ETestContext): + """Test that preToolUse hook is invoked when model runs a tool""" + pre_tool_use_inputs = [] + invocation_session_ids = [] + + async def on_pre_tool_use(input_data, invocation): + pre_tool_use_inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + # Allow the tool to run + return {"permissionDecision": "allow"} -async def _allow(*_args): - return {"permissionDecision": "allow"} + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_pre_tool_use": on_pre_tool_use}, + ) + # Create a file for the model to read + write_file(ctx.work_dir, "hello.txt", "Hello from the test!") -async def _deny(*_args): - return {"permissionDecision": "deny"} + await session.send_and_wait("Read the contents of hello.txt and tell me what it says") + # Should have received at least one preToolUse hook call + assert len(pre_tool_use_inputs) > 0 + assert all(session_id == session.session_id for session_id in invocation_session_ids) -async def _noop(*_args): - return None + # Should have received the tool name + assert any(inp.get("toolName") for inp in pre_tool_use_inputs) + await session.disconnect() -async def assert_unsupported_hooks(ctx: E2ETestContext, hooks: dict): - with pytest.raises(Exception, match=UNSUPPORTED_SDK_HOOKS_MESSAGE): - await ctx.client.create_session( + async def test_should_invoke_posttooluse_hook_after_model_runs_a_tool( + self, ctx: E2ETestContext + ): + """Test that postToolUse hook is invoked after model runs a tool""" + post_tool_use_inputs = [] + invocation_session_ids = [] + + async def on_post_tool_use(input_data, invocation): + post_tool_use_inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + return None + + session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, - hooks=hooks, + hooks={"on_post_tool_use": on_post_tool_use}, ) + # Create a file for the model to read + write_file(ctx.work_dir, "world.txt", "World from the test!") -class TestHooks: - @pytest.mark.parametrize( - "hooks", - [ - {"on_pre_tool_use": _allow}, - {"on_post_tool_use": _noop}, - {"on_pre_tool_use": _deny}, - {"on_pre_tool_use": _allow, "on_post_tool_use": _noop}, - ], - ) - async def test_rejects_sdk_callback_hooks(self, ctx: E2ETestContext, hooks: dict): - await assert_unsupported_hooks(ctx, hooks) + await session.send_and_wait("Read the contents of world.txt and tell me what it says") + + # Should have received at least one postToolUse hook call + assert len(post_tool_use_inputs) > 0 + assert all(session_id == session.session_id for session_id in invocation_session_ids) + + # Should have received the tool name and result + assert any(inp.get("toolName") for inp in post_tool_use_inputs) + assert any(inp.get("toolResult") is not None for inp in post_tool_use_inputs) + + await session.disconnect() + + async def test_should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call( + self, ctx: E2ETestContext + ): + """Test that both preToolUse and postToolUse hooks fire for the same tool call""" + pre_tool_use_inputs = [] + post_tool_use_inputs = [] + + async def on_pre_tool_use(input_data, invocation): + pre_tool_use_inputs.append(input_data) + return {"permissionDecision": "allow"} + + async def on_post_tool_use(input_data, invocation): + post_tool_use_inputs.append(input_data) + return None + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={ + "on_pre_tool_use": on_pre_tool_use, + "on_post_tool_use": on_post_tool_use, + }, + ) + + write_file(ctx.work_dir, "both.txt", "Testing both hooks!") + + await session.send_and_wait("Read the contents of both.txt") + + # Both hooks should have been called + assert len(pre_tool_use_inputs) > 0 + assert len(post_tool_use_inputs) > 0 + + # The same tool should appear in both + pre_tool_names = [inp.get("toolName") for inp in pre_tool_use_inputs] + post_tool_names = [inp.get("toolName") for inp in post_tool_use_inputs] + common_tool = next((name for name in pre_tool_names if name in post_tool_names), None) + assert common_tool is not None + + await session.disconnect() + + async def test_should_deny_tool_execution_when_pretooluse_returns_deny( + self, ctx: E2ETestContext + ): + """Test that returning deny in preToolUse prevents tool execution""" + pre_tool_use_inputs = [] + + async def on_pre_tool_use(input_data, invocation): + pre_tool_use_inputs.append(input_data) + # Deny all tool calls + return {"permissionDecision": "deny"} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_pre_tool_use": on_pre_tool_use}, + ) + + # Create a file + original_content = "Original content that should not be modified" + write_file(ctx.work_dir, "protected.txt", original_content) + + response = await session.send_and_wait( + "Edit protected.txt and replace 'Original' with 'Modified'" + ) + + # The hook should have been called + assert len(pre_tool_use_inputs) > 0 + + # The response should indicate the tool was denied (behavior may vary) + # At minimum, we verify the hook was invoked + assert response is not None + + # Strengthen: verify the actual deny behavior — the protected file was NOT + # modified by the runtime even though the LLM tried to edit it. The + # pre-tool-use hook denial blocks tool execution before it can mutate state. + with open(os.path.join(ctx.work_dir, "protected.txt")) as f: + actual_content = f.read() + assert actual_content == original_content, ( + f"protected.txt should be unchanged after deny; got: {actual_content!r}" + ) + + await session.disconnect() diff --git a/python/e2e/test_hooks_extended_e2e.py b/python/e2e/test_hooks_extended_e2e.py index 4055bfa08..841c14c77 100644 --- a/python/e2e/test_hooks_extended_e2e.py +++ b/python/e2e/test_hooks_extended_e2e.py @@ -1,52 +1,241 @@ -"""E2E coverage for SDK lifecycle callback hook rejection.""" +""" +Extended hook lifecycle tests that mirror dotnet/test/HookLifecycleAndOutputTests.cs. + +E2E coverage for every handler exposed on ``SessionHooks``: +``on_pre_tool_use``, ``on_post_tool_use``, ``on_post_tool_use_failure``, +``on_user_prompt_submitted``, ``on_session_start``, ``on_session_end``, +``on_error_occurred``. Output-shape behavior (modifiedPrompt / +additionalContext / errorHandling / modifiedArgs / modifiedResult / +sessionSummary) is asserted alongside hook invocation. +""" + +from __future__ import annotations + +import asyncio import pytest from copilot.session import PermissionHandler +from copilot.tools import Tool, ToolInvocation, ToolResult from .testharness import E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") -UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported" - - -async def _allow(*_args): - return {"permissionDecision": "allow"} - - -async def _noop(*_args): - return None - -async def _modified_prompt(*_args): - return {"modifiedPrompt": "not used"} - - -async def _post_failure(*_args): - return {"additionalContext": "not used"} - - -async def assert_unsupported_hooks(ctx: E2ETestContext, hooks: dict): - with pytest.raises(Exception, match=UNSUPPORTED_SDK_HOOKS_MESSAGE): - await ctx.client.create_session( +class TestHooksExtended: + async def test_should_invoke_userpromptsubmitted_hook_and_modify_prompt( + self, ctx: E2ETestContext + ): + inputs: list[dict] = [] + invocation_session_ids: list[str] = [] + + async def on_user_prompt_submitted(input_data, invocation): + inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + return {"modifiedPrompt": "Reply with exactly: HOOKED_PROMPT"} + + session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, - hooks=hooks, + hooks={"on_user_prompt_submitted": on_user_prompt_submitted}, ) - - -class TestHooksExtended: - @pytest.mark.parametrize( - "hooks", - [ - {"on_user_prompt_submitted": _modified_prompt}, - {"on_session_start": _noop}, - {"on_session_end": _noop}, - {"on_error_occurred": _noop}, - {"on_pre_tool_use": _allow}, - {"on_post_tool_use": _noop}, - {"on_post_tool_use": _noop, "on_post_tool_use_failure": _post_failure}, - ], + try: + response = await session.send_and_wait("Say something else") + assert inputs + assert all(session_id == session.session_id for session_id in invocation_session_ids) + assert "Say something else" in inputs[0].get("prompt", "") + assert "HOOKED_PROMPT" in (response.data.content or "") + finally: + await session.disconnect() + + async def test_should_invoke_sessionstart_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + invocation_session_ids: list[str] = [] + + async def on_session_start(input_data, invocation): + inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + return {"additionalContext": "Session start hook context."} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_session_start": on_session_start}, + ) + try: + await session.send_and_wait("Say hi") + assert inputs + assert all(session_id == session.session_id for session_id in invocation_session_ids) + assert inputs[0].get("source") == "new" + assert inputs[0].get("workingDirectory") + finally: + await session.disconnect() + + async def test_should_invoke_sessionend_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + invocation_session_ids: list[str] = [] + hook_invoked: asyncio.Future = asyncio.get_event_loop().create_future() + + async def on_session_end(input_data, invocation): + inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + if not hook_invoked.done(): + hook_invoked.set_result(input_data) + return {"sessionSummary": "session ended"} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_session_end": on_session_end}, + ) + await session.send_and_wait("Say bye") + await session.disconnect() + await asyncio.wait_for(hook_invoked, 10.0) + assert inputs + assert all(session_id == session.session_id for session_id in invocation_session_ids) + + async def test_should_register_erroroccurred_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + invocation_session_ids: list[str] = [] + + async def on_error_occurred(input_data, invocation): + inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + return {"errorHandling": "skip"} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_error_occurred": on_error_occurred}, + ) + try: + await session.send_and_wait("Say hi") + # Registration-only test: a healthy turn shouldn't fire OnErrorOccurred. + assert not inputs + assert not invocation_session_ids + assert session.session_id + finally: + await session.disconnect() + + async def test_should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput( + self, ctx: E2ETestContext + ): + inputs: list[dict] = [] + + def echo_value(invocation: ToolInvocation) -> ToolResult: + args = invocation.arguments or {} + return ToolResult(text_result_for_llm=str(args.get("value", ""))) + + async def on_pre_tool_use(input_data, invocation): + inputs.append(input_data) + if input_data.get("toolName") != "echo_value": + return {"permissionDecision": "allow"} + return { + "permissionDecision": "allow", + "modifiedArgs": {"value": "modified by hook"}, + "suppressOutput": False, + } + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + tools=[ + Tool( + name="echo_value", + description="Echoes the supplied value", + parameters={ + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value to echo", + } + }, + "required": ["value"], + }, + handler=echo_value, + ) + ], + hooks={"on_pre_tool_use": on_pre_tool_use}, + ) + try: + response = await session.send_and_wait( + "Call echo_value with value 'original', then reply with the result." + ) + assert inputs + assert any(inp.get("toolName") == "echo_value" for inp in inputs) + assert "modified by hook" in (response.data.content or "") + finally: + await session.disconnect() + + async def test_should_allow_posttooluse_to_return_modifiedresult(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_post_tool_use(input_data, invocation): + inputs.append(input_data) + if input_data.get("toolName") != "view": + return None + return { + "modifiedResult": { + "textResultForLlm": "modified by post hook", + "resultType": "success", + "toolTelemetry": {}, + }, + "suppressOutput": False, + } + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_post_tool_use": on_post_tool_use}, + ) + try: + response = await session.send_and_wait( + "Call the view tool to read the current directory, then reply done." + ) + assert any(inp.get("toolName") == "view" for inp in inputs) + assert "done" in (response.data.content or "").lower() + finally: + await session.disconnect() + + @pytest.mark.skip( + reason="Fails with 1.0.64-0 runtime: built-in tools are not available when hooks " + "restrict availableTools, so the failure path cannot be exercised. " + "Follow up with runtime team." ) - async def test_rejects_sdk_callback_hooks(self, ctx: E2ETestContext, hooks: dict): - await assert_unsupported_hooks(ctx, hooks) + async def test_should_invoke_posttoolusefailure_hook_for_failed_tool_result( + self, ctx: E2ETestContext + ): + failure_inputs: list[dict] = [] + post_tool_use_inputs: list[dict] = [] + invocation_session_ids: list[str] = [] + + async def on_post_tool_use(input_data, invocation): + post_tool_use_inputs.append(input_data) + return None + + async def on_post_tool_use_failure(input_data, invocation): + failure_inputs.append(input_data) + invocation_session_ids.append(invocation["session_id"]) + return {"additionalContext": "HOOK_FAILURE_GUIDANCE_APPLIED"} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=["report_intent"], + hooks={ + "on_post_tool_use": on_post_tool_use, + "on_post_tool_use_failure": on_post_tool_use_failure, + }, + ) + try: + response = await session.send_and_wait( + "Call the view tool with path 'missing.txt'. " + "If it fails, use the hook guidance to answer." + ) + assert not post_tool_use_inputs + assert len(failure_inputs) == 1 + assert all(session_id == session.session_id for session_id in invocation_session_ids) + failure_input = failure_inputs[0] + assert failure_input["toolName"] == "view" + assert "does not exist" in failure_input["error"] + assert "missing.txt" in failure_input["toolArgs"]["path"] + assert failure_input["timestamp"].timestamp() > 0 + assert failure_input["workingDirectory"] + assert "HOOK_FAILURE_GUIDANCE_APPLIED" in (response.data.content or "") + finally: + await session.disconnect() diff --git a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py index 8706e8962..c59994437 100644 --- a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py +++ b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py @@ -1,24 +1,120 @@ -"""E2E coverage for SDK preMcpToolCall callback hook rejection.""" +""" +E2E tests for the preMcpToolCall hook, verifying meta manipulation scenarios: +setting meta, replacing meta, and removing meta. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path import pytest -from copilot.session import PermissionHandler +from copilot.session import MCPServerConfig, PermissionHandler from .testharness import E2ETestContext -pytestmark = pytest.mark.asyncio(loop_scope="module") +TEST_MCP_META_ECHO_SERVER = str( + (Path(__file__).parents[2] / "test" / "harness" / "test-mcp-meta-echo-server.mjs").resolve() +) +TEST_HARNESS_DIR = str((Path(__file__).parents[2] / "test" / "harness").resolve()) -UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported" +pytestmark = pytest.mark.asyncio(loop_scope="module") -async def _pre_mcp_tool_call(*_args): - return {"metaToUse": {"injected": "by-hook"}} +def meta_echo_mcp_config() -> dict[str, MCPServerConfig]: + return { + "meta-echo": { + "command": "node", + "args": [TEST_MCP_META_ECHO_SERVER], + "working_directory": TEST_HARNESS_DIR, + "tools": ["*"], + } + } class TestPreMcpToolCallHook: - async def test_rejects_sdk_premcptoolcall_callback_hook(self, ctx: E2ETestContext): - with pytest.raises(Exception, match=UNSUPPORTED_SDK_HOOKS_MESSAGE): - await ctx.client.create_session( - on_permission_request=PermissionHandler.approve_all, - hooks={"on_pre_mcp_tool_call": _pre_mcp_tool_call}, + async def test_should_set_meta_via_premcptoolcall_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_pre_mcp_tool_call(input_data, invocation): + inputs.append(input_data) + return {"metaToUse": {"injected": "by-hook", "source": "test"}} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=meta_echo_mcp_config(), + hooks={"on_pre_mcp_tool_call": on_pre_mcp_tool_call}, + ) + try: + response = await session.send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-set'." + " Reply with just the raw tool result." + ) + assert response is not None + assert "injected" in (response.data.content or "") + assert "by-hook" in (response.data.content or "") + + assert inputs + assert inputs[0].get("serverName") == "meta-echo" + assert inputs[0].get("toolName") == "echo_meta" + assert inputs[0].get("workingDirectory") + assert isinstance(inputs[0].get("timestamp"), datetime) + finally: + await session.disconnect() + + async def test_should_replace_meta_via_premcptoolcall_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_pre_mcp_tool_call(input_data, invocation): + inputs.append(input_data) + return {"metaToUse": {"completely": "replaced"}} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=meta_echo_mcp_config(), + hooks={"on_pre_mcp_tool_call": on_pre_mcp_tool_call}, + ) + try: + response = await session.send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-replace'." + " Reply with just the raw tool result." ) + assert response is not None + assert "completely" in (response.data.content or "") + assert "replaced" in (response.data.content or "") + + assert inputs + assert inputs[0].get("serverName") == "meta-echo" + assert inputs[0].get("toolName") == "echo_meta" + finally: + await session.disconnect() + + async def test_should_remove_meta_via_premcptoolcall_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_pre_mcp_tool_call(input_data, invocation): + inputs.append(input_data) + return {"metaToUse": None} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=meta_echo_mcp_config(), + hooks={"on_pre_mcp_tool_call": on_pre_mcp_tool_call}, + ) + try: + response = await session.send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-remove'." + " Reply with just the raw tool result." + ) + assert response is not None + assert '"meta":null' in (response.data.content or "") or '"meta": null' in ( + response.data.content or "" + ) + assert "test-remove" in (response.data.content or "") + + assert inputs + assert inputs[0].get("serverName") == "meta-echo" + assert inputs[0].get("toolName") == "echo_meta" + finally: + await session.disconnect() diff --git a/python/e2e/test_session_fs_e2e.py b/python/e2e/test_session_fs_e2e.py index 2fb99e5c4..fbfde6ee7 100644 --- a/python/e2e/test_session_fs_e2e.py +++ b/python/e2e/test_session_fs_e2e.py @@ -613,7 +613,7 @@ async def rm(self, path: str, recursive: bool, force: bool) -> None: async def rename(self, src: str, dest: str) -> None: d = self._path(dest) d.parent.mkdir(parents=True, exist_ok=True) - self._path(src).rename(d) + self._path(src).replace(d) def create_test_session_fs_handler(provider_root: Path): diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index 9206a6f2e..1ca2a54c1 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -1,28 +1,92 @@ -"""E2E coverage for SDK sub-agent callback hook rejection.""" +""" +Tests for sub-agent hooks functionality — verifies preToolUse/postToolUse hooks +fire for tool calls made by sub-agents spawned via the task tool. +""" + +import os import pytest +from copilot.client import CopilotClient, RuntimeConnection from copilot.session import PermissionHandler from .testharness import E2ETestContext +from .testharness.helper import write_file pytestmark = pytest.mark.asyncio(loop_scope="module") -UNSUPPORTED_SDK_HOOKS_MESSAGE = "SDK hook callbacks are no longer supported" +class TestSubagentHooks: + async def test_should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls( + self, ctx: E2ETestContext + ): + """Test that preToolUse/postToolUse hooks fire for sub-agent tool calls""" + hook_log = [] -async def _allow(*_args): - return {"permissionDecision": "allow"} + async def on_pre_tool_use(input_data, invocation): + hook_log.append( + { + "kind": "pre", + "toolName": input_data.get("toolName"), + "sessionId": input_data.get("sessionId"), + } + ) + return {"permissionDecision": "allow"} + async def on_post_tool_use(input_data, invocation): + hook_log.append( + { + "kind": "post", + "toolName": input_data.get("toolName"), + "sessionId": input_data.get("sessionId"), + } + ) + return None -async def _noop(*_args): - return None + # Create a client with the session-based subagents feature flag + env = ctx.get_env() + env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true" + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=env, + github_token=github_token, + ) + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={ + "on_pre_tool_use": on_pre_tool_use, + "on_post_tool_use": on_post_tool_use, + }, + ) -class TestSubagentHooks: - async def test_rejects_sdk_callback_hooks_for_sub_agents(self, ctx: E2ETestContext): - with pytest.raises(Exception, match=UNSUPPORTED_SDK_HOOKS_MESSAGE): - await ctx.client.create_session( - on_permission_request=PermissionHandler.approve_all, - hooks={"on_pre_tool_use": _allow, "on_post_tool_use": _noop}, - ) + # Create a file for the sub-agent to read + write_file(ctx.work_dir, "subagent-test.txt", "Hello from subagent test!") + + await session.send_and_wait( + "Use the task tool to spawn an explore agent that reads the file " + "subagent-test.txt in the current directory and reports its contents. " + "You must use the task tool." + ) + + # Parent tool hooks fire for "task" + task_pre = [h for h in hook_log if h["kind"] == "pre" and h["toolName"] == "task"] + assert len(task_pre) >= 1, "preToolUse should fire for the parent's 'task' tool call" + + # Sub-agent tool hooks fire for "view" + view_pre = [h for h in hook_log if h["kind"] == "pre" and h["toolName"] == "view"] + view_post = [h for h in hook_log if h["kind"] == "post" and h["toolName"] == "view"] + assert len(view_pre) > 0, "preToolUse should fire for the sub-agent's 'view' tool call" + assert len(view_post) > 0, "postToolUse should fire for the sub-agent's 'view' tool call" + + # input.session_id distinguishes parent from sub-agent + assert view_pre[0]["sessionId"] != task_pre[0]["sessionId"], ( + "Sub-agent tool hooks should have a different sessionId than parent tool hooks" + ) + + await session.disconnect() + await client.stop() diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 18a877d3a..f59022b0d 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -1458,6 +1458,8 @@ pub struct CopilotUserResponse { skip_serializing_if = "Option::is_none" )] pub restricted_telemetry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub te: Option, #[serde( rename = "token_based_billing", skip_serializing_if = "Option::is_none" @@ -3445,6 +3447,114 @@ pub struct GhCliAuthInfo { pub r#type: GhCliAuthInfoType, } +/// Client environment metadata describing the process that produced a telemetry event. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryClientInfo { + /// Copilot CLI version string. + #[serde(rename = "cli_version")] + pub cli_version: String, + /// Name of the client application. + #[serde(rename = "client_name", skip_serializing_if = "Option::is_none")] + pub client_name: Option, + /// Type of client. + #[serde(rename = "client_type", skip_serializing_if = "Option::is_none")] + pub client_type: Option, + /// Copilot subscription plan, when known. + #[serde(rename = "copilot_plan", skip_serializing_if = "Option::is_none")] + pub copilot_plan: Option, + /// Stable machine identifier for the device. + #[serde(rename = "dev_device_id", skip_serializing_if = "Option::is_none")] + pub dev_device_id: Option, + /// Whether the user is a GitHub/Microsoft staff member. + #[serde(rename = "is_staff", skip_serializing_if = "Option::is_none")] + pub is_staff: Option, + /// Node.js runtime version string. + #[serde(rename = "node_version")] + pub node_version: String, + /// Operating system architecture (e.g. arm64, x64). + #[serde(rename = "os_arch")] + pub os_arch: String, + /// Operating system platform (e.g. darwin, linux, win32). + #[serde(rename = "os_platform")] + pub os_platform: String, + /// Operating system version string. + #[serde(rename = "os_version")] + pub os_version: String, +} + +/// A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryEvent { + /// Client environment metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, + /// Copilot tracking ID for user-level attribution. + #[serde( + rename = "copilot_tracking_id", + skip_serializing_if = "Option::is_none" + )] + pub copilot_tracking_id: Option, + /// Timestamp when the event was created (ISO 8601 format). + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// Experiment assignment context. + #[serde( + rename = "exp_assignment_context", + skip_serializing_if = "Option::is_none" + )] + pub exp_assignment_context: Option, + /// Feature flags enabled for this session, as a map from flag to value. + #[serde(skip_serializing_if = "Option::is_none")] + pub features: Option>, + /// Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + pub kind: String, + /// Numeric metrics as a map from key to value. + pub metrics: HashMap, + /// Reference to the model call that produced this event. + #[serde(rename = "model_call_id", skip_serializing_if = "Option::is_none")] + pub model_call_id: Option, + /// String-valued properties as a map from key to value. + pub properties: HashMap, + /// Session identifier the event belongs to. + #[serde(rename = "session_id", skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry forwarding for the session. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryNotification { + /// The telemetry event, in the runtime's native GitHub-shaped telemetry format. + pub event: GitHubTelemetryEvent, + /// Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + pub restricted: bool, + /// Session the telemetry event belongs to. + pub session_id: SessionId, +} + /// Pending external tool call request ID, with the tool result or an error describing why it failed. /// ///
diff --git a/rust/tests/e2e/hooks.rs b/rust/tests/e2e/hooks.rs index c4487ab6d..b4a211d87 100644 --- a/rust/tests/e2e/hooks.rs +++ b/rust/tests/e2e/hooks.rs @@ -1,64 +1,228 @@ +use std::collections::HashSet; use std::sync::Arc; use async_trait::async_trait; use github_copilot_sdk::hooks::{ - HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, - SessionHooks, + HookContext, PostToolUseInput, PreToolUseInput, PreToolUseOutput, SessionHooks, }; +use tokio::sync::mpsc; -use super::support::{assert_unsupported_hooks_error, with_e2e_context}; +use super::support::{recv_with_timeout, with_e2e_context}; #[tokio::test] -async fn rejects_sdk_callback_hooks() { - with_e2e_context("hooks", "rejects_sdk_callback_hooks", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - assert_unsupported_hooks( - &client, - ctx.approve_all_session_config() - .with_hooks(Arc::new(RecordingHooks)), - ) - .await; - client.stop().await.expect("stop client"); - }) - }) +async fn should_invoke_pretooluse_hook_when_model_runs_a_tool() { + with_e2e_context( + "hooks", + "should_invoke_pretooluse_hook_when_model_runs_a_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("hello.txt"), "Hello from the test!") + .expect("write hello"); + let (pre_tx, mut pre_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: Some(pre_tx), + post_tx: None, + deny: false, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of hello.txt and tell me what it says") + .await + .expect("send"); + + let input = recv_with_timeout(&mut pre_rx, "preToolUse hook").await; + assert_eq!(input.0, *session.id()); + assert!(!input.1.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } -async fn assert_unsupported_hooks( - client: &github_copilot_sdk::Client, - config: github_copilot_sdk::SessionConfig, -) { - match client.create_session(config).await { - Ok(session) => { - session.disconnect().await.expect("disconnect session"); - panic!("expected SDK callback hooks to be rejected"); - } - Err(err) => assert_unsupported_hooks_error(err), - } +#[tokio::test] +async fn should_invoke_posttooluse_hook_after_model_runs_a_tool() { + with_e2e_context( + "hooks", + "should_invoke_posttooluse_hook_after_model_runs_a_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("world.txt"), "World from the test!") + .expect("write world"); + let (post_tx, mut post_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: None, + post_tx: Some(post_tx), + deny: false, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of world.txt and tell me what it says") + .await + .expect("send"); + + let input = recv_with_timeout(&mut post_rx, "postToolUse hook").await; + assert_eq!(input.0, *session.id()); + assert!(!input.1.is_empty()); + assert!(input.2); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; } -struct RecordingHooks; +#[tokio::test] +async fn should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call() { + with_e2e_context( + "hooks", + "should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("both.txt"), "Testing both hooks!") + .expect("write both"); + let (pre_tx, mut pre_rx) = mpsc::unbounded_channel(); + let (post_tx, mut post_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: Some(pre_tx), + post_tx: Some(post_tx), + deny: false, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of both.txt") + .await + .expect("send"); + + let pre = recv_with_timeout(&mut pre_rx, "preToolUse hook").await; + let post = recv_with_timeout(&mut post_rx, "postToolUse hook").await; + assert_eq!(pre.0, *session.id()); + assert_eq!(post.0, *session.id()); + + let mut pre_tools: HashSet = HashSet::from([pre.1]); + while let Ok((_, tool_name)) = pre_rx.try_recv() { + pre_tools.insert(tool_name); + } + let mut post_tools: HashSet = HashSet::from([post.1]); + while let Ok((_, tool_name, _)) = post_rx.try_recv() { + post_tools.insert(tool_name); + } + assert!( + pre_tools.intersection(&post_tools).next().is_some(), + "expected a tool to appear in both pre and post hooks, got pre={pre_tools:?} post={post_tools:?}" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_deny_tool_execution_when_pretooluse_returns_deny() { + with_e2e_context( + "hooks", + "should_deny_tool_execution_when_pretooluse_returns_deny", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let original_content = "Original content that should not be modified"; + let protected_path = ctx.work_dir().join("protected.txt"); + std::fs::write(&protected_path, original_content).expect("write protected"); + let (pre_tx, mut pre_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: Some(pre_tx), + post_tx: None, + deny: true, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Edit protected.txt and replace 'Original' with 'Modified'") + .await + .expect("send"); + + let pre = recv_with_timeout(&mut pre_rx, "preToolUse hook").await; + assert_eq!(pre.0, *session.id()); + assert_eq!( + std::fs::read_to_string(protected_path).expect("read protected"), + original_content + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct RecordingHooks { + pre_tx: Option>, + post_tx: Option>, + deny: bool, +} #[async_trait] impl SessionHooks for RecordingHooks { async fn on_pre_tool_use( &self, - _input: PreToolUseInput, - _ctx: HookContext, + input: PreToolUseInput, + ctx: HookContext, ) -> Option { + if let Some(pre_tx) = &self.pre_tx { + let _ = pre_tx.send((ctx.session_id, input.tool_name)); + } Some(PreToolUseOutput { - permission_decision: Some("allow".to_string()), + permission_decision: Some(if self.deny { "deny" } else { "allow" }.to_string()), ..PreToolUseOutput::default() }) } async fn on_post_tool_use( &self, - _input: PostToolUseInput, - _ctx: HookContext, - ) -> Option { + input: PostToolUseInput, + ctx: HookContext, + ) -> Option { + if let Some(post_tx) = &self.post_tx { + let _ = post_tx.send(( + ctx.session_id, + input.tool_name, + !input.tool_result.is_null(), + )); + } None } } diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index 6fe031c26..259d462d5 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -1,37 +1,45 @@ use std::sync::Arc; use async_trait::async_trait; +use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::hooks::{ ErrorOccurredInput, ErrorOccurredOutput, HookContext, PostToolUseFailureInput, PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, SessionStartInput, SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput, }; +use github_copilot_sdk::tool::ToolHandler; +use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; +use serde_json::json; +use tokio::sync::mpsc; -use super::support::{assert_unsupported_hooks_error, with_e2e_context}; +use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; #[tokio::test] -async fn rejects_extended_sdk_callback_hooks() { +async fn should_invoke_onsessionstart_hook_on_new_session() { with_e2e_context( "hooks_extended", - "rejects_extended_sdk_callback_hooks", + "should_invoke_onsessionstart_hook_on_new_session", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); let client = ctx.start_client().await; - match client + let session = client .create_session( ctx.approve_all_session_config() - .with_hooks(Arc::new(ExtendedHooks)), + .with_hooks(Arc::new(RecordingHooks::session_start(tx, None))), ) .await - { - Ok(session) => { - session.disconnect().await.expect("disconnect session"); - panic!("expected SDK callback hooks to be rejected"); - } - Err(err) => assert_unsupported_hooks_error(err), - } + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + let input = recv_with_timeout(&mut rx, "sessionStart hook").await; + assert_eq!(input.source, "new"); + assert!(input.timestamp > 0); + assert!(!input.working_directory.as_os_str().is_empty()); + + session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, @@ -39,83 +47,611 @@ async fn rejects_extended_sdk_callback_hooks() { .await; } -struct ExtendedHooks; +#[tokio::test] +async fn should_invoke_onuserpromptsubmitted_hook_when_sending_a_message() { + with_e2e_context( + "hooks_extended", + "should_invoke_onuserpromptsubmitted_hook_when_sending_a_message", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::user_prompt(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hello").await.expect("send"); + let input = recv_with_timeout(&mut rx, "userPromptSubmitted hook").await; + assert!(input.prompt.contains("Say hello")); + assert!(input.timestamp > 0); + assert!(!input.working_directory.as_os_str().is_empty()); -#[async_trait] -impl SessionHooks for ExtendedHooks { - async fn on_user_prompt_submitted( - &self, - _input: UserPromptSubmittedInput, - _ctx: HookContext, - ) -> Option { - Some(UserPromptSubmittedOutput { - modified_prompt: Some("not used".to_string()), - ..UserPromptSubmittedOutput::default() + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_onsessionend_hook_when_session_is_disconnected() { + with_e2e_context( + "hooks_extended", + "should_invoke_onsessionend_hook_when_session_is_disconnected", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::session_end(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; + assert!(input.timestamp > 0); + assert!(!input.working_directory.as_os_str().is_empty()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_onerroroccurred_hook_when_error_occurs() { + with_e2e_context( + "hooks_extended", + "should_invoke_onerroroccurred_hook_when_error_occurs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::error(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + rx.try_recv() + .map(drop) + .expect_err("errorOccurred hook should not run"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_userpromptsubmitted_hook_and_modify_prompt() { + with_e2e_context( + "hooks_extended", + "should_invoke_userpromptsubmitted_hook_and_modify_prompt", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::user_prompt( + tx, + Some(UserPromptSubmittedOutput { + modified_prompt: Some( + "Reply with exactly: HOOKED_PROMPT".to_string(), + ), + ..UserPromptSubmittedOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Say something else") + .await + .expect("send") + .expect("assistant message"); + let input = recv_with_timeout(&mut rx, "userPromptSubmitted hook").await; + assert!(input.prompt.contains("Say something else")); + assert!(assistant_message_content(&answer).contains("HOOKED_PROMPT")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_sessionstart_hook() { + with_e2e_context("hooks_extended", "should_invoke_sessionstart_hook", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::session_start( + tx, + Some(SessionStartOutput { + additional_context: Some("Session start hook context.".to_string()), + ..SessionStartOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + let input = recv_with_timeout(&mut rx, "sessionStart hook").await; + assert_eq!(input.source, "new"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_invoke_sessionend_hook() { + with_e2e_context("hooks_extended", "should_invoke_sessionend_hook", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::session_end( + tx, + Some(SessionEndOutput { + session_summary: Some("session ended".to_string()), + ..SessionEndOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say bye").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; + assert!(input.timestamp > 0); + + client.stop().await.expect("stop client"); }) + }) + .await; +} + +#[tokio::test] +async fn should_register_erroroccurred_hook() { + with_e2e_context( + "hooks_extended", + "should_register_erroroccurred_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::error( + tx, + Some(ErrorOccurredOutput { + error_handling: Some("skip".to_string()), + ..ErrorOccurredOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + rx.try_recv() + .map(drop) + .expect_err("errorOccurred hook should not run"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput() { + with_e2e_context( + "hooks_extended", + "should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_tools(vec![echo_value_tool()]) + .with_hooks(Arc::new(RecordingHooks::pre_tool(tx))), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Call echo_value with value 'original', then reply with the result.", + ) + .await + .expect("send") + .expect("assistant message"); + let mut saw_echo = false; + while let Ok(input) = rx.try_recv() { + saw_echo |= input.tool_name == "echo_value"; + } + assert!(saw_echo, "expected preToolUse hook for echo_value"); + assert!(assistant_message_content(&answer).contains("modified by hook")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_posttooluse_to_return_modifiedresult() { + with_e2e_context( + "hooks_extended", + "should_allow_posttooluse_to_return_modifiedresult", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::post_tool(tx))), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Call the view tool to read the current directory, then reply done.", + ) + .await + .expect("send") + .expect("assistant message"); + let mut saw_view = false; + while let Ok(input) = rx.try_recv() { + saw_view |= input.tool_name == "view"; + } + assert!(saw_view, "expected postToolUse hook for view"); + assert!( + assistant_message_content(&answer) + .to_lowercase() + .contains("done"), + "expected assistant message to contain 'done'" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +#[ignore = "Fails with 1.0.64-0 runtime: built-in tools are not available when hooks restrict availableTools, so the failure path cannot be exercised. Follow up with runtime team."] +async fn should_invoke_posttoolusefailure_hook_for_failed_tool_result() { + with_e2e_context( + "hooks_extended", + "should_invoke_posttoolusefailure_hook_for_failed_tool_result", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (failure_tx, mut failure_rx) = mpsc::unbounded_channel(); + let (post_tx, mut post_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_available_tools(["report_intent"]) + .with_hooks(Arc::new(RecordingHooks::post_tool_failure( + failure_tx, post_tx, + ))), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Call the view tool with path 'missing.txt'. If it fails, use the hook guidance to answer.", + ) + .await + .expect("send") + .expect("assistant message"); + + let input = recv_with_timeout(&mut failure_rx, "postToolUseFailure hook").await; + post_rx + .try_recv() + .map(drop) + .expect_err("postToolUse hook should not run"); + assert_eq!(input.tool_name, "view"); + assert!(input.error.contains("does not exist")); + assert!( + input.tool_args["path"] + .as_str() + .is_some_and(|path| path.contains("missing.txt")) + ); + assert!(input.timestamp > 0); + assert!(!input.working_directory.as_os_str().is_empty()); + assert!( + assistant_message_content(&answer).contains("HOOK_FAILURE_GUIDANCE_APPLIED") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[derive(Default)] +struct RecordingHooks { + session_start: Option>, + session_start_output: Option, + session_end: Option>, + session_end_output: Option, + user_prompt: Option>, + user_prompt_output: Option, + error: Option>, + error_output: Option, + pre_tool: Option>, + post_tool: Option>, + post_tool_failure: Option>, +} + +impl RecordingHooks { + fn session_start( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + session_start: Some(tx), + session_start_output: output, + ..Self::default() + } + } + + fn session_end( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + session_end: Some(tx), + session_end_output: output, + ..Self::default() + } } + fn user_prompt( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + user_prompt: Some(tx), + user_prompt_output: output, + ..Self::default() + } + } + + fn error( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + error: Some(tx), + error_output: output, + ..Self::default() + } + } + + fn pre_tool(tx: mpsc::UnboundedSender) -> Self { + Self { + pre_tool: Some(tx), + ..Self::default() + } + } + + fn post_tool(tx: mpsc::UnboundedSender) -> Self { + Self { + post_tool: Some(tx), + ..Self::default() + } + } + + fn post_tool_failure( + failure_tx: mpsc::UnboundedSender, + post_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + post_tool: Some(post_tx), + post_tool_failure: Some(failure_tx), + ..Self::default() + } + } +} + +#[async_trait] +impl SessionHooks for RecordingHooks { async fn on_session_start( &self, - _input: SessionStartInput, - _ctx: HookContext, + input: SessionStartInput, + ctx: HookContext, ) -> Option { - Some(SessionStartOutput { - additional_context: Some("not used".to_string()), - ..SessionStartOutput::default() - }) + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.session_start { + let _ = tx.send(input); + } + self.session_start_output.clone() } async fn on_session_end( &self, - _input: SessionEndInput, - _ctx: HookContext, + input: SessionEndInput, + ctx: HookContext, ) -> Option { - Some(SessionEndOutput { - session_summary: Some("not used".to_string()), - ..SessionEndOutput::default() - }) + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.session_end { + let _ = tx.send(input); + } + self.session_end_output.clone() + } + + async fn on_user_prompt_submitted( + &self, + input: UserPromptSubmittedInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.user_prompt { + let _ = tx.send(input); + } + self.user_prompt_output.clone() } async fn on_error_occurred( &self, - _input: ErrorOccurredInput, - _ctx: HookContext, + input: ErrorOccurredInput, + ctx: HookContext, ) -> Option { - Some(ErrorOccurredOutput { - error_handling: Some("skip".to_string()), - ..ErrorOccurredOutput::default() - }) + assert!(!ctx.session_id.as_str().is_empty()); + assert!( + ["model_call", "tool_execution", "system", "user_input"] + .contains(&input.error_context.as_str()) + ); + if let Some(tx) = &self.error { + let _ = tx.send(input); + } + self.error_output.clone() } async fn on_pre_tool_use( &self, - _input: PreToolUseInput, + input: PreToolUseInput, _ctx: HookContext, ) -> Option { - Some(PreToolUseOutput { - permission_decision: Some("allow".to_string()), - ..PreToolUseOutput::default() - }) + let output = if input.tool_name == "echo_value" { + PreToolUseOutput { + permission_decision: Some("allow".to_string()), + modified_args: Some(json!({ "value": "modified by hook" })), + suppress_output: Some(false), + ..PreToolUseOutput::default() + } + } else { + PreToolUseOutput { + permission_decision: Some("allow".to_string()), + ..PreToolUseOutput::default() + } + }; + if let Some(tx) = &self.pre_tool { + let _ = tx.send(input); + } + Some(output) } async fn on_post_tool_use( &self, - _input: PostToolUseInput, + input: PostToolUseInput, _ctx: HookContext, ) -> Option { - Some(PostToolUseOutput { - suppress_output: Some(false), - ..PostToolUseOutput::default() - }) + let output = + (self.post_tool.is_some() && input.tool_name == "view").then(|| PostToolUseOutput { + modified_result: Some(json!({ + "textResultForLlm": "modified by post hook", + "resultType": "success", + "toolTelemetry": {}, + })), + suppress_output: Some(false), + ..PostToolUseOutput::default() + }); + if let Some(tx) = &self.post_tool { + let _ = tx.send(input); + } + output } async fn on_post_tool_use_failure( &self, - _input: PostToolUseFailureInput, - _ctx: HookContext, + input: PostToolUseFailureInput, + ctx: HookContext, ) -> Option { - Some(PostToolUseFailureOutput { - additional_context: Some("not used".to_string()), - }) + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.post_tool_failure { + let _ = tx.send(input); + return Some(PostToolUseFailureOutput { + additional_context: Some("HOOK_FAILURE_GUIDANCE_APPLIED".to_string()), + }); + } + None + } +} + +struct EchoValueTool; + +fn echo_value_tool() -> Tool { + Tool::new("echo_value") + .with_description("Echoes the supplied value") + .with_parameters(json!({ + "type": "object", + "properties": { + "value": { "type": "string" } + }, + "required": ["value"] + })) + .with_handler(Arc::new(EchoValueTool)) +} + +#[async_trait] +impl ToolHandler for EchoValueTool { + async fn call(&self, invocation: ToolInvocation) -> Result { + Ok(ToolResult::Text( + invocation + .arguments + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + )) } } diff --git a/rust/tests/e2e/pre_mcp_tool_call_hook.rs b/rust/tests/e2e/pre_mcp_tool_call_hook.rs index 219032bd5..973672f70 100644 --- a/rust/tests/e2e/pre_mcp_tool_call_hook.rs +++ b/rust/tests/e2e/pre_mcp_tool_call_hook.rs @@ -1,35 +1,183 @@ +use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; use github_copilot_sdk::hooks::{ HookContext, PreMcpToolCallInput, PreMcpToolCallOutput, SessionHooks, }; -use serde_json::json; +use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; +use serde_json::{Value, json}; +use tokio::sync::mpsc; -use super::support::{assert_unsupported_hooks_error, with_e2e_context}; +use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; + +fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> HashMap { + let harness_dir = repo_root.join("test").join("harness"); + let server_path = harness_dir + .join("test-mcp-meta-echo-server.mjs") + .to_string_lossy() + .to_string(); + HashMap::from([( + "meta-echo".to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + tools: Some(vec!["*".to_string()]), + command: if cfg!(windows) { + "node.exe".to_string() + } else { + "node".to_string() + }, + args: vec![server_path], + working_directory: Some(harness_dir.to_string_lossy().to_string()), + ..McpStdioServerConfig::default() + }), + )]) +} + +struct SetMetaHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for SetMetaHooks { + async fn on_pre_mcp_tool_call( + &self, + input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + let _ = self.tx.send(input); + Some(PreMcpToolCallOutput { + meta_to_use: Some(json!({"injected": "by-hook", "source": "test"})), + }) + } +} + +struct ReplaceMetaHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for ReplaceMetaHooks { + async fn on_pre_mcp_tool_call( + &self, + input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + let _ = self.tx.send(input); + Some(PreMcpToolCallOutput { + meta_to_use: Some(json!({"completely": "replaced"})), + }) + } +} + +struct RemoveMetaHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for RemoveMetaHooks { + async fn on_pre_mcp_tool_call( + &self, + input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + let _ = self.tx.send(input); + Some(PreMcpToolCallOutput { + meta_to_use: Some(Value::Null), + }) + } +} + +#[tokio::test] +async fn should_set_meta_via_premcptoolcall_hook() { + with_e2e_context( + "pre_mcp_tool_call_hook", + "should_set_meta_via_premcptoolcall_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root())) + .with_hooks(Arc::new(SetMetaHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.", + ) + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!( + content.contains("injected"), + "Expected 'injected' in response, got: {content}" + ); + assert!( + content.contains("by-hook"), + "Expected 'by-hook' in response, got: {content}" + ); + + let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await; + assert_eq!(input.server_name, "meta-echo"); + assert_eq!(input.tool_name, "echo_meta"); + assert!(!input.working_directory.as_os_str().is_empty()); + assert!(input.timestamp > 0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} #[tokio::test] -async fn rejects_sdk_premcptoolcall_callback_hooks() { +async fn should_replace_meta_via_premcptoolcall_hook() { with_e2e_context( "pre_mcp_tool_call_hook", - "rejects_sdk_premcptoolcall_callback_hooks", + "should_replace_meta_via_premcptoolcall_hook", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); let client = ctx.start_client().await; - match client + let session = client .create_session( ctx.approve_all_session_config() - .with_hooks(Arc::new(PreMcpHooks)), + .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root())) + .with_hooks(Arc::new(ReplaceMetaHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.", ) .await - { - Ok(session) => { - session.disconnect().await.expect("disconnect session"); - panic!("expected SDK callback hooks to be rejected"); - } - Err(err) => assert_unsupported_hooks_error(err), - } + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!( + content.contains("completely"), + "Expected 'completely' in response, got: {content}" + ); + assert!( + content.contains("replaced"), + "Expected 'replaced' in response, got: {content}" + ); + + let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await; + assert_eq!(input.server_name, "meta-echo"); + assert_eq!(input.tool_name, "echo_meta"); + + session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, @@ -37,17 +185,50 @@ async fn rejects_sdk_premcptoolcall_callback_hooks() { .await; } -struct PreMcpHooks; +#[tokio::test] +async fn should_remove_meta_via_premcptoolcall_hook() { + with_e2e_context( + "pre_mcp_tool_call_hook", + "should_remove_meta_via_premcptoolcall_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root())) + .with_hooks(Arc::new(RemoveMetaHooks { tx })), + ) + .await + .expect("create session"); -#[async_trait] -impl SessionHooks for PreMcpHooks { - async fn on_pre_mcp_tool_call( - &self, - _input: PreMcpToolCallInput, - _ctx: HookContext, - ) -> Option { - Some(PreMcpToolCallOutput { - meta_to_use: Some(json!({"injected": "by-hook"})), - }) - } + let answer = session + .send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.", + ) + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!( + content.contains("\"meta\":null"), + "Expected '\"meta\":null' in response, got: {content}" + ); + assert!( + content.contains("test-remove"), + "Expected 'test-remove' in response, got: {content}" + ); + + let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await; + assert_eq!(input.server_name, "meta-echo"); + assert_eq!(input.tool_name, "echo_meta"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; } diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs index 0329cadf0..99529c433 100644 --- a/rust/tests/e2e/subagent_hooks.rs +++ b/rust/tests/e2e/subagent_hooks.rs @@ -5,31 +5,91 @@ use github_copilot_sdk::hooks::{ HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, SessionHooks, }; +use parking_lot::Mutex; -use super::support::{assert_unsupported_hooks_error, with_e2e_context}; +use super::support::with_e2e_context; #[tokio::test] -async fn rejects_sdk_callback_hooks_for_sub_agent_hook_propagation() { +async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls() { with_e2e_context( "subagent_hooks", - "rejects_sdk_callback_hooks_for_sub_agent_hook_propagation", + "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - match client - .create_session( - ctx.approve_all_session_config() - .with_hooks(Arc::new(SubagentHooks)), + std::fs::write( + ctx.work_dir().join("subagent-test.txt"), + "Hello from subagent test!", + ) + .expect("write test file"); + + let hook_log = Arc::new(Mutex::new(Vec::::new())); + + let mut opts = ctx.client_options(); + opts.env.push(( + "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS".into(), + "true".into(), + )); + + let client = github_copilot_sdk::Client::start(opts) + .await + .expect("start client"); + + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + log: Arc::clone(&hook_log), + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait( + "Use the task tool to spawn an explore agent that reads the file \ + subagent-test.txt in the current directory and reports its contents. \ + You must use the task tool.", ) .await - { - Ok(session) => { - session.disconnect().await.expect("disconnect session"); - panic!("expected SDK callback hooks to be rejected"); - } - Err(err) => assert_unsupported_hooks_error(err), - } + .expect("send"); + + let log = hook_log.lock().clone(); + + // Parent tool hooks fire for "task" + let task_pre = log + .iter() + .find(|h| h.kind == "pre" && h.tool_name == "task"); + assert!( + task_pre.is_some(), + "preToolUse should fire for the parent's 'task' tool call" + ); + + // Sub-agent tool hooks fire for "view" + let view_pre: Vec<_> = log + .iter() + .filter(|h| h.kind == "pre" && h.tool_name == "view") + .collect(); + let view_post: Vec<_> = log + .iter() + .filter(|h| h.kind == "post" && h.tool_name == "view") + .collect(); + assert!( + !view_pre.is_empty(), + "preToolUse should fire for the sub-agent's 'view' tool call" + ); + assert!( + !view_post.is_empty(), + "postToolUse should fire for the sub-agent's 'view' tool call" + ); + + // input.session_id distinguishes parent from sub-agent + assert_ne!( + view_pre[0].session_id, + task_pre.unwrap().session_id, + "Sub-agent tool hooks should have a different sessionId than parent tool hooks" + ); + + session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) }, @@ -37,15 +97,29 @@ async fn rejects_sdk_callback_hooks_for_sub_agent_hook_propagation() { .await; } -struct SubagentHooks; +#[derive(Clone, Debug)] +struct HookEntry { + kind: String, + tool_name: String, + session_id: String, +} + +struct RecordingHooks { + log: Arc>>, +} #[async_trait] -impl SessionHooks for SubagentHooks { +impl SessionHooks for RecordingHooks { async fn on_pre_tool_use( &self, - _input: PreToolUseInput, + input: PreToolUseInput, _ctx: HookContext, ) -> Option { + self.log.lock().push(HookEntry { + kind: "pre".to_string(), + tool_name: input.tool_name, + session_id: input.session_id, + }); Some(PreToolUseOutput { permission_decision: Some("allow".to_string()), ..PreToolUseOutput::default() @@ -54,9 +128,14 @@ impl SessionHooks for SubagentHooks { async fn on_post_tool_use( &self, - _input: PostToolUseInput, + input: PostToolUseInput, _ctx: HookContext, ) -> Option { + self.log.lock().push(HookEntry { + kind: "post".to_string(), + tool_name: input.tool_name, + session_id: input.session_id, + }); None } } diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 1936fd889..5052ef1be 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -21,19 +21,9 @@ use tokio::sync::Semaphore; static E2E_CONCURRENCY: LazyLock = LazyLock::new(|| Semaphore::new(e2e_concurrency())); pub const DEFAULT_TEST_TOKEN: &str = "rust-e2e-token"; -const UNSUPPORTED_SDK_HOOKS_MESSAGE: &str = "SDK hook callbacks are no longer supported"; type TestFuture<'a> = Pin + 'a>>; -pub fn assert_unsupported_hooks_error(err: impl std::fmt::Display) { - let message = err.to_string(); - if message.contains(UNSUPPORTED_SDK_HOOKS_MESSAGE) { - return; - } - - panic!("expected unsupported hooks error, got: {message}"); -} - pub async fn with_e2e_context(category: &str, snapshot_name: &str, test: F) where F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 5403fb444..57957499e 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -3784,6 +3784,7 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {}), ...collectRpcMethods(schema.clientSession || {}), + ...collectRpcMethods(schema.clientGlobal || {}), ].sort((left, right) => left.rpcMethod.localeCompare(right.rpcMethod)); // Build a combined definition map, including shared API definitions plus diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 770bae27a..656982f7f 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -501,9 +501,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66.tgz", - "integrity": "sha512-m3+3FLSgum90xN4+eiwnLvdrDvM+oZzur5DfhOH88duNDKBcLQvKQY9fG/I1l1t8a1iBwjpgtRpsBwykE8k3Zw==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.67.tgz", + "integrity": "sha512-5YEY9LNXBT9Q8uShjCdYcornJJJhGtdIzSYla2+pjfXYpHsDVibqYubzYjfgffOUKFChyzOpH7n/868+t56iIg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -513,20 +513,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.66", - "@github/copilot-darwin-x64": "1.0.66", - "@github/copilot-linux-arm64": "1.0.66", - "@github/copilot-linux-x64": "1.0.66", - "@github/copilot-linuxmusl-arm64": "1.0.66", - "@github/copilot-linuxmusl-x64": "1.0.66", - "@github/copilot-win32-arm64": "1.0.66", - "@github/copilot-win32-x64": "1.0.66" + "@github/copilot-darwin-arm64": "1.0.67", + "@github/copilot-darwin-x64": "1.0.67", + "@github/copilot-linux-arm64": "1.0.67", + "@github/copilot-linux-x64": "1.0.67", + "@github/copilot-linuxmusl-arm64": "1.0.67", + "@github/copilot-linuxmusl-x64": "1.0.67", + "@github/copilot-win32-arm64": "1.0.67", + "@github/copilot-win32-x64": "1.0.67" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66.tgz", - "integrity": "sha512-cJPXE2rWSjR+B8GRBUUd0k9PM4euWRUe3xgHoJqi9o/jJjtRYn6DZMrmFt9xgjoYWf0WZOyrlDgedqO1V+zDAg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.67.tgz", + "integrity": "sha512-CO3mpgFXcN6e7ZsSmjMkt1AKxMfb1+mjdn3yrf2DRnnWIURSK9kGvw+E+E1+YE37D1MBiUn/VOBmhRad5+vl0A==", "cpu": [ "arm64" ], @@ -541,9 +541,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66.tgz", - "integrity": "sha512-44mpx2ZcRFHDx4B9xlrL5OQyTgaD/Hn+bAkeStXgcG8UkkfYSsRtLhnaxqUEQrtIEiVQrw++XWvUO0AscRrX+w==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.67.tgz", + "integrity": "sha512-M20Hpn3bOJRkVwAIVRK4ZlX66AqtmGfXZRxZBRFQC045QIwcfmVUP45sTSgXDb4uHWeK0cZgdTdniHwKGtMplw==", "cpu": [ "x64" ], @@ -558,9 +558,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66.tgz", - "integrity": "sha512-uXtTs/rYjk6kacNs/T0s/lxn0JBvAgu78pBoZeWpU5APhICkPy9kC+lNAzLYoZujVVDOHT05IoeifHppFpQ8+w==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.67.tgz", + "integrity": "sha512-b4ePtFBow+Ior+aVLKA1hHxhR5wF+ql5CD7TSg/NHGYgc1kwD+3a9uKSENy05J5Lit/G/DZ9C6JwowvvdMWSKg==", "cpu": [ "arm64" ], @@ -575,9 +575,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66.tgz", - "integrity": "sha512-tXn3OuJCx/YEDNgYg8mdOGSFiIjmLJtTEyZ/VoEA86ffUIPxrunc0wnapEFk2zOW1unwdJeBuVIkzlB3RS1/eA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.67.tgz", + "integrity": "sha512-4ynZyfKnWAdvEPAFDDBIz1wpFttcOTJu4Y8Mlz5oXCBA0NM/rwr8K4l7Adp8UzwbfmdrMJ9y+zivqRBMDbPInA==", "cpu": [ "x64" ], @@ -592,9 +592,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66.tgz", - "integrity": "sha512-sHRag7W5CG0kbbX3j9v9cUmIafk/0N8MGGr2knvPeIHtxwZQYYjx397gT1nN6xagLWt5mvchkYybfQFCyCBaxg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.67.tgz", + "integrity": "sha512-IjezxBU8fYUr/b5hEiniXqzwoOrJ4egrQSBbG96M+roLTqd9txP0MgxZtcRtKV7phRIdIGE109wwrn4H6hSqmA==", "cpu": [ "arm64" ], @@ -609,9 +609,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66.tgz", - "integrity": "sha512-bdIgHOaVZlvsF/4awzMxsby6T+4k7aWe9HZr+sr+qU8tuG19jwi/1LXGB6tKdlFeFgY78yX0lR+ywByVJc5loA==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.67.tgz", + "integrity": "sha512-Zy/rbja1lnhzDoNfn051H0EybCseCvjvH7WmbcHCayjXUjzXeKF6OmAt4hvqFZH87ttT3KbKtQ8/6oDUhhM2YQ==", "cpu": [ "x64" ], @@ -626,9 +626,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66.tgz", - "integrity": "sha512-T7FGONCVWIPjjAxp22cu4WKqNogq56FknHGAvj7Ryn5ZoanFAR3vXXlXDsYnDKLBcshjRYGxocl2UnmRTMxgvg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.67.tgz", + "integrity": "sha512-O3VFRS5v9NXRP8o+N1SvcFbBqECDzZP7XQBeBj2Vcrma80gdJc5GQub/w2mwmr1w5UbwgzJkRasm0Ec/jxbcoA==", "cpu": [ "arm64" ], @@ -643,9 +643,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.66", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66.tgz", - "integrity": "sha512-eroxRUSJZOJCk0luLyX6A1qqGIWs8p4w0EjZFhCzvdFvJ0abIovGyt3R/gN9DeyJM8Qs7ROPGvqevUlXh6DhCg==", + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.67.tgz", + "integrity": "sha512-td5tQ/nve5dB7RPvNglBZwa/6DJqiOBgacXXa1GpYcohqpCzoI8gONNkeaeyr6oF4iu5wXJ9krUNr6QXL4yB5Q==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 3c59cb235..4e167fe7b 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -14,7 +14,7 @@ "node": "^20.19.0 || >=22.12.0" }, "devDependencies": { - "@github/copilot": "^1.0.66", + "@github/copilot": "^1.0.67", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14",