Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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}]");
Copy link
Contributor

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
23 changes: 23 additions & 0 deletions dotnet/test/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,27 @@ 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);

// Cleanup - ForceStop should handle the disconnected state gracefully
try { await client.ForceStopAsync(); } catch { /* 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
35 changes: 28 additions & 7 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ package copilot

import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
Expand Down Expand Up @@ -85,6 +87,8 @@ type Client struct {
lifecycleHandlers []SessionLifecycleHandler
typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler
lifecycleHandlersMux sync.Mutex
stderrBuf bytes.Buffer // captures CLI stderr for error messages
processDone chan error // signals when CLI process exits

// RPC provides typed server-scoped RPC methods.
// This field is nil until the client is connected via Start().
Expand Down Expand Up @@ -149,6 +153,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 +1029,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 @@ -1087,21 +1097,32 @@ func (c *Client) startCLIServer(ctx context.Context) error {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}

// Read stderr in background
// Read stderr in background, capturing for error messages
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
// Optionally log stderr
// fmt.Fprintf(os.Stderr, "CLI stderr: %s\n", scanner.Text())
}
io.Copy(&c.stderrBuf, stderr)
}()

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

// Monitor process exit to signal pending requests
c.processDone = make(chan error, 1)
go func() {
err := c.process.Wait()
stderrOutput := strings.TrimSpace(c.stderrBuf.String())
if stderrOutput != "" {
c.processDone <- fmt.Errorf("CLI process exited: %v\nstderr: %s", err, stderrOutput)
} else if err != nil {
c.processDone <- fmt.Errorf("CLI process exited: %v", err)
} else {
c.processDone <- fmt.Errorf("CLI process exited unexpectedly")
}
}()

// Create JSON-RPC client immediately
c.client = jsonrpc2.NewClient(stdin, stdout)
c.client.SetProcessDone(c.processDone)
c.RPC = rpc.NewServerRpc(c.client)
c.setupNotificationHandler()
c.client.Start()
Expand Down
21 changes: 21 additions & 0 deletions go/internal/e2e/client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package e2e

import (
"strings"
"testing"
"time"

Expand Down Expand Up @@ -225,4 +226,24 @@ func TestClient(t *testing.T) {

client.Stop()
})

t.Run("should report error with stderr 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")
}

errStr := err.Error()
// Verify we get the stderr output in the error message
if !strings.Contains(errStr, "stderr") || !strings.Contains(errStr, "nonexistent") {
t.Errorf("Expected error to contain stderr output about invalid flag, got: %v", err)
}
})
}
Loading
Loading