/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using GitHub.Copilot.Rpc; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Channels; namespace GitHub.Copilot; /// /// 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<SessionEvent>(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 { private readonly Dictionary _toolHandlers = []; private readonly Dictionary> _commandHandlers = []; private readonly Dictionary>> _bearerTokenProviders = new(StringComparer.Ordinal); private readonly ILogger _logger; private readonly CopilotClient _parentClient; private volatile Func>? _permissionHandler; private volatile Func>? _mcpAuthHandler; private volatile Func>? _userInputHandler; private volatile Func>? _elicitationHandler; private volatile Func>? _exitPlanModeHandler; private volatile Func>? _autoModeSwitchHandler; private ImmutableArray _eventHandlers = ImmutableArray.Empty; private sealed record EventSubscription(Type EventType, Action Handler); private SessionHooks? _hooks; private readonly SemaphoreSlim _hooksLock = new(1, 1); private Dictionary>>? _transformCallbacks; private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1); private IReadOnlyList _openCanvases = Array.Empty(); private int _isDisposed; /// /// Channel that serializes event dispatch. enqueues; /// a single background consumer () dequeues and /// invokes handlers one at a time, preserving arrival order. /// private readonly Channel _eventChannel = Channel.CreateUnbounded( new() { SingleReader = true }); /// /// 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 => field ?? Interlocked.CompareExchange(ref field, new(this), null) ?? field; internal JsonRpc JsonRpc { get; } /// /// 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; internal set; } /// /// Gets the capabilities reported by the host for this session. /// /// /// A object describing what the host supports. /// Capabilities are populated from the session create/resume response and updated /// in real time via capabilities.changed events. /// public SessionCapabilities Capabilities { get => field ?? Interlocked.CompareExchange(ref field, new(), null) ?? field; private set; } /// /// Canvas instances currently known to be open for this session. /// /// /// Populated from the most recent session.resume response and live /// session.canvas.opened and session.canvas.closed events. /// [Experimental(Diagnostics.Experimental)] public IReadOnlyList OpenCanvases => _openCanvases; /// /// Gets the UI API for eliciting information from the user during this session. /// /// /// An implementation with convenience methods for /// confirm, select, input, and custom elicitation dialogs. /// /// /// All methods on this property throw /// if the host does not report elicitation support via . /// Check session.Capabilities.Ui?.Elicitation == true before calling. /// public ISessionUiApi Ui => field ?? Interlocked.CompareExchange(ref field, new SessionUiApiImpl(this), null) ?? field; internal ClientSessionApiHandlers ClientSessionApis { get; } = new(); /// /// Initializes a new instance of the class. /// /// The unique identifier for this session. /// The JSON-RPC connection to the Copilot CLI. /// Logger for diagnostics. /// The owning client used to route session events. /// The workspace path if infinite sessions are enabled. /// /// This constructor is internal. Use to create sessions. /// internal CopilotSession( string sessionId, JsonRpc rpc, ILogger logger, CopilotClient client, string? workspacePath = null) { SessionId = sessionId; JsonRpc = rpc; _logger = logger; _parentClient = client; WorkspacePath = workspacePath; } /// /// Finalizes the session and releases the client's references to it. /// ~CopilotSession() { RemoveFromClient(); } /// /// Removes the current session from its parent client if it is no longer referenced or if the reference points to /// this instance. /// internal void RemoveFromClient() { ((ICollection>)_parentClient._sessions).Remove(new(SessionId, this)); } internal void StartProcessingEvents() { _ = ProcessEventsAsync(); } private Task InvokeRpcAsync(string method, object?[]? args, CancellationToken cancellationToken) { return CopilotClient.InvokeRpcAsync(JsonRpc, method, args, cancellationToken); } /// /// Sends a plain-text user message and returns the message ID without waiting for /// the assistant to reply. Convenience overload for . /// /// The user message text. /// A that can be used to cancel the operation. /// A task that resolves with the message ID. public Task SendAsync(string prompt, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(prompt); return SendAsync(new MessageOptions { Prompt = prompt }, cancellationToken); } /// /// Sends a plain-text user message and waits until the session becomes idle. /// Convenience overload for . /// /// The user message text. /// Timeout duration (default: 60 seconds). /// 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. public Task SendAndWaitAsync(string prompt, TimeSpan? timeout = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(prompt); return SendAndWaitAsync(new MessageOptions { Prompt = prompt }, timeout, cancellationToken); } /// /// Sends a message to the Copilot session. /// /// 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) { ArgumentNullException.ThrowIfNull(options); ThrowIfDisposed(); var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext(); var request = new SendMessageRequest { SessionId = SessionId, Prompt = options.Prompt, DisplayPrompt = options.DisplayPrompt, Attachments = options.Attachments, Mode = options.Mode, AgentMode = options.AgentMode, Traceparent = traceparent, Tracestate = tracestate, RequestHeaders = options.RequestHeaders, }; var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( "session.send", [request], cancellationToken); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.SendAsync completed successfully. Elapsed={Elapsed}, SessionId={SessionId}, MessageId={MessageId}", rpcTimestamp, SessionId, response.MessageId); 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) { ArgumentNullException.ThrowIfNull(options); ThrowIfDisposed(); var totalTimestamp = Stopwatch.GetTimestamp(); var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); AssistantMessageEvent? lastAssistantMessage = null; var firstAssistantMessageLogged = false; void Handler(SessionEvent evt) { switch (evt) { case AssistantMessageEvent assistantMessage: lastAssistantMessage = assistantMessage; if (!firstAssistantMessageLogged) { firstAssistantMessageLogged = true; LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.SendAndWaitAsync first assistant message. Elapsed={Elapsed}, SessionId={SessionId}", totalTimestamp, SessionId); } break; case SessionIdleEvent: LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.SendAndWaitAsync idle received. Elapsed={Elapsed}, SessionId={SessionId}", totalTimestamp, SessionId); 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}")); }); try { var result = await tcs.Task; LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.SendAndWaitAsync complete. Elapsed={Elapsed}, SessionId={SessionId}, CompletedBy={CompletedBy}, AssistantMessageReceived={AssistantMessageReceived}", totalTimestamp, SessionId, "idle", result is not null); return result; } catch (Exception ex) when (ex is TimeoutException) { LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex, "CopilotSession.SendAndWaitAsync failed. Elapsed={Elapsed}, SessionId={SessionId}, CompletedBy={CompletedBy}", totalTimestamp, SessionId, "timeout"); throw; } catch (Exception ex) when (ex is not OperationCanceledException) { LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex, "CopilotSession.SendAndWaitAsync failed. Elapsed={Elapsed}, SessionId={SessionId}, CompletedBy={CompletedBy}", totalTimestamp, SessionId, "error"); throw; } } /// /// 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. /// /// /// Handlers are invoked serially in event-arrival order on a background thread. /// A handler will never be called concurrently with itself or with other handlers /// on the same session. /// /// /// /// /// using var subscription = session.On<SessionEvent>(evt => /// { /// switch (evt) /// { /// case AssistantMessageEvent: /// Console.WriteLine($"Assistant: {evt.Data?.Content}"); /// break; /// case SessionErrorEvent: /// Console.WriteLine($"Error: {evt.Data?.Message}"); /// break; /// } /// }); /// /// // Or filter to a specific event kind at compile time: /// using var sub2 = session.On<AssistantMessageEvent>(evt => /// Console.WriteLine(evt.Data?.Content)); /// /// // The handler is automatically unsubscribed when the subscription is disposed. /// /// public IDisposable On(Action handler) where T : SessionEvent { ArgumentNullException.ThrowIfNull(handler); ThrowIfDisposed(); var subscription = new EventSubscription(typeof(T), evt => handler((T)evt)); ImmutableInterlocked.Update(ref _eventHandlers, array => array.Add(subscription)); return new ActionDisposable(() => ImmutableInterlocked.Update(ref _eventHandlers, array => array.Remove(subscription))); } /// /// Enqueues an event for serial dispatch to all registered handlers. /// /// The session event to dispatch. /// /// This method is non-blocking. Broadcast request events (external_tool.requested, /// permission.requested) are fired concurrently so that a stalled handler does not /// block event delivery. The event is then placed into an in-memory channel and /// processed by a single background consumer (), /// which guarantees user handlers see events one at a time, in order. /// internal void DispatchEvent(SessionEvent sessionEvent) { UpdateOpenCanvasesFromEvent(sessionEvent); // Fire broadcast work concurrently (fire-and-forget with error logging). // This is done outside the channel so broadcast handlers don't block the // consumer loop — important when a secondary client's handler intentionally // never completes (multi-client permission scenario). _ = HandleBroadcastEventAsync(sessionEvent); // Queue the event for serial processing by user handlers. _eventChannel.Writer.TryWrite(sessionEvent); } /// /// Single-reader consumer loop that processes events from the channel. /// Ensures user event handlers are invoked serially and in FIFO order. /// private async Task ProcessEventsAsync() { await foreach (var sessionEvent in _eventChannel.Reader.ReadAllAsync()) { var dispatchTimestamp = Stopwatch.GetTimestamp(); var eventType = sessionEvent.GetType(); foreach (var subscription in _eventHandlers) { if (!subscription.EventType.IsAssignableFrom(eventType)) { continue; } try { subscription.Handler(sessionEvent); } catch (Exception ex) { LogEventHandlerError(ex); } } LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ProcessEventsAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, EventType={EventType}", dispatchTimestamp, SessionId, sessionEvent.Type); } } /// /// Registers custom tool handlers for this session. /// /// A collection of AI function declarations available to the assistant. /// /// Tools backed by an are invoked automatically. Declaration-only tools are /// left pending for the client to resolve via the external tool request event. /// internal void RegisterTools(ICollection tools) { _toolHandlers.Clear(); foreach (var tool in tools) { if (tool.GetService() is { } function) { _toolHandlers.Add(tool.Name, function); } } } /// /// 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(Func>? handler) { _permissionHandler = handler; } internal void RegisterMcpAuthHandler(Func>? handler) { _mcpAuthHandler = 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 PermissionDecision.UserNotAvailable(); } var request = JsonSerializer.Deserialize(permissionRequestData.GetRawText(), SessionEventsJsonContext.Default.PermissionRequest) ?? throw new InvalidOperationException("Failed to deserialize permission request"); var invocation = new PermissionInvocation { SessionId = SessionId }; var permissionTimestamp = Stopwatch.GetTimestamp(); var result = await handler(request, invocation); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandlePermissionRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", permissionTimestamp, SessionId); return result; } /// /// 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 Task HandleBroadcastEventAsync(SessionEvent sessionEvent) { var dispatchTimestamp = Stopwatch.GetTimestamp(); try { 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. using (TelemetryHelpers.RestoreTraceContext(data.Traceparent, data.Tracestate)) 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; if (data.ResolvedByHook == true) return; // Already resolved by a permissionRequest hook; no client action needed. 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; } case McpOauthRequiredEvent authEvent: { var data = authEvent.Data; if (string.IsNullOrEmpty(data.RequestId)) return; var handler = _mcpAuthHandler; if (handler is null) { if (_logger.IsEnabled(LogLevel.Warning)) { _logger.LogWarning( "Received MCP OAuth request without a registered MCP auth handler. SessionId={SessionId}, RequestId={RequestId}", SessionId, data.RequestId); } return; } await ExecuteMcpAuthAndRespondAsync(data.RequestId, new McpAuthContext { SessionId = SessionId, RequestId = data.RequestId, ServerName = data.ServerName, ServerUrl = data.ServerUrl, Reason = data.Reason, WwwAuthenticateParams = data.WwwAuthenticateParams, ResourceMetadata = data.ResourceMetadata, StaticClientConfig = data.StaticClientConfig }, handler); break; } case CommandExecuteEvent cmdEvent: { var data = cmdEvent.Data; if (string.IsNullOrEmpty(data.RequestId)) return; await ExecuteCommandAndRespondAsync(data.RequestId, data.CommandName, data.Command, data.Args); break; } case ElicitationRequestedEvent elicitEvent: { var data = elicitEvent.Data; if (string.IsNullOrEmpty(data.RequestId)) return; if (_elicitationHandler is not null) { var schema = data.RequestedSchema is not null ? new ElicitationSchema { Type = data.RequestedSchema.Type, Properties = data.RequestedSchema.Properties.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value), Required = data.RequestedSchema.Required?.ToList() } : null; await HandleElicitationRequestAsync( new ElicitationContext { SessionId = SessionId, Message = data.Message, RequestedSchema = schema, Mode = data.Mode, ElicitationSource = data.ElicitationSource, Url = data.Url }, data.RequestId); } break; } case CapabilitiesChangedEvent capEvent: { var data = capEvent.Data; Capabilities = new SessionCapabilities { Ui = data.Ui is not null ? new SessionUiCapabilities { Elicitation = data.Ui.Elicitation } : Capabilities.Ui }; break; } } } catch (Exception ex) when (ex is not OperationCanceledException) { LogBroadcastHandlerError(ex); } finally { LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleBroadcastEventAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, EventType={EventType}", dispatchTimestamp, SessionId, sessionEvent.Type); } } private async Task ExecuteMcpAuthAndRespondAsync( string requestId, McpAuthContext context, Func> handler) { try { var result = await handler(context); McpOauthPendingRequestResponse response = result is { Cancelled: false, Token: { } token } ? new McpOauthPendingRequestResponseToken { AccessToken = token.AccessToken, TokenType = token.TokenType, ExpiresIn = token.ExpiresIn } : new McpOauthPendingRequestResponseCancelled(); await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, response); } catch (OperationCanceledException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (ObjectDisposedException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (InvalidOperationException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (ArgumentException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (NotSupportedException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (JsonException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (RemoteRpcException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (IOException) { await TryCancelMcpAuthRequestAsync(requestId); } catch (Exception ex) when (IsRecoverableMcpAuthFailure(ex)) { await TryCancelMcpAuthRequestAsync(requestId); } } private static bool IsRecoverableMcpAuthFailure(Exception exception) => exception is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException and not AccessViolationException and not AppDomainUnloadedException; private async Task TryCancelMcpAuthRequestAsync(string requestId) { try { await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, new McpOauthPendingRequestResponseCancelled()); } catch (IOException) { // Connection lost — nothing we can do. } catch (ObjectDisposedException) { // Connection already disposed — nothing we can do. } catch (RemoteRpcException) { // The pending request may already be gone — nothing we can do. } } /// /// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC. /// private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, JsonElement? 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 JsonElement incomingJsonArgs) { foreach (var prop in incomingJsonArgs.EnumerateObject()) { aiFunctionArgs[prop.Name] = prop.Value; } } var toolTimestamp = Stopwatch.GetTimestamp(); var result = await tool.InvokeAsync(aiFunctionArgs); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ExecuteToolAndRespondAsync tool dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, ToolCallId={ToolCallId}, Tool={ToolName}", toolTimestamp, SessionId, requestId, toolCallId, toolName); var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions); var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ExecuteToolAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, ToolCallId={ToolCallId}, Tool={ToolName}", responseRpcTimestamp, SessionId, requestId, toolCallId, toolName); } 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, PermissionRequest permissionRequest, Func> handler) { try { var invocation = new PermissionInvocation { SessionId = SessionId }; var permissionTimestamp = Stopwatch.GetTimestamp(); var decision = await handler(permissionRequest, invocation); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ExecutePermissionAndRespondAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", permissionTimestamp, SessionId, requestId); if (decision is PermissionDecisionNoResult) { return; } var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, decision); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ExecutePermissionAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", responseRpcTimestamp, SessionId, requestId); } catch (Exception) { try { await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, PermissionDecision.UserNotAvailable()); } 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(Func> handler) { _userInputHandler = handler; } /// /// Registers command handlers for this session. /// /// The command definitions to register. internal void RegisterCommands(IEnumerable? commands) { _commandHandlers.Clear(); if (commands is null) return; foreach (var cmd in commands) { _commandHandlers[cmd.Name] = cmd.Handler; } } /// /// Registers an elicitation handler for this session. /// /// The handler to invoke when an elicitation request is received. internal void RegisterElicitationHandler(Func>? handler) { _elicitationHandler = handler; } /// /// Registers an exit-plan-mode handler for this session. /// /// The handler to invoke when an exit-plan-mode request is received. internal void RegisterExitPlanModeHandler(Func>? handler) { _exitPlanModeHandler = handler; } /// /// Registers an auto-mode-switch handler for this session. /// /// The handler to invoke when an auto-mode-switch request is received. internal void RegisterAutoModeSwitchHandler(Func>? handler) { _autoModeSwitchHandler = handler; } /// /// Registers per-provider BearerTokenProvider callbacks for BYOK /// providers configured with managed-identity / on-demand bearer-token auth. /// /// /// The runtime never receives the callback itself; the SDK strips it from the /// provider config and instead sends hasBearerTokenProvider: true. When /// the runtime needs a token it issues a session-scoped /// providerToken.getToken request, which this handler routes to the /// matching per-provider callback. /// /// Map of provider name to callback, or null/empty to clear. internal void RegisterBearerTokenProviders(IReadOnlyDictionary>>? providers) { _bearerTokenProviders.Clear(); if (providers is null || providers.Count == 0) { ClientSessionApis.ProviderToken = null; return; } foreach (var (name, callback) in providers) { _bearerTokenProviders[name] = callback; } ClientSessionApis.ProviderToken = new BearerTokenProviderHandler(this); } /// /// Routes runtime providerToken.getToken requests to the matching /// per-provider BearerTokenProvider callback registered on the session. /// private sealed class BearerTokenProviderHandler(CopilotSession session) : IProviderTokenHandler { public async Task GetTokenAsync(ProviderTokenAcquireRequest request, CancellationToken cancellationToken = default) { if (!session._bearerTokenProviders.TryGetValue(request.ProviderName, out var callback)) { throw new InvalidOperationException( $"No bearer-token provider registered for provider \"{request.ProviderName}\""); } var token = await callback(new ProviderTokenArgs { ProviderName = request.ProviderName, SessionId = request.SessionId }).ConfigureAwait(false); return new ProviderTokenAcquireResult { Token = token }; } } /// /// Sets the capabilities reported by the host for this session. /// /// The capabilities to set. internal void SetCapabilities(SessionCapabilities? capabilities) { Capabilities = capabilities ?? new SessionCapabilities(); } internal void SetOpenCanvases(IList? canvases) { _openCanvases = canvases is { Count: > 0 } ? new List(canvases).AsReadOnly() : Array.Empty(); } private void UpdateOpenCanvasesFromEvent(SessionEvent sessionEvent) { if (sessionEvent is SessionCanvasClosedEvent closedEvent) { var closedInstanceId = closedEvent.Data.InstanceId; if (string.IsNullOrEmpty(closedInstanceId)) { _logger.LogWarning("failed to deserialize session.canvas.closed payload"); return; } RemoveOpenCanvas(closedInstanceId); return; } if (sessionEvent is not SessionCanvasOpenedEvent canvasEvent) return; var data = canvasEvent.Data; if (string.IsNullOrEmpty(data.InstanceId) || string.IsNullOrEmpty(data.CanvasId) || string.IsNullOrEmpty(data.ExtensionId)) { _logger.LogWarning("failed to deserialize session.canvas.opened payload"); return; } UpsertOpenCanvas(new OpenCanvasInstance { CanvasId = data.CanvasId, ExtensionId = data.ExtensionId, ExtensionName = data.ExtensionName, Input = data.Input, InstanceId = data.InstanceId, Status = data.Status, Title = data.Title, Url = data.Url, }); } private void UpsertOpenCanvas(OpenCanvasInstance canvas) { var canvases = _openCanvases.ToList(); var index = canvases.FindIndex(open => open.InstanceId == canvas.InstanceId); if (index >= 0) canvases[index] = canvas; else canvases.Add(canvas); _openCanvases = canvases.AsReadOnly(); } private void RemoveOpenCanvas(string instanceId) { var canvases = _openCanvases.Where(open => open.InstanceId != instanceId).ToList(); _openCanvases = canvases.AsReadOnly(); } internal void SetCanvasHandler(ICanvasHandler? handler) { ClientSessionApis.Canvas = handler is null ? null : new CanvasHandlerAdapter(handler); } private static readonly JsonElement NullJsonElement = JsonDocument.Parse("null").RootElement.Clone(); private static JsonElement SerializeActionResult(object? value) { var element = CopilotClient.ToJsonElementForWire(value); return element ?? NullJsonElement; } private sealed class CanvasHandlerAdapter(ICanvasHandler handler) : Rpc.ICanvasHandler { public async Task OpenAsync(CanvasProviderOpenRequest request, CancellationToken cancellationToken = default) { try { return await handler.OnOpenAsync(request, cancellationToken).ConfigureAwait(false); } catch (CanvasException ce) { throw CanvasErrorHelpers.ToRpcException(ce); } catch (Exception ex) when (ex is not OperationCanceledException) { throw CanvasErrorHelpers.HandlerError(ex.Message); } } public async Task CloseAsync(CanvasProviderCloseRequest request, CancellationToken cancellationToken = default) { try { await handler.OnCloseAsync(request, cancellationToken).ConfigureAwait(false); } catch (CanvasException ce) { throw CanvasErrorHelpers.ToRpcException(ce); } catch (Exception ex) when (ex is not OperationCanceledException) { throw CanvasErrorHelpers.HandlerError(ex.Message); } } public async Task InvokeAsync(CanvasProviderInvokeActionRequest request, CancellationToken cancellationToken = default) { try { var result = await handler.OnActionAsync(request, cancellationToken).ConfigureAwait(false); return SerializeActionResult(result); } catch (CanvasException ce) { throw CanvasErrorHelpers.ToRpcException(ce); } catch (Exception ex) when (ex is not OperationCanceledException) { throw CanvasErrorHelpers.HandlerError(ex.Message); } } } /// /// Dispatches a command.execute event to the registered handler and /// responds via the commands.handlePendingCommand RPC. /// private async Task ExecuteCommandAndRespondAsync(string requestId, string commandName, string command, string args) { if (!_commandHandlers.TryGetValue(commandName, out var handler)) { try { await Rpc.Commands.HandlePendingCommandAsync(requestId, error: $"Unknown command: {commandName}"); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { // Connection lost — nothing we can do } return; } try { var commandTimestamp = Stopwatch.GetTimestamp(); await handler(new CommandContext { SessionId = SessionId, Command = command, CommandName = commandName, Args = args }); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ExecuteCommandAndRespondAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, Command={CommandName}", commandTimestamp, SessionId, requestId, commandName); var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Commands.HandlePendingCommandAsync(requestId); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.ExecuteCommandAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, Command={CommandName}", responseRpcTimestamp, SessionId, requestId, commandName); } catch (Exception error) when (error is not OperationCanceledException) { // User handler can throw any exception — report the error back to the server // so the pending command doesn't hang. var message = error.Message; try { await Rpc.Commands.HandlePendingCommandAsync(requestId, error: message); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { // Connection lost — nothing we can do } } } /// /// Dispatches an elicitation.requested event to the registered handler and /// responds via the ui.handlePendingElicitation RPC. Auto-cancels on handler errors. /// private async Task HandleElicitationRequestAsync(ElicitationContext context, string requestId) { var handler = _elicitationHandler; if (handler is null) return; try { var elicitationTimestamp = Stopwatch.GetTimestamp(); var result = await handler(context); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleElicitationRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", elicitationTimestamp, SessionId, requestId); var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Ui.HandlePendingElicitationAsync(requestId, new UIElicitationResponse { Action = result.Action, Content = result.Content?.ToDictionary( kvp => kvp.Key, kvp => CopilotClient.ToJsonElementForWire(kvp.Value)!.Value) }); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleElicitationRequestAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", responseRpcTimestamp, SessionId, requestId); } catch (Exception ex) when (ex is not OperationCanceledException) { // User handler can throw any exception — attempt to cancel so the request doesn't hang. try { await Rpc.Ui.HandlePendingElicitationAsync(requestId, new UIElicitationResponse { Action = UIElicitationResponseAction.Cancel }); } catch (Exception innerEx) when (innerEx is IOException or ObjectDisposedException) { // Connection lost — nothing we can do } } } /// /// Throws if the host does not support elicitation. /// private void AssertElicitation() { if (Capabilities.Ui?.Elicitation != true) { throw new InvalidOperationException( "Elicitation is not supported by the host. " + "Check session.Capabilities.Ui?.Elicitation before calling UI methods."); } } /// /// Implements backed by the session's RPC connection. /// private sealed class SessionUiApiImpl(CopilotSession session) : ISessionUiApi { // Parses a JSON string and returns a detached JsonElement. Using `using` // ensures the pooled buffers backing the JsonDocument are released // promptly; the cloned RootElement is independent of the document. private static JsonElement ParseJsonElement(string json) { using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); } public async Task ElicitAsync(ElicitationParams elicitationParams, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(elicitationParams); session.ThrowIfDisposed(); session.AssertElicitation(); var schema = new UIElicitationSchema { Type = elicitationParams.RequestedSchema.Type, Properties = elicitationParams.RequestedSchema.Properties.ToDictionary( kvp => kvp.Key, kvp => CopilotClient.ToJsonElementForWire(kvp.Value)!.Value), Required = elicitationParams.RequestedSchema.Required }; var result = await session.Rpc.Ui.ElicitationAsync(elicitationParams.Message, schema, cancellationToken); return new ElicitationResult { Action = result.Action, Content = result.Content?.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value) }; } public async Task ConfirmAsync(string message, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(message); session.ThrowIfDisposed(); session.AssertElicitation(); var schema = new UIElicitationSchema { Type = "object", Properties = new Dictionary { ["confirmed"] = ParseJsonElement("""{"type":"boolean","default":true}""") }, Required = ["confirmed"] }; var result = await session.Rpc.Ui.ElicitationAsync(message, schema, cancellationToken); if (result.Action == UIElicitationResponseAction.Accept && result.Content != null && result.Content.TryGetValue("confirmed", out var val)) { return val.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, _ => false }; } return false; } public async Task SelectAsync(string message, string[] options, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(message); ArgumentNullException.ThrowIfNull(options); session.ThrowIfDisposed(); session.AssertElicitation(); var enumJson = JsonSerializer.Serialize(options, TypesJsonContext.Default.StringArray); var schema = new UIElicitationSchema { Type = "object", Properties = new Dictionary { ["selection"] = ParseJsonElement($$"""{"type":"string","enum":{{enumJson}}}""") }, Required = ["selection"] }; var result = await session.Rpc.Ui.ElicitationAsync(message, schema, cancellationToken); if (result.Action == UIElicitationResponseAction.Accept && result.Content != null && result.Content.TryGetValue("selection", out var val)) { return val.ValueKind == JsonValueKind.String ? val.GetString() : val.ToString(); } return null; } public async Task InputAsync(string message, UiInputOptions? options, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(message); session.ThrowIfDisposed(); session.AssertElicitation(); var fieldNode = new System.Text.Json.Nodes.JsonObject { ["type"] = "string" }; if (options?.Title != null) fieldNode["title"] = options.Title; if (options?.Description != null) fieldNode["description"] = options.Description; if (options?.MinLength != null) fieldNode["minLength"] = options.MinLength; if (options?.MaxLength != null) fieldNode["maxLength"] = options.MaxLength; if (options?.Format != null) fieldNode["format"] = options.Format; if (options?.Default != null) fieldNode["default"] = options.Default; var schema = new UIElicitationSchema { Type = "object", Properties = new Dictionary { ["value"] = ParseJsonElement(fieldNode.ToJsonString()) }, Required = ["value"] }; var result = await session.Rpc.Ui.ElicitationAsync(message, schema, cancellationToken); if (result.Action == UIElicitationResponseAction.Accept && result.Content != null && result.Content.TryGetValue("value", out var val)) { return val.ValueKind == JsonValueKind.String ? val.GetString() : val.ToString(); } return null; } } /// /// 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 }; var userInputTimestamp = Stopwatch.GetTimestamp(); var response = await handler(request, invocation); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleUserInputRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", userInputTimestamp, SessionId); return response; } /// /// Handles an exit-plan-mode request from the Copilot CLI. /// /// The exit-plan-mode request from the CLI. /// A task that resolves with the user's decision. internal async Task HandleExitPlanModeRequestAsync(ExitPlanModeRequest request) { var handler = _exitPlanModeHandler; if (handler is null) { return new ExitPlanModeResult { Approved = true }; } var invocation = new ExitPlanModeInvocation { SessionId = SessionId }; var timestamp = Stopwatch.GetTimestamp(); var response = await handler(request, invocation); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleExitPlanModeRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", timestamp, SessionId); return response; } /// /// Handles an auto-mode-switch request from the Copilot CLI. /// /// The auto-mode-switch request from the CLI. /// A task that resolves with the user's decision. internal async Task HandleAutoModeSwitchRequestAsync(AutoModeSwitchRequest request) { var handler = _autoModeSwitchHandler; if (handler is null) { return AutoModeSwitchResponse.No; } var invocation = new AutoModeSwitchInvocation { SessionId = SessionId }; var timestamp = Stopwatch.GetTimestamp(); var response = await handler(request, invocation); LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleAutoModeSwitchRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", timestamp, SessionId); return response; } /// /// 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 }; var hookTimestamp = Stopwatch.GetTimestamp(); try { return hookType switch { "preToolUse" => hooks.OnPreToolUse != null ? await hooks.OnPreToolUse( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!, invocation) : null, "preMcpToolCall" => hooks.OnPreMcpToolCall != null ? SerializeHookOutput(await hooks.OnPreMcpToolCall( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreMcpToolCallHookInput)!, invocation)) : null, "postToolUse" => hooks.OnPostToolUse != null ? await hooks.OnPostToolUse( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!, invocation) : null, "postToolUseFailure" => hooks.OnPostToolUseFailure != null ? await hooks.OnPostToolUseFailure( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseFailureHookInput)!, 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, _ => null }; } finally { LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null, "CopilotSession.HandleHooksInvokeAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, Hook={HookType}", hookTimestamp, SessionId, hookType); } } /// /// Pre-serializes a hook output to JsonElement so that the object? typed /// property writes the /// correct JSON without relying on polymorphic type resolution. /// private static JsonElement? SerializeHookOutput(PreMcpToolCallHookOutput? output) => output is null ? null : JsonSerializer.SerializeToElement(output, SessionJsonContext.Default.PreMcpToolCallHookOutput); /// /// Registers transform callbacks for system message sections. /// /// The transform callbacks keyed by section identifier. internal void RegisterTransformCallbacks(Dictionary>>? callbacks) { _transformCallbacksLock.Wait(); try { _transformCallbacks = callbacks; } finally { _transformCallbacksLock.Release(); } } /// /// Handles a systemMessage.transform RPC call from the Copilot CLI. /// /// The raw JSON element containing sections to transform. /// A task that resolves with the transformed sections. internal async Task HandleSystemMessageTransformAsync(JsonElement sections) { Dictionary>>? callbacks; await _transformCallbacksLock.WaitAsync(); try { callbacks = _transformCallbacks; } finally { _transformCallbacksLock.Release(); } var parsed = JsonSerializer.Deserialize( sections.GetRawText(), SessionJsonContext.Default.DictionaryStringSystemMessageTransformSection) ?? new(); var result = new Dictionary(); foreach (var (sectionId, data) in parsed) { Func>? callback = null; callbacks?.TryGetValue(sectionId, out callback); if (callback != null) { try { var transformed = await callback(data.Content ?? ""); result[sectionId] = new SystemMessageTransformSection { Content = transformed }; } catch { result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" }; } } else { result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" }; } } return new SystemMessageTransformRpcResponse { Sections = result }; } /// /// 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.GetEventsAsync(); /// foreach (var evt in events) /// { /// if (evt is AssistantMessageEvent) /// { /// Console.WriteLine($"Assistant: {evt.Data?.Content}"); /// } /// } /// /// public async Task> GetEventsAsync(CancellationToken cancellationToken = default) { ThrowIfDisposed(); var response = await InvokeRpcAsync( "session.getMessages", [new GetMessagesRequest { SessionId = SessionId }], cancellationToken); return response.Events .Select(static 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) { ThrowIfDisposed(); 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"). /// Reasoning effort level (e.g., "low", "medium", "high", "xhigh"). /// Per-property overrides for model capabilities, deep-merged over runtime defaults. /// Optional cancellation token. /// /// /// await session.SetModelAsync("gpt-4.1"); /// await session.SetModelAsync("claude-sonnet-4.6", "high"); /// await session.SetModelAsync("gpt-4.1", new SetModelOptions { ContextTier = ContextTier.LongContext }); /// /// public Task SetModelAsync(string model, string? reasoningEffort, ModelCapabilitiesOverride? modelCapabilities = null, CancellationToken cancellationToken = default) { return SetModelAsync( model, new SetModelOptions { ReasoningEffort = reasoningEffort, ModelCapabilities = modelCapabilities, }, 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"). /// Settings for the new model. /// Optional cancellation token. public async Task SetModelAsync(string model, SetModelOptions options, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(model); ThrowIfDisposed(); await Rpc.Model.SwitchToAsync( model, options.ReasoningEffort, options.ReasoningSummary, null, options.ModelCapabilities, options.ContextTier, cancellationToken); } /// /// Changes the model for this session. /// public Task SetModelAsync(string model, CancellationToken cancellationToken = default) { ThrowIfDisposed(); return SetModelAsync(model, new SetModelOptions(), cancellationToken); } /// /// Log a message to the session timeline. /// The message appears in the session event stream and is visible to SDK consumers /// and (for non-ephemeral messages) persisted to the session event log on disk. /// /// The message to log. /// Log level (default: info). /// When true, the message is not persisted to disk. /// Optional URL to associate with the log entry. /// Optional cancellation token. /// /// /// await session.LogAsync("Build completed successfully"); /// await session.LogAsync("Disk space low", level: SessionLogLevel.Warning); /// await session.LogAsync("Connection failed", level: SessionLogLevel.Error); /// await session.LogAsync("Temporary status", ephemeral: true); /// /// public async Task LogAsync(string message, SessionLogLevel? level = null, bool? ephemeral = null, string? url = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(message); ThrowIfDisposed(); await Rpc.LogAsync(message, level, ephemeral: ephemeral, url: url, cancellationToken: cancellationToken); } /// /// Closes this session and releases all in-memory resources (event handlers, /// tool handlers, permission handlers). /// /// A task representing the dispose operation. /// /// /// The caller should ensure the session is idle (e.g., /// has returned) before disposing. If the session is not idle, in-flight event handlers /// or tool handlers may observe failures. /// /// /// 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; } _eventChannel.Writer.TryComplete(); 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 } finally { RemoveFromClient(); GC.SuppressFinalize(this); } _eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray.Empty); _toolHandlers.Clear(); _commandHandlers.Clear(); _permissionHandler = null; _userInputHandler = null; _elicitationHandler = null; _exitPlanModeHandler = null; _autoModeSwitchHandler = null; } [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception in broadcast event handler")] private partial void LogBroadcastHandlerError(Exception exception); [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception in session event handler")] private partial void LogEventHandlerError(Exception exception); internal record SendMessageRequest { public string SessionId { get; init; } = string.Empty; public string Prompt { get; init; } = string.Empty; public string? DisplayPrompt { get; init; } public IList? Attachments { get; init; } public string? Mode { get; init; } [JsonPropertyName("agentMode")] public AgentMode? AgentMode { get; init; } public string? Traceparent { get; init; } public string? Tracestate { get; init; } public IDictionary? RequestHeaders { 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 IList Events { get => field ??= []; init; } } internal record SessionAbortRequest { public string SessionId { get; init; } = string.Empty; } internal record SessionDestroyRequest { public string SessionId { get; init; } = string.Empty; } internal void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); } [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(AutoModeSwitchRequest))] [JsonSerializable(typeof(AutoModeSwitchResponse))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ErrorOccurredHookInput))] [JsonSerializable(typeof(ErrorOccurredHookOutput))] [JsonSerializable(typeof(ExitPlanModeRequest))] [JsonSerializable(typeof(ExitPlanModeResult))] [JsonSerializable(typeof(GetMessagesRequest))] [JsonSerializable(typeof(GetMessagesResponse))] [JsonSerializable(typeof(PostToolUseFailureHookInput))] [JsonSerializable(typeof(PostToolUseFailureHookOutput))] [JsonSerializable(typeof(PostToolUseHookInput))] [JsonSerializable(typeof(PostToolUseHookOutput))] [JsonSerializable(typeof(PreMcpToolCallHookInput))] [JsonSerializable(typeof(PreMcpToolCallHookOutput))] [JsonSerializable(typeof(PreToolUseHookInput))] [JsonSerializable(typeof(PreToolUseHookOutput))] [JsonSerializable(typeof(SendMessageRequest))] [JsonSerializable(typeof(SendMessageResponse))] [JsonSerializable(typeof(SessionAbortRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] [JsonSerializable(typeof(SessionEndHookInput))] [JsonSerializable(typeof(SessionEndHookOutput))] [JsonSerializable(typeof(SessionStartHookInput))] [JsonSerializable(typeof(SessionStartHookOutput))] [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(SystemMessageTransformSection))] [JsonSerializable(typeof(Attachment))] [JsonSerializable(typeof(UserPromptSubmittedHookInput))] [JsonSerializable(typeof(UserPromptSubmittedHookOutput))] internal partial class SessionJsonContext : JsonSerializerContext; }