/*--------------------------------------------------------------------------------------------- * 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. /// /// /// implements . Use the /// await using pattern for automatic cleanup, or call /// explicitly. Disposing a session releases in-memory resources but preserves session data /// on disk — the conversation can be resumed later via /// . To permanently delete session data, /// use . /// /// /// /// /// 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 sealed partial class CopilotSession : IAsyncDisposable { /// /// Multicast delegate used as a thread-safe, insertion-ordered handler list. /// The compiler-generated add/remove accessors use a lock-free CAS loop over the backing field. /// Dispatch reads the field once (inherent snapshot, no allocation). /// Expected handler count is small (typically 1–3), so Delegate.Combine/Remove cost is negligible. /// private event SessionEventHandler? EventHandlers; private readonly Dictionary _toolHandlers = []; private readonly JsonRpc _rpc; private volatile PermissionRequestHandler? _permissionHandler; private volatile UserInputHandler? _userInputHandler; 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) { return 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 += handler; return new ActionDisposable(() => EventHandlers -= 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. /// Broadcast request events (external_tool.requested, permission.requested) are handled /// internally before being forwarded to user handlers. /// internal void DispatchEvent(SessionEvent sessionEvent) { // Handle broadcast request events (protocol v3) before dispatching to user handlers. // Fire-and-forget: the response is sent asynchronously via RPC. HandleBroadcastEventAsync(sessionEvent); // Reading the field once gives us a snapshot; delegates are immutable. EventHandlers?.Invoke(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) { return _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) { _permissionHandler = handler; } /// /// 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) { var handler = _permissionHandler; if (handler == null) { return new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }; } 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); } /// /// Handles broadcast request events by executing local handlers and responding via RPC. /// Implements the protocol v3 broadcast model where tool calls and permission requests /// are broadcast as session events to all clients. /// private async void HandleBroadcastEventAsync(SessionEvent sessionEvent) { switch (sessionEvent) { case ExternalToolRequestedEvent toolEvent: { var data = toolEvent.Data; if (string.IsNullOrEmpty(data.RequestId) || string.IsNullOrEmpty(data.ToolName)) return; var tool = GetTool(data.ToolName); if (tool is null) return; // This client doesn't handle this tool; another client will. await ExecuteToolAndRespondAsync(data.RequestId, data.ToolName, data.ToolCallId, data.Arguments, tool); break; } case PermissionRequestedEvent permEvent: { var data = permEvent.Data; if (string.IsNullOrEmpty(data.RequestId) || data.PermissionRequest is null) return; var handler = _permissionHandler; if (handler is null) return; // This client doesn't handle permissions; another client will. await ExecutePermissionAndRespondAsync(data.RequestId, data.PermissionRequest, handler); break; } } } /// /// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC. /// private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, object? arguments, AIFunction tool) { try { var invocation = new ToolInvocation { SessionId = SessionId, ToolCallId = toolCallId, ToolName = toolName, Arguments = arguments }; var aiFunctionArgs = new AIFunctionArguments { Context = new Dictionary { [typeof(ToolInvocation)] = invocation } }; if (arguments is not null) { if (arguments is not JsonElement incomingJsonArgs) { throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}"); } foreach (var prop in incomingJsonArgs.EnumerateObject()) { aiFunctionArgs[prop.Name] = prop.Value; } } var result = await tool.InvokeAsync(aiFunctionArgs); var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject { ResultType = "success", TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je ? je.GetString()! : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), }; await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null); } catch (Exception ex) { try { await Rpc.Tools.HandlePendingToolCallAsync(requestId, result: null, error: ex.Message); } catch (IOException) { // Connection lost or RPC error — nothing we can do } catch (ObjectDisposedException) { // Connection already disposed — nothing we can do } } } /// /// Executes a permission handler and sends the result back via the HandlePendingPermissionRequest RPC. /// private async Task ExecutePermissionAndRespondAsync(string requestId, object permissionRequestData, PermissionRequestHandler handler) { try { // PermissionRequestedData.PermissionRequest is typed as `object` in generated code, // but StreamJsonRpc deserializes it as a JsonElement. if (permissionRequestData is not JsonElement permJsonElement) { throw new InvalidOperationException( $"Permission request data must be a {nameof(JsonElement)}; received {permissionRequestData.GetType().Name}"); } var request = JsonSerializer.Deserialize(permJsonElement.GetRawText(), SessionJsonContext.Default.PermissionRequest) ?? throw new InvalidOperationException("Failed to deserialize permission request"); var invocation = new PermissionInvocation { SessionId = SessionId }; var result = await handler(request, invocation); await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, result); } catch (Exception) { try { await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }); } catch (IOException) { // Connection lost or RPC error — nothing we can do } catch (ObjectDisposedException) { // Connection already disposed — nothing we can do } } } /// /// Registers a handler for user input requests from the agent. /// /// The handler to invoke when user input is requested. internal void RegisterUserInputHandler(UserInputHandler handler) { _userInputHandler = handler; } /// /// 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) { var handler = _userInputHandler ?? 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); } /// /// Changes the model for this session. /// The new model takes effect for the next message. Conversation history is preserved. /// /// Model ID to switch to (e.g., "gpt-4.1"). /// Optional cancellation token. /// /// /// await session.SetModelAsync("gpt-4.1"); /// /// public async Task SetModelAsync(string model, CancellationToken cancellationToken = default) { await Rpc.Model.SwitchToAsync(model, cancellationToken); } /// /// Closes this session and releases all in-memory resources (event handlers, /// tool handlers, permission handlers). /// /// A task representing the dispose operation. /// /// /// Session state on disk (conversation history, planning state, artifacts) is /// preserved, so the conversation can be resumed later by calling /// with the session ID. To /// permanently remove all session data including files on disk, use /// instead. /// /// /// After calling this method, the session object can no longer be used. /// /// /// /// /// // Using 'await using' for automatic disposal — session can still be resumed later /// 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 = null; _toolHandlers.Clear(); _permissionHandler = null; } 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; } = []; } 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; }