Skip to content

Commit ed51ab8

Browse files
Add minimal chat samples for all SDK languages (#492)
1 parent e22d235 commit ed51ab8

File tree

26 files changed

+1261
-31
lines changed

26 files changed

+1261
-31
lines changed

dotnet/GitHub.Copilot.SDK.slnx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@
1010
<Folder Name="/test/">
1111
<Project Path="test/GitHub.Copilot.SDK.Test.csproj" />
1212
</Folder>
13+
<Folder Name="/samples/">
14+
<Project Path="samples/Chat.csproj" />
15+
</Folder>
1316
</Solution>

dotnet/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ SDK for programmatic control of GitHub Copilot CLI.
1010
dotnet add package GitHub.Copilot.SDK
1111
```
1212

13+
## Run the Sample
14+
15+
Try the interactive chat sample (from the repo root):
16+
17+
```bash
18+
cd dotnet/samples
19+
dotnet run
20+
```
21+
1322
## Quick Start
1423

1524
```csharp

dotnet/samples/Chat.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using GitHub.Copilot.SDK;
2+
3+
await using var client = new CopilotClient();
4+
await using var session = await client.CreateSessionAsync();
5+
6+
using var _ = session.On(evt =>
7+
{
8+
Console.ForegroundColor = ConsoleColor.Blue;
9+
switch (evt)
10+
{
11+
case AssistantReasoningEvent reasoning:
12+
Console.WriteLine($"[reasoning: {reasoning.Data.Content}]");
13+
break;
14+
case ToolExecutionStartEvent tool:
15+
Console.WriteLine($"[tool: {tool.Data.ToolName}]");
16+
break;
17+
}
18+
Console.ResetColor();
19+
});
20+
21+
Console.WriteLine("Chat with Copilot (Ctrl+C to exit)\n");
22+
23+
while (true)
24+
{
25+
Console.Write("You: ");
26+
var input = Console.ReadLine()?.Trim();
27+
if (string.IsNullOrEmpty(input)) continue;
28+
Console.WriteLine();
29+
30+
var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = input });
31+
Console.WriteLine($"\nAssistant: {reply?.Data.Content}\n");
32+
}

dotnet/samples/Chat.csproj

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<ProjectReference Include="..\src\GitHub.Copilot.SDK.csproj" />
10+
</ItemGroup>
11+
</Project>

dotnet/src/Client.cs

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Diagnostics;
1212
using System.Diagnostics.CodeAnalysis;
1313
using System.Net.Sockets;
14+
using System.Text;
1415
using System.Text.Json;
1516
using System.Text.Json.Serialization;
1617
using System.Text.RegularExpressions;
@@ -183,13 +184,13 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
183184
if (_optionsHost is not null && _optionsPort is not null)
184185
{
185186
// External server (TCP)
186-
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, ct);
187+
result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct);
187188
}
188189
else
189190
{
190191
// Child process (stdio or TCP)
191-
var (cliProcess, portOrNull) = await StartCliServerAsync(_options, _logger, ct);
192-
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, ct);
192+
var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct);
193+
result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct);
193194
}
194195

195196
var connection = await result;
@@ -842,11 +843,33 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
842843
}
843844

844845
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken)
846+
{
847+
return await InvokeRpcAsync<T>(rpc, method, args, null, cancellationToken);
848+
}
849+
850+
internal static async Task<T> InvokeRpcAsync<T>(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken)
845851
{
846852
try
847853
{
848854
return await rpc.InvokeWithCancellationAsync<T>(method, args, cancellationToken);
849855
}
856+
catch (StreamJsonRpc.ConnectionLostException ex)
857+
{
858+
string? stderrOutput = null;
859+
if (stderrBuffer is not null)
860+
{
861+
lock (stderrBuffer)
862+
{
863+
stderrOutput = stderrBuffer.ToString().Trim();
864+
}
865+
}
866+
867+
if (!string.IsNullOrEmpty(stderrOutput))
868+
{
869+
throw new IOException($"CLI process exited unexpectedly.\nstderr: {stderrOutput}", ex);
870+
}
871+
throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex);
872+
}
850873
catch (StreamJsonRpc.RemoteRpcException ex)
851874
{
852875
throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex);
@@ -868,7 +891,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
868891
{
869892
var expectedVersion = SdkProtocolVersion.GetVersion();
870893
var pingResponse = await InvokeRpcAsync<PingResponse>(
871-
connection.Rpc, "ping", [new PingRequest()], cancellationToken);
894+
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
872895

873896
if (!pingResponse.ProtocolVersion.HasValue)
874897
{
@@ -887,7 +910,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
887910
}
888911
}
889912

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

