diff --git a/.vscode/settings.json b/.vscode/settings.json index 0feadb3b..bd13188f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,8 @@ }, "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, - "python.testing.pytestArgs": ["python"] + "python.testing.pytestArgs": ["python"], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } } diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index cebdc335..b7f64c00 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -336,6 +336,14 @@ public async Task CreateSessionAsync(SessionConfig? config = nul { var connection = await EnsureConnectedAsync(cancellationToken); + var hasHooks = config?.Hooks != null && ( + config.Hooks.OnPreToolUse != null || + config.Hooks.OnPostToolUse != null || + config.Hooks.OnUserPromptSubmitted != null || + config.Hooks.OnSessionStart != null || + config.Hooks.OnSessionEnd != null || + config.Hooks.OnErrorOccurred != null); + var request = new CreateSessionRequest( config?.Model, config?.SessionId, @@ -345,6 +353,9 @@ public async Task CreateSessionAsync(SessionConfig? config = nul config?.ExcludedTools, config?.Provider, config?.OnPermissionRequest != null ? true : null, + config?.OnUserInputRequest != null ? true : null, + hasHooks ? true : null, + config?.WorkingDirectory, config?.Streaming == true ? true : null, config?.McpServers, config?.CustomAgents, @@ -362,6 +373,14 @@ public async Task CreateSessionAsync(SessionConfig? config = nul { session.RegisterPermissionHandler(config.OnPermissionRequest); } + if (config?.OnUserInputRequest != null) + { + session.RegisterUserInputHandler(config.OnUserInputRequest); + } + if (config?.Hooks != null) + { + session.RegisterHooks(config.Hooks); + } if (!_sessions.TryAdd(response.SessionId, session)) { @@ -399,11 +418,23 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { var connection = await EnsureConnectedAsync(cancellationToken); + var hasHooks = config?.Hooks != null && ( + config.Hooks.OnPreToolUse != null || + config.Hooks.OnPostToolUse != null || + config.Hooks.OnUserPromptSubmitted != null || + config.Hooks.OnSessionStart != null || + config.Hooks.OnSessionEnd != null || + config.Hooks.OnErrorOccurred != null); + var request = new ResumeSessionRequest( sessionId, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config?.Provider, config?.OnPermissionRequest != null ? true : null, + config?.OnUserInputRequest != null ? true : null, + hasHooks ? true : null, + config?.WorkingDirectory, + config?.DisableResume == true ? true : null, config?.Streaming == true ? true : null, config?.McpServers, config?.CustomAgents, @@ -419,6 +450,14 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { session.RegisterPermissionHandler(config.OnPermissionRequest); } + if (config?.OnUserInputRequest != null) + { + session.RegisterUserInputHandler(config.OnUserInputRequest); + } + if (config?.Hooks != null) + { + session.RegisterHooks(config.Hooks); + } // Replace any existing session entry to ensure new config (like permission handler) is used _sessions[response.SessionId] = session; @@ -804,6 +843,8 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall); rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest); + rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); + rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.StartListening(); return new Connection(rpc, cliProcess, tcpClient, networkStream); } @@ -990,6 +1031,37 @@ public async Task OnPermissionRequest(string sessionI }); } } + + public async Task OnUserInputRequest(string sessionId, string question, List? choices = null, bool? allowFreeform = null) + { + var session = client.GetSession(sessionId); + if (session == null) + { + throw new ArgumentException($"Unknown session {sessionId}"); + } + + var request = new UserInputRequest + { + Question = question, + Choices = choices, + AllowFreeform = allowFreeform + }; + + var result = await session.HandleUserInputRequestAsync(request); + return new UserInputRequestResponse(result.Answer, result.WasFreeform); + } + + public async Task OnHooksInvoke(string sessionId, string hookType, JsonElement input) + { + var session = client.GetSession(sessionId); + if (session == null) + { + throw new ArgumentException($"Unknown session {sessionId}"); + } + + var output = await session.HandleHooksInvokeAsync(hookType, input); + return new HooksInvokeResponse(output); + } } private class Connection( @@ -1024,6 +1096,9 @@ internal record CreateSessionRequest( List? ExcludedTools, ProviderConfig? Provider, bool? RequestPermission, + bool? RequestUserInput, + bool? Hooks, + string? WorkingDirectory, bool? Streaming, Dictionary? McpServers, List? CustomAgents, @@ -1050,6 +1125,10 @@ internal record ResumeSessionRequest( List? Tools, ProviderConfig? Provider, bool? RequestPermission, + bool? RequestUserInput, + bool? Hooks, + string? WorkingDirectory, + bool? DisableResume, bool? Streaming, Dictionary? McpServers, List? CustomAgents, @@ -1079,6 +1158,13 @@ internal record ToolCallResponse( internal record PermissionRequestResponse( PermissionRequestResult Result); + internal record UserInputRequestResponse( + string Answer, + bool WasFreeform); + + internal record HooksInvokeResponse( + object? Output); + /// Trace source that forwards all logs to the ILogger. internal sealed class LoggerTraceSource : TraceSource { @@ -1131,6 +1217,7 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(DeleteSessionRequest))] [JsonSerializable(typeof(DeleteSessionResponse))] [JsonSerializable(typeof(GetLastSessionIdResponse))] + [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(PermissionRequestResponse))] [JsonSerializable(typeof(PermissionRequestResult))] @@ -1143,6 +1230,9 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] + [JsonSerializable(typeof(UserInputRequestResponse))] + [JsonSerializable(typeof(UserInputRequest))] + [JsonSerializable(typeof(UserInputResponse))] internal partial class ClientJsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 7f1cc4e4..aa2d5b04 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -48,6 +48,10 @@ public partial class CopilotSession : IAsyncDisposable private readonly JsonRpc _rpc; private PermissionHandler? _permissionHandler; private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1); + private UserInputHandler? _userInputHandler; + private readonly SemaphoreSlim _userInputHandlerLock = new(1, 1); + private SessionHooks? _hooks; + private readonly SemaphoreSlim _hooksLock = new(1, 1); /// /// Gets the unique identifier for this session. @@ -330,6 +334,136 @@ internal async Task HandlePermissionRequestAsync(JsonEl return await handler(request, invocation); } + /// + /// Registers a handler for user input requests from the agent. + /// + /// The handler to invoke when user input is requested. + internal void RegisterUserInputHandler(UserInputHandler handler) + { + _userInputHandlerLock.Wait(); + try + { + _userInputHandler = handler; + } + finally + { + _userInputHandlerLock.Release(); + } + } + + /// + /// Handles a user input request from the Copilot CLI. + /// + /// The user input request from the CLI. + /// A task that resolves with the user's response. + internal async Task HandleUserInputRequestAsync(UserInputRequest request) + { + await _userInputHandlerLock.WaitAsync(); + UserInputHandler? handler; + try + { + handler = _userInputHandler; + } + finally + { + _userInputHandlerLock.Release(); + } + + if (handler == null) + { + throw new InvalidOperationException("No user input handler registered"); + } + + var invocation = new UserInputInvocation + { + SessionId = SessionId + }; + + return await handler(request, invocation); + } + + /// + /// Registers hook handlers for this session. + /// + /// The hooks configuration. + internal void RegisterHooks(SessionHooks hooks) + { + _hooksLock.Wait(); + try + { + _hooks = hooks; + } + finally + { + _hooksLock.Release(); + } + } + + /// + /// Handles a hook invocation from the Copilot CLI. + /// + /// The type of hook to invoke. + /// The hook input data. + /// A task that resolves with the hook output. + internal async Task HandleHooksInvokeAsync(string hookType, JsonElement input) + { + await _hooksLock.WaitAsync(); + SessionHooks? hooks; + try + { + hooks = _hooks; + } + finally + { + _hooksLock.Release(); + } + + if (hooks == null) + { + return null; + } + + var invocation = new HookInvocation + { + SessionId = SessionId + }; + + return hookType switch + { + "preToolUse" => hooks.OnPreToolUse != null + ? await hooks.OnPreToolUse( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!, + invocation) + : null, + "postToolUse" => hooks.OnPostToolUse != null + ? await hooks.OnPostToolUse( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!, + invocation) + : null, + "userPromptSubmitted" => hooks.OnUserPromptSubmitted != null + ? await hooks.OnUserPromptSubmitted( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptSubmittedHookInput)!, + invocation) + : null, + "sessionStart" => hooks.OnSessionStart != null + ? await hooks.OnSessionStart( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionStartHookInput)!, + invocation) + : null, + "sessionEnd" => hooks.OnSessionEnd != null + ? await hooks.OnSessionEnd( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionEndHookInput)!, + invocation) + : null, + "errorOccurred" => hooks.OnErrorOccurred != null + ? await hooks.OnErrorOccurred( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.ErrorOccurredHookInput)!, + invocation) + : null, + _ => throw new ArgumentException($"Unknown hook type: {hookType}") + }; + } + /// /// Gets the complete list of messages and events in the session. /// @@ -487,5 +621,17 @@ internal record SessionDestroyRequest [JsonSerializable(typeof(SessionAbortRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] [JsonSerializable(typeof(UserMessageDataAttachmentsItem))] + [JsonSerializable(typeof(PreToolUseHookInput))] + [JsonSerializable(typeof(PreToolUseHookOutput))] + [JsonSerializable(typeof(PostToolUseHookInput))] + [JsonSerializable(typeof(PostToolUseHookOutput))] + [JsonSerializable(typeof(UserPromptSubmittedHookInput))] + [JsonSerializable(typeof(UserPromptSubmittedHookOutput))] + [JsonSerializable(typeof(SessionStartHookInput))] + [JsonSerializable(typeof(SessionStartHookOutput))] + [JsonSerializable(typeof(SessionEndHookInput))] + [JsonSerializable(typeof(SessionEndHookOutput))] + [JsonSerializable(typeof(ErrorOccurredHookInput))] + [JsonSerializable(typeof(ErrorOccurredHookOutput))] internal partial class SessionJsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index cfc9a7c2..c2aac260 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -126,6 +126,350 @@ public class PermissionInvocation public delegate Task PermissionHandler(PermissionRequest request, PermissionInvocation invocation); +// ============================================================================ +// User Input Handler Types +// ============================================================================ + +/// +/// Request for user input from the agent. +/// +public class UserInputRequest +{ + /// + /// The question to ask the user. + /// + [JsonPropertyName("question")] + public string Question { get; set; } = string.Empty; + + /// + /// Optional choices for multiple choice questions. + /// + [JsonPropertyName("choices")] + public List? Choices { get; set; } + + /// + /// Whether freeform text input is allowed. + /// + [JsonPropertyName("allowFreeform")] + public bool? AllowFreeform { get; set; } +} + +/// +/// Response to a user input request. +/// +public class UserInputResponse +{ + /// + /// The user's answer. + /// + [JsonPropertyName("answer")] + public string Answer { get; set; } = string.Empty; + + /// + /// Whether the answer was freeform (not from the provided choices). + /// + [JsonPropertyName("wasFreeform")] + public bool WasFreeform { get; set; } +} + +/// +/// Context for a user input request invocation. +/// +public class UserInputInvocation +{ + public string SessionId { get; set; } = string.Empty; +} + +/// +/// Handler for user input requests from the agent. +/// +public delegate Task UserInputHandler(UserInputRequest request, UserInputInvocation invocation); + +// ============================================================================ +// Hook Handler Types +// ============================================================================ + +/// +/// Context for a hook invocation. +/// +public class HookInvocation +{ + public string SessionId { get; set; } = string.Empty; +} + +/// +/// Input for a pre-tool-use hook. +/// +public class PreToolUseHookInput +{ + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = string.Empty; + + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = string.Empty; + + [JsonPropertyName("toolArgs")] + public object? ToolArgs { get; set; } +} + +/// +/// Output for a pre-tool-use hook. +/// +public class PreToolUseHookOutput +{ + /// + /// Permission decision: "allow", "deny", or "ask". + /// + [JsonPropertyName("permissionDecision")] + public string? PermissionDecision { get; set; } + + [JsonPropertyName("permissionDecisionReason")] + public string? PermissionDecisionReason { get; set; } + + [JsonPropertyName("modifiedArgs")] + public object? ModifiedArgs { get; set; } + + [JsonPropertyName("additionalContext")] + public string? AdditionalContext { get; set; } + + [JsonPropertyName("suppressOutput")] + public bool? SuppressOutput { get; set; } +} + +public delegate Task PreToolUseHandler(PreToolUseHookInput input, HookInvocation invocation); + +/// +/// Input for a post-tool-use hook. +/// +public class PostToolUseHookInput +{ + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = string.Empty; + + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = string.Empty; + + [JsonPropertyName("toolArgs")] + public object? ToolArgs { get; set; } + + [JsonPropertyName("toolResult")] + public object? ToolResult { get; set; } +} + +/// +/// Output for a post-tool-use hook. +/// +public class PostToolUseHookOutput +{ + [JsonPropertyName("modifiedResult")] + public object? ModifiedResult { get; set; } + + [JsonPropertyName("additionalContext")] + public string? AdditionalContext { get; set; } + + [JsonPropertyName("suppressOutput")] + public bool? SuppressOutput { get; set; } +} + +public delegate Task PostToolUseHandler(PostToolUseHookInput input, HookInvocation invocation); + +/// +/// Input for a user-prompt-submitted hook. +/// +public class UserPromptSubmittedHookInput +{ + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = string.Empty; + + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = string.Empty; +} + +/// +/// Output for a user-prompt-submitted hook. +/// +public class UserPromptSubmittedHookOutput +{ + [JsonPropertyName("modifiedPrompt")] + public string? ModifiedPrompt { get; set; } + + [JsonPropertyName("additionalContext")] + public string? AdditionalContext { get; set; } + + [JsonPropertyName("suppressOutput")] + public bool? SuppressOutput { get; set; } +} + +public delegate Task UserPromptSubmittedHandler(UserPromptSubmittedHookInput input, HookInvocation invocation); + +/// +/// Input for a session-start hook. +/// +public class SessionStartHookInput +{ + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = string.Empty; + + /// + /// Source of the session start: "startup", "resume", or "new". + /// + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + [JsonPropertyName("initialPrompt")] + public string? InitialPrompt { get; set; } +} + +/// +/// Output for a session-start hook. +/// +public class SessionStartHookOutput +{ + [JsonPropertyName("additionalContext")] + public string? AdditionalContext { get; set; } + + [JsonPropertyName("modifiedConfig")] + public Dictionary? ModifiedConfig { get; set; } +} + +public delegate Task SessionStartHandler(SessionStartHookInput input, HookInvocation invocation); + +/// +/// Input for a session-end hook. +/// +public class SessionEndHookInput +{ + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = string.Empty; + + /// + /// Reason for session end: "complete", "error", "abort", "timeout", or "user_exit". + /// + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonPropertyName("finalMessage")] + public string? FinalMessage { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// +/// Output for a session-end hook. +/// +public class SessionEndHookOutput +{ + [JsonPropertyName("suppressOutput")] + public bool? SuppressOutput { get; set; } + + [JsonPropertyName("cleanupActions")] + public List? CleanupActions { get; set; } + + [JsonPropertyName("sessionSummary")] + public string? SessionSummary { get; set; } +} + +public delegate Task SessionEndHandler(SessionEndHookInput input, HookInvocation invocation); + +/// +/// Input for an error-occurred hook. +/// +public class ErrorOccurredHookInput +{ + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("cwd")] + public string Cwd { get; set; } = string.Empty; + + [JsonPropertyName("error")] + public string Error { get; set; } = string.Empty; + + /// + /// Context of the error: "model_call", "tool_execution", "system", or "user_input". + /// + [JsonPropertyName("errorContext")] + public string ErrorContext { get; set; } = string.Empty; + + [JsonPropertyName("recoverable")] + public bool Recoverable { get; set; } +} + +/// +/// Output for an error-occurred hook. +/// +public class ErrorOccurredHookOutput +{ + [JsonPropertyName("suppressOutput")] + public bool? SuppressOutput { get; set; } + + /// + /// Error handling strategy: "retry", "skip", or "abort". + /// + [JsonPropertyName("errorHandling")] + public string? ErrorHandling { get; set; } + + [JsonPropertyName("retryCount")] + public int? RetryCount { get; set; } + + [JsonPropertyName("userNotification")] + public string? UserNotification { get; set; } +} + +public delegate Task ErrorOccurredHandler(ErrorOccurredHookInput input, HookInvocation invocation); + +/// +/// Hook handlers configuration for a session. +/// +public class SessionHooks +{ + /// + /// Handler called before a tool is executed. + /// + public PreToolUseHandler? OnPreToolUse { get; set; } + + /// + /// Handler called after a tool has been executed. + /// + public PostToolUseHandler? OnPostToolUse { get; set; } + + /// + /// Handler called when the user submits a prompt. + /// + public UserPromptSubmittedHandler? OnUserPromptSubmitted { get; set; } + + /// + /// Handler called when a session starts. + /// + public SessionStartHandler? OnSessionStart { get; set; } + + /// + /// Handler called when a session ends. + /// + public SessionEndHandler? OnSessionEnd { get; set; } + + /// + /// Handler called when an error occurs. + /// + public ErrorOccurredHandler? OnErrorOccurred { get; set; } +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SystemMessageMode { @@ -366,6 +710,22 @@ public class SessionConfig /// public PermissionHandler? OnPermissionRequest { get; set; } + /// + /// Handler for user input requests from the agent. + /// When provided, enables the ask_user tool for the agent to request user input. + /// + public UserInputHandler? OnUserInputRequest { get; set; } + + /// + /// Hook handlers for session lifecycle events. + /// + public SessionHooks? Hooks { get; set; } + + /// + /// Working directory for the session. + /// + public string? WorkingDirectory { get; set; } + /// /// Enable streaming of assistant message and reasoning chunks. /// When true, assistant.message_delta and assistant.reasoning_delta events @@ -412,6 +772,28 @@ public class ResumeSessionConfig /// public PermissionHandler? OnPermissionRequest { get; set; } + /// + /// Handler for user input requests from the agent. + /// When provided, enables the ask_user tool for the agent to request user input. + /// + public UserInputHandler? OnUserInputRequest { get; set; } + + /// + /// Hook handlers for session lifecycle events. + /// + public SessionHooks? Hooks { get; set; } + + /// + /// Working directory for the session. + /// + public string? WorkingDirectory { get; set; } + + /// + /// When true, the session.resume event is not emitted. + /// Default: false (resume event is emitted). + /// + public bool DisableResume { get; set; } + /// /// Enable streaming of assistant message and reasoning chunks. /// When true, assistant.message_delta and assistant.reasoning_delta events diff --git a/dotnet/test/AskUserTests.cs b/dotnet/test/AskUserTests.cs new file mode 100644 index 00000000..c21df302 --- /dev/null +++ b/dotnet/test/AskUserTests.cs @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class AskUserTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "ask-user", output) +{ + [Fact] + public async Task Should_Invoke_User_Input_Handler_When_Model_Uses_Ask_User_Tool() + { + var userInputRequests = new List(); + CopilotSession? session = null; + session = await Client.CreateSessionAsync(new SessionConfig + { + OnUserInputRequest = (request, invocation) => + { + userInputRequests.Add(request); + Assert.Equal(session!.SessionId, invocation.SessionId); + + // Return the first choice if available, otherwise a freeform answer + var answer = request.Choices?.FirstOrDefault() ?? "freeform answer"; + var wasFreeform = request.Choices == null || request.Choices.Count == 0; + + return Task.FromResult(new UserInputResponse { Answer = answer, WasFreeform = wasFreeform }); + } + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before continuing." + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Should have received at least one user input request + Assert.NotEmpty(userInputRequests); + + // The request should have a question + Assert.Contains(userInputRequests, r => !string.IsNullOrEmpty(r.Question)); + } + + [Fact] + public async Task Should_Receive_Choices_In_User_Input_Request() + { + var userInputRequests = new List(); + + var session = await Client.CreateSessionAsync(new SessionConfig + { + OnUserInputRequest = (request, invocation) => + { + userInputRequests.Add(request); + + // Pick the first choice + var answer = request.Choices?.FirstOrDefault() ?? "default"; + + return Task.FromResult(new UserInputResponse { Answer = answer, WasFreeform = false }); + } + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use the ask_user tool to ask me to pick between exactly two options: 'Red' and 'Blue'. These should be provided as choices. Wait for my answer." + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Should have received a request + Assert.NotEmpty(userInputRequests); + + // At least one request should have choices + Assert.Contains(userInputRequests, r => r.Choices != null && r.Choices.Count > 0); + } + + [Fact] + public async Task Should_Handle_Freeform_User_Input_Response() + { + var userInputRequests = new List(); + var freeformAnswer = "This is my custom freeform answer that was not in the choices"; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + OnUserInputRequest = (request, invocation) => + { + userInputRequests.Add(request); + + // Return a freeform answer (not from choices) + return Task.FromResult(new UserInputResponse { Answer = freeformAnswer, WasFreeform = true }); + } + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Ask me a question using ask_user and then include my answer in your response. The question should be 'What is your favorite color?'" + }); + + var response = await TestHelper.GetFinalAssistantMessageAsync(session); + + // Should have received a request + Assert.NotEmpty(userInputRequests); + + // The model's response should be defined + Assert.NotNull(response); + } +} diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs new file mode 100644 index 00000000..34f6ecab --- /dev/null +++ b/dotnet/test/HooksTests.cs @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class HooksTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "hooks", output) +{ + [Fact] + public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() + { + var preToolUseInputs = new List(); + CopilotSession? session = null; + session = await Client.CreateSessionAsync(new SessionConfig + { + 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.Combine(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)); + } + + [Fact] + public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() + { + var postToolUseInputs = new List(); + CopilotSession? session = null; + session = await Client.CreateSessionAsync(new SessionConfig + { + 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.Combine(Ctx.WorkDir, "world.txt"), "World from the test!"); + + await session.SendAsync(new MessageOptions + { + 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 Client.CreateSessionAsync(new SessionConfig + { + 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.Combine(Ctx.WorkDir, "both.txt"), "Testing both hooks!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of both.txt" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Both hooks should have been called + Assert.NotEmpty(preToolUseInputs); + Assert.NotEmpty(postToolUseInputs); + + // 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() + { + var preToolUseInputs = new List(); + + var session = await Client.CreateSessionAsync(new SessionConfig + { + 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.Combine(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); + } +} diff --git a/go/client.go b/go/client.go index afbcf7fa..581ba7fc 100644 --- a/go/client.go +++ b/go/client.go @@ -512,6 +512,23 @@ func (c *Client) CreateSession(config *SessionConfig) (*Session, error) { if config.OnPermissionRequest != nil { params["requestPermission"] = true } + // Add user input request flag + if config.OnUserInputRequest != nil { + params["requestUserInput"] = true + } + // Add hooks flag + if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPostToolUse != nil || + config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnSessionStart != nil || + config.Hooks.OnSessionEnd != nil || + config.Hooks.OnErrorOccurred != nil) { + params["hooks"] = true + } + // Add working directory + if config.WorkingDirectory != "" { + params["workingDirectory"] = config.WorkingDirectory + } // Add MCP servers configuration if len(config.MCPServers) > 0 { params["mcpServers"] = config.MCPServers @@ -590,6 +607,12 @@ func (c *Client) CreateSession(config *SessionConfig) (*Session, error) { if config.OnPermissionRequest != nil { session.registerPermissionHandler(config.OnPermissionRequest) } + if config.OnUserInputRequest != nil { + session.registerUserInputHandler(config.OnUserInputRequest) + } + if config.Hooks != nil { + session.registerHooks(config.Hooks) + } } else { session.registerTools(nil) } @@ -668,6 +691,27 @@ func (c *Client) ResumeSessionWithOptions(sessionID string, config *ResumeSessio if config.OnPermissionRequest != nil { params["requestPermission"] = true } + // Add user input request flag + if config.OnUserInputRequest != nil { + params["requestUserInput"] = true + } + // Add hooks flag + if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPostToolUse != nil || + config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnSessionStart != nil || + config.Hooks.OnSessionEnd != nil || + config.Hooks.OnErrorOccurred != nil) { + params["hooks"] = true + } + // Add working directory + if config.WorkingDirectory != "" { + params["workingDirectory"] = config.WorkingDirectory + } + // Add disable resume flag + if config.DisableResume { + params["disableResume"] = true + } // Add MCP servers configuration if len(config.MCPServers) > 0 { params["mcpServers"] = config.MCPServers @@ -727,6 +771,12 @@ func (c *Client) ResumeSessionWithOptions(sessionID string, config *ResumeSessio if config.OnPermissionRequest != nil { session.registerPermissionHandler(config.OnPermissionRequest) } + if config.OnUserInputRequest != nil { + session.registerUserInputHandler(config.OnUserInputRequest) + } + if config.Hooks != nil { + session.registerHooks(config.Hooks) + } } else { session.registerTools(nil) } @@ -1187,6 +1237,8 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("tool.call", c.handleToolCallRequest) c.client.SetRequestHandler("permission.request", c.handlePermissionRequest) + c.client.SetRequestHandler("userInput.request", c.handleUserInputRequest) + c.client.SetRequestHandler("hooks.invoke", c.handleHooksInvoke) } // handleToolCallRequest handles a tool call request from the CLI server. @@ -1278,7 +1330,83 @@ func (c *Client) handlePermissionRequest(params map[string]interface{}) (map[str return map[string]interface{}{"result": result}, nil } -// buildFailedToolResult creates a failure ToolResult with an internal error message. +// handleUserInputRequest handles a user input request from the CLI server. +func (c *Client) handleUserInputRequest(params map[string]interface{}) (map[string]interface{}, *JSONRPCError) { + sessionID, _ := params["sessionId"].(string) + question, _ := params["question"].(string) + + if sessionID == "" || question == "" { + return nil, &JSONRPCError{Code: -32602, Message: "invalid user input request payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[sessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, &JSONRPCError{Code: -32602, Message: fmt.Sprintf("unknown session %s", sessionID)} + } + + // Parse choices + var choices []string + if choicesRaw, ok := params["choices"].([]interface{}); ok { + for _, choice := range choicesRaw { + if s, ok := choice.(string); ok { + choices = append(choices, s) + } + } + } + + var allowFreeform *bool + if af, ok := params["allowFreeform"].(bool); ok { + allowFreeform = &af + } + + request := UserInputRequest{ + Question: question, + Choices: choices, + AllowFreeform: allowFreeform, + } + + response, err := session.handleUserInputRequest(request) + if err != nil { + return nil, &JSONRPCError{Code: -32603, Message: err.Error()} + } + + return map[string]interface{}{ + "answer": response.Answer, + "wasFreeform": response.WasFreeform, + }, nil +} + +// handleHooksInvoke handles a hooks invocation from the CLI server. +func (c *Client) handleHooksInvoke(params map[string]interface{}) (map[string]interface{}, *JSONRPCError) { + sessionID, _ := params["sessionId"].(string) + hookType, _ := params["hookType"].(string) + input, _ := params["input"].(map[string]interface{}) + + if sessionID == "" || hookType == "" { + return nil, &JSONRPCError{Code: -32602, Message: "invalid hooks invoke payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[sessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, &JSONRPCError{Code: -32602, Message: fmt.Sprintf("unknown session %s", sessionID)} + } + + output, err := session.handleHooksInvoke(hookType, input) + if err != nil { + return nil, &JSONRPCError{Code: -32603, Message: err.Error()} + } + + result := make(map[string]interface{}) + if output != nil { + result["output"] = output + } + return result, nil +} + // The detailed error is stored in the Error field but not exposed to the LLM for security. func buildFailedToolResult(internalError string) ToolResult { return ToolResult{ diff --git a/go/e2e/ask_user_test.go b/go/e2e/ask_user_test.go new file mode 100644 index 00000000..e2952356 --- /dev/null +++ b/go/e2e/ask_user_test.go @@ -0,0 +1,174 @@ +package e2e + +import ( + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/e2e/testharness" +) + +func TestAskUser(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should invoke user input handler when model uses ask_user tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var userInputRequests []copilot.UserInputRequest + var mu sync.Mutex + + session, err := client.CreateSession(&copilot.SessionConfig{ + OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + mu.Lock() + userInputRequests = append(userInputRequests, request) + mu.Unlock() + + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + + // Return the first choice if available, otherwise a freeform answer + answer := "freeform answer" + wasFreeform := true + if len(request.Choices) > 0 { + answer = request.Choices[0] + wasFreeform = false + } + + return copilot.UserInputResponse{ + Answer: answer, + WasFreeform: wasFreeform, + }, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(copilot.MessageOptions{ + Prompt: "Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before continuing.", + }, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(userInputRequests) == 0 { + t.Error("Expected at least one user input request") + } + + hasQuestion := false + for _, req := range userInputRequests { + if req.Question != "" { + hasQuestion = true + break + } + } + if !hasQuestion { + t.Error("Expected at least one request with a question") + } + }) + + t.Run("should receive choices in user input request", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var userInputRequests []copilot.UserInputRequest + var mu sync.Mutex + + session, err := client.CreateSession(&copilot.SessionConfig{ + OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + mu.Lock() + userInputRequests = append(userInputRequests, request) + mu.Unlock() + + // Pick the first choice + answer := "default" + if len(request.Choices) > 0 { + answer = request.Choices[0] + } + + return copilot.UserInputResponse{ + Answer: answer, + WasFreeform: false, + }, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(copilot.MessageOptions{ + Prompt: "Use the ask_user tool to ask me to pick between exactly two options: 'Red' and 'Blue'. These should be provided as choices. Wait for my answer.", + }, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(userInputRequests) == 0 { + t.Error("Expected at least one user input request") + } + + hasChoices := false + for _, req := range userInputRequests { + if len(req.Choices) > 0 { + hasChoices = true + break + } + } + if !hasChoices { + t.Error("Expected at least one request with choices") + } + }) + + t.Run("should handle freeform user input response", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var userInputRequests []copilot.UserInputRequest + var mu sync.Mutex + freeformAnswer := "This is my custom freeform answer that was not in the choices" + + session, err := client.CreateSession(&copilot.SessionConfig{ + OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + mu.Lock() + userInputRequests = append(userInputRequests, request) + mu.Unlock() + + // Return a freeform answer (not from choices) + return copilot.UserInputResponse{ + Answer: freeformAnswer, + WasFreeform: true, + }, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(copilot.MessageOptions{ + Prompt: "Ask me a question using ask_user and then include my answer in your response. The question should be 'What is your favorite color?'", + }, 60*time.Second) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if len(userInputRequests) == 0 { + t.Error("Expected at least one user input request") + } + + // The model's response should be defined + if response == nil { + t.Error("Expected non-nil response") + } + }) +} diff --git a/go/e2e/hooks_test.go b/go/e2e/hooks_test.go new file mode 100644 index 00000000..1486c088 --- /dev/null +++ b/go/e2e/hooks_test.go @@ -0,0 +1,259 @@ +package e2e + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/e2e/testharness" +) + +func TestHooks(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + 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(&copilot.SessionConfig{ + 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 + }, + }, + }) + 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(copilot.MessageOptions{ + Prompt: "Read the contents of hello.txt and tell me what it says", + }, 60*time.Second) + 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(&copilot.SessionConfig{ + 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 + }, + }, + }) + 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(copilot.MessageOptions{ + Prompt: "Read the contents of world.txt and tell me what it says", + }, 60*time.Second) + 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(&copilot.SessionConfig{ + 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 + }, + }, + }) + 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(copilot.MessageOptions{ + Prompt: "Read the contents of both.txt", + }, 60*time.Second) + 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(&copilot.SessionConfig{ + 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) + } + + response, err := session.SendAndWait(copilot.MessageOptions{ + Prompt: "Edit protected.txt and replace 'Original' with 'Modified'", + }, 60*time.Second) + 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") + } + }) +} diff --git a/go/session.go b/go/session.go index d6b0b23a..63838569 100644 --- a/go/session.go +++ b/go/session.go @@ -56,6 +56,10 @@ type Session struct { toolHandlersM sync.RWMutex permissionHandler PermissionHandler permissionMux sync.RWMutex + userInputHandler UserInputHandler + userInputMux sync.RWMutex + hooks *SessionHooks + hooksMux sync.RWMutex } // WorkspacePath returns the path to the session workspace directory when infinite @@ -326,6 +330,225 @@ func (s *Session) handlePermissionRequest(requestData map[string]interface{}) (P return handler(request, invocation) } +// registerUserInputHandler registers a user input handler for this session. +// +// When the assistant needs to ask the user a question (e.g., via ask_user tool), +// this handler is called to get the user's response. +// +// This method is internal and typically called when creating a session. +func (s *Session) registerUserInputHandler(handler UserInputHandler) { + s.userInputMux.Lock() + defer s.userInputMux.Unlock() + s.userInputHandler = handler +} + +// getUserInputHandler returns the currently registered user input handler, or nil. +func (s *Session) getUserInputHandler() UserInputHandler { + s.userInputMux.RLock() + defer s.userInputMux.RUnlock() + return s.userInputHandler +} + +// handleUserInputRequest handles a user input request from the Copilot CLI. +// This is an internal method called by the SDK when the CLI requests user input. +func (s *Session) handleUserInputRequest(request UserInputRequest) (UserInputResponse, error) { + handler := s.getUserInputHandler() + + if handler == nil { + return UserInputResponse{}, fmt.Errorf("no user input handler registered") + } + + invocation := UserInputInvocation{ + SessionID: s.SessionID, + } + + return handler(request, invocation) +} + +// registerHooks registers hook handlers for this session. +// +// Hooks are called at various points during session execution to allow +// customization and observation of the session lifecycle. +// +// This method is internal and typically called when creating a session. +func (s *Session) registerHooks(hooks *SessionHooks) { + s.hooksMux.Lock() + defer s.hooksMux.Unlock() + s.hooks = hooks +} + +// getHooks returns the currently registered hooks, or nil. +func (s *Session) getHooks() *SessionHooks { + s.hooksMux.RLock() + defer s.hooksMux.RUnlock() + return s.hooks +} + +// handleHooksInvoke handles a hook invocation from the Copilot CLI. +// This is an internal method called by the SDK when the CLI invokes a hook. +func (s *Session) handleHooksInvoke(hookType string, input map[string]interface{}) (interface{}, error) { + hooks := s.getHooks() + + if hooks == nil { + return nil, nil + } + + invocation := HookInvocation{ + SessionID: s.SessionID, + } + + switch hookType { + case "preToolUse": + if hooks.OnPreToolUse == nil { + return nil, nil + } + hookInput := parsePreToolUseInput(input) + return hooks.OnPreToolUse(hookInput, invocation) + + case "postToolUse": + if hooks.OnPostToolUse == nil { + return nil, nil + } + hookInput := parsePostToolUseInput(input) + return hooks.OnPostToolUse(hookInput, invocation) + + case "userPromptSubmitted": + if hooks.OnUserPromptSubmitted == nil { + return nil, nil + } + hookInput := parseUserPromptSubmittedInput(input) + return hooks.OnUserPromptSubmitted(hookInput, invocation) + + case "sessionStart": + if hooks.OnSessionStart == nil { + return nil, nil + } + hookInput := parseSessionStartInput(input) + return hooks.OnSessionStart(hookInput, invocation) + + case "sessionEnd": + if hooks.OnSessionEnd == nil { + return nil, nil + } + hookInput := parseSessionEndInput(input) + return hooks.OnSessionEnd(hookInput, invocation) + + case "errorOccurred": + if hooks.OnErrorOccurred == nil { + return nil, nil + } + hookInput := parseErrorOccurredInput(input) + return hooks.OnErrorOccurred(hookInput, invocation) + + default: + return nil, fmt.Errorf("unknown hook type: %s", hookType) + } +} + +// Helper functions to parse hook inputs + +func parsePreToolUseInput(input map[string]interface{}) PreToolUseHookInput { + result := PreToolUseHookInput{} + if ts, ok := input["timestamp"].(float64); ok { + result.Timestamp = int64(ts) + } + if cwd, ok := input["cwd"].(string); ok { + result.Cwd = cwd + } + if name, ok := input["toolName"].(string); ok { + result.ToolName = name + } + result.ToolArgs = input["toolArgs"] + return result +} + +func parsePostToolUseInput(input map[string]interface{}) PostToolUseHookInput { + result := PostToolUseHookInput{} + if ts, ok := input["timestamp"].(float64); ok { + result.Timestamp = int64(ts) + } + if cwd, ok := input["cwd"].(string); ok { + result.Cwd = cwd + } + if name, ok := input["toolName"].(string); ok { + result.ToolName = name + } + result.ToolArgs = input["toolArgs"] + result.ToolResult = input["toolResult"] + return result +} + +func parseUserPromptSubmittedInput(input map[string]interface{}) UserPromptSubmittedHookInput { + result := UserPromptSubmittedHookInput{} + if ts, ok := input["timestamp"].(float64); ok { + result.Timestamp = int64(ts) + } + if cwd, ok := input["cwd"].(string); ok { + result.Cwd = cwd + } + if prompt, ok := input["prompt"].(string); ok { + result.Prompt = prompt + } + return result +} + +func parseSessionStartInput(input map[string]interface{}) SessionStartHookInput { + result := SessionStartHookInput{} + if ts, ok := input["timestamp"].(float64); ok { + result.Timestamp = int64(ts) + } + if cwd, ok := input["cwd"].(string); ok { + result.Cwd = cwd + } + if source, ok := input["source"].(string); ok { + result.Source = source + } + if prompt, ok := input["initialPrompt"].(string); ok { + result.InitialPrompt = prompt + } + return result +} + +func parseSessionEndInput(input map[string]interface{}) SessionEndHookInput { + result := SessionEndHookInput{} + if ts, ok := input["timestamp"].(float64); ok { + result.Timestamp = int64(ts) + } + if cwd, ok := input["cwd"].(string); ok { + result.Cwd = cwd + } + if reason, ok := input["reason"].(string); ok { + result.Reason = reason + } + if msg, ok := input["finalMessage"].(string); ok { + result.FinalMessage = msg + } + if errStr, ok := input["error"].(string); ok { + result.Error = errStr + } + return result +} + +func parseErrorOccurredInput(input map[string]interface{}) ErrorOccurredHookInput { + result := ErrorOccurredHookInput{} + if ts, ok := input["timestamp"].(float64); ok { + result.Timestamp = int64(ts) + } + if cwd, ok := input["cwd"].(string); ok { + result.Cwd = cwd + } + if errMsg, ok := input["error"].(string); ok { + result.Error = errMsg + } + if ctx, ok := input["errorContext"].(string); ok { + result.ErrorContext = ctx + } + if rec, ok := input["recoverable"].(bool); ok { + result.Recoverable = rec + } + return result +} + // dispatchEvent dispatches an event to all registered handlers. // This is an internal method; handlers are called synchronously and any panics // are recovered to prevent crashing the event dispatcher. diff --git a/go/types.go b/go/types.go index 4ac5bf9e..15c08bb9 100644 --- a/go/types.go +++ b/go/types.go @@ -106,6 +106,155 @@ type PermissionInvocation struct { SessionID string } +// UserInputRequest represents a request for user input from the agent +type UserInputRequest struct { + Question string `json:"question"` + Choices []string `json:"choices,omitempty"` + AllowFreeform *bool `json:"allowFreeform,omitempty"` +} + +// UserInputResponse represents the user's response to an input request +type UserInputResponse struct { + Answer string `json:"answer"` + WasFreeform bool `json:"wasFreeform"` +} + +// UserInputHandler handles user input requests from the agent +// The handler should return a UserInputResponse. Returning an error fails the request. +type UserInputHandler func(request UserInputRequest, invocation UserInputInvocation) (UserInputResponse, error) + +// UserInputInvocation provides context about a user input request +type UserInputInvocation struct { + SessionID string +} + +// PreToolUseHookInput is the input for a pre-tool-use hook +type PreToolUseHookInput struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + ToolName string `json:"toolName"` + ToolArgs interface{} `json:"toolArgs"` +} + +// PreToolUseHookOutput is the output for a pre-tool-use hook +type PreToolUseHookOutput struct { + PermissionDecision string `json:"permissionDecision,omitempty"` // "allow", "deny", "ask" + PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` + ModifiedArgs interface{} `json:"modifiedArgs,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` + SuppressOutput bool `json:"suppressOutput,omitempty"` +} + +// PreToolUseHandler handles pre-tool-use hook invocations +type PreToolUseHandler func(input PreToolUseHookInput, invocation HookInvocation) (*PreToolUseHookOutput, error) + +// PostToolUseHookInput is the input for a post-tool-use hook +type PostToolUseHookInput struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + ToolName string `json:"toolName"` + ToolArgs interface{} `json:"toolArgs"` + ToolResult interface{} `json:"toolResult"` +} + +// PostToolUseHookOutput is the output for a post-tool-use hook +type PostToolUseHookOutput struct { + ModifiedResult interface{} `json:"modifiedResult,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` + SuppressOutput bool `json:"suppressOutput,omitempty"` +} + +// PostToolUseHandler handles post-tool-use hook invocations +type PostToolUseHandler func(input PostToolUseHookInput, invocation HookInvocation) (*PostToolUseHookOutput, error) + +// UserPromptSubmittedHookInput is the input for a user-prompt-submitted hook +type UserPromptSubmittedHookInput struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + Prompt string `json:"prompt"` +} + +// UserPromptSubmittedHookOutput is the output for a user-prompt-submitted hook +type UserPromptSubmittedHookOutput struct { + ModifiedPrompt string `json:"modifiedPrompt,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` + SuppressOutput bool `json:"suppressOutput,omitempty"` +} + +// UserPromptSubmittedHandler handles user-prompt-submitted hook invocations +type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocation HookInvocation) (*UserPromptSubmittedHookOutput, error) + +// SessionStartHookInput is the input for a session-start hook +type SessionStartHookInput struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + Source string `json:"source"` // "startup", "resume", "new" + InitialPrompt string `json:"initialPrompt,omitempty"` +} + +// SessionStartHookOutput is the output for a session-start hook +type SessionStartHookOutput struct { + AdditionalContext string `json:"additionalContext,omitempty"` + ModifiedConfig map[string]interface{} `json:"modifiedConfig,omitempty"` +} + +// SessionStartHandler handles session-start hook invocations +type SessionStartHandler func(input SessionStartHookInput, invocation HookInvocation) (*SessionStartHookOutput, error) + +// SessionEndHookInput is the input for a session-end hook +type SessionEndHookInput struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit" + FinalMessage string `json:"finalMessage,omitempty"` + Error string `json:"error,omitempty"` +} + +// SessionEndHookOutput is the output for a session-end hook +type SessionEndHookOutput struct { + SuppressOutput bool `json:"suppressOutput,omitempty"` + CleanupActions []string `json:"cleanupActions,omitempty"` + SessionSummary string `json:"sessionSummary,omitempty"` +} + +// SessionEndHandler handles session-end hook invocations +type SessionEndHandler func(input SessionEndHookInput, invocation HookInvocation) (*SessionEndHookOutput, error) + +// ErrorOccurredHookInput is the input for an error-occurred hook +type ErrorOccurredHookInput struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + Error string `json:"error"` + ErrorContext string `json:"errorContext"` // "model_call", "tool_execution", "system", "user_input" + Recoverable bool `json:"recoverable"` +} + +// ErrorOccurredHookOutput is the output for an error-occurred hook +type ErrorOccurredHookOutput struct { + SuppressOutput bool `json:"suppressOutput,omitempty"` + ErrorHandling string `json:"errorHandling,omitempty"` // "retry", "skip", "abort" + RetryCount int `json:"retryCount,omitempty"` + UserNotification string `json:"userNotification,omitempty"` +} + +// ErrorOccurredHandler handles error-occurred hook invocations +type ErrorOccurredHandler func(input ErrorOccurredHookInput, invocation HookInvocation) (*ErrorOccurredHookOutput, error) + +// HookInvocation provides context about a hook invocation +type HookInvocation struct { + SessionID string +} + +// SessionHooks configures hook handlers for a session +type SessionHooks struct { + OnPreToolUse PreToolUseHandler + OnPostToolUse PostToolUseHandler + OnUserPromptSubmitted UserPromptSubmittedHandler + OnSessionStart SessionStartHandler + OnSessionEnd SessionEndHandler + OnErrorOccurred ErrorOccurredHandler +} + // MCPLocalServerConfig configures a local/stdio MCP server type MCPLocalServerConfig struct { Tools []string `json:"tools"` @@ -183,6 +332,13 @@ type SessionConfig struct { ExcludedTools []string // OnPermissionRequest is a handler for permission requests from the server OnPermissionRequest PermissionHandler + // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) + OnUserInputRequest UserInputHandler + // Hooks configures hook handlers for session lifecycle events + Hooks *SessionHooks + // WorkingDirectory is the working directory for the session. + // Tool operations will be relative to this directory. + WorkingDirectory string // Streaming enables streaming of assistant message and reasoning chunks. // When true, assistant.message_delta and assistant.reasoning_delta events // with deltaContent are sent as the response is generated. @@ -240,6 +396,13 @@ type ResumeSessionConfig struct { Provider *ProviderConfig // OnPermissionRequest is a handler for permission requests from the server OnPermissionRequest PermissionHandler + // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) + OnUserInputRequest UserInputHandler + // Hooks configures hook handlers for session lifecycle events + Hooks *SessionHooks + // WorkingDirectory is the working directory for the session. + // Tool operations will be relative to this directory. + WorkingDirectory string // Streaming enables streaming of assistant message and reasoning chunks. // When true, assistant.message_delta and assistant.reasoning_delta events // with deltaContent are sent as the response is generated. @@ -252,6 +415,9 @@ type ResumeSessionConfig struct { SkillDirectories []string // DisabledSkills is a list of skill names to disable DisabledSkills []string + // DisableResume, when true, skips emitting the session.resume event. + // Useful for reconnecting to a session without triggering resume-related side effects. + DisableResume bool } // ProviderConfig configures a custom model provider diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6c2bb94f..77f2ecf0 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.394", + "@github/copilot": "^0.0.399", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.394.tgz", - "integrity": "sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.399.tgz", + "integrity": "sha512-ULRmqkH6L6mdokjMTfLaJQDidJXDUy0EYE8lHsIDxXD4dUoGjfsNRswZ4We+JxIJiXuTF9U9KqQM55QRj/4kQA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.394", - "@github/copilot-darwin-x64": "0.0.394", - "@github/copilot-linux-arm64": "0.0.394", - "@github/copilot-linux-x64": "0.0.394", - "@github/copilot-win32-arm64": "0.0.394", - "@github/copilot-win32-x64": "0.0.394" + "@github/copilot-darwin-arm64": "0.0.399", + "@github/copilot-darwin-x64": "0.0.399", + "@github/copilot-linux-arm64": "0.0.399", + "@github/copilot-linux-x64": "0.0.399", + "@github/copilot-win32-arm64": "0.0.399", + "@github/copilot-win32-x64": "0.0.399" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.394.tgz", - "integrity": "sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.399.tgz", + "integrity": "sha512-8TQWPO9PrjR5g/y13nUmKBpk8oCHPpBPgHgp7hozIGedsbNJrcuABeNEsbkxowTwTiKgXEz5A5DGo5iN2AY+Ww==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.394.tgz", - "integrity": "sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.399.tgz", + "integrity": "sha512-Ge1a6zGpjTbVOUQyvT18NbphmyPidPoVERq7DOTTo3XdKDm8n2jFh6UjQl7atL0hnUaWsKOTx9qrgTPdQmqMDA==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.394.tgz", - "integrity": "sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.399.tgz", + "integrity": "sha512-41TCc1zxSd6qI56xzOyVR91mW8i7SXQ6IrRbsDBAF8i1dbyzIDO2YZ4c23fn8IRJ3C0aCQvfMoFf/BpzHQ6igw==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.394.tgz", - "integrity": "sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.399.tgz", + "integrity": "sha512-TNjJ/Kli74wdFycayvFW8mSmBc6ssURGxc+kiJkL+DYKloefBvViqz4I9Luwee7jrCdP1sgHwcelMrGbQ9fDHQ==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.394.tgz", - "integrity": "sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.399.tgz", + "integrity": "sha512-VInXjz1gaQcP0PZ18FBT0wdCNJld1huhisxUcegRpYdGgKHfUxdwU1N/2LcoZZZ+iin1NPt3mNsBJiI5W24RCA==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.394.tgz", - "integrity": "sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.399.tgz", + "integrity": "sha512-ddIYUm+HPvwhTOcVFIZNoIH8a00Wp6PtE/Zq7slShSMMjzqWLuvY0s9NdNt6ijPqxtNgwuHWPfPVS4OvqfkwSA==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 887bac91..9ef9478d 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.394", + "@github/copilot": "^0.0.399", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 5c162ddb..2ee38b3f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -462,6 +462,9 @@ export class CopilotClient { excludedTools: config.excludedTools, provider: config.provider, requestPermission: !!config.onPermissionRequest, + requestUserInput: !!config.onUserInputRequest, + hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), + workingDirectory: config.workingDirectory, streaming: config.streaming, mcpServers: config.mcpServers, customAgents: config.customAgents, @@ -480,6 +483,12 @@ export class CopilotClient { if (config.onPermissionRequest) { session.registerPermissionHandler(config.onPermissionRequest); } + if (config.onUserInputRequest) { + session.registerUserInputHandler(config.onUserInputRequest); + } + if (config.hooks) { + session.registerHooks(config.hooks); + } this.sessions.set(sessionId, session); return session; @@ -529,11 +538,15 @@ export class CopilotClient { })), provider: config.provider, requestPermission: !!config.onPermissionRequest, + requestUserInput: !!config.onUserInputRequest, + hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), + workingDirectory: config.workingDirectory, streaming: config.streaming, mcpServers: config.mcpServers, customAgents: config.customAgents, skillDirectories: config.skillDirectories, disabledSkills: config.disabledSkills, + disableResume: config.disableResume, }); const { sessionId: resumedSessionId, workspacePath } = response as { @@ -545,6 +558,12 @@ export class CopilotClient { if (config.onPermissionRequest) { session.registerPermissionHandler(config.onPermissionRequest); } + if (config.onUserInputRequest) { + session.registerUserInputHandler(config.onUserInputRequest); + } + if (config.hooks) { + session.registerHooks(config.hooks); + } this.sessions.set(resumedSessionId, session); return session; @@ -964,6 +983,26 @@ export class CopilotClient { }): Promise<{ result: unknown }> => await this.handlePermissionRequest(params) ); + this.connection.onRequest( + "userInput.request", + async (params: { + sessionId: string; + question: string; + choices?: string[]; + allowFreeform?: boolean; + }): Promise<{ answer: string; wasFreeform: boolean }> => + await this.handleUserInputRequest(params) + ); + + this.connection.onRequest( + "hooks.invoke", + async (params: { + sessionId: string; + hookType: string; + input: unknown; + }): Promise<{ output?: unknown }> => await this.handleHooksInvoke(params) + ); + this.connection.onClose(() => { if (this.state === "connected" && this.options.autoRestart) { void this.reconnect(); @@ -1072,6 +1111,55 @@ export class CopilotClient { } } + private async handleUserInputRequest(params: { + sessionId: string; + question: string; + choices?: string[]; + allowFreeform?: boolean; + }): Promise<{ answer: string; wasFreeform: boolean }> { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.question !== "string" + ) { + throw new Error("Invalid user input request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const result = await session._handleUserInputRequest({ + question: params.question, + choices: params.choices, + allowFreeform: params.allowFreeform, + }); + return result; + } + + private async handleHooksInvoke(params: { + sessionId: string; + hookType: string; + input: unknown; + }): Promise<{ output?: unknown }> { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.hookType !== "string" + ) { + throw new Error("Invalid hooks invoke payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const output = await session._handleHooksInvoke(params.hookType, params.input); + return { output }; + } + private normalizeToolResult(result: unknown): ToolResultObject { if (result === undefined || result === null) { return { diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index e285e7ca..22f745cf 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -15,8 +15,12 @@ import type { PermissionRequestResult, SessionEvent, SessionEventHandler, + SessionHooks, Tool, ToolHandler, + UserInputHandler, + UserInputRequest, + UserInputResponse, } from "./types.js"; /** Assistant message event - the final response from the assistant. */ @@ -51,6 +55,8 @@ export class CopilotSession { private eventHandlers: Set = new Set(); private toolHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; + private userInputHandler?: UserInputHandler; + private hooks?: SessionHooks; /** * Creates a new CopilotSession instance. @@ -271,6 +277,32 @@ export class CopilotSession { this.permissionHandler = handler; } + /** + * Registers a user input handler for ask_user requests. + * + * When the agent needs input from the user (via ask_user tool), + * this handler is called to provide the response. + * + * @param handler - The user input handler function, or undefined to remove the handler + * @internal This method is typically called internally when creating a session. + */ + registerUserInputHandler(handler?: UserInputHandler): void { + this.userInputHandler = handler; + } + + /** + * Registers hook handlers for session lifecycle events. + * + * Hooks allow custom logic to be executed at various points during + * the session lifecycle (before/after tool use, session start/end, etc.). + * + * @param hooks - The hook handlers object, or undefined to remove all hooks + * @internal This method is typically called internally when creating a session. + */ + registerHooks(hooks?: SessionHooks): void { + this.hooks = hooks; + } + /** * Handles a permission request from the Copilot CLI. * @@ -295,6 +327,72 @@ export class CopilotSession { } } + /** + * Handles a user input request from the Copilot CLI. + * + * @param request - The user input request data from the CLI + * @returns A promise that resolves with the user's response + * @internal This method is for internal use by the SDK. + */ + async _handleUserInputRequest(request: unknown): Promise { + if (!this.userInputHandler) { + // No handler registered, throw error + throw new Error("User input requested but no handler registered"); + } + + try { + const result = await this.userInputHandler(request as UserInputRequest, { + sessionId: this.sessionId, + }); + return result; + } catch (error) { + // Handler failed, rethrow + throw error; + } + } + + /** + * Handles a hooks invocation from the Copilot CLI. + * + * @param hookType - The type of hook being invoked + * @param input - The input data for the hook + * @returns A promise that resolves with the hook output, or undefined + * @internal This method is for internal use by the SDK. + */ + async _handleHooksInvoke(hookType: string, input: unknown): Promise { + if (!this.hooks) { + return undefined; + } + + // Type-safe handler lookup with explicit casting + type GenericHandler = ( + input: unknown, + invocation: { sessionId: string } + ) => Promise | unknown; + + const handlerMap: Record = { + preToolUse: this.hooks.onPreToolUse as GenericHandler | undefined, + postToolUse: this.hooks.onPostToolUse as GenericHandler | undefined, + userPromptSubmitted: this.hooks.onUserPromptSubmitted as GenericHandler | undefined, + sessionStart: this.hooks.onSessionStart as GenericHandler | undefined, + sessionEnd: this.hooks.onSessionEnd as GenericHandler | undefined, + errorOccurred: this.hooks.onErrorOccurred as GenericHandler | undefined, + }; + + const handler = handlerMap[hookType]; + if (!handler) { + return undefined; + } + + try { + const result = await handler(input, { sessionId: this.sessionId }); + return result; + } catch (_error) { + // Hook failed, return undefined + return undefined; + } + } + /** * Retrieves all events and messages from this session's history. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 7fa3f14b..697e4a9f 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -230,6 +230,255 @@ export type PermissionHandler = ( invocation: { sessionId: string } ) => Promise | PermissionRequestResult; +// ============================================================================ +// User Input Request Types +// ============================================================================ + +/** + * Request for user input from the agent (enables ask_user tool) + */ +export interface UserInputRequest { + /** + * The question to ask the user + */ + question: string; + + /** + * Optional choices for multiple choice questions + */ + choices?: string[]; + + /** + * Whether to allow freeform text input in addition to choices + * @default true + */ + allowFreeform?: boolean; +} + +/** + * Response to a user input request + */ +export interface UserInputResponse { + /** + * The user's answer + */ + answer: string; + + /** + * Whether the answer was freeform (not from choices) + */ + wasFreeform: boolean; +} + +/** + * Handler for user input requests from the agent + */ +export type UserInputHandler = ( + request: UserInputRequest, + invocation: { sessionId: string } +) => Promise | UserInputResponse; + +// ============================================================================ +// Hook Types +// ============================================================================ + +/** + * Base interface for all hook inputs + */ +export interface BaseHookInput { + timestamp: number; + cwd: string; +} + +/** + * Input for pre-tool-use hook + */ +export interface PreToolUseHookInput extends BaseHookInput { + toolName: string; + toolArgs: unknown; +} + +/** + * Output for pre-tool-use hook + */ +export interface PreToolUseHookOutput { + permissionDecision?: "allow" | "deny" | "ask"; + permissionDecisionReason?: string; + modifiedArgs?: unknown; + additionalContext?: string; + suppressOutput?: boolean; +} + +/** + * Handler for pre-tool-use hook + */ +export type PreToolUseHandler = ( + input: PreToolUseHookInput, + invocation: { sessionId: string } +) => Promise | PreToolUseHookOutput | void; + +/** + * Input for post-tool-use hook + */ +export interface PostToolUseHookInput extends BaseHookInput { + toolName: string; + toolArgs: unknown; + toolResult: ToolResultObject; +} + +/** + * Output for post-tool-use hook + */ +export interface PostToolUseHookOutput { + modifiedResult?: ToolResultObject; + additionalContext?: string; + suppressOutput?: boolean; +} + +/** + * Handler for post-tool-use hook + */ +export type PostToolUseHandler = ( + input: PostToolUseHookInput, + invocation: { sessionId: string } +) => Promise | PostToolUseHookOutput | void; + +/** + * Input for user-prompt-submitted hook + */ +export interface UserPromptSubmittedHookInput extends BaseHookInput { + prompt: string; +} + +/** + * Output for user-prompt-submitted hook + */ +export interface UserPromptSubmittedHookOutput { + modifiedPrompt?: string; + additionalContext?: string; + suppressOutput?: boolean; +} + +/** + * Handler for user-prompt-submitted hook + */ +export type UserPromptSubmittedHandler = ( + input: UserPromptSubmittedHookInput, + invocation: { sessionId: string } +) => Promise | UserPromptSubmittedHookOutput | void; + +/** + * Input for session-start hook + */ +export interface SessionStartHookInput extends BaseHookInput { + source: "startup" | "resume" | "new"; + initialPrompt?: string; +} + +/** + * Output for session-start hook + */ +export interface SessionStartHookOutput { + additionalContext?: string; + modifiedConfig?: Record; +} + +/** + * Handler for session-start hook + */ +export type SessionStartHandler = ( + input: SessionStartHookInput, + invocation: { sessionId: string } +) => Promise | SessionStartHookOutput | void; + +/** + * Input for session-end hook + */ +export interface SessionEndHookInput extends BaseHookInput { + reason: "complete" | "error" | "abort" | "timeout" | "user_exit"; + finalMessage?: string; + error?: string; +} + +/** + * Output for session-end hook + */ +export interface SessionEndHookOutput { + suppressOutput?: boolean; + cleanupActions?: string[]; + sessionSummary?: string; +} + +/** + * Handler for session-end hook + */ +export type SessionEndHandler = ( + input: SessionEndHookInput, + invocation: { sessionId: string } +) => Promise | SessionEndHookOutput | void; + +/** + * Input for error-occurred hook + */ +export interface ErrorOccurredHookInput extends BaseHookInput { + error: string; + errorContext: "model_call" | "tool_execution" | "system" | "user_input"; + recoverable: boolean; +} + +/** + * Output for error-occurred hook + */ +export interface ErrorOccurredHookOutput { + suppressOutput?: boolean; + errorHandling?: "retry" | "skip" | "abort"; + retryCount?: number; + userNotification?: string; +} + +/** + * Handler for error-occurred hook + */ +export type ErrorOccurredHandler = ( + input: ErrorOccurredHookInput, + invocation: { sessionId: string } +) => Promise | ErrorOccurredHookOutput | void; + +/** + * Configuration for session hooks + */ +export interface SessionHooks { + /** + * Called before a tool is executed + */ + onPreToolUse?: PreToolUseHandler; + + /** + * Called after a tool is executed + */ + onPostToolUse?: PostToolUseHandler; + + /** + * Called when the user submits a prompt + */ + onUserPromptSubmitted?: UserPromptSubmittedHandler; + + /** + * Called when a session starts + */ + onSessionStart?: SessionStartHandler; + + /** + * Called when a session ends + */ + onSessionEnd?: SessionEndHandler; + + /** + * Called when an error occurs + */ + onErrorOccurred?: ErrorOccurredHandler; +} + // ============================================================================ // MCP Server Configuration Types // ============================================================================ @@ -407,6 +656,25 @@ export interface SessionConfig { * When provided, the server will call this handler to request permission for operations. */ onPermissionRequest?: PermissionHandler; + + /** + * Handler for user input requests from the agent. + * When provided, enables the ask_user tool allowing the agent to ask questions. + */ + onUserInputRequest?: UserInputHandler; + + /** + * Hook handlers for intercepting session lifecycle events. + * When provided, enables hooks callback allowing custom logic at various points. + */ + hooks?: SessionHooks; + + /** + * Working directory for the session. + * Tool operations will be relative to this directory. + */ + workingDirectory?: string; + /* * Enable streaming of assistant message and reasoning chunks. * When true, ephemeral assistant.message_delta and assistant.reasoning_delta @@ -454,11 +722,21 @@ export type ResumeSessionConfig = Pick< | "provider" | "streaming" | "onPermissionRequest" + | "onUserInputRequest" + | "hooks" + | "workingDirectory" | "mcpServers" | "customAgents" | "skillDirectories" | "disabledSkills" ->; +> & { + /** + * When true, skips emitting the session.resume event. + * Useful for reconnecting to a session without triggering resume-related side effects. + * @default false + */ + disableResume?: boolean; +}; /** * Configuration for a custom API provider. diff --git a/nodejs/test/e2e/ask-user.test.ts b/nodejs/test/e2e/ask-user.test.ts new file mode 100644 index 00000000..d6c89a24 --- /dev/null +++ b/nodejs/test/e2e/ask-user.test.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import type { UserInputRequest, UserInputResponse } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("User input (ask_user)", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should invoke user input handler when model uses ask_user tool", async () => { + const userInputRequests: UserInputRequest[] = []; + + const session = await client.createSession({ + onUserInputRequest: async (request, invocation) => { + userInputRequests.push(request); + expect(invocation.sessionId).toBe(session.sessionId); + + // Return the first choice if available, otherwise a freeform answer + const response: UserInputResponse = { + answer: request.choices?.[0] ?? "freeform answer", + wasFreeform: !request.choices?.length, + }; + return response; + }, + }); + + await session.sendAndWait({ + prompt: "Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before continuing.", + }); + + // Should have received at least one user input request + expect(userInputRequests.length).toBeGreaterThan(0); + + // The request should have a question + expect(userInputRequests.some((req) => req.question && req.question.length > 0)).toBe(true); + + await session.destroy(); + }); + + it("should receive choices in user input request", async () => { + const userInputRequests: UserInputRequest[] = []; + + const session = await client.createSession({ + onUserInputRequest: async (request) => { + userInputRequests.push(request); + // Pick the first choice + return { + answer: request.choices?.[0] ?? "default", + wasFreeform: false, + }; + }, + }); + + await session.sendAndWait({ + prompt: "Use the ask_user tool to ask me to pick between exactly two options: 'Red' and 'Blue'. These should be provided as choices. Wait for my answer.", + }); + + // Should have received a request + expect(userInputRequests.length).toBeGreaterThan(0); + + // At least one request should have choices + const requestWithChoices = userInputRequests.find( + (req) => req.choices && req.choices.length > 0 + ); + expect(requestWithChoices).toBeDefined(); + + await session.destroy(); + }); + + it("should handle freeform user input response", async () => { + const userInputRequests: UserInputRequest[] = []; + const freeformAnswer = "This is my custom freeform answer that was not in the choices"; + + const session = await client.createSession({ + onUserInputRequest: async (request) => { + userInputRequests.push(request); + // Return a freeform answer (not from choices) + return { + answer: freeformAnswer, + wasFreeform: true, + }; + }, + }); + + const response = await session.sendAndWait({ + prompt: "Ask me a question using ask_user and then include my answer in your response. The question should be 'What is your favorite color?'", + }); + + // Should have received a request + expect(userInputRequests.length).toBeGreaterThan(0); + + // The model's response should reference the freeform answer we provided + // (This is a soft check since the model may paraphrase) + expect(response).toBeDefined(); + + await session.destroy(); + }); +}); diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts new file mode 100644 index 00000000..0a91f466 --- /dev/null +++ b/nodejs/test/e2e/hooks.test.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Session hooks", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should invoke preToolUse hook when model runs a tool", async () => { + const preToolUseInputs: PreToolUseHookInput[] = []; + + const session = await client.createSession({ + hooks: { + onPreToolUse: async (input, invocation) => { + preToolUseInputs.push(input); + expect(invocation.sessionId).toBe(session.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); + + // Should have received the tool name + expect(preToolUseInputs.some((input) => input.toolName)).toBe(true); + + await session.destroy(); + }); + + it("should invoke postToolUse hook after model runs a tool", async () => { + const postToolUseInputs: PostToolUseHookInput[] = []; + + const session = await client.createSession({ + hooks: { + onPostToolUse: async (input, invocation) => { + postToolUseInputs.push(input); + expect(invocation.sessionId).toBe(session.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); + + // 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.destroy(); + }); + + 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({ + 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.destroy(); + }); + + it("should deny tool execution when preToolUse returns deny", async () => { + const preToolUseInputs: PreToolUseHookInput[] = []; + + const session = await client.createSession({ + 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(); + + await session.destroy(); + }); +}); diff --git a/python/copilot/client.py b/python/copilot/client.py index 1b21ae7c..cb5bea89 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -407,6 +407,22 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo on_permission_request = cfg.get("on_permission_request") if on_permission_request: payload["requestPermission"] = True + + # Enable user input request callback if handler provided + on_user_input_request = cfg.get("on_user_input_request") + if on_user_input_request: + payload["requestUserInput"] = True + + # Enable hooks callback if any hook handler provided + hooks = cfg.get("hooks") + if hooks and any(hooks.values()): + payload["hooks"] = True + + # Add working directory if provided + working_directory = cfg.get("working_directory") + if working_directory: + payload["workingDirectory"] = working_directory + # Add streaming option if provided streaming = cfg.get("streaming") if streaming is not None: @@ -470,6 +486,10 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo session._register_tools(tools) if on_permission_request: session._register_permission_handler(on_permission_request) + if on_user_input_request: + session._register_user_input_handler(on_user_input_request) + if hooks: + session._register_hooks(hooks) with self._sessions_lock: self._sessions[session_id] = session @@ -542,6 +562,26 @@ async def resume_session( if on_permission_request: payload["requestPermission"] = True + # Enable user input request callback if handler provided + on_user_input_request = cfg.get("on_user_input_request") + if on_user_input_request: + payload["requestUserInput"] = True + + # Enable hooks callback if any hook handler provided + hooks = cfg.get("hooks") + if hooks and any(hooks.values()): + payload["hooks"] = True + + # Add working directory if provided + working_directory = cfg.get("working_directory") + if working_directory: + payload["workingDirectory"] = working_directory + + # Add disable resume flag if provided + disable_resume = cfg.get("disable_resume") + if disable_resume: + payload["disableResume"] = True + # Add MCP servers configuration if provided mcp_servers = cfg.get("mcp_servers") if mcp_servers: @@ -574,6 +614,10 @@ async def resume_session( session._register_tools(cfg.get("tools")) if on_permission_request: session._register_permission_handler(on_permission_request) + if on_user_input_request: + session._register_user_input_handler(on_user_input_request) + if hooks: + session._register_hooks(hooks) with self._sessions_lock: self._sessions[resumed_session_id] = session @@ -944,6 +988,8 @@ def handle_notification(method: str, params: dict): self._client.set_notification_handler(handle_notification) self._client.set_request_handler("tool.call", self._handle_tool_call_request) self._client.set_request_handler("permission.request", self._handle_permission_request) + self._client.set_request_handler("userInput.request", self._handle_user_input_request) + self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) # Start listening for messages loop = asyncio.get_running_loop() @@ -1018,6 +1064,8 @@ def handle_notification(method: str, params: dict): self._client.set_notification_handler(handle_notification) self._client.set_request_handler("tool.call", self._handle_tool_call_request) self._client.set_request_handler("permission.request", self._handle_permission_request) + self._client.set_request_handler("userInput.request", self._handle_user_input_request) + self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) # Start listening for messages loop = asyncio.get_running_loop() @@ -1058,6 +1106,61 @@ async def _handle_permission_request(self, params: dict) -> dict: } } + async def _handle_user_input_request(self, params: dict) -> dict: + """ + Handle a user input request from the CLI server. + + Args: + params: The user input request parameters from the server. + + Returns: + A dict containing the user's response. + + Raises: + ValueError: If the request payload is invalid. + """ + session_id = params.get("sessionId") + question = params.get("question") + + if not session_id or not question: + raise ValueError("invalid user input request payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + result = await session._handle_user_input_request(params) + return {"answer": result["answer"], "wasFreeform": result["wasFreeform"]} + + async def _handle_hooks_invoke(self, params: dict) -> dict: + """ + Handle a hooks invocation from the CLI server. + + Args: + params: The hooks invocation parameters from the server. + + Returns: + A dict containing the hook output. + + Raises: + ValueError: If the request payload is invalid. + """ + session_id = params.get("sessionId") + hook_type = params.get("hookType") + input_data = params.get("input") + + if not session_id or not hook_type: + raise ValueError("invalid hooks invoke payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + output = await session._handle_hooks_invoke(hook_type, input_data) + return {"output": output} + async def _handle_tool_call_request(self, params: dict) -> dict: """ Handle a tool call request from the CLI server. diff --git a/python/copilot/session.py b/python/copilot/session.py index 996b5e9f..2c4f922a 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -14,8 +14,12 @@ from .types import ( MessageOptions, PermissionHandler, + SessionHooks, Tool, ToolHandler, + UserInputHandler, + UserInputRequest, + UserInputResponse, ) from .types import ( SessionEvent as SessionEventTypeAlias, @@ -71,6 +75,10 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._tool_handlers_lock = threading.Lock() self._permission_handler: Optional[PermissionHandler] = None self._permission_handler_lock = threading.Lock() + self._user_input_handler: Optional[UserInputHandler] = None + self._user_input_handler_lock = threading.Lock() + self._hooks: Optional[SessionHooks] = None + self._hooks_lock = threading.Lock() @property def workspace_path(self) -> Optional[str]: @@ -320,6 +328,116 @@ async def _handle_permission_request(self, request: dict) -> dict: # Handler failed, deny permission return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + def _register_user_input_handler(self, handler: Optional[UserInputHandler]) -> None: + """ + Register a handler for user input requests. + + When the agent needs input from the user (via ask_user tool), + this handler is called to provide the response. + + Note: + This method is internal. User input handlers are typically registered + when creating a session via :meth:`CopilotClient.create_session`. + + Args: + handler: The user input handler function, or None to remove the handler. + """ + with self._user_input_handler_lock: + self._user_input_handler = handler + + async def _handle_user_input_request(self, request: dict) -> UserInputResponse: + """ + Handle a user input request from the Copilot CLI. + + Note: + This method is internal and should not be called directly. + + Args: + request: The user input request data from the CLI. + + Returns: + A dictionary containing the user's response. + """ + with self._user_input_handler_lock: + handler = self._user_input_handler + + if not handler: + raise RuntimeError("User input requested but no handler registered") + + try: + result = handler( + UserInputRequest( + question=request.get("question", ""), + choices=request.get("choices"), + allowFreeform=request.get("allowFreeform", True), + ), + {"session_id": self.session_id}, + ) + if inspect.isawaitable(result): + result = await result + return result + except Exception: + raise + + def _register_hooks(self, hooks: Optional[SessionHooks]) -> None: + """ + Register hook handlers for session lifecycle events. + + Hooks allow custom logic to be executed at various points during + the session lifecycle (before/after tool use, session start/end, etc.). + + Note: + This method is internal. Hooks are typically registered + when creating a session via :meth:`CopilotClient.create_session`. + + Args: + hooks: The hooks configuration object, or None to remove all hooks. + """ + with self._hooks_lock: + self._hooks = hooks + + async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: + """ + Handle a hooks invocation from the Copilot CLI. + + Note: + This method is internal and should not be called directly. + + Args: + hook_type: The type of hook being invoked. + input_data: The input data for the hook. + + Returns: + The hook output, or None if no handler is registered. + """ + with self._hooks_lock: + hooks = self._hooks + + if not hooks: + return None + + handler_map = { + "preToolUse": hooks.get("on_pre_tool_use"), + "postToolUse": hooks.get("on_post_tool_use"), + "userPromptSubmitted": hooks.get("on_user_prompt_submitted"), + "sessionStart": hooks.get("on_session_start"), + "sessionEnd": hooks.get("on_session_end"), + "errorOccurred": hooks.get("on_error_occurred"), + } + + handler = handler_map.get(hook_type) + if not handler: + return None + + try: + result = handler(input_data, {"session_id": self.session_id}) + if inspect.isawaitable(result): + result = await result + return result + except Exception: # pylint: disable=broad-except + # Hook failed, return None + return None + async def get_messages(self) -> list[SessionEvent]: """ Retrieve all events and messages from this session's history. diff --git a/python/copilot/types.py b/python/copilot/types.py index 7cc831f0..f8824a1c 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -153,6 +153,201 @@ class PermissionRequestResult(TypedDict, total=False): ] +# ============================================================================ +# User Input Request Types +# ============================================================================ + + +class UserInputRequest(TypedDict, total=False): + """Request for user input from the agent (enables ask_user tool)""" + + question: str + choices: list[str] + allowFreeform: bool + + +class UserInputResponse(TypedDict): + """Response to a user input request""" + + answer: str + wasFreeform: bool + + +UserInputHandler = Callable[ + [UserInputRequest, dict[str, str]], + Union[UserInputResponse, Awaitable[UserInputResponse]], +] + + +# ============================================================================ +# Hook Types +# ============================================================================ + + +class BaseHookInput(TypedDict): + """Base interface for all hook inputs""" + + timestamp: int + cwd: str + + +class PreToolUseHookInput(TypedDict): + """Input for pre-tool-use hook""" + + timestamp: int + cwd: str + toolName: str + toolArgs: Any + + +class PreToolUseHookOutput(TypedDict, total=False): + """Output for pre-tool-use hook""" + + permissionDecision: Literal["allow", "deny", "ask"] + permissionDecisionReason: str + modifiedArgs: Any + additionalContext: str + suppressOutput: bool + + +PreToolUseHandler = Callable[ + [PreToolUseHookInput, dict[str, str]], + Union[PreToolUseHookOutput, None, Awaitable[Union[PreToolUseHookOutput, None]]], +] + + +class PostToolUseHookInput(TypedDict): + """Input for post-tool-use hook""" + + timestamp: int + cwd: str + toolName: str + toolArgs: Any + toolResult: Any + + +class PostToolUseHookOutput(TypedDict, total=False): + """Output for post-tool-use hook""" + + modifiedResult: Any + additionalContext: str + suppressOutput: bool + + +PostToolUseHandler = Callable[ + [PostToolUseHookInput, dict[str, str]], + Union[PostToolUseHookOutput, None, Awaitable[Union[PostToolUseHookOutput, None]]], +] + + +class UserPromptSubmittedHookInput(TypedDict): + """Input for user-prompt-submitted hook""" + + timestamp: int + cwd: str + prompt: str + + +class UserPromptSubmittedHookOutput(TypedDict, total=False): + """Output for user-prompt-submitted hook""" + + modifiedPrompt: str + additionalContext: str + suppressOutput: bool + + +UserPromptSubmittedHandler = Callable[ + [UserPromptSubmittedHookInput, dict[str, str]], + Union[ + UserPromptSubmittedHookOutput, + None, + Awaitable[Union[UserPromptSubmittedHookOutput, None]], + ], +] + + +class SessionStartHookInput(TypedDict): + """Input for session-start hook""" + + timestamp: int + cwd: str + source: Literal["startup", "resume", "new"] + initialPrompt: NotRequired[str] + + +class SessionStartHookOutput(TypedDict, total=False): + """Output for session-start hook""" + + additionalContext: str + modifiedConfig: dict[str, Any] + + +SessionStartHandler = Callable[ + [SessionStartHookInput, dict[str, str]], + Union[SessionStartHookOutput, None, Awaitable[Union[SessionStartHookOutput, None]]], +] + + +class SessionEndHookInput(TypedDict): + """Input for session-end hook""" + + timestamp: int + cwd: str + reason: Literal["complete", "error", "abort", "timeout", "user_exit"] + finalMessage: NotRequired[str] + error: NotRequired[str] + + +class SessionEndHookOutput(TypedDict, total=False): + """Output for session-end hook""" + + suppressOutput: bool + cleanupActions: list[str] + sessionSummary: str + + +SessionEndHandler = Callable[ + [SessionEndHookInput, dict[str, str]], + Union[SessionEndHookOutput, None, Awaitable[Union[SessionEndHookOutput, None]]], +] + + +class ErrorOccurredHookInput(TypedDict): + """Input for error-occurred hook""" + + timestamp: int + cwd: str + error: str + errorContext: Literal["model_call", "tool_execution", "system", "user_input"] + recoverable: bool + + +class ErrorOccurredHookOutput(TypedDict, total=False): + """Output for error-occurred hook""" + + suppressOutput: bool + errorHandling: Literal["retry", "skip", "abort"] + retryCount: int + userNotification: str + + +ErrorOccurredHandler = Callable[ + [ErrorOccurredHookInput, dict[str, str]], + Union[ErrorOccurredHookOutput, None, Awaitable[Union[ErrorOccurredHookOutput, None]]], +] + + +class SessionHooks(TypedDict, total=False): + """Configuration for session hooks""" + + on_pre_tool_use: PreToolUseHandler + on_post_tool_use: PostToolUseHandler + on_user_prompt_submitted: UserPromptSubmittedHandler + on_session_start: SessionStartHandler + on_session_end: SessionEndHandler + on_error_occurred: ErrorOccurredHandler + + # ============================================================================ # MCP Server Configuration Types # ============================================================================ @@ -237,6 +432,12 @@ class SessionConfig(TypedDict, total=False): excluded_tools: list[str] # Handler for permission requests from the server on_permission_request: PermissionHandler + # Handler for user input requests from the agent (enables ask_user tool) + on_user_input_request: UserInputHandler + # Hook handlers for intercepting session lifecycle events + hooks: SessionHooks + # Working directory for the session. Tool operations will be relative to this directory. + working_directory: str # Custom provider configuration (BYOK - Bring Your Own Key) provider: ProviderConfig # Enable streaming of assistant message and reasoning chunks @@ -289,6 +490,12 @@ class ResumeSessionConfig(TypedDict, total=False): tools: list[Tool] provider: ProviderConfig on_permission_request: PermissionHandler + # Handler for user input requests from the agent (enables ask_user tool) + on_user_input_request: UserInputHandler + # Hook handlers for intercepting session lifecycle events + hooks: SessionHooks + # Working directory for the session. Tool operations will be relative to this directory. + working_directory: str # Enable streaming of assistant message chunks streaming: bool # MCP server configurations for the session @@ -299,6 +506,9 @@ class ResumeSessionConfig(TypedDict, total=False): skill_directories: list[str] # List of skill names to disable disabled_skills: list[str] + # When True, skips emitting the session.resume event. + # Useful for reconnecting to a session without triggering resume-related side effects. + disable_resume: bool # Options for sending a message to a session diff --git a/python/e2e/test_ask_user.py b/python/e2e/test_ask_user.py new file mode 100644 index 00000000..282b529b --- /dev/null +++ b/python/e2e/test_ask_user.py @@ -0,0 +1,118 @@ +""" +Tests for user input (ask_user) functionality +""" + +import pytest + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestAskUser: + async def test_invoke_user_input_handler_when_model_uses_ask_user_tool( + self, ctx: E2ETestContext + ): + """Test that user input handler is invoked when model uses ask_user tool""" + user_input_requests = [] + + async def on_user_input_request(request, invocation): + user_input_requests.append(request) + assert invocation["session_id"] == session.session_id + + # Return the first choice if available, otherwise a freeform answer + choices = request.get("choices") + return { + "answer": choices[0] if choices else "freeform answer", + "wasFreeform": not bool(choices), + } + + session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + + await session.send_and_wait( + { + "prompt": ( + "Ask me to choose between 'Option A' and 'Option B' using the ask_user " + "tool. Wait for my response before continuing." + ) + } + ) + + # Should have received at least one user input request + assert len(user_input_requests) > 0 + + # The request should have a question + assert any( + req.get("question") and len(req.get("question")) > 0 for req in user_input_requests + ) + + await session.destroy() + + async def test_receive_choices_in_user_input_request(self, ctx: E2ETestContext): + """Test that choices are received in user input request""" + user_input_requests = [] + + async def on_user_input_request(request, invocation): + user_input_requests.append(request) + # Pick the first choice + choices = request.get("choices") + return { + "answer": choices[0] if choices else "default", + "wasFreeform": False, + } + + session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + + await session.send_and_wait( + { + "prompt": ( + "Use the ask_user tool to ask me to pick between exactly two options: " + "'Red' and 'Blue'. These should be provided as choices. Wait for my answer." + ) + } + ) + + # Should have received a request + assert len(user_input_requests) > 0 + + # At least one request should have choices + request_with_choices = next( + (req for req in user_input_requests if req.get("choices") and len(req["choices"]) > 0), + None, + ) + assert request_with_choices is not None + + await session.destroy() + + async def test_handle_freeform_user_input_response(self, ctx: E2ETestContext): + """Test that freeform user input responses work""" + user_input_requests = [] + freeform_answer = "This is my custom freeform answer that was not in the choices" + + async def on_user_input_request(request, invocation): + user_input_requests.append(request) + # Return a freeform answer (not from choices) + return { + "answer": freeform_answer, + "wasFreeform": True, + } + + session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + + response = await session.send_and_wait( + { + "prompt": ( + "Ask me a question using ask_user and then include my answer in your " + "response. The question should be 'What is your favorite color?'" + ) + } + ) + + # Should have received a request + assert len(user_input_requests) > 0 + + # The model's response should reference the freeform answer we provided + # (This is a soft check since the model may paraphrase) + assert response is not None + + await session.destroy() diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py new file mode 100644 index 00000000..57aaee18 --- /dev/null +++ b/python/e2e/test_hooks.py @@ -0,0 +1,132 @@ +""" +Tests for session hooks functionality +""" + +import pytest + +from .testharness import E2ETestContext +from .testharness.helper import write_file + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestHooks: + async def test_invoke_pre_tool_use_hook_when_model_runs_a_tool(self, ctx: E2ETestContext): + """Test that preToolUse hook is invoked when model runs a tool""" + pre_tool_use_inputs = [] + + async def on_pre_tool_use(input_data, invocation): + pre_tool_use_inputs.append(input_data) + assert invocation["session_id"] == session.session_id + # Allow the tool to run + return {"permissionDecision": "allow"} + + session = await ctx.client.create_session({"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!") + + await session.send_and_wait( + {"prompt": "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 + + # Should have received the tool name + assert any(inp.get("toolName") for inp in pre_tool_use_inputs) + + await session.destroy() + + async def test_invoke_post_tool_use_hook_after_model_runs_a_tool(self, ctx: E2ETestContext): + """Test that postToolUse hook is invoked after model runs a tool""" + post_tool_use_inputs = [] + + async def on_post_tool_use(input_data, invocation): + post_tool_use_inputs.append(input_data) + assert invocation["session_id"] == session.session_id + return None + + session = await ctx.client.create_session({"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!") + + await session.send_and_wait( + {"prompt": "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 + + # 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.destroy() + + async def test_invoke_both_hooks_for_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( + { + "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({"prompt": "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.destroy() + + async def test_deny_tool_execution_when_pre_tool_use_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({"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( + {"prompt": "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 + + await session.destroy() diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index e5624654..1e05947a 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": "^0.0.394", + "@github/copilot": "^0.0.399", "@types/node": "^25.0.3", "openai": "^6.15.0", "tsx": "^4.21.0", @@ -461,27 +461,27 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.394.tgz", - "integrity": "sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.399.tgz", + "integrity": "sha512-ULRmqkH6L6mdokjMTfLaJQDidJXDUy0EYE8lHsIDxXD4dUoGjfsNRswZ4We+JxIJiXuTF9U9KqQM55QRj/4kQA==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.394", - "@github/copilot-darwin-x64": "0.0.394", - "@github/copilot-linux-arm64": "0.0.394", - "@github/copilot-linux-x64": "0.0.394", - "@github/copilot-win32-arm64": "0.0.394", - "@github/copilot-win32-x64": "0.0.394" + "@github/copilot-darwin-arm64": "0.0.399", + "@github/copilot-darwin-x64": "0.0.399", + "@github/copilot-linux-arm64": "0.0.399", + "@github/copilot-linux-x64": "0.0.399", + "@github/copilot-win32-arm64": "0.0.399", + "@github/copilot-win32-x64": "0.0.399" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.394.tgz", - "integrity": "sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.399.tgz", + "integrity": "sha512-8TQWPO9PrjR5g/y13nUmKBpk8oCHPpBPgHgp7hozIGedsbNJrcuABeNEsbkxowTwTiKgXEz5A5DGo5iN2AY+Ww==", "cpu": [ "arm64" ], @@ -496,9 +496,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.394.tgz", - "integrity": "sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.399.tgz", + "integrity": "sha512-Ge1a6zGpjTbVOUQyvT18NbphmyPidPoVERq7DOTTo3XdKDm8n2jFh6UjQl7atL0hnUaWsKOTx9qrgTPdQmqMDA==", "cpu": [ "x64" ], @@ -513,9 +513,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.394.tgz", - "integrity": "sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.399.tgz", + "integrity": "sha512-41TCc1zxSd6qI56xzOyVR91mW8i7SXQ6IrRbsDBAF8i1dbyzIDO2YZ4c23fn8IRJ3C0aCQvfMoFf/BpzHQ6igw==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.394.tgz", - "integrity": "sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.399.tgz", + "integrity": "sha512-TNjJ/Kli74wdFycayvFW8mSmBc6ssURGxc+kiJkL+DYKloefBvViqz4I9Luwee7jrCdP1sgHwcelMrGbQ9fDHQ==", "cpu": [ "x64" ], @@ -547,9 +547,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.394.tgz", - "integrity": "sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.399.tgz", + "integrity": "sha512-VInXjz1gaQcP0PZ18FBT0wdCNJld1huhisxUcegRpYdGgKHfUxdwU1N/2LcoZZZ+iin1NPt3mNsBJiI5W24RCA==", "cpu": [ "arm64" ], @@ -564,9 +564,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.394.tgz", - "integrity": "sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==", + "version": "0.0.399", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.399.tgz", + "integrity": "sha512-ddIYUm+HPvwhTOcVFIZNoIH8a00Wp6PtE/Zq7slShSMMjzqWLuvY0s9NdNt6ijPqxtNgwuHWPfPVS4OvqfkwSA==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 55de10f5..e859b49b 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^0.0.394", + "@github/copilot": "^0.0.399", "@types/node": "^25.0.3", "openai": "^6.15.0", "tsx": "^4.21.0", diff --git a/test/snapshots/ask-user/should_handle_freeform_user_input_response.yaml b/test/snapshots/ask-user/should_handle_freeform_user_input_response.yaml new file mode 100644 index 00000000..ce663ce6 --- /dev/null +++ b/test/snapshots/ask-user/should_handle_freeform_user_input_response.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Ask me a question using ask_user and then include my answer in your response. The question should be 'What is + your favorite color?' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"What is your favorite color?","allow_freeform":true}' + - role: tool + tool_call_id: toolcall_0 + content: "User response (freeform): This is my custom freeform answer that was not in the choices" + - role: assistant + content: 'Your answer was: "This is my custom freeform answer that was not in the choices"' diff --git a/test/snapshots/ask-user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml b/test/snapshots/ask-user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml new file mode 100644 index 00000000..fdd57bec --- /dev/null +++ b/test/snapshots/ask-user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before + continuing. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"Please choose one of the following options:","choices":["Option A","Option B"]}' + - role: tool + tool_call_id: toolcall_0 + content: "User selected: Option A" + - role: assistant + content: You selected **Option A**. How would you like to proceed? diff --git a/test/snapshots/ask-user/should_receive_choices_in_user_input_request.yaml b/test/snapshots/ask-user/should_receive_choices_in_user_input_request.yaml new file mode 100644 index 00000000..70537806 --- /dev/null +++ b/test/snapshots/ask-user/should_receive_choices_in_user_input_request.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use the ask_user tool to ask me to pick between exactly two options: 'Red' and 'Blue'. These should be + provided as choices. Wait for my answer." + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"Please pick one of the following options:","choices":["Red","Blue"],"allow_freeform":false}' + - role: tool + tool_call_id: toolcall_0 + content: "User selected: Red" + - role: assistant + content: You selected **Red**. diff --git a/test/snapshots/ask_user/handle_freeform_user_input_response.yaml b/test/snapshots/ask_user/handle_freeform_user_input_response.yaml new file mode 100644 index 00000000..e8ee48ed --- /dev/null +++ b/test/snapshots/ask_user/handle_freeform_user_input_response.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Ask me a question using ask_user and then include my answer in your response. The question should be 'What is + your favorite color?' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"What is your favorite color?","allow_freeform":true}' + - role: tool + tool_call_id: toolcall_0 + content: "User response (freeform): This is my custom freeform answer that was not in the choices" + - role: assistant + content: 'You answered: "This is my custom freeform answer that was not in the choices"' diff --git a/test/snapshots/ask_user/invoke_user_input_handler_when_model_uses_ask_user_tool.yaml b/test/snapshots/ask_user/invoke_user_input_handler_when_model_uses_ask_user_tool.yaml new file mode 100644 index 00000000..beb7a584 --- /dev/null +++ b/test/snapshots/ask_user/invoke_user_input_handler_when_model_uses_ask_user_tool.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before + continuing. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"Please choose between the following options:","choices":["Option A","Option B"]}' + - role: tool + tool_call_id: toolcall_0 + content: "User selected: Option A" + - role: assistant + content: You selected **Option A**. How would you like to proceed? diff --git a/test/snapshots/ask_user/receive_choices_in_user_input_request.yaml b/test/snapshots/ask_user/receive_choices_in_user_input_request.yaml new file mode 100644 index 00000000..70537806 --- /dev/null +++ b/test/snapshots/ask_user/receive_choices_in_user_input_request.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use the ask_user tool to ask me to pick between exactly two options: 'Red' and 'Blue'. These should be + provided as choices. Wait for my answer." + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"Please pick one of the following options:","choices":["Red","Blue"],"allow_freeform":false}' + - role: tool + tool_call_id: toolcall_0 + content: "User selected: Red" + - role: assistant + content: You selected **Red**. diff --git a/test/snapshots/askuser/should_handle_freeform_user_input_response.yaml b/test/snapshots/askuser/should_handle_freeform_user_input_response.yaml new file mode 100644 index 00000000..437cc2dd --- /dev/null +++ b/test/snapshots/askuser/should_handle_freeform_user_input_response.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Ask me a question using ask_user and then include my answer in your response. The question should be 'What is + your favorite color?' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"What is your favorite color?"}' + - role: tool + tool_call_id: toolcall_0 + content: "User response (freeform): This is my custom freeform answer that was not in the choices" + - role: assistant + content: 'You answered: "This is my custom freeform answer that was not in the choices"' diff --git a/test/snapshots/askuser/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml b/test/snapshots/askuser/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml new file mode 100644 index 00000000..beb7a584 --- /dev/null +++ b/test/snapshots/askuser/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before + continuing. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"Please choose between the following options:","choices":["Option A","Option B"]}' + - role: tool + tool_call_id: toolcall_0 + content: "User selected: Option A" + - role: assistant + content: You selected **Option A**. How would you like to proceed? diff --git a/test/snapshots/askuser/should_receive_choices_in_user_input_request.yaml b/test/snapshots/askuser/should_receive_choices_in_user_input_request.yaml new file mode 100644 index 00000000..70537806 --- /dev/null +++ b/test/snapshots/askuser/should_receive_choices_in_user_input_request.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use the ask_user tool to ask me to pick between exactly two options: 'Red' and 'Blue'. These should be + provided as choices. Wait for my answer." + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ask_user + arguments: '{"question":"Please pick one of the following options:","choices":["Red","Blue"],"allow_freeform":false}' + - role: tool + tool_call_id: toolcall_0 + content: "User selected: Red" + - role: assistant + content: You selected **Red**. diff --git a/test/snapshots/hooks/deny_tool_execution_when_pre_tool_use_returns_deny.yaml b/test/snapshots/hooks/deny_tool_execution_when_pre_tool_use_returns_deny.yaml new file mode 100644 index 00000000..10bca8e4 --- /dev/null +++ b/test/snapshots/hooks/deny_tool_execution_when_pre_tool_use_returns_deny.yaml @@ -0,0 +1,96 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'Original' with 'Modified' + - role: assistant + content: I'll view the file first and then make the edit. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'Original' with 'Modified' + - role: assistant + content: I'll view the file first and then make the edit. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: "Denied by preToolUse hook: No reason provided" + - role: tool + tool_call_id: toolcall_1 + content: "Denied by preToolUse hook: No reason provided" + - role: assistant + content: "The tools were denied by a preToolUse hook. Let me try to read the file using bash instead:" + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: ${shell} + arguments: '{"command":"cat protected.txt","description":"View protected.txt content"}' + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'Original' with 'Modified' + - role: assistant + content: I'll view the file first and then make the edit. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: "Denied by preToolUse hook: No reason provided" + - role: tool + tool_call_id: toolcall_1 + content: "Denied by preToolUse hook: No reason provided" + - role: assistant + content: "The tools were denied by a preToolUse hook. Let me try to read the file using bash instead:" + tool_calls: + - id: toolcall_2 + type: function + function: + name: ${shell} + arguments: '{"command":"cat protected.txt","description":"View protected.txt content"}' + - role: tool + tool_call_id: toolcall_2 + content: "Denied by preToolUse hook: No reason provided" + - role: assistant + content: It appears all tools are being denied by a hook. This might be a permissions or security configuration issue + with the file or environment. The file is named "protected.txt" which suggests it may have special protection + in place that's preventing access or modification. diff --git a/test/snapshots/hooks/invoke_both_hooks_for_single_tool_call.yaml b/test/snapshots/hooks/invoke_both_hooks_for_single_tool_call.yaml new file mode 100644 index 00000000..0bb88c13 --- /dev/null +++ b/test/snapshots/hooks/invoke_both_hooks_for_single_tool_call.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of both.txt + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/both.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of both.txt + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/both.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Testing both hooks! + - role: assistant + content: 'The file contains: "Testing both hooks!"' diff --git a/test/snapshots/hooks/invoke_post_tool_use_hook_after_model_runs_a_tool.yaml b/test/snapshots/hooks/invoke_post_tool_use_hook_after_model_runs_a_tool.yaml new file mode 100644 index 00000000..59369b1c --- /dev/null +++ b/test/snapshots/hooks/invoke_post_tool_use_hook_after_model_runs_a_tool.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of world.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/world.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of world.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/world.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. World from the test! + - role: assistant + content: 'The file world.txt contains: "World from the test!"' diff --git a/test/snapshots/hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool.yaml b/test/snapshots/hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool.yaml new file mode 100644 index 00000000..24241d90 --- /dev/null +++ b/test/snapshots/hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of hello.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of hello.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello from the test! + - role: assistant + content: The file says "Hello from the test!" diff --git a/test/snapshots/hooks/should_deny_tool_execution_when_pretooluse_returns_deny.yaml b/test/snapshots/hooks/should_deny_tool_execution_when_pretooluse_returns_deny.yaml new file mode 100644 index 00000000..2860f52c --- /dev/null +++ b/test/snapshots/hooks/should_deny_tool_execution_when_pretooluse_returns_deny.yaml @@ -0,0 +1,51 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'Original' with 'Modified' + - role: assistant + content: I'll view the file first and then make the edit. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'Original' with 'Modified' + - role: assistant + content: I'll view the file first and then make the edit. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: "Denied by preToolUse hook: No reason provided" + - role: tool + tool_call_id: toolcall_1 + content: "Denied by preToolUse hook: No reason provided" + - role: assistant + content: It appears that access to protected.txt is being denied by a security hook. This file seems to be protected + from viewing and editing operations. diff --git a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml new file mode 100644 index 00000000..0bb88c13 --- /dev/null +++ b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of both.txt + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/both.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of both.txt + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/both.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Testing both hooks! + - role: assistant + content: 'The file contains: "Testing both hooks!"' diff --git a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml new file mode 100644 index 00000000..ff422fee --- /dev/null +++ b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of both.txt + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/both.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of both.txt + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/both.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Testing both hooks! + - role: assistant + content: 'The file `both.txt` contains: "Testing both hooks!"' diff --git a/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml b/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml new file mode 100644 index 00000000..518f6d06 --- /dev/null +++ b/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of world.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/world.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of world.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/world.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. World from the test! + - role: assistant + content: 'The file contains: "World from the test!"' diff --git a/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml b/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml new file mode 100644 index 00000000..24241d90 --- /dev/null +++ b/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of hello.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of hello.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello from the test! + - role: assistant + content: The file says "Hello from the test!"