/*---------------------------------------------------------------------------------------------
* 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