/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using Microsoft.Extensions.AI; using StreamJsonRpc; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using GitHub.Copilot.SDK.Rpc; namespace GitHub.Copilot.SDK; /// /// Represents a single conversation session with the Copilot CLI. /// /// /// /// A session maintains conversation state, handles events, and manages tool execution. /// Sessions are created via or resumed via /// . /// /// /// The session provides methods to send messages, subscribe to events, retrieve /// conversation history, and manage the session lifecycle. /// /// /// /// /// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Subscribe to events /// using var subscription = session.On(evt => /// { /// if (evt is AssistantMessageEvent assistantMessage) /// { /// Console.WriteLine($"Assistant: {assistantMessage.Data?.Content}"); /// } /// }); /// /// // Send a message and wait for completion /// await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello, world!" }); /// /// public partial class CopilotSession : IAsyncDisposable { private readonly HashSet _eventHandlers = new(); private readonly Dictionary _toolHandlers = new(); private readonly JsonRpc _rpc; private PermissionRequestHandler? _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); private SessionRpc? _sessionRpc; private int _isDisposed; /// /// Gets the unique identifier for this session. /// /// A string that uniquely identifies this session. public string SessionId { get; } /// /// Gets the typed RPC client for session-scoped methods. /// public SessionRpc Rpc => _sessionRpc ??= new SessionRpc(_rpc, SessionId); /// /// Gets the path to the session workspace directory when infinite sessions are enabled. /// /// /// The path to the workspace containing checkpoints/, plan.md, and files/ subdirectories, /// or null if infinite sessions are disabled. /// public string? WorkspacePath { get; } /// /// Initializes a new instance of the class. /// /// The unique identifier for this session. /// The JSON-RPC connection to the Copilot CLI. /// The workspace path if infinite sessions are enabled. /// /// This constructor is internal. Use to create sessions. /// internal CopilotSession(string sessionId, JsonRpc rpc, string? workspacePath = null) { SessionId = sessionId; _rpc = rpc; WorkspacePath = workspacePath; } private Task InvokeRpcAsync(string method, object?[]? args, CancellationToken cancellationToken) => CopilotClient.InvokeRpcAsync(_rpc, method, args, cancellationToken); /// /// Sends a message to the Copilot session and waits for the response. /// /// Options for the message to be sent, including the prompt and optional attachments. /// A that can be used to cancel the operation. /// A task that resolves with the ID of the response message, which can be used to correlate events. /// Thrown if the session has been disposed. /// /// /// This method returns immediately after the message is queued. Use /// if you need to wait for the assistant to finish processing. /// /// /// Subscribe to events via to receive streaming responses and other session events. /// /// /// /// /// var messageId = await session.SendAsync(new MessageOptions /// { /// Prompt = "Explain this code", /// Attachments = new List<Attachment> /// { /// new() { Type = "file", Path = "./Program.cs" } /// } /// }); /// /// public async Task SendAsync(MessageOptions options, CancellationToken cancellationToken = default) { var request = new SendMessageRequest { SessionId = SessionId, Prompt = options.Prompt, Attachments = options.Attachments, Mode = options.Mode }; var response = await InvokeRpcAsync( "session.send", [request], cancellationToken); return response.MessageId; } /// /// Sends a message to the Copilot session and waits until the session becomes idle. /// /// Options for the message to be sent, including the prompt and optional attachments. /// Timeout duration (default: 60 seconds). Controls how long to wait; does not abort in-flight agent work. /// A that can be used to cancel the operation. /// A task that resolves with the final assistant message event, or null if none was received. /// Thrown if the timeout is reached before the session becomes idle. /// Thrown if the is cancelled. /// Thrown if the session has been disposed. /// /// /// This is a convenience method that combines with waiting for /// the session.idle event. Use this when you want to block until the assistant /// has finished processing the message. /// /// /// Events are still delivered to handlers registered via while waiting. /// /// /// /// /// // Send and wait for completion with default 60s timeout /// var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); /// Console.WriteLine(response?.Data?.Content); // "4" /// /// public async Task SendAndWaitAsync( MessageOptions options, TimeSpan? timeout = null, CancellationToken cancellationToken = default) { var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); var tcs = new TaskCompletionSource(); AssistantMessageEvent? lastAssistantMessage = null; void Handler(SessionEvent evt) { switch (evt) { case AssistantMessageEvent assistantMessage: lastAssistantMessage = assistantMessage; break; case SessionIdleEvent: tcs.TrySetResult(lastAssistantMessage); break; case SessionErrorEvent errorEvent: var message = errorEvent.Data?.Message ?? "session error"; tcs.TrySetException(new InvalidOperationException($"Session error: {message}")); break; } } using var subscription = On(Handler); await SendAsync(options, cancellationToken); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(effectiveTimeout); using var registration = cts.Token.Register(() => { if (cancellationToken.IsCancellationRequested) tcs.TrySetCanceled(cancellationToken); else tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")); }); return await tcs.Task; } /// /// Registers a callback for session events. /// /// A callback to be invoked when a session event occurs. /// An that, when disposed, unsubscribes the handler. /// /// /// Events include assistant messages, tool executions, errors, and session state changes. /// Multiple handlers can be registered and will all receive events. /// /// /// Handler exceptions are allowed to propagate so they are not lost. /// /// /// /// /// using var subscription = session.On(evt => /// { /// switch (evt) /// { /// case AssistantMessageEvent: /// Console.WriteLine($"Assistant: {evt.Data?.Content}"); /// break; /// case SessionErrorEvent: /// Console.WriteLine($"Error: {evt.Data?.Message}"); /// break; /// } /// }); /// /// // The handler is automatically unsubscribed when the subscription is disposed. /// /// public IDisposable On(SessionEventHandler handler) { _eventHandlers.Add(handler); return new OnDisposeCall(() => _eventHandlers.Remove(handler)); } /// /// Dispatches an event to all registered handlers. /// /// The session event to dispatch. /// /// This method is internal. Handler exceptions are allowed to propagate so they are not lost. /// internal void DispatchEvent(SessionEvent sessionEvent) { foreach (var handler in _eventHandlers.ToArray()) { // We allow handler exceptions to propagate so they are not lost handler(sessionEvent); } } /// /// Registers custom tool handlers for this session. /// /// A collection of AI functions that can be invoked by the assistant. /// /// Tools allow the assistant to execute custom functions. When the assistant invokes a tool, /// the corresponding handler is called with the tool arguments. /// internal void RegisterTools(ICollection tools) { _toolHandlers.Clear(); foreach (var tool in tools) { _toolHandlers.Add(tool.Name, tool); } } /// /// Retrieves a registered tool by name. /// /// The name of the tool to retrieve. /// The tool if found; otherwise, null. internal AIFunction? GetTool(string name) => _toolHandlers.TryGetValue(name, out var tool) ? tool : null; /// /// Registers a handler for permission requests. /// /// The permission handler function. /// /// When the assistant needs permission to perform certain actions (e.g., file operations), /// this handler is called to approve or deny the request. /// internal void RegisterPermissionHandler(PermissionRequestHandler handler) { _permissionHandlerLock.Wait(); try { _permissionHandler = handler; } finally { _permissionHandlerLock.Release(); } } /// /// Handles a permission request from the Copilot CLI. /// /// The permission request data from the CLI. /// A task that resolves with the permission decision. internal async Task HandlePermissionRequestAsync(JsonElement permissionRequestData) { await _permissionHandlerLock.WaitAsync(); PermissionRequestHandler? handler; try { handler = _permissionHandler; } finally { _permissionHandlerLock.Release(); } if (handler == null) { return new PermissionRequestResult { Kind = "denied-no-approval-rule-and-could-not-request-from-user" }; } var request = JsonSerializer.Deserialize(permissionRequestData.GetRawText(), SessionJsonContext.Default.PermissionRequest) ?? throw new InvalidOperationException("Failed to deserialize permission request"); var invocation = new PermissionInvocation { SessionId = SessionId }; 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. /// /// A that can be used to cancel the operation. /// A task that, when resolved, gives the list of all session events in chronological order. /// Thrown if the session has been disposed. /// /// This returns the complete conversation history including user messages, assistant responses, /// tool executions, and other session events. /// /// /// /// var events = await session.GetMessagesAsync(); /// foreach (var evt in events) /// { /// if (evt is AssistantMessageEvent) /// { /// Console.WriteLine($"Assistant: {evt.Data?.Content}"); /// } /// } /// /// public async Task> GetMessagesAsync(CancellationToken cancellationToken = default) { var response = await InvokeRpcAsync( "session.getMessages", [new GetMessagesRequest { SessionId = SessionId }], cancellationToken); return response.Events .Select(e => SessionEvent.FromJson(e.ToJsonString())) .OfType() .ToList(); } /// /// Aborts the currently processing message in this session. /// /// A that can be used to cancel the operation. /// A task representing the abort operation. /// Thrown if the session has been disposed. /// /// Use this to cancel a long-running request. The session remains valid and can continue /// to be used for new messages. /// /// /// /// // Start a long-running request /// var messageTask = session.SendAsync(new MessageOptions /// { /// Prompt = "Write a very long story..." /// }); /// /// // Abort after 5 seconds /// await Task.Delay(TimeSpan.FromSeconds(5)); /// await session.AbortAsync(); /// /// public async Task AbortAsync(CancellationToken cancellationToken = default) { await InvokeRpcAsync( "session.abort", [new SessionAbortRequest { SessionId = SessionId }], cancellationToken); } /// /// Disposes the and releases all associated resources. /// /// A task representing the dispose operation. /// /// /// After calling this method, the session can no longer be used. All event handlers /// and tool handlers are cleared. /// /// /// To continue the conversation, use /// with the session ID. /// /// /// /// /// // Using 'await using' for automatic disposal /// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Or manually dispose /// var session2 = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// // ... use the session ... /// await session2.DisposeAsync(); /// /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _isDisposed, 1) == 1) { return; } try { await InvokeRpcAsync( "session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None); } catch (ObjectDisposedException) { // Connection was already disposed (e.g., client.StopAsync() was called first) } catch (IOException) { // Connection is broken or closed } _eventHandlers.Clear(); _toolHandlers.Clear(); await _permissionHandlerLock.WaitAsync(); try { _permissionHandler = null; } finally { _permissionHandlerLock.Release(); } } private class OnDisposeCall(Action callback) : IDisposable { public void Dispose() => callback(); } internal record SendMessageRequest { public string SessionId { get; init; } = string.Empty; public string Prompt { get; init; } = string.Empty; public List? Attachments { get; init; } public string? Mode { get; init; } } internal record SendMessageResponse { public string MessageId { get; init; } = string.Empty; } internal record GetMessagesRequest { public string SessionId { get; init; } = string.Empty; } internal record GetMessagesResponse { public List Events { get; init; } = new(); } internal record SessionAbortRequest { public string SessionId { get; init; } = string.Empty; } internal record SessionDestroyRequest { public string SessionId { get; init; } = string.Empty; } [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(GetMessagesRequest))] [JsonSerializable(typeof(GetMessagesResponse))] [JsonSerializable(typeof(PermissionRequest))] [JsonSerializable(typeof(SendMessageRequest))] [JsonSerializable(typeof(SendMessageResponse))] [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; }