/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using StreamJsonRpc; using System.Collections.Concurrent; using System.Data; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using GitHub.Copilot.SDK.Rpc; using System.Globalization; namespace GitHub.Copilot.SDK; /// /// Provides a client for interacting with the Copilot CLI server. /// /// /// /// The manages the connection to the Copilot CLI server and provides /// methods to create and manage conversation sessions. It can either spawn a CLI server process /// or connect to an existing server. /// /// /// The client supports both stdio (default) and TCP transport modes for communication with the CLI server. /// /// /// /// /// // Create a client with default options (spawns CLI server) /// await using var client = new CopilotClient(); /// /// // Create a session /// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Handle events /// using var subscription = session.On(evt => /// { /// if (evt is AssistantMessageEvent assistantMessage) /// Console.WriteLine(assistantMessage.Data?.Content); /// }); /// /// // Send a message /// await session.SendAsync(new MessageOptions { Prompt = "Hello!" }); /// /// public sealed partial class CopilotClient : IDisposable, IAsyncDisposable { internal const string NoResultPermissionV2ErrorMessage = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."; /// /// Minimum protocol version this SDK can communicate with. /// private const int MinProtocolVersion = 2; private readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; private readonly ILogger _logger; private Task? _connectionTask; private volatile bool _disconnected; private bool _disposed; private readonly int? _optionsPort; private readonly string? _optionsHost; private int? _actualPort; private int? _negotiatedProtocolVersion; private List? _modelsCache; private readonly SemaphoreSlim _modelsCacheLock = new(1, 1); private readonly Func>>? _onListModels; private readonly List> _lifecycleHandlers = []; private readonly Dictionary>> _typedLifecycleHandlers = []; private readonly object _lifecycleHandlersLock = new(); private ServerRpc? _rpc; /// /// Gets the typed RPC client for server-scoped methods (no session required). /// /// /// The client must be started before accessing this property. Use or set to true. /// /// Thrown if the client has been disposed. /// Thrown if the client is not started. public ServerRpc Rpc => _disposed ? throw new ObjectDisposedException(nameof(CopilotClient)) : _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first."); /// /// Gets the actual TCP port the CLI server is listening on, if using TCP transport. /// public int? ActualPort => _actualPort; /// /// Creates a new instance of . /// /// Options for creating the client. If null, default options are used. /// Thrown when mutually exclusive options are provided (e.g., CliUrl with UseStdio or CliPath). /// /// /// // Default options - spawns CLI server using stdio /// var client = new CopilotClient(); /// /// // Connect to an existing server /// var client = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:3000", UseStdio = false }); /// /// // Custom CLI path with specific log level /// var client = new CopilotClient(new CopilotClientOptions /// { /// CliPath = "/usr/local/bin/copilot", /// LogLevel = "debug" /// }); /// /// public CopilotClient(CopilotClientOptions? options = null) { _options = options ?? new(); // Validate mutually exclusive options if (!string.IsNullOrEmpty(_options.CliUrl) && _options.CliPath != null) { throw new ArgumentException("CliUrl is mutually exclusive with CliPath"); } // When CliUrl is provided, disable UseStdio (we connect to an external server, not spawn one) if (!string.IsNullOrEmpty(_options.CliUrl)) { _options.UseStdio = false; } // Validate auth options with external server if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GitHubToken) || _options.UseLoggedInUser != null)) { throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); } _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; // Parse CliUrl if provided if (!string.IsNullOrEmpty(_options.CliUrl)) { var uri = ParseCliUrl(_options.CliUrl!); _optionsHost = uri.Host; _optionsPort = uri.Port; } } /// /// Parses a CLI URL into a URI with host and port. /// /// The URL to parse. Supports formats: "port", "host:port", "http://host:port". /// A containing the parsed host and port. private static Uri ParseCliUrl(string url) { // If it's just a port number, treat as localhost if (int.TryParse(url, out var port)) { return new Uri($"http://localhost:{port}"); } // Add scheme if missing if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { url = "https://" + url; } return new Uri(url); } /// /// Starts the Copilot client and connects to the server. /// /// A that can be used to cancel the operation. /// A representing the asynchronous operation. /// /// /// If the server is not already running and the client is configured to spawn one (default), it will be started. /// If connecting to an external server (via CliUrl), only establishes the connection. /// /// /// This method is called automatically when creating a session if is true (default). /// /// /// /// /// var client = new CopilotClient(new CopilotClientOptions { AutoStart = false }); /// await client.StartAsync(); /// // Now ready to create sessions /// /// public Task StartAsync(CancellationToken cancellationToken = default) { return _connectionTask ??= StartCoreAsync(cancellationToken); async Task StartCoreAsync(CancellationToken ct) { _logger.LogDebug("Starting Copilot client"); _disconnected = false; Task result; if (_optionsHost is not null && _optionsPort is not null) { // External server (TCP) _actualPort = _optionsPort; result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); } else { // Child process (stdio or TCP) var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct); _actualPort = portOrNull; result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); } var connection = await result; // Verify protocol version compatibility await VerifyProtocolVersionAsync(connection, ct); await ConfigureSessionFsAsync(ct); _logger.LogInformation("Copilot client connected"); return connection; } } /// /// Disconnects from the Copilot server and closes all active sessions. /// /// A representing the asynchronous operation. /// /// /// This method performs graceful cleanup: /// /// Closes all active sessions (releases in-memory resources) /// Closes the JSON-RPC connection /// Terminates the CLI server process (if spawned by this client) /// /// /// /// Note: session data on disk is preserved, so sessions can be resumed later. /// To permanently remove session data before stopping, call /// for each session first. /// /// /// Thrown when multiple errors occur during cleanup. /// /// /// await client.StopAsync(); /// /// public async Task StopAsync() { var errors = new List(); foreach (var session in _sessions.Values.ToArray()) { try { await session.DisposeAsync(); } catch (Exception ex) { errors.Add(new Exception($"Failed to dispose session {session.SessionId}: {ex.Message}", ex)); } } _sessions.Clear(); await CleanupConnectionAsync(errors); _connectionTask = null; ThrowErrors(errors); } /// /// Forces an immediate stop of the client without graceful cleanup. /// /// A representing the asynchronous operation. /// /// Use this when fails or takes too long. This method: /// /// Clears all sessions immediately without destroying them /// Force closes the connection /// Kills the CLI process (if spawned by this client) /// /// /// /// /// // If normal stop hangs, force stop /// var stopTask = client.StopAsync(); /// if (!stopTask.Wait(TimeSpan.FromSeconds(5))) /// { /// await client.ForceStopAsync(); /// } /// /// public async Task ForceStopAsync() { var errors = new List(); _sessions.Clear(); await CleanupConnectionAsync(errors); _connectionTask = null; ThrowErrors(errors); } private static void ThrowErrors(List errors) { if (errors.Count == 1) { throw errors[0]; } else if (errors.Count > 0) { throw new AggregateException(errors); } } private async Task CleanupConnectionAsync(List? errors) { if (_connectionTask is null) { return; } var ctx = await _connectionTask; _connectionTask = null; try { ctx.Rpc.Dispose(); } catch (Exception ex) { errors?.Add(ex); } // Clear RPC and models cache _rpc = null; _modelsCache = null; if (ctx.NetworkStream is not null) { try { await ctx.NetworkStream.DisposeAsync(); } catch (Exception ex) { errors?.Add(ex); } } if (ctx.TcpClient is not null) { try { ctx.TcpClient.Dispose(); } catch (Exception ex) { errors?.Add(ex); } } if (ctx.CliProcess is { } childProcess) { try { if (!childProcess.HasExited) childProcess.Kill(); childProcess.Dispose(); } catch (Exception ex) { errors?.Add(ex); } } } private static (SystemMessageConfig? wireConfig, Dictionary>>? callbacks) ExtractTransformCallbacks(SystemMessageConfig? systemMessage) { if (systemMessage?.Mode != SystemMessageMode.Customize || systemMessage.Sections == null) { return (systemMessage, null); } var callbacks = new Dictionary>>(); var wireSections = new Dictionary(); foreach (var (sectionId, sectionOverride) in systemMessage.Sections) { if (sectionOverride.Transform != null) { callbacks[sectionId] = sectionOverride.Transform; wireSections[sectionId] = new SectionOverride { Action = SectionOverrideAction.Transform }; } else { wireSections[sectionId] = sectionOverride; } } if (callbacks.Count == 0) { return (systemMessage, null); } var wireConfig = new SystemMessageConfig { Mode = systemMessage.Mode, Content = systemMessage.Content, Sections = wireSections }; return (wireConfig, callbacks); } /// /// Creates a new Copilot session with the specified configuration. /// /// Configuration for the session, including the required handler. /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// /// Sessions maintain conversation state, handle events, and manage tool execution. /// If the client is not connected and is enabled (default), /// this will automatically start the connection. /// /// /// /// // Basic session /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Session with model and tools /// var session = await client.CreateSessionAsync(new() /// { /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Model = "gpt-4", /// Tools = [AIFunctionFactory.Create(MyToolMethod)] /// }); /// /// public async Task CreateSessionAsync(SessionConfig config, CancellationToken cancellationToken = default) { if (config.OnPermissionRequest == null) { throw new ArgumentException( "An OnPermissionRequest handler is required when creating a session. " + "For example, to allow all permissions, use CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll });"); } var connection = await EnsureConnectedAsync(cancellationToken); var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); var sessionId = config.SessionId ?? Guid.NewGuid().ToString(); // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. var session = new CopilotSession(sessionId, connection.Rpc, _logger); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } if (transformCallbacks != null) { session.RegisterTransformCallbacks(transformCallbacks); } if (config.OnEvent != null) { session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsHandler); _sessions[sessionId] = session; try { var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext(); var request = new CreateSessionRequest( config.Model, sessionId, config.ClientName, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, config.WorkingDirectory, config.Streaming is true ? true : null, config.McpServers, "direct", config.CustomAgents, config.Agent, config.ConfigDir, config.EnableConfigDiscovery, config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, Traceparent: traceparent, Tracestate: tracestate, ModelCapabilities: config.ModelCapabilities); var response = await InvokeRpcAsync( connection.Rpc, "session.create", [request], cancellationToken); session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); } catch { _sessions.TryRemove(sessionId, out _); throw; } return session; } /// /// Resumes an existing Copilot session with the specified configuration. /// /// The ID of the session to resume. /// Configuration for the resumed session, including the required handler. /// A that can be used to cancel the operation. /// A task that resolves to provide the . /// Thrown when is not set. /// Thrown when the session does not exist or the client is not connected. /// /// This allows you to continue a previous conversation, maintaining all conversation history. /// The session must have been previously created and not deleted. /// /// /// /// // Resume a previous session /// var session = await client.ResumeSessionAsync("session-123", new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Resume with new tools /// var session = await client.ResumeSessionAsync("session-123", new() /// { /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Tools = [AIFunctionFactory.Create(MyNewToolMethod)] /// }); /// /// public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig config, CancellationToken cancellationToken = default) { if (config.OnPermissionRequest == null) { throw new ArgumentException( "An OnPermissionRequest handler is required when resuming a session. " + "For example, to allow all permissions, use new() { OnPermissionRequest = PermissionHandler.ApproveAll }."); } var connection = await EnsureConnectedAsync(cancellationToken); var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. var session = new CopilotSession(sessionId, connection.Rpc, _logger); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } if (transformCallbacks != null) { session.RegisterTransformCallbacks(transformCallbacks); } if (config.OnEvent != null) { session.On(config.OnEvent); } ConfigureSessionFsHandlers(session, config.CreateSessionFsHandler); _sessions[sessionId] = session; try { var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext(); var request = new ResumeSessionRequest( sessionId, config.ClientName, config.Model, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, (bool?)true, config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, config.WorkingDirectory, config.ConfigDir, config.EnableConfigDiscovery, config.DisableResume is true ? true : null, config.Streaming is true ? true : null, config.McpServers, "direct", config.CustomAgents, config.Agent, config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, Traceparent: traceparent, Tracestate: tracestate, ModelCapabilities: config.ModelCapabilities); var response = await InvokeRpcAsync( connection.Rpc, "session.resume", [request], cancellationToken); session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); } catch { _sessions.TryRemove(sessionId, out _); throw; } return session; } /// /// Gets the current connection state of the client. /// /// /// The current : Disconnected, Connecting, Connected, or Error. /// /// /// /// if (client.State == ConnectionState.Connected) /// { /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// public ConnectionState State { get { if (_connectionTask == null) return ConnectionState.Disconnected; if (_connectionTask.IsFaulted) return ConnectionState.Error; if (!_connectionTask.IsCompleted) return ConnectionState.Connecting; if (_disconnected) return ConnectionState.Disconnected; return ConnectionState.Connected; } } /// /// Validates the health of the connection by sending a ping request. /// /// An optional message that will be reflected back in the response. /// A that can be used to cancel the operation. /// A task that resolves with the containing the message and server timestamp. /// Thrown when the client is not connected. /// /// /// var response = await client.PingAsync("health check"); /// Console.WriteLine($"Server responded at {response.Timestamp}"); /// /// public async Task PingAsync(string? message = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await InvokeRpcAsync( connection.Rpc, "ping", [new PingRequest { Message = message }], cancellationToken); } /// /// Gets CLI status including version and protocol information. /// /// A that can be used to cancel the operation. /// A task that resolves with the status response containing version and protocol version. /// Thrown when the client is not connected. public async Task GetStatusAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await InvokeRpcAsync( connection.Rpc, "status.get", [], cancellationToken); } /// /// Gets current authentication status. /// /// A that can be used to cancel the operation. /// A task that resolves with the authentication status. /// Thrown when the client is not connected. public async Task GetAuthStatusAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); return await InvokeRpcAsync( connection.Rpc, "auth.getStatus", [], cancellationToken); } /// /// Lists available models with their metadata. /// /// A that can be used to cancel the operation. /// A task that resolves with a list of available models. /// /// Results are cached after the first successful call to avoid rate limiting. /// The cache is cleared when the client disconnects. /// /// Thrown when the client is not connected or not authenticated. public async Task> ListModelsAsync(CancellationToken cancellationToken = default) { await _modelsCacheLock.WaitAsync(cancellationToken); try { // Check cache (already inside lock) if (_modelsCache is not null) { return [.. _modelsCache]; // Return a copy to prevent cache mutation } List models; if (_onListModels is not null) { // Use custom handler instead of CLI RPC models = await _onListModels(cancellationToken); } else { var connection = await EnsureConnectedAsync(cancellationToken); // Cache miss - fetch from backend while holding lock var response = await InvokeRpcAsync( connection.Rpc, "models.list", [], cancellationToken); models = response.Models; } // Update cache before releasing lock (copy to prevent external mutation) _modelsCache = [.. models]; return [.. models]; // Return a copy to prevent cache mutation } finally { _modelsCacheLock.Release(); } } /// /// Gets the ID of the most recently used session. /// /// A that can be used to cancel the operation. /// A task that resolves with the session ID, or null if no sessions exist. /// Thrown when the client is not connected. /// /// /// var lastId = await client.GetLastSessionIdAsync(); /// if (lastId != null) /// { /// var session = await client.ResumeSessionAsync(lastId, new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// public async Task GetLastSessionIdAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.getLastId", [], cancellationToken); return response.SessionId; } /// /// Permanently deletes a session and all its data from disk, including /// conversation history, planning state, and artifacts. /// /// The ID of the session to delete. /// A that can be used to cancel the operation. /// A task that represents the asynchronous delete operation. /// Thrown when the session does not exist or deletion fails. /// /// Unlike , which only releases in-memory /// resources and preserves session data for later resumption, this method is /// irreversible. The session cannot be resumed after deletion. /// /// /// /// await client.DeleteSessionAsync("session-123"); /// /// public async Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.delete", [new DeleteSessionRequest(sessionId)], cancellationToken); if (!response.Success) { throw new InvalidOperationException($"Failed to delete session {sessionId}: {response.Error}"); } _sessions.TryRemove(sessionId, out _); } /// /// Lists all sessions known to the Copilot server. /// /// Optional filter to narrow down the session list by cwd, git root, repository, or branch. /// A that can be used to cancel the operation. /// A task that resolves with a list of for all available sessions. /// Thrown when the client is not connected. /// /// /// var sessions = await client.ListSessionsAsync(); /// foreach (var session in sessions) /// { /// Console.WriteLine($"{session.SessionId}: {session.Summary}"); /// } /// /// public async Task> ListSessionsAsync(SessionListFilter? filter = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.list", [new ListSessionsRequest(filter)], cancellationToken); return response.Sessions; } /// /// Gets metadata for a specific session by ID. /// /// /// This provides an efficient O(1) lookup of a single session's metadata /// instead of listing all sessions. /// /// The ID of the session to look up. /// A that can be used to cancel the operation. /// A task that resolves with the , or null if the session was not found. /// Thrown when the client is not connected. /// /// /// var metadata = await client.GetSessionMetadataAsync("session-123"); /// if (metadata != null) /// { /// Console.WriteLine($"Session started at: {metadata.StartTime}"); /// } /// /// public async Task GetSessionMetadataAsync(string sessionId, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.getMetadata", [new GetSessionMetadataRequest(sessionId)], cancellationToken); return response.Session; } /// /// Gets the ID of the session currently displayed in the TUI. /// /// /// This is only available when connecting to a server running in TUI+server mode /// (--ui-server). /// /// A token to cancel the operation. /// The session ID, or null if no foreground session is set. /// /// /// var sessionId = await client.GetForegroundSessionIdAsync(); /// if (sessionId != null) /// { /// Console.WriteLine($"TUI is displaying session: {sessionId}"); /// } /// /// public async Task GetForegroundSessionIdAsync(CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.getForeground", [], cancellationToken); return response.SessionId; } /// /// Requests the TUI to switch to displaying the specified session. /// /// /// This is only available when connecting to a server running in TUI+server mode /// (--ui-server). /// /// The ID of the session to display in the TUI. /// A token to cancel the operation. /// Thrown if the operation fails. /// /// /// await client.SetForegroundSessionIdAsync("session-123"); /// /// public async Task SetForegroundSessionIdAsync(string sessionId, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( connection.Rpc, "session.setForeground", [new { sessionId }], cancellationToken); if (!response.Success) { throw new InvalidOperationException(response.Error ?? "Failed to set foreground session"); } } /// /// Subscribes to all session lifecycle events. /// /// /// Lifecycle events are emitted when sessions are created, deleted, updated, /// or change foreground/background state (in TUI+server mode). /// /// A callback function that receives lifecycle events. /// An IDisposable that, when disposed, unsubscribes the handler. /// /// /// using var subscription = client.On(evt => /// { /// Console.WriteLine($"Session {evt.SessionId}: {evt.Type}"); /// }); /// /// public IDisposable On(Action handler) { lock (_lifecycleHandlersLock) { _lifecycleHandlers.Add(handler); } return new ActionDisposable(() => { lock (_lifecycleHandlersLock) { _lifecycleHandlers.Remove(handler); } }); } /// /// Subscribes to a specific session lifecycle event type. /// /// The event type to listen for (use SessionLifecycleEventTypes constants). /// A callback function that receives events of the specified type. /// An IDisposable that, when disposed, unsubscribes the handler. /// /// /// using var subscription = client.On(SessionLifecycleEventTypes.Foreground, evt => /// { /// Console.WriteLine($"Session {evt.SessionId} is now in foreground"); /// }); /// /// public IDisposable On(string eventType, Action handler) { lock (_lifecycleHandlersLock) { if (!_typedLifecycleHandlers.TryGetValue(eventType, out var handlers)) { handlers = []; _typedLifecycleHandlers[eventType] = handlers; } handlers.Add(handler); } return new ActionDisposable(() => { lock (_lifecycleHandlersLock) { if (_typedLifecycleHandlers.TryGetValue(eventType, out var handlers)) { handlers.Remove(handler); } } }); } private void DispatchLifecycleEvent(SessionLifecycleEvent evt) { List> typedHandlers; List> wildcardHandlers; lock (_lifecycleHandlersLock) { typedHandlers = _typedLifecycleHandlers.TryGetValue(evt.Type, out var handlers) ? [.. handlers] : []; wildcardHandlers = [.. _lifecycleHandlers]; } foreach (var handler in typedHandlers) { try { handler(evt); } catch { /* Ignore handler errors */ } } foreach (var handler in wildcardHandlers) { try { handler(evt); } catch { /* Ignore handler errors */ } } } internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) { return await InvokeRpcAsync(rpc, method, args, null, cancellationToken); } internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { try { return await rpc.InvokeWithCancellationAsync(method, args, cancellationToken); } catch (StreamJsonRpc.ConnectionLostException ex) { string? stderrOutput = null; if (stderrBuffer is not null) { lock (stderrBuffer) { stderrOutput = stderrBuffer.ToString().Trim(); } } if (!string.IsNullOrEmpty(stderrOutput)) { throw new IOException($"CLI process exited unexpectedly.\nstderr: {stderrOutput}", ex); } throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex); } catch (StreamJsonRpc.RemoteRpcException ex) { throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex); } } private Task EnsureConnectedAsync(CancellationToken cancellationToken) { if (_connectionTask is null && !_options.AutoStart) { throw new InvalidOperationException($"Client not connected. Call {nameof(StartAsync)}() first."); } // If already started or starting, this will return the existing task return (Task)StartAsync(cancellationToken); } private async Task ConfigureSessionFsAsync(CancellationToken cancellationToken) { if (_options.SessionFs is null) { return; } await Rpc.SessionFs.SetProviderAsync( _options.SessionFs.InitialCwd, _options.SessionFs.SessionStatePath, _options.SessionFs.Conventions, cancellationToken); } private void ConfigureSessionFsHandlers(CopilotSession session, Func? createSessionFsHandler) { if (_options.SessionFs is null) { return; } if (createSessionFsHandler is null) { throw new InvalidOperationException( "CreateSessionFsHandler is required in the session config when CopilotClientOptions.SessionFs is configured."); } session.ClientSessionApis.SessionFs = createSessionFsHandler(session) ?? throw new InvalidOperationException("CreateSessionFsHandler returned null."); } private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) { var maxVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await InvokeRpcAsync( connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); if (!pingResponse.ProtocolVersion.HasValue) { throw new InvalidOperationException( $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + $"but server does not report a protocol version. " + $"Please update your server to ensure compatibility."); } var serverVersion = pingResponse.ProtocolVersion.Value; if (serverVersion < MinProtocolVersion || serverVersion > maxVersion) { throw new InvalidOperationException( $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + $"but server reports version {serverVersion}. " + $"Please update your SDK or server to ensure compatibility."); } _negotiatedProtocolVersion = serverVersion; } private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) { // Use explicit path, COPILOT_CLI_PATH env var (from options.Environment or process env), or bundled CLI - no PATH fallback var envCliPath = options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); var cliPath = options.CliPath ?? envCliPath ?? GetBundledCliPath(out var searchedPath) ?? throw new InvalidOperationException($"Copilot CLI not found at '{searchedPath}'. Ensure the SDK NuGet package was restored correctly or provide an explicit CliPath."); var args = new List(); if (options.CliArgs != null) { args.AddRange(options.CliArgs); } args.AddRange(["--headless", "--no-auto-update", "--log-level", options.LogLevel]); if (options.UseStdio) { args.Add("--stdio"); } else if (options.Port > 0) { args.AddRange(["--port", options.Port.ToString(CultureInfo.InvariantCulture)]); } // Add auth-related flags if (!string.IsNullOrEmpty(options.GitHubToken)) { args.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]); } // Default UseLoggedInUser to false when GitHubToken is provided var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GitHubToken); if (!useLoggedInUser) { args.Add("--no-auto-login"); } var (fileName, processArgs) = ResolveCliCommand(cliPath, args); var startInfo = new ProcessStartInfo { FileName = fileName, Arguments = string.Join(" ", processArgs.Select(ProcessArgumentEscaper.Escape)), UseShellExecute = false, RedirectStandardInput = options.UseStdio, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = options.Cwd, CreateNoWindow = true }; if (options.Environment != null) { startInfo.Environment.Clear(); foreach (var (key, value) in options.Environment) { startInfo.Environment[key] = value; } } startInfo.Environment.Remove("NODE_DEBUG"); // Set auth token in environment if provided if (!string.IsNullOrEmpty(options.GitHubToken)) { startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken; } // Set telemetry environment variables if configured if (options.Telemetry is { } telemetry) { startInfo.Environment["COPILOT_OTEL_ENABLED"] = "true"; if (telemetry.OtlpEndpoint is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry.OtlpEndpoint; if (telemetry.FilePath is not null) startInfo.Environment["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry.FilePath; if (telemetry.ExporterType is not null) startInfo.Environment["COPILOT_OTEL_EXPORTER_TYPE"] = telemetry.ExporterType; if (telemetry.SourceName is not null) startInfo.Environment["COPILOT_OTEL_SOURCE_NAME"] = telemetry.SourceName; if (telemetry.CaptureContent is { } capture) startInfo.Environment["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = capture ? "true" : "false"; } var cliProcess = new Process { StartInfo = startInfo }; cliProcess.Start(); // Capture stderr for error messages and forward to logger var stderrBuffer = new StringBuilder(); _ = Task.Run(async () => { while (cliProcess != null && !cliProcess.HasExited) { var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken); if (line != null) { lock (stderrBuffer) { stderrBuffer.AppendLine(line); } if (logger.IsEnabled(LogLevel.Debug)) { logger.LogDebug("[CLI] {Line}", line); } } } }, cancellationToken); var detectedLocalhostTcpPort = (int?)null; if (!options.UseStdio) { // Wait for port announcement using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); while (!cts.Token.IsCancellationRequested) { var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); if (ListeningOnPortRegex().Match(line) is { Success: true } match) { detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); break; } } } return (cliProcess, detectedLocalhostTcpPort, stderrBuffer); } private static string? GetBundledCliPath(out string searchedPath) { var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"; // Always use portable RID (e.g., linux-x64) to match the build-time placement, // since distro-specific RIDs (e.g., ubuntu.24.04-x64) are normalized at build time. var rid = GetPortableRid() ?? Path.GetFileName(System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier); searchedPath = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", binaryName); return File.Exists(searchedPath) ? searchedPath : null; } private static string? GetPortableRid() { string os; if (OperatingSystem.IsWindows()) os = "win"; else if (OperatingSystem.IsLinux()) os = "linux"; else if (OperatingSystem.IsMacOS()) os = "osx"; else return null; var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch { System.Runtime.InteropServices.Architecture.X64 => "x64", System.Runtime.InteropServices.Architecture.Arm64 => "arm64", _ => null, }; return arch != null ? $"{os}-{arch}" : null; } private static (string FileName, IEnumerable Args) ResolveCliCommand(string cliPath, IEnumerable args) { var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase); if (isJsFile) { return ("node", new[] { cliPath }.Concat(args)); } return (cliPath, args); } private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { Stream inputStream, outputStream; TcpClient? tcpClient = null; NetworkStream? networkStream = null; if (_options.UseStdio) { if (cliProcess == null) throw new InvalidOperationException("CLI process not started"); inputStream = cliProcess.StandardOutput.BaseStream; outputStream = cliProcess.StandardInput.BaseStream; } else { if (tcpHost is null || tcpPort is null) { throw new InvalidOperationException("Cannot connect because TCP host or port are not available"); } tcpClient = new(); await tcpClient.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken); networkStream = tcpClient.GetStream(); inputStream = networkStream; outputStream = networkStream; } var rpc = new JsonRpc(new HeaderDelimitedMessageHandler( outputStream, inputStream, CreateSystemTextJsonFormatter())) { TraceSource = new LoggerTraceSource(_logger), }; var handler = new RpcHandler(this); rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); // Protocol v3 servers send tool calls / permission requests as broadcast events. // Protocol v2 servers use the older tool.call / permission.request RPC model. // We always register v2 adapters because handlers are set up before version // negotiation; a v3 server will simply never send these requests. rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2); rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => { var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); return session.ClientSessionApis; }); rpc.StartListening(); // Transition state to Disconnected if the JSON-RPC connection drops _ = rpc.Completion.ContinueWith(_ => _disconnected = true, TaskScheduler.Default); _rpc = new ServerRpc(rpc); return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() { return new() { JsonSerializerOptions = SerializerOptionsForMessageFormatter }; } private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions(); private static JsonSerializerOptions CreateSerializerOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { AllowOutOfOrderMetadataProperties = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; options.TypeInfoResolverChain.Add(ClientJsonContext.Default); options.TypeInfoResolverChain.Add(TypesJsonContext.Default); options.TypeInfoResolverChain.Add(CopilotSession.SessionJsonContext.Default); options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); options.TypeInfoResolverChain.Add(SDK.Rpc.RpcJsonContext.Default); // StreamJsonRpc's RequestId needs serialization when CancellationToken fires during // JSON-RPC operations. Its built-in converter (RequestIdSTJsonConverter) is internal, // and [JsonSerializable] can't source-gen for it (SYSLIB1220), so we provide our own // AOT-safe resolver + converter. options.TypeInfoResolverChain.Add(new RequestIdTypeInfoResolver()); options.MakeReadOnly(); return options; } internal CopilotSession? GetSession(string sessionId) { return _sessions.TryGetValue(sessionId, out var session) ? session : null; } /// /// Disposes the synchronously. /// /// /// Prefer using for better performance in async contexts. /// public void Dispose() { DisposeAsync().AsTask().GetAwaiter().GetResult(); } /// /// Disposes the asynchronously. /// /// A representing the asynchronous dispose operation. /// /// This method calls to immediately release all resources. /// public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; await ForceStopAsync(); } private class RpcHandler(CopilotClient client) { public void OnSessionEvent(string sessionId, JsonElement? @event) { var session = client.GetSession(sessionId); if (session != null && @event != null) { var evt = SessionEvent.FromJson(@event.Value.GetRawText()); if (evt != null) { session.DispatchEvent(evt); } } } public void OnSessionLifecycle(string type, string sessionId, JsonElement? metadata) { var evt = new SessionLifecycleEvent { Type = type, SessionId = sessionId }; if (metadata != null) { evt.Metadata = JsonSerializer.Deserialize( metadata.Value.GetRawText(), TypesJsonContext.Default.SessionLifecycleEventMetadata); } client.DispatchLifecycleEvent(evt); } public async Task OnUserInputRequest(string sessionId, string question, List? choices = null, bool? allowFreeform = null) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var request = new UserInputRequest { Question = question, Choices = choices, AllowFreeform = allowFreeform }; var result = await session.HandleUserInputRequestAsync(request); return new UserInputRequestResponse(result.Answer, result.WasFreeform); } public async Task OnHooksInvoke(string sessionId, string hookType, JsonElement input) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var output = await session.HandleHooksInvokeAsync(hookType, input); return new HooksInvokeResponse(output); } public async Task OnSystemMessageTransform(string sessionId, JsonElement sections) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); return await session.HandleSystemMessageTransformAsync(sections); } // Protocol v2 backward-compatibility adapters public async Task OnToolCallV2(string sessionId, string toolCallId, string toolName, object? arguments, string? traceparent = null, string? tracestate = null) { using var _ = TelemetryHelpers.RestoreTraceContext(traceparent, tracestate); var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); if (session.GetTool(toolName) is not { } tool) { return new ToolCallResponseV2(new ToolResultObject { TextResultForLlm = $"Tool '{toolName}' is not supported.", ResultType = "failure", Error = $"tool '{toolName}' not supported" }); } 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 = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions); return new ToolCallResponseV2(toolResultObject); } catch (Exception ex) { return new ToolCallResponseV2(new ToolResultObject { TextResultForLlm = "Invoking this tool produced an error. Detailed information is not available.", ResultType = "failure", Error = ex.Message }); } } public async Task OnPermissionRequestV2(string sessionId, JsonElement permissionRequest) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); try { var result = await session.HandlePermissionRequestAsync(permissionRequest); if (result.Kind == new PermissionRequestResultKind("no-result")) { throw new InvalidOperationException(NoResultPermissionV2ErrorMessage); } return new PermissionRequestResponseV2(result); } catch (InvalidOperationException ex) when (ex.Message == NoResultPermissionV2ErrorMessage) { throw; } catch (Exception) { return new PermissionRequestResponseV2(new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }); } } } private class Connection( JsonRpc rpc, Process? cliProcess, // Set if we created the child process TcpClient? tcpClient, // Set if using TCP NetworkStream? networkStream, // Set if using TCP StringBuilder? stderrBuffer = null) // Captures stderr for error messages { public Process? CliProcess => cliProcess; public TcpClient? TcpClient => tcpClient; public JsonRpc Rpc => rpc; public NetworkStream? NetworkStream => networkStream; public StringBuilder? StderrBuffer => stderrBuffer; } private static class ProcessArgumentEscaper { public static string Escape(string arg) { if (string.IsNullOrEmpty(arg)) return "\"\""; if (!arg.Contains(' ') && !arg.Contains('"')) return arg; return "\"" + arg.Replace("\"", "\\\"") + "\""; } } // Request/Response types for RPC internal record CreateSessionRequest( string? Model, string? SessionId, string? ClientName, string? ReasoningEffort, List? Tools, SystemMessageConfig? SystemMessage, List? AvailableTools, List? ExcludedTools, ProviderConfig? Provider, bool? RequestPermission, bool? RequestUserInput, bool? Hooks, string? WorkingDirectory, bool? Streaming, Dictionary? McpServers, string? EnvValueMode, List? CustomAgents, string? Agent, string? ConfigDir, bool? EnableConfigDiscovery, List? SkillDirectories, List? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, List? Commands = null, bool? RequestElicitation = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null); internal record ToolDefinition( string Name, string? Description, JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null, bool? SkipPermission = null) { public static ToolDefinition FromAIFunction(AIFunction function) { var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true; var skipPerm = function.AdditionalProperties.TryGetValue("skip_permission", out var skipVal) && skipVal is true; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null); } } internal record CreateSessionResponse( string SessionId, string? WorkspacePath, SessionCapabilities? Capabilities = null); internal record ResumeSessionRequest( string SessionId, string? ClientName, string? Model, string? ReasoningEffort, List? Tools, SystemMessageConfig? SystemMessage, List? AvailableTools, List? ExcludedTools, ProviderConfig? Provider, bool? RequestPermission, bool? RequestUserInput, bool? Hooks, string? WorkingDirectory, string? ConfigDir, bool? EnableConfigDiscovery, bool? DisableResume, bool? Streaming, Dictionary? McpServers, string? EnvValueMode, List? CustomAgents, string? Agent, List? SkillDirectories, List? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, List? Commands = null, bool? RequestElicitation = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null); internal record ResumeSessionResponse( string SessionId, string? WorkspacePath, SessionCapabilities? Capabilities = null); internal record CommandWireDefinition( string Name, string? Description); internal record GetLastSessionIdResponse( string? SessionId); internal record DeleteSessionRequest( string SessionId); internal record DeleteSessionResponse( bool Success, string? Error); internal record ListSessionsRequest( SessionListFilter? Filter); internal record ListSessionsResponse( List Sessions); internal record GetSessionMetadataRequest( string SessionId); internal record GetSessionMetadataResponse( SessionMetadata? Session); internal record UserInputRequestResponse( string Answer, bool WasFreeform); internal record HooksInvokeResponse( object? Output); // Protocol v2 backward-compatibility response types internal record ToolCallResponseV2( ToolResultObject Result); internal record PermissionRequestResponseV2( PermissionRequestResult Result); /// Trace source that forwards all logs to the ILogger. internal sealed class LoggerTraceSource : TraceSource { public LoggerTraceSource(ILogger logger) : base(nameof(LoggerTraceSource), SourceLevels.All) { Listeners.Clear(); Listeners.Add(new LoggerTraceListener(logger)); } private sealed class LoggerTraceListener(ILogger logger) : TraceListener { public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? message) { LogLevel level = MapLevel(eventType); if (logger.IsEnabled(level)) { logger.Log(level, "[{Source}] {Message}", source, message); } } public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? format, params object?[]? args) { LogLevel level = MapLevel(eventType); if (logger.IsEnabled(level)) { logger.Log(level, "[{Source}] {Message}", source, args is null || args.Length == 0 ? format : string.Format(CultureInfo.InvariantCulture, format ?? "", args)); } } public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, object? data) { LogLevel level = MapLevel(eventType); if (logger.IsEnabled(level)) { logger.Log(level, "[{Source}] {Data}", source, data); } } public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, params object?[]? data) { LogLevel level = MapLevel(eventType); if (logger.IsEnabled(level)) { logger.Log(level, "[{Source}] {Data}", source, data is null ? null : string.Join(", ", data)); } } public override void Write(string? message) { if (logger.IsEnabled(LogLevel.Trace)) { logger.LogTrace("{Message}", message); } } public override void WriteLine(string? message) { if (logger.IsEnabled(LogLevel.Trace)) { logger.LogTrace("{Message}", message); } } private static LogLevel MapLevel(TraceEventType eventType) { return eventType switch { TraceEventType.Critical => LogLevel.Critical, TraceEventType.Error => LogLevel.Error, TraceEventType.Warning => LogLevel.Warning, TraceEventType.Information => LogLevel.Information, TraceEventType.Verbose => LogLevel.Debug, _ => LogLevel.Trace }; } } } [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(CreateSessionRequest))] [JsonSerializable(typeof(CreateSessionResponse))] [JsonSerializable(typeof(CustomAgentConfig))] [JsonSerializable(typeof(DeleteSessionRequest))] [JsonSerializable(typeof(DeleteSessionResponse))] [JsonSerializable(typeof(GetLastSessionIdResponse))] [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(GetSessionMetadataRequest))] [JsonSerializable(typeof(GetSessionMetadataResponse))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(PermissionRequestResult))] [JsonSerializable(typeof(PermissionRequestResponseV2))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionCapabilities))] [JsonSerializable(typeof(SessionUiCapabilities))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(CommandWireDefinition))] [JsonSerializable(typeof(ToolCallResponseV2))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] [JsonSerializable(typeof(UserInputRequestResponse))] [JsonSerializable(typeof(UserInputRequest))] [JsonSerializable(typeof(UserInputResponse))] internal partial class ClientJsonContext : JsonSerializerContext; /// /// AOT-safe type info resolver for . /// StreamJsonRpc's own RequestIdSTJsonConverter is internal (SYSLIB1220/CS0122), /// so we provide our own converter and wire it through /// to stay fully AOT/trimming-compatible. /// private sealed class RequestIdTypeInfoResolver : IJsonTypeInfoResolver { public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) { if (type == typeof(RequestId)) return JsonMetadataServices.CreateValueInfo(options, new RequestIdJsonConverter()); return null; } } private sealed class RequestIdJsonConverter : JsonConverter { public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return reader.TokenType switch { JsonTokenType.Number => reader.TryGetInt64(out long val) ? new RequestId(val) : new RequestId(reader.HasValueSequence ? Encoding.UTF8.GetString(reader.ValueSequence) : Encoding.UTF8.GetString(reader.ValueSpan)), JsonTokenType.String => new RequestId(reader.GetString()!), JsonTokenType.Null => RequestId.Null, _ => throw new JsonException($"Unexpected token type for RequestId: {reader.TokenType}"), }; } public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options) { if (value.Number.HasValue) writer.WriteNumberValue(value.Number.Value); else if (value.String is not null) writer.WriteStringValue(value.String); else writer.WriteNullValue(); } } [GeneratedRegex(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase)] private static partial Regex ListeningOnPortRegex(); } /// /// Wraps a as to pass structured tool results /// back through Microsoft.Extensions.AI without JSON serialization. /// /// The tool result to wrap. public class ToolResultAIContent(ToolResultObject toolResult) : AIContent { /// /// Gets the underlying . /// public ToolResultObject Result => toolResult; }