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