Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions dotnet/GitHub.Copilot.SDK.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,4 @@
<Folder Name="/test/">
<Project Path="test/GitHub.Copilot.SDK.Test.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/Chat.csproj" />
</Folder>
</Solution>
23 changes: 14 additions & 9 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ SDK for programmatic control of GitHub Copilot CLI.
dotnet add package GitHub.Copilot.SDK
```

## Run the Sample
## Run the Samples

Try the interactive chat sample (from the repo root):

```bash
cd dotnet/samples
dotnet run
dotnet run --file dotnet/samples/Chat.cs
```

The manual permission/tool-result resume sample can be run the same way:

```bash
dotnet run --file dotnet/samples/ManualToolResume.cs
```

## Quick Start
Expand All @@ -28,7 +33,7 @@ using GitHub.Copilot.SDK;
await using var client = new CopilotClient();
await client.StartAsync();

// Create a session (OnPermissionRequest is required)
// Create a session (OnPermissionRequest is optional; ApproveAll allows every tool)
await using var session = await client.CreateSessionAsync(new SessionConfig
{
Model = "gpt-5",
Expand Down Expand Up @@ -105,14 +110,14 @@ Create a new conversation session.
- `SessionId` - Custom session ID
- `Model` - Model to use ("gpt-5", "claude-sonnet-4.5", etc.)
- `ReasoningEffort` - Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `ListModelsAsync()` to check which models support this option.
- `Tools` - Custom tools exposed to the CLI
- `Tools` - Custom tool declarations exposed to the CLI. Declarations without an invocable `AIFunction` are left pending for manual resolution.
- `SystemMessage` - System message customization
- `AvailableTools` - List of tool names to allow
- `ExcludedTools` - List of tool names to disable
- `Provider` - Custom API provider configuration (BYOK)
- `Streaming` - Enable streaming of response chunks (default: false)
- `InfiniteSessions` - Configure automatic context compaction (see below)
- `OnPermissionRequest` - **Required.** Handler called before each tool execution to approve or deny it. Use `PermissionHandler.ApproveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section.
- `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. Use `PermissionHandler.ApproveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section.
- `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section.
- `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section.

Expand All @@ -122,7 +127,7 @@ Resume an existing session. Returns the session with `WorkspacePath` populated i

**ResumeSessionConfig:**

