Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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: 3 additions & 0 deletions dotnet/GitHub.Copilot.SDK.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@
<Folder Name="/test/">
<Project Path="test/GitHub.Copilot.SDK.Test.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/Chat.csproj" />
</Folder>
</Solution>
9 changes: 9 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ SDK for programmatic control of GitHub Copilot CLI.
dotnet add package GitHub.Copilot.SDK
```

## Run the Sample

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

```bash
cd dotnet/samples
dotnet run
```

## Quick Start

```csharp
Expand Down
32 changes: 32 additions & 0 deletions dotnet/samples/Chat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using GitHub.Copilot.SDK;

await using var client = new CopilotClient();
await using var session = await client.CreateSessionAsync();

using var _ = session.On(evt =>
{
Console.ForegroundColor = ConsoleColor.Blue;
switch (evt)
{
case AssistantReasoningEvent reasoning:
Console.WriteLine($"[reasoning: {reasoning.Data.Content}]");
break;
case ToolExecutionStartEvent tool:
Console.WriteLine($"[tool: {tool.Data.ToolName}]");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor inconsistency: Missing tool arguments in output

The .NET sample only logs the tool name, while the Node.js, Python, and Go samples all include the tool arguments in their output.

For consistency across all SDK samples, consider adding the arguments:

case ToolExecutionStartEvent tool:
    var args = System.Text.Json.JsonSerializer.Serialize(tool.Data.Arguments);
    Console.WriteLine($"[tool: {tool.Data.ToolName} {args}]");
    break;

This would match the pattern in:

  • Node.js (line 13): `[tool: ${event.data.toolName} ${JSON.stringify(event.data.arguments)}]`
  • Python (line 18): f"[tool: {event.data.tool_name} {json.dumps(event.data.arguments)}]"
  • Go (lines 40-41): fmt.Sprintf("[tool: %s %s]", *event.Data.ToolName, args)

AI generated by SDK Consistency Review Agent for #492

break;
}
Console.ResetColor();
});

Console.WriteLine("Chat with Copilot (Ctrl+C to exit)\n");

while (true)
{
Console.Write("You: ");
var input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input)) continue;
Console.WriteLine();

var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = input });
Console.WriteLine($"\nAssistant: {reply?.Data.Content}\n");
}
11 changes: 11 additions & 0 deletions dotnet/samples/Chat.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\src\GitHub.Copilot.SDK.csproj" />
</ItemGroup>
</Project>
50 changes: 40 additions & 10 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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.RegularExpressions;
Expand Down Expand Up @@ -183,13 +184,13 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
if (_optionsHost is not null && _optionsPort is not null)
{
// External server (TCP)
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, ct);
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct);
}
else
{
// Child process (stdio or TCP)
var (cliProcess, portOrNull) = await StartCliServerAsync(_options, _logger, ct);
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, ct);
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
}

var connection = await result;
Expand Down Expand Up @@ -842,11 +843,33 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
}

internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken)
{
return await InvokeRpcAsync<T>(rpc, method, args, null, cancellationToken);
}

internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken)
{
try
{
return await rpc.InvokeWithCancellationAsync<T>(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);
Expand All @@ -868,7 +891,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
{
var expectedVersion = SdkProtocolVersion.GetVersion();
var pingResponse = await InvokeRpcAsync<PingResponse>(
connection.Rpc, "ping", [new PingRequest()], cancellationToken);
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);

if (!pingResponse.ProtocolVersion.HasValue)
{
Expand All @@ -887,7 +910,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
}
}

private static async Task<(Process Process, int? DetectedLocalhostTcpPort)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
{
// Use explicit path or bundled CLI - no PATH fallback
var cliPath = options.CliPath ?? GetBundledCliPath(out var searchedPath)
Expand Down Expand Up @@ -957,14 +980,19 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
var cliProcess = new Process { StartInfo = startInfo };
cliProcess.Start();

// Forward stderr to logger
// 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);
}
logger.LogDebug("[CLI] {Line}", line);
}
}
Expand All @@ -991,7 +1019,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
}
}

return (cliProcess, detectedLocalhostTcpPort);
return (cliProcess, detectedLocalhostTcpPort, stderrBuffer);
}

private static string? GetBundledCliPath(out string searchedPath)
Expand Down Expand Up @@ -1035,7 +1063,7 @@ private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(str
return (cliPath, args);
}

