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