960-
// Forward stderr to logger
983+
// Capture stderr for error messages and forward to logger
984+
var stderrBuffer = new StringBuilder();
961985
_ = Task.Run(async () =>
962986
{
963987
while (cliProcess != null && !cliProcess.HasExited)
964988
{
965989
var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken);
966990
if (line != null)
967991
{
992+
lock (stderrBuffer)
993+
{
994+
stderrBuffer.AppendLine(line);
995+
}
968996
logger.LogDebug("[CLI] {Line}", line);
969997
}
970998
}
@@ -991,7 +1019,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
9911019
}
9921020
}
9931021

994-
return (cliProcess, detectedLocalhostTcpPort);
1022+
return (cliProcess, detectedLocalhostTcpPort, stderrBuffer);
9951023
}
9961024

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

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

10811109
_rpc = new ServerRpc(rpc);
10821110

1083-
return new Connection(rpc, cliProcess, tcpClient, networkStream);
1111+
return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer);
10841112
}
10851113

10861114
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")]
@@ -1321,12 +1349,14 @@ private class Connection(
13211349
JsonRpc rpc,
13221350
Process? cliProcess, // Set if we created the child process
13231351
TcpClient? tcpClient, // Set if using TCP
1324-
NetworkStream? networkStream) // Set if using TCP
1352+
NetworkStream? networkStream, // Set if using TCP
1353+
StringBuilder? stderrBuffer = null) // Captures stderr for error messages
13251354
{
13261355
public Process? CliProcess => cliProcess;
13271356
public TcpClient? TcpClient => tcpClient;
13281357
public JsonRpc Rpc => rpc;
13291358
public NetworkStream? NetworkStream => networkStream;
1359+
public StringBuilder? StderrBuffer => stderrBuffer;
13301360
}
13311361

13321362
private static class ProcessArgumentEscaper

dotnet/test/ClientTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,35 @@ public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client(
224224

225225
await client.StopAsync();
226226
}
227+
228+
[Fact]
229+
public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start()
230+
{
231+
var client = new CopilotClient(new CopilotClientOptions
232+
{
233+
CliArgs = new[] { "--nonexistent-flag-for-testing" },
234+
UseStdio = true
235+
});
236+
237+
var ex = await Assert.ThrowsAsync<IOException>(async () =>
238+
{
239+
await client.StartAsync();
240+
});
241+
242+
var errorMessage = ex.Message;
243+
// Verify we get the stderr output in the error message
244+
Assert.Contains("stderr", errorMessage, StringComparison.OrdinalIgnoreCase);
245+
Assert.Contains("nonexistent", errorMessage, StringComparison.OrdinalIgnoreCase);
246+
247+
// Verify subsequent calls also fail (don't hang)
248+
var ex2 = await Assert.ThrowsAnyAsync<Exception>(async () =>
249+
{
250+
var session = await client.CreateSessionAsync();
251+
await session.SendAsync(new MessageOptions { Prompt = "test" });
252+
});
253+
Assert.Contains("exited", ex2.Message, StringComparison.OrdinalIgnoreCase);
254+
255+
// Cleanup - ForceStop should handle the disconnected state gracefully
256+
try { await client.ForceStopAsync(); } catch (Exception) { /* Expected */ }
257+
}
227258
}

go/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ A Go SDK for programmatic access to the GitHub Copilot CLI.
1010
go get github.com/github/copilot-sdk/go
1111
```
1212

13+
## Run the Sample
14+
15+
Try the interactive chat sample (from the repo root):
16+
17+
```bash
18+
cd go/samples
19+
go run chat.go
20+
```
21+
1322
## Quick Start
1423

1524
```go