private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, CancellationToken cancellationToken)
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken)
{
Stream inputStream, outputStream;
TcpClient? tcpClient = null;
Expand Down Expand Up @@ -1080,7 +1108,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?

_rpc = new ServerRpc(rpc);

return new Connection(rpc, cliProcess, tcpClient, networkStream);
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")]
Expand Down Expand Up @@ -1321,12 +1349,14 @@ 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
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
Expand Down
31 changes: 31 additions & 0 deletions dotnet/test/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,35 @@ public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client(

await client.StopAsync();
}

[Fact]
public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start()
{
var client = new CopilotClient(new CopilotClientOptions
{
CliArgs = new[] { "--nonexistent-flag-for-testing" },
UseStdio = true
});

var ex = await Assert.ThrowsAsync<IOException>(async () =>
{
await client.StartAsync();
});

var errorMessage = ex.Message;
// Verify we get the stderr output in the error message
Assert.Contains("stderr", errorMessage, StringComparison.OrdinalIgnoreCase);
Assert.Contains("nonexistent", errorMessage, StringComparison.OrdinalIgnoreCase);

// Verify subsequent calls also fail (don't hang)
var ex2 = await Assert.ThrowsAnyAsync<Exception>(async () =>
{
var session = await client.CreateSessionAsync();
await session.SendAsync(new MessageOptions { Prompt = "test" });
});
Assert.Contains("exited", ex2.Message, StringComparison.OrdinalIgnoreCase);

// Cleanup - ForceStop should handle the disconnected state gracefully
try { await client.ForceStopAsync(); } catch (Exception) { /* Expected */ }
}
}
9 changes: 9 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ A Go SDK for programmatic access to the GitHub Copilot CLI.
go get github.com/github/copilot-sdk/go
```

## Run the Sample

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

```bash
cd go/samples
go run chat.go
```

## Quick Start

```go
Expand Down
33 changes: 20 additions & 13 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ type Client struct {
lifecycleHandlers []SessionLifecycleHandler
typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler
lifecycleHandlersMux sync.Mutex
processDone chan struct{} // closed when CLI process exits
processError error // set before processDone is closed

// RPC provides typed server-scoped RPC methods.
// This field is nil until the client is connected via Start().
Expand Down Expand Up @@ -149,6 +151,9 @@ func NewClient(options *ClientOptions) *Client {
if options.CLIPath != "" {
opts.CLIPath = options.CLIPath
}
if len(options.CLIArgs) > 0 {
opts.CLIArgs = append([]string{}, options.CLIArgs...)
}
if options.Cwd != "" {
opts.Cwd = options.Cwd
}
Expand Down Expand Up @@ -1022,7 +1027,10 @@ func (c *Client) startCLIServer(ctx context.Context) error {
// Default to "copilot" in PATH if no embedded CLI is available and no custom path is set
cliPath = "copilot"
}
args := []string{"--headless", "--no-auto-update", "--log-level", c.options.LogLevel}

// Start with user-provided CLIArgs, then add SDK-managed args
args := append([]string{}, c.options.CLIArgs...)
args = append(args, "--headless", "--no-auto-update", "--log-level", c.options.LogLevel)

// Choose transport mode
if c.useStdio {
Expand Down Expand Up @@ -1082,26 +1090,25 @@ func (c *Client) startCLIServer(ctx context.Context) error {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}

stderr, err := c.process.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
if err := c.process.Start(); err != nil {
return fmt.Errorf("failed to start CLI server: %w", err)
}

// Read stderr in background
// Monitor process exit to signal pending requests
c.processDone = make(chan struct{})
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
// Optionally log stderr
// fmt.Fprintf(os.Stderr, "CLI stderr: %s\n", scanner.Text())
waitErr := c.process.Wait()
if waitErr != nil {
c.processError = fmt.Errorf("CLI process exited: %v", waitErr)
} else {
c.processError = fmt.Errorf("CLI process exited unexpectedly")
}
close(c.processDone)
}()

if err := c.process.Start(); err != nil {
return fmt.Errorf("failed to start CLI server: %w", err)
}

// Create JSON-RPC client immediately
c.client = jsonrpc2.NewClient(stdin, stdout)
c.client.SetProcessDone(c.processDone, &c.processError)
c.RPC = rpc.NewServerRpc(c.client)
c.setupNotificationHandler()
c.client.Start()
Expand Down
23 changes: 23 additions & 0 deletions go/internal/e2e/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,27 @@ func TestClient(t *testing.T) {

client.Stop()
})

t.Run("should report error when CLI fails to start", func(t *testing.T) {
client := copilot.NewClient(&copilot.ClientOptions{
CLIPath: cliPath,
CLIArgs: []string{"--nonexistent-flag-for-testing"},
UseStdio: copilot.Bool(true),
})
t.Cleanup(func() { client.ForceStop() })

err := client.Start(t.Context())
if err == nil {
t.Fatal("Expected Start to fail with invalid CLI args")
}

// Verify subsequent calls also fail (don't hang)
session, err := client.CreateSession(t.Context(), nil)
if err == nil {
_, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "test"})
}
if err == nil {
t.Fatal("Expected CreateSession/Send to fail after CLI exit")
}
})
}
Loading
Loading