diff --git a/dotnet/GitHub.Copilot.SDK.slnx b/dotnet/GitHub.Copilot.SDK.slnx index 1b82fb55..96fc3f0d 100644 --- a/dotnet/GitHub.Copilot.SDK.slnx +++ b/dotnet/GitHub.Copilot.SDK.slnx @@ -10,4 +10,7 @@ + + + diff --git a/dotnet/README.md b/dotnet/README.md index d78e7a6b..bda10059 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -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 diff --git a/dotnet/samples/Chat.cs b/dotnet/samples/Chat.cs new file mode 100644 index 00000000..abaefc7b --- /dev/null +++ b/dotnet/samples/Chat.cs @@ -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}]"); + 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"); +} diff --git a/dotnet/samples/Chat.csproj b/dotnet/samples/Chat.csproj new file mode 100644 index 00000000..4121ceae --- /dev/null +++ b/dotnet/samples/Chat.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 225b893c..0653443b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -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; @@ -183,13 +184,13 @@ async Task 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; @@ -842,11 +843,33 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) } internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) + { + return await InvokeRpcAsync(rpc, method, args, null, cancellationToken); + } + + internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { try { return await rpc.InvokeWithCancellationAsync(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); @@ -868,7 +891,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio { var expectedVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await InvokeRpcAsync( - connection.Rpc, "ping", [new PingRequest()], cancellationToken); + connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); if (!pingResponse.ProtocolVersion.HasValue) { @@ -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) @@ -957,7 +980,8 @@ 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) @@ -965,6 +989,10 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken); if (line != null) { + lock (stderrBuffer) + { + stderrBuffer.AppendLine(line); + } logger.LogDebug("[CLI] {Line}", line); } } @@ -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) @@ -1035,7 +1063,7 @@ private static (string FileName, IEnumerable Args) ResolveCliCommand(str return (cliPath, args); } - private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, CancellationToken cancellationToken) + private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { Stream inputStream, outputStream; TcpClient? tcpClient = null; @@ -1080,7 +1108,7 @@ private async Task 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")] @@ -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 diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index 9e336a7e..ee5b73bc 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -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(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(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 */ } + } } diff --git a/go/README.md b/go/README.md index 36749907..37cb7ce0 100644 --- a/go/README.md +++ b/go/README.md @@ -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 diff --git a/go/client.go b/go/client.go index d383d977..185cab20 100644 --- a/go/client.go +++ b/go/client.go @@ -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(). @@ -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 } @@ -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 { @@ -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() diff --git a/go/internal/e2e/client_test.go b/go/internal/e2e/client_test.go index d82b0926..8f5cf249 100644 --- a/go/internal/e2e/client_test.go +++ b/go/internal/e2e/client_test.go @@ -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") + } + }) } diff --git a/go/internal/jsonrpc2/jsonrpc2.go b/go/internal/jsonrpc2/jsonrpc2.go index e44e1231..03cf49b3 100644 --- a/go/internal/jsonrpc2/jsonrpc2.go +++ b/go/internal/jsonrpc2/jsonrpc2.go @@ -57,6 +57,9 @@ type Client struct { running bool stopChan chan struct{} wg sync.WaitGroup + processDone chan struct{} // closed when the underlying process exits + processError error // set before processDone is closed + processErrorMu sync.RWMutex // protects processError } // NewClient creates a new JSON-RPC client @@ -70,6 +73,28 @@ func NewClient(stdin io.WriteCloser, stdout io.ReadCloser) *Client { } } +// SetProcessDone sets a channel that will be closed when the process exits, +// and stores the error that should be returned to pending/future requests. +func (c *Client) SetProcessDone(done chan struct{}, errPtr *error) { + c.processDone = done + // Monitor the channel and copy the error when it closes + go func() { + <-done + if errPtr != nil { + c.processErrorMu.Lock() + c.processError = *errPtr + c.processErrorMu.Unlock() + } + }() +} + +// getProcessError returns the process exit error if the process has exited +func (c *Client) getProcessError() error { + c.processErrorMu.RLock() + defer c.processErrorMu.RUnlock() + return c.processError +} + // Start begins listening for messages in a background goroutine func (c *Client) Start() { c.running = true @@ -172,6 +197,19 @@ func (c *Client) Request(method string, params any) (json.RawMessage, error) { c.mu.Unlock() }() + // Check if process already exited before sending + if c.processDone != nil { + select { + case <-c.processDone: + if err := c.getProcessError(); err != nil { + return nil, err + } + return nil, fmt.Errorf("process exited unexpectedly") + default: + // Process still running, continue + } + } + paramsData, err := json.Marshal(params) if err != nil { return nil, fmt.Errorf("failed to marshal params: %w", err) @@ -189,7 +227,23 @@ func (c *Client) Request(method string, params any) (json.RawMessage, error) { return nil, fmt.Errorf("failed to send request: %w", err) } - // Wait for response + // Wait for response, also checking for process exit + if c.processDone != nil { + select { + case response := <-responseChan: + if response.Error != nil { + return nil, response.Error + } + return response.Result, nil + case <-c.processDone: + if err := c.getProcessError(); err != nil { + return nil, err + } + return nil, fmt.Errorf("process exited unexpectedly") + case <-c.stopChan: + return nil, fmt.Errorf("client stopped") + } + } select { case response := <-responseChan: if response.Error != nil { diff --git a/go/samples/chat.go b/go/samples/chat.go new file mode 100644 index 00000000..0e6e0d9a --- /dev/null +++ b/go/samples/chat.go @@ -0,0 +1,70 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/github/copilot-sdk/go" +) + +const blue = "\033[34m" +const reset = "\033[0m" + +func main() { + ctx := context.Background() + cliPath := filepath.Join("..", "..", "nodejs", "node_modules", "@github", "copilot", "index.js") + client := copilot.NewClient(&copilot.ClientOptions{CLIPath: cliPath}) + if err := client.Start(ctx); err != nil { + panic(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, nil) + if err != nil { + panic(err) + } + defer session.Destroy() + + session.On(func(event copilot.SessionEvent) { + var output string + switch event.Type { + case copilot.AssistantReasoning: + if event.Data.Content != nil { + output = fmt.Sprintf("[reasoning: %s]", *event.Data.Content) + } + case copilot.ToolExecutionStart: + if event.Data.ToolName != nil { + output = fmt.Sprintf("[tool: %s]", *event.Data.ToolName) + } + } + if output != "" { + fmt.Printf("%s%s%s\n", blue, output, reset) + } + }) + + fmt.Println("Chat with Copilot (Ctrl+C to exit)\n") + scanner := bufio.NewScanner(os.Stdin) + + for { + fmt.Print("You: ") + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + fmt.Println() + + reply, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: input}) + content := "" + if reply != nil && reply.Data.Content != nil { + content = *reply.Data.Content + } + fmt.Printf("\nAssistant: %s\n\n", content) + } +} diff --git a/go/samples/go.mod b/go/samples/go.mod new file mode 100644 index 00000000..889070f6 --- /dev/null +++ b/go/samples/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/go/samples + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../ diff --git a/go/samples/go.sum b/go/samples/go.sum new file mode 100644 index 00000000..6e171099 --- /dev/null +++ b/go/samples/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/go/types.go b/go/types.go index 47af6dc8..e757f04c 100644 --- a/go/types.go +++ b/go/types.go @@ -16,6 +16,8 @@ const ( type ClientOptions struct { // CLIPath is the path to the Copilot CLI executable (default: "copilot") CLIPath string + // CLIArgs are extra arguments to pass to the CLI executable (inserted before SDK-managed args) + CLIArgs []string // Cwd is the working directory for the CLI process (default: "" = inherit from current process) Cwd string // Port for TCP transport (default: 0 = random port) diff --git a/nodejs/README.md b/nodejs/README.md index ed0d897c..31558b8a 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -10,6 +10,19 @@ TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC. npm install @github/copilot-sdk ``` +## Run the Sample + +Try the interactive chat sample (from the repo root): + +```bash +cd nodejs +npm ci +npm run build +cd samples +npm install +npm start +``` + ## Quick Start ```typescript diff --git a/nodejs/samples/chat.ts b/nodejs/samples/chat.ts new file mode 100644 index 00000000..f0381bb8 --- /dev/null +++ b/nodejs/samples/chat.ts @@ -0,0 +1,33 @@ +import * as readline from "node:readline"; +import { CopilotClient, type SessionEvent } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient(); + const session = await client.createSession(); + + session.on((event: SessionEvent) => { + let output: string | null = null; + if (event.type === "assistant.reasoning") { + output = `[reasoning: ${event.data.content}]`; + } else if (event.type === "tool.execution_start") { + output = `[tool: ${event.data.toolName}]`; + } + if (output) console.log(`\x1b[34m${output}\x1b[0m`); + }); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const prompt = (q: string) => new Promise((r) => rl.question(q, r)); + + console.log("Chat with Copilot (Ctrl+C to exit)\n"); + + while (true) { + const input = await prompt("You: "); + if (!input.trim()) continue; + console.log(); + + const reply = await session.sendAndWait({ prompt: input }); + console.log(`\nAssistant: ${reply?.data.content}\n`); + } +} + +main().catch(console.error); diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json new file mode 100644 index 00000000..3272df55 --- /dev/null +++ b/nodejs/samples/package-lock.json @@ -0,0 +1,610 @@ +{ + "name": "copilot-sdk-sample", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilot-sdk-sample", + "dependencies": { + "@github/copilot-sdk": "file:.." + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.20.6" + } + }, + "..": { + "name": "@github/copilot-sdk", + "version": "0.1.8", + "license": "MIT", + "dependencies": { + "@github/copilot": "^0.0.411-0", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.2.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "esbuild": "^0.27.2", + "eslint": "^9.0.0", + "glob": "^13.0.1", + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "prettier": "^3.8.1", + "quicktype-core": "^23.2.6", + "rimraf": "^6.1.2", + "semver": "^7.7.3", + "tsx": "^4.20.6", + "typescript": "^5.0.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@github/copilot-sdk": { + "resolved": "..", + "link": true + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/nodejs/samples/package.json b/nodejs/samples/package.json new file mode 100644 index 00000000..7ff4cd9f --- /dev/null +++ b/nodejs/samples/package.json @@ -0,0 +1,14 @@ +{ + "name": "copilot-sdk-sample", + "type": "module", + "scripts": { + "start": "npx tsx chat.ts" + }, + "dependencies": { + "@github/copilot-sdk": "file:.." + }, + "devDependencies": { + "tsx": "^4.20.6", + "@types/node": "^22.0.0" + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 50764363..2eaad282 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -128,6 +128,7 @@ export class CopilotClient { private actualHost: string = "localhost"; private state: ConnectionState = "disconnected"; private sessions: Map = new Map(); + private stderrBuffer: string = ""; // Captures CLI stderr for error messages private options: Required< Omit > & { @@ -145,6 +146,7 @@ export class CopilotClient { Set<(event: SessionLifecycleEvent) => void> > = new Map(); private _rpc: ReturnType | null = null; + private processExitPromise: Promise | null = null; // Rejects when CLI process exits /** * Typed server-scoped RPC methods. @@ -395,6 +397,8 @@ export class CopilotClient { this.state = "disconnected"; this.actualPort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; return errors; } @@ -465,6 +469,8 @@ export class CopilotClient { this.state = "disconnected"; this.actualPort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; } /** @@ -746,7 +752,15 @@ export class CopilotClient { */ private async verifyProtocolVersion(): Promise { const expectedVersion = getSdkProtocolVersion(); - const pingResult = await this.ping(); + + // Race ping against process exit to detect early CLI failures + let pingResult: Awaited>; + if (this.processExitPromise) { + pingResult = await Promise.race([this.ping(), this.processExitPromise]); + } else { + pingResult = await this.ping(); + } + const serverVersion = pingResult.protocolVersion; if (serverVersion === undefined) { @@ -1002,6 +1016,9 @@ export class CopilotClient { */ private async startCLIServer(): Promise { return new Promise((resolve, reject) => { + // Clear stderr buffer for fresh capture + this.stderrBuffer = ""; + const args = [ ...this.options.cliArgs, "--headless", @@ -1085,6 +1102,8 @@ export class CopilotClient { } this.cliProcess.stderr?.on("data", (data: Buffer) => { + // Capture stderr for error messages + this.stderrBuffer += data.toString(); // Forward CLI stderr to parent's stderr so debug logs are visible const lines = data.toString().split("\n"); for (const line of lines) { @@ -1097,14 +1116,55 @@ export class CopilotClient { this.cliProcess.on("error", (error) => { if (!resolved) { resolved = true; - reject(new Error(`Failed to start CLI server: ${error.message}`)); + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + reject( + new Error( + `Failed to start CLI server: ${error.message}\nstderr: ${stderrOutput}` + ) + ); + } else { + reject(new Error(`Failed to start CLI server: ${error.message}`)); + } } }); + // Set up a promise that rejects when the process exits (used to race against RPC calls) + this.processExitPromise = new Promise((_, rejectProcessExit) => { + this.cliProcess!.on("exit", (code) => { + // Give a small delay for stderr to be fully captured + setTimeout(() => { + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + rejectProcessExit( + new Error( + `CLI server exited with code ${code}\nstderr: ${stderrOutput}` + ) + ); + } else { + rejectProcessExit( + new Error(`CLI server exited unexpectedly with code ${code}`) + ); + } + }, 50); + }); + }); + // Prevent unhandled rejection when process exits normally (we only use this in Promise.race) + this.processExitPromise.catch(() => {}); + this.cliProcess.on("exit", (code) => { if (!resolved) { resolved = true; - reject(new Error(`CLI server exited with code ${code}`)); + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + reject( + new Error( + `CLI server exited with code ${code}\nstderr: ${stderrOutput}` + ) + ); + } else { + reject(new Error(`CLI server exited with code ${code}`)); + } } else if (this.options.autoRestart && this.state === "connected") { void this.reconnect(); } diff --git a/nodejs/test/e2e/client.test.ts b/nodejs/test/e2e/client.test.ts index 526e9509..aa8ddcbd 100644 --- a/nodejs/test/e2e/client.test.ts +++ b/nodejs/test/e2e/client.test.ts @@ -132,4 +132,31 @@ describe("Client", () => { await client.stop(); }); + + it("should report error with stderr when CLI fails to start", async () => { + const client = new CopilotClient({ + cliArgs: ["--nonexistent-flag-for-testing"], + useStdio: true, + }); + onTestFinishedForceStop(client); + + let initialError: Error | undefined; + try { + await client.start(); + expect.fail("Expected start() to throw an error"); + } catch (error) { + initialError = error as Error; + expect(initialError.message).toContain("stderr"); + expect(initialError.message).toContain("nonexistent"); + } + + // Verify subsequent calls also fail (don't hang) + try { + const session = await client.createSession(); + await session.send("test"); + expect.fail("Expected send() to throw an error after CLI exit"); + } catch (error) { + expect((error as Error).message).toContain("Connection is closed"); + } + }); }); diff --git a/python/README.md b/python/README.md index 7aa11e1a..aa82e0c3 100644 --- a/python/README.md +++ b/python/README.md @@ -12,6 +12,15 @@ pip install -e ".[dev]" uv pip install -e ".[dev]" ``` +## Run the Sample + +Try the interactive chat sample (from the repo root): + +```bash +cd python/samples +python chat.py +``` + ## Quick Start ```python diff --git a/python/copilot/client.py b/python/copilot/client.py index 03be8ca1..2a65b4d8 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -25,7 +25,7 @@ from .generated.rpc import ServerRpc from .generated.session_events import session_event_from_dict -from .jsonrpc import JsonRpcClient +from .jsonrpc import JsonRpcClient, ProcessExitedError from .sdk_protocol_version import get_sdk_protocol_version from .session import CopilotSession from .types import ( @@ -185,6 +185,8 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): "auto_restart": opts.get("auto_restart", True), "use_logged_in_user": use_logged_in_user, } + if opts.get("cli_args"): + self.options["cli_args"] = opts["cli_args"] if opts.get("cli_url"): self.options["cli_url"] = opts["cli_url"] if opts.get("env"): @@ -292,8 +294,21 @@ async def start(self) -> None: await self._verify_protocol_version() self._state = "connected" - except Exception: + except ProcessExitedError as e: + # Process exited with error - reraise as RuntimeError with stderr self._state = "error" + raise RuntimeError(str(e)) from None + except Exception as e: + self._state = "error" + # Check if process exited and capture any remaining stderr + if self._process and hasattr(self._process, "poll"): + return_code = self._process.poll() + if return_code is not None and self._client: + stderr_output = self._client.get_stderr_output() + if stderr_output: + raise RuntimeError( + f"CLI process exited with code {return_code}\nstderr: {stderr_output}" + ) from e raise async def stop(self) -> list["StopError"]: @@ -1141,7 +1156,14 @@ async def _start_cli_server(self) -> None: if not os.path.exists(cli_path): raise RuntimeError(f"Copilot CLI not found at {cli_path}") - args = ["--headless", "--no-auto-update", "--log-level", self.options["log_level"]] + # Start with user-provided cli_args, then add SDK-managed args + cli_args = self.options.get("cli_args") or [] + args = list(cli_args) + [ + "--headless", + "--no-auto-update", + "--log-level", + self.options["log_level"], + ] # Add auth-related flags if self.options.get("github_token"): diff --git a/python/copilot/jsonrpc.py b/python/copilot/jsonrpc.py index b9322fd4..cb6c5408 100644 --- a/python/copilot/jsonrpc.py +++ b/python/copilot/jsonrpc.py @@ -24,6 +24,12 @@ def __init__(self, code: int, message: str, data: Any = None): super().__init__(f"JSON-RPC Error {code}: {message}") +class ProcessExitedError(Exception): + """Error raised when the CLI process exits unexpectedly""" + + pass + + RequestHandler = Callable[[dict], Union[dict, Awaitable[dict]]] @@ -47,9 +53,13 @@ def __init__(self, process): self.request_handlers: dict[str, RequestHandler] = {} self._running = False self._read_thread: Optional[threading.Thread] = None + self._stderr_thread: Optional[threading.Thread] = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._write_lock = threading.Lock() self._pending_lock = threading.Lock() + self._process_exit_error: Optional[str] = None + self._stderr_output: list[str] = [] + self._stderr_lock = threading.Lock() def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): """Start listening for messages in background thread""" @@ -59,12 +69,39 @@ def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): self._loop = loop or asyncio.get_running_loop() self._read_thread = threading.Thread(target=self._read_loop, daemon=True) self._read_thread.start() + # Start stderr reader thread if process has stderr + if hasattr(self.process, "stderr") and self.process.stderr: + self._stderr_thread = threading.Thread(target=self._stderr_loop, daemon=True) + self._stderr_thread.start() + + def _stderr_loop(self): + """Read stderr in background to capture error messages""" + try: + while self._running: + if not self.process.stderr: + break + line = self.process.stderr.readline() + if not line: + break + with self._stderr_lock: + self._stderr_output.append( + line.decode("utf-8") if isinstance(line, bytes) else line + ) + except Exception: + pass # Ignore errors reading stderr + + def get_stderr_output(self) -> str: + """Get captured stderr output""" + with self._stderr_lock: + return "".join(self._stderr_output).strip() async def stop(self): """Stop listening and clean up""" self._running = False if self._read_thread: self._read_thread.join(timeout=1.0) + if self._stderr_thread: + self._stderr_thread.join(timeout=1.0) async def request( self, method: str, params: Optional[dict] = None, timeout: float = 30.0 @@ -157,9 +194,43 @@ def _read_loop(self): message = self._read_message() if message: self._handle_message(message) + else: + # No message means stream closed - process likely exited + break + except EOFError: + # Stream closed - check if process exited + pass except Exception as e: if self._running: - print(f"JSON-RPC read loop error: {e}") + # Store error for pending requests + self._process_exit_error = str(e) + + # Process exited or read failed - fail all pending requests + if self._running: + self._fail_pending_requests() + + def _fail_pending_requests(self): + """Fail all pending requests when process exits""" + # Build error message with stderr output + stderr_output = self.get_stderr_output() + return_code = None + if hasattr(self.process, "poll"): + return_code = self.process.poll() + + if stderr_output: + error_msg = f"CLI process exited with code {return_code}\nstderr: {stderr_output}" + elif return_code is not None: + error_msg = f"CLI process exited with code {return_code}" + else: + error_msg = "CLI process exited unexpectedly" + + # Fail all pending requests + with self._pending_lock: + for request_id, future in list(self.pending_requests.items()): + if not future.done(): + exc = ProcessExitedError(error_msg) + loop = future.get_loop() + loop.call_soon_threadsafe(future.set_exception, exc) def _read_exact(self, num_bytes: int) -> bytes: """ diff --git a/python/copilot/types.py b/python/copilot/types.py index b77e36be..0f127d44 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -73,6 +73,8 @@ class CopilotClientOptions(TypedDict, total=False): """Options for creating a CopilotClient""" cli_path: str # Path to the Copilot CLI executable (default: "copilot") + # Extra arguments to pass to the CLI executable (inserted before SDK-managed args) + cli_args: list[str] # Working directory for the CLI process (default: current process's cwd) cwd: str port: int # Port for the CLI server (TCP mode only, default: 0) diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index aeaddbd9..c18764e5 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -179,3 +179,37 @@ async def test_should_cache_models_list(self): await client.stop() finally: await client.force_stop() + + @pytest.mark.asyncio + async def test_should_report_error_with_stderr_when_cli_fails_to_start(self): + """Test that CLI startup errors include stderr output in the error message.""" + client = CopilotClient( + { + "cli_path": CLI_PATH, + "cli_args": ["--nonexistent-flag-for-testing"], + "use_stdio": True, + } + ) + + try: + with pytest.raises(RuntimeError) as exc_info: + await client.start() + + error_message = str(exc_info.value) + # Verify we get the stderr output in the error message + assert "stderr" in error_message, ( + f"Expected error to contain 'stderr', got: {error_message}" + ) + assert "nonexistent" in error_message, ( + f"Expected error to contain 'nonexistent', got: {error_message}" + ) + + # Verify subsequent calls also fail (don't hang) + with pytest.raises(Exception) as exc_info2: + session = await client.create_session() + await session.send("test") + # Error message varies by platform (EINVAL on Windows, EPIPE on Linux) + error_msg = str(exc_info2.value).lower() + assert "invalid" in error_msg or "pipe" in error_msg or "closed" in error_msg + finally: + await client.force_stop() diff --git a/python/samples/chat.py b/python/samples/chat.py new file mode 100644 index 00000000..cfdd2eee --- /dev/null +++ b/python/samples/chat.py @@ -0,0 +1,41 @@ +import asyncio + +from copilot import CopilotClient + +BLUE = "\033[34m" +RESET = "\033[0m" + + +async def main(): + client = CopilotClient() + await client.start() + session = await client.create_session() + + def on_event(event): + output = None + if event.type.value == "assistant.reasoning": + output = f"[reasoning: {event.data.content}]" + elif event.type.value == "tool.execution_start": + output = f"[tool: {event.data.tool_name}]" + if output: + print(f"{BLUE}{output}{RESET}") + + session.on(on_event) + + print("Chat with Copilot (Ctrl+C to exit)\n") + + while True: + user_input = input("You: ").strip() + if not user_input: + continue + print() + + reply = await session.send_and_wait({"prompt": user_input}) + print(f"\nAssistant: {reply.data.content if reply else None}\n") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nBye!")