go/client.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ type Client struct {
8585
lifecycleHandlers []SessionLifecycleHandler
8686
typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler
8787
lifecycleHandlersMux sync.Mutex
88+
processDone chan struct{} // closed when CLI process exits
89+
processError error // set before processDone is closed
8890

8991
// RPC provides typed server-scoped RPC methods.
9092
// This field is nil until the client is connected via Start().
@@ -149,6 +151,9 @@ func NewClient(options *ClientOptions) *Client {
149151
if options.CLIPath != "" {
150152
opts.CLIPath = options.CLIPath
151153
}
154+
if len(options.CLIArgs) > 0 {
155+
opts.CLIArgs = append([]string{}, options.CLIArgs...)
156+
}
152157
if options.Cwd != "" {
153158
opts.Cwd = options.Cwd
154159
}
@@ -1022,7 +1027,10 @@ func (c *Client) startCLIServer(ctx context.Context) error {
10221027
// Default to "copilot" in PATH if no embedded CLI is available and no custom path is set
10231028
cliPath = "copilot"
10241029
}
1025-
args := []string{"--headless", "--no-auto-update", "--log-level", c.options.LogLevel}
1030+
1031+
// Start with user-provided CLIArgs, then add SDK-managed args
1032+
args := append([]string{}, c.options.CLIArgs...)
1033+
args = append(args, "--headless", "--no-auto-update", "--log-level", c.options.LogLevel)
10261034

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

1085-
stderr, err := c.process.StderrPipe()
1086-
if err != nil {
1087-
return fmt.Errorf("failed to create stderr pipe: %w", err)
1093+
if err := c.process.Start(); err != nil {
1094+
return fmt.Errorf("failed to start CLI server: %w", err)
10881095
}
10891096

1090-
// Read stderr in background
1097+
// Monitor process exit to signal pending requests
1098+
c.processDone = make(chan struct{})
10911099
go func() {
1092-
scanner := bufio.NewScanner(stderr)
1093-
for scanner.Scan() {
1094-
// Optionally log stderr
1095-
// fmt.Fprintf(os.Stderr, "CLI stderr: %s\n", scanner.Text())
1100+
waitErr := c.process.Wait()
1101+
if waitErr != nil {
1102+
c.processError = fmt.Errorf("CLI process exited: %v", waitErr)
1103+
} else {
1104+
c.processError = fmt.Errorf("CLI process exited unexpectedly")
10961105
}
1106+
close(c.processDone)
10971107
}()
10981108

1099-
if err := c.process.Start(); err != nil {
1100-
return fmt.Errorf("failed to start CLI server: %w", err)
1101-
}
1102-
11031109
// Create JSON-RPC client immediately
11041110
c.client = jsonrpc2.NewClient(stdin, stdout)
1111+
c.client.SetProcessDone(c.processDone, &c.processError)
11051112
c.RPC = rpc.NewServerRpc(c.client)
11061113
c.setupNotificationHandler()
11071114
c.client.Start()

go/internal/e2e/client_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,27 @@ func TestClient(t *testing.T) {
225225

226226
client.Stop()
227227
})
228+
229+
t.Run("should report error when CLI fails to start", func(t *testing.T) {
230+
client := copilot.NewClient(&copilot.ClientOptions{
231+
CLIPath: cliPath,
232+
CLIArgs: []string{"--nonexistent-flag-for-testing"},
233+
UseStdio: copilot.Bool(true),
234+
})
235+
t.Cleanup(func() { client.ForceStop() })
236+
237+
err := client.Start(t.Context())
238+
if err == nil {
239+
t.Fatal("Expected Start to fail with invalid CLI args")
240+
}
241+
242+
// Verify subsequent calls also fail (don't hang)
243+
session, err := client.CreateSession(t.Context(), nil)
244+
if err == nil {
245+
_, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "test"})
246+
}
247+
if err == nil {
248+
t.Fatal("Expected CreateSession/Send to fail after CLI exit")
249+
}
250+
})
228251
}

0 commit comments

Comments
 (0)