- `OnPermissionRequest` - **Required.** Handler called before each tool execution to approve or deny it. See [Permission Handling](#permission-handling) section.
- `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. See [Permission Handling](#permission-handling) section.

##### `PingAsync(string? message = null): Task<PingResponse>`

Expand Down Expand Up @@ -715,7 +720,7 @@ No extra dependencies — uses built-in `System.Diagnostics.Activity`.

## Permission Handling

An `OnPermissionRequest` handler is **required** whenever you create or resume a session. The handler is called before the agent executes each tool (file writes, shell commands, custom tools, etc.) and must return a decision.
An `OnPermissionRequest` handler is optional when you create or resume a session. When provided, it is called before the agent executes each tool (file writes, shell commands, custom tools, etc.) and returns a decision. When omitted, permission requests are emitted as events and left pending for the consumer to resolve with the pending permission RPC.

### Approve All (simplest)

Expand Down Expand Up @@ -778,7 +783,7 @@ var session = await client.CreateSessionAsync(new SessionConfig

### Resuming Sessions

Pass `OnPermissionRequest` when resuming a session too — it is required:
You may pass `OnPermissionRequest` when resuming a session too:

```csharp
var session = await client.ResumeSessionAsync("session-id", new ResumeSessionConfig
Expand Down
2 changes: 2 additions & 0 deletions dotnet/samples/Chat.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#:project ../src/GitHub.Copilot.SDK.csproj

using GitHub.Copilot.SDK;

await using var client = new CopilotClient();
Expand Down
8 changes: 0 additions & 8 deletions dotnet/samples/Chat.csproj

This file was deleted.

92 changes: 92 additions & 0 deletions dotnet/samples/ManualToolResume.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#:project ../src/GitHub.Copilot.SDK.csproj

using System.ComponentModel;
using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
using Microsoft.Extensions.AI;

var tool = ManualToolDeclaration();

// 1. Create a session with a declaration-only tool, then stop after the permission prompt.
await using CopilotClient client1 = new();
await using var session1 = await client1.CreateSessionAsync(new() { Tools = [tool] });

// Subscribe before sending so the permission event cannot be missed.
var permissionRequested = WaitForEventAsync<PermissionRequestedEvent>(session1);
await session1.SendAsync(new MessageOptions
{
Prompt = "Use the manual_resume_status tool with id 'alpha', then tell me the status.",
});

var permissionEvent = await permissionRequested;
await client1.ForceStopAsync();

await PauseAsync();

// 2. Resume pending work and grant permission to invoke the tool.
await using CopilotClient client2 = new();
await using var session2 = await client2.ResumeSessionAsync(session1.SessionId, new()
{
Tools = [tool],
ContinuePendingWork = true,
});

// Subscribe before approving so the external tool request cannot be missed.
var toolRequested = WaitForEventAsync<ExternalToolRequestedEvent>(
session2,
evt => evt.Data.ToolName == "manual_resume_status");

await session2.Rpc.Permissions.HandlePendingPermissionRequestAsync(
permissionEvent.Data.RequestId,
new PermissionDecisionApproveOnce());

var toolEvent = await toolRequested;
await client2.ForceStopAsync();

await PauseAsync();

// 3. Resume again and manually provide the pending tool result.
await using var client3 = new CopilotClient();
await using var session3 = await client3.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
{
Tools = [tool],
ContinuePendingWork = true,
});

var assistantMessage = WaitForEventAsync<AssistantMessageEvent>(session3);
await session3.Rpc.Tools.HandlePendingToolCallAsync(
toolEvent.Data.RequestId,
result: "MANUAL_STATUS_READY");

var answer = await assistantMessage;
Console.WriteLine(answer.Data.Content);

static Task PauseAsync()
{
Console.WriteLine("Simulating time passing...\n");
return Task.Delay(TimeSpan.FromSeconds(1));
}

static AIFunctionDeclaration ManualToolDeclaration() =>
AIFunctionFactory.Create(
([Description("Identifier to look up")] string id) => $"not used: {id}",
"manual_resume_status",
"Looks up a status value. The SDK consumer supplies the result manually.")
// Remove the invocable callback so the SDK leaves tool execution pending.
.AsDeclarationOnly();

static async Task<T> WaitForEventAsync<T>(CopilotSession session, Func<T, bool>? predicate = null)
where T : SessionEvent
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
IDisposable? subscription = null;
subscription = session.On(evt =>
{
if (evt is T typed && (predicate?.Invoke(typed) ?? true))
{
subscription?.Dispose();
tcs.TrySetResult(typed);
}
});
return await tcs.Task.WaitAsync(TimeSpan.FromMinutes(2));
}
23 changes: 5 additions & 18 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ private static (SystemMessageConfig? wireConfig, Dictionary<string, Func<string,
/// <summary>
/// Creates a new Copilot session with the specified configuration.
/// </summary>
/// <param name="config">Configuration for the session, including the required <see cref="SessionConfig.OnPermissionRequest"/> handler.</param>
/// <param name="config">Configuration for the session.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
/// <returns>A task that resolves to provide the <see cref="CopilotSession"/>.</returns>
/// <remarks>
Expand All @@ -532,13 +532,6 @@ private static (SystemMessageConfig? wireConfig, Dictionary<string, Func<string,
/// </example>
public async Task<CopilotSession> 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 totalTimestamp = Stopwatch.GetTimestamp();

Expand Down Expand Up @@ -667,10 +660,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
/// Resumes an existing Copilot session with the specified configuration.
/// </summary>
/// <param name="sessionId">The ID of the session to resume.</param>
/// <param name="config">Configuration for the resumed session, including the required <see cref="ResumeSessionConfig.OnPermissionRequest"/> handler.</param>
/// <param name="config">Configuration for the resumed session.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
/// <returns>A task that resolves to provide the <see cref="CopilotSession"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <see cref="ResumeSessionConfig.OnPermissionRequest"/> is not set.</exception>
/// <exception cref="InvalidOperationException">Thrown when the session does not exist or the client is not connected.</exception>
/// <remarks>
/// This allows you to continue a previous conversation, maintaining all conversation history.
Expand All @@ -691,13 +683,6 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
/// </example>
public async Task<CopilotSession> 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 totalTimestamp = Stopwatch.GetTimestamp();

Expand Down Expand Up @@ -1835,6 +1820,8 @@ public async ValueTask<ToolCallResponseV2> OnToolCallV2(string sessionId,
var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}");
if (session.GetTool(toolName) is not { } tool)
{
// Support for not providing the tool handler is only available in the v3+ model.
// For v2, it must have been provided.
return new ToolCallResponseV2(new ToolResultObject
{
TextResultForLlm = $"Tool '{toolName}' is not supported.",
Expand Down Expand Up @@ -1993,7 +1980,7 @@ internal record ToolDefinition(
bool? OverridesBuiltInTool = null,
bool? SkipPermission = null)
{
public static ToolDefinition FromAIFunction(AIFunction function)
public static ToolDefinition FromAIFunction(AIFunctionDeclaration 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;
Expand Down
15 changes: 9 additions & 6 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -419,17 +419,20 @@ private async Task ProcessEventsAsync()
/// <summary>
/// Registers custom tool handlers for this session.
/// </summary>
/// <param name="tools">A collection of AI functions that can be invoked by the assistant.</param>
/// <param name="tools">A collection of AI function declarations available to the assistant.</param>
/// <remarks>
/// Tools allow the assistant to execute custom functions. When the assistant invokes a tool,
/// the corresponding handler is called with the tool arguments.
/// Tools backed by an <see cref="AIFunction"/> are invoked automatically. Declaration-only tools are
/// left pending for the client to resolve via the external tool request event.
/// </remarks>
internal void RegisterTools(ICollection<AIFunction> tools)
internal void RegisterTools(ICollection<AIFunctionDeclaration> tools)
{
_toolHandlers.Clear();
foreach (var tool in tools)
{
_toolHandlers.Add(tool.Name, tool);
if (tool.GetService<AIFunction>() is { } function)
{
_toolHandlers.Add(tool.Name, function);
}
}
}

Expand All @@ -451,7 +454,7 @@ internal void RegisterTools(ICollection<AIFunction> tools)
/// When the assistant needs permission to perform certain actions (e.g., file operations),
/// this handler is called to approve or deny the request.
/// </remarks>
internal void RegisterPermissionHandler(PermissionRequestHandler handler)
internal void RegisterPermissionHandler(PermissionRequestHandler? handler)
{
_permissionHandler = handler;
}
Expand Down
12 changes: 8 additions & 4 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2087,9 +2087,11 @@ protected SessionConfig(SessionConfig? other)
public bool? EnableConfigDiscovery { get; set; }

/// <summary>
/// Custom tool functions available to the language model during the session.
/// Custom tool declarations available to the language model during the session.
/// Declarations backed by an <see cref="AIFunction"/> are invoked automatically; declarations without one
/// are left for the client to handle via external tool request events.
/// </summary>
public ICollection<AIFunction>? Tools { get; set; }
public ICollection<AIFunctionDeclaration>? Tools { get; set; }
/// <summary>
/// System message configuration for the session.
/// </summary>
Expand Down Expand Up @@ -2352,9 +2354,11 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
public string? Model { get; set; }

/// <summary>
/// Custom tool functions available to the language model during the resumed session.
/// Custom tool declarations available to the language model during the resumed session.
/// Declarations backed by an <see cref="AIFunction"/> are invoked automatically; declarations without one
/// are left for the client to handle via external tool request events.
/// </summary>
public ICollection<AIFunction>? Tools { get; set; }
public ICollection<AIFunctionDeclaration>? Tools { get; set; }

/// <summary>
/// System message configuration.
Expand Down
21 changes: 9 additions & 12 deletions dotnet/test/E2E/ClientE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,27 +181,24 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start(bool u
[Theory]
[InlineData(true)] // stdio transport
[InlineData(false)] // TCP transport
public async Task Should_Throw_When_CreateSession_Called_Without_PermissionHandler(bool useStdio)
public async Task Should_Allow_CreateSession_Called_Without_PermissionHandler(bool useStdio)
{
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = useStdio });

var ex = await Assert.ThrowsAsync<ArgumentException>(() => client.CreateSessionAsync(new SessionConfig()));
await using var client = new CopilotClient(new CopilotClientOptions { UseStdio = useStdio });
await using var session = await client.CreateSessionAsync(new SessionConfig());

Assert.Contains("OnPermissionRequest", ex.Message);
Assert.Contains("is required", ex.Message);
Assert.NotNull(session.SessionId);
}

[Theory]
[InlineData(true)] // stdio transport
[InlineData(false)] // TCP transport
public async Task Should_Throw_When_ResumeSession_Called_Without_PermissionHandler(bool useStdio)
public async Task Should_Allow_ResumeSession_Called_Without_PermissionHandler(bool useStdio)
{
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = useStdio });

var ex = await Assert.ThrowsAsync<ArgumentException>(() => client.ResumeSessionAsync("some-session-id", new()));
await using var client = new CopilotClient(new CopilotClientOptions { UseStdio = useStdio });
await using var originalSession = await client.CreateSessionAsync(new SessionConfig());
await using var resumedSession = await client.ResumeSessionAsync(originalSession.SessionId, new());

Assert.Contains("OnPermissionRequest", ex.Message);
Assert.Contains("is required", ex.Message);
Assert.Equal(originalSession.SessionId, resumedSession.SessionId);
}

[Theory]
Expand Down
Loading
Loading