diff --git a/CHANGELOG.md b/CHANGELOG.md index 788b983621..d50ebba9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,121 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu Host-owned external tool callbacks are now cancelled when their runtime request completes or their SDK session terminates. The cancellation primitive is idiomatic per SDK: .NET passes a request token to `AIFunction`, Node.js exposes `ToolInvocation.signal`, Go cancels `ToolInvocation.TraceContext`, Java cancels the returned `CompletableFuture`, Python cancels the handler task, and Rust drops the handler future. Go handlers that retain `TraceContext` for background work must derive a separate lifetime because the invocation context is cancelled when the request ends. +### Breaking: Java process settings moved onto out-of-process connections (#2523) + +Java `CopilotClientOptions` no longer carries client-wide `cwd` or `environment`. Those process-scoped settings now live directly on `StdioRuntimeConnection` and `TcpRuntimeConnection` as `workingDirectory` and `environment`, because they only apply when the SDK spawns and manages a runtime process. Unlike the equivalent change in .NET, Go, Python, Node.js, and the broader cross-SDK plan, Java had no shared child-process base class to rename, so this is a smaller-scoped move onto the two concrete out-of-process connection types. + +Migration: + +```java +// Before +var client = new CopilotClient(new CopilotClientOptions() + .setCwd("/srv/app") + .setEnvironment(Map.of("KEY", "value")) + .setConnection(RuntimeConnection.forStdio("/usr/local/bin/copilot"))); + +// After +var client = new CopilotClient(new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio("/usr/local/bin/copilot") + .setWorkingDirectory("/srv/app") + .setEnvironment(Map.of("KEY", "value")))); +``` + +### Breaking: Rust process-scoped options moved onto out-of-process transports (#2523) + +`ClientOptions` no longer carries `program`, `prefix_args`, `working_directory`, `env`, `env_remove`, or `extra_args`. These settings never applied to `Transport::InProcess` or `Transport::External` (there's no SDK-managed CLI subprocess to configure in either case), so keeping them on the shared options struct made it possible to set values that were silently ignored. They now live on a new `OutOfProcessOptions` struct carried directly by the transport variants that spawn a CLI process: `Transport::Stdio(OutOfProcessOptions)` and `Transport::Tcp { process: OutOfProcessOptions, .. }`. `Transport::External` intentionally has no such field, since the SDK connects to a server it doesn't own. + +This also fixes a long-standing inconsistency: Rust's `env` previously merged into the inherited process environment (`Command::env(k, v)` without clearing first), while every other SDK replaced the environment when the caller supplied one. A non-empty `OutOfProcessOptions::env` now calls `env_clear()` before applying it, matching the other SDKs' replace semantics. SDK-managed variables (auth token, telemetry, `COPILOT_HOME`, keytar-disable, TCP connection token) are still injected before user `env`/`env_remove`, so callers can continue to override or strip them — this ordering is unchanged from before. + +Migration: + +```rust +// Before +let options = ClientOptions::new() + .with_program(CliProgram::Path("/usr/local/bin/copilot".into())) + .with_cwd("/srv/app") + .with_env([("KEY", "value")]) + .with_transport(Transport::Stdio); + +// After +let options = ClientOptions::new().with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path("/usr/local/bin/copilot".into())) + .with_working_directory("/srv/app") + .with_env([("KEY", "value")]), +)); +``` + +`Transport::stdio()` is a new convenience constructor for `Transport::Stdio(OutOfProcessOptions::default())`. `OutOfProcessOptions::with_cwd` was renamed `with_working_directory` for clarity and consistency with the field name. + +### Breaking: .NET out-of-process launch settings moved (#2523) + +`.NET` `CopilotClientOptions` no longer carries `WorkingDirectory` or `Environment`. Those process-scoped settings now live on `StdioRuntimeConnection` and `TcpRuntimeConnection` through the renamed `OutOfProcessRuntimeConnection` base class, because they only apply when the SDK spawns and manages a runtime process. `ChildProcessRuntimeConnection` was renamed to `OutOfProcessRuntimeConnection`. + +Migration: + +```csharp +// Before +var client = new CopilotClient(new CopilotClientOptions +{ + WorkingDirectory = "/srv/app", + Environment = new Dictionary { ["KEY"] = "value" }, + Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot"), +}); + +// After +var client = new CopilotClient(new CopilotClientOptions +{ + Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot") + { + WorkingDirectory = "/srv/app", + Environment = new Dictionary { ["KEY"] = "value" }, + }, +}); +``` + +### Breaking: Python and Node.js out-of-process launch settings moved (#2523) + +Python `CopilotClient` and Node.js `CopilotClientOptions` no longer carry client-wide `working_directory` / `workingDirectory` or `env` launch settings. Those process-scoped settings now live on the renamed `OutOfProcessRuntimeConnection` base for stdio/TCP connections, because they only apply when the SDK spawns and manages a runtime process. `ChildProcessRuntimeConnection` was renamed to `OutOfProcessRuntimeConnection` in both packages. + +Migration: + +```python +# Before +client = CopilotClient( + working_directory="/srv/app", + env={"KEY": "value"}, + connection=RuntimeConnection.for_stdio(path="/usr/local/bin/copilot"), +) + +# After +client = CopilotClient( + connection=RuntimeConnection.for_stdio( + path="/usr/local/bin/copilot", + working_directory="/srv/app", + env={"KEY": "value"}, + ), +) +``` + +```ts +// Before +const client = new CopilotClient({ + workingDirectory: "/srv/app", + env: { KEY: "value" }, + connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }), +}); + +// After +const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ + path: "/usr/local/bin/copilot", + workingDirectory: "/srv/app", + env: { KEY: "value" }, + }), +}); +``` + ### Feature: declare application identity with client info Client options now accept optional client info (application name and version, integration name and version) across all six SDKs, exposed idiomatically per language (`clientInfo` in Node.js, `client_info` in Python and Rust, `ClientInfo` in Go and .NET, `setClientInfo` in Java). When set, the SDK forwards it on the `server.connect` handshake so the telemetry the runtime emits on the connection is attributed to the application and its Copilot integration instead of the runtime's own build. All fields are optional, and leaving client info unset keeps the runtime's default attribution. See [Client info](./docs/features/client-info.md). @@ -105,6 +220,30 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +### Breaking: Go out-of-process launch settings moved (#2523) + +Go `ClientOptions` no longer carries `WorkingDirectory` or `Env`. Those process-scoped settings now live on `StdioConnection` and `TCPConnection`, because they only apply when the SDK spawns and manages an out-of-process runtime. The internal unexported `childProcessConnection` helper was renamed to `outOfProcessConnection`; this does not change the public Go API surface. + +Migration: + +```go +// Before +client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{Path: "/usr/local/bin/copilot"}, + WorkingDirectory: "/srv/app", + Env: []string{"KEY=value"}, +}) + +// After +client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{ + Path: "/usr/local/bin/copilot", + WorkingDirectory: "/srv/app", + Env: []string{"KEY=value"}, + }, +}) +``` + ## [v1.0.7](https://github.com/github/copilot-sdk/releases/tag/v1.0.7) (2026-07-16) ### Feature: in-process (FFI) transport diff --git a/docs/auth/server-to-server-tokens.md b/docs/auth/server-to-server-tokens.md index b7b4fcf409..0a5676bc56 100644 --- a/docs/auth/server-to-server-tokens.md +++ b/docs/auth/server-to-server-tokens.md @@ -61,15 +61,15 @@ const token = process.env.INSTALLATION_TOKEN; if (!token) throw new Error("INSTALLATION_TOKEN is required"); const client = new CopilotClient({ - connection: RuntimeConnection.forStdio(), - env: { - ...process.env, - COPILOT_GITHUB_TOKEN: token, - }, + connection: RuntimeConnection.forStdio({ + env: { + ...process.env, + COPILOT_GITHUB_TOKEN: token, + }, + }), useLoggedInUser: false, }); ``` -
Python @@ -80,8 +80,9 @@ import os from copilot import CopilotClient, RuntimeConnection client = CopilotClient( - connection=RuntimeConnection.for_stdio(), - env={**os.environ, "COPILOT_GITHUB_TOKEN": os.environ["INSTALLATION_TOKEN"]}, + connection=RuntimeConnection.for_stdio( + env={**os.environ, "COPILOT_GITHUB_TOKEN": os.environ["INSTALLATION_TOKEN"]}, + ), use_logged_in_user=False, ) ``` @@ -106,8 +107,9 @@ func main() { log.Fatal("INSTALLATION_TOKEN is required") } client := copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{}, - Env: append(os.Environ(), "COPILOT_GITHUB_TOKEN="+token), + Connection: copilot.StdioConnection{ + Env: append(os.Environ(), "COPILOT_GITHUB_TOKEN="+token), + }, UseLoggedInUser: copilot.Bool(false), }) _ = client @@ -119,13 +121,14 @@ func main() { Rust ```rust -use github_copilot_sdk::{ClientOptions, Transport}; +use github_copilot_sdk::{ClientOptions, OutOfProcessOptions, Transport}; fn main() { let token = std::env::var("INSTALLATION_TOKEN").expect("INSTALLATION_TOKEN is required"); let options = ClientOptions::new() - .with_transport(Transport::Stdio) - .with_env([("COPILOT_GITHUB_TOKEN", token)]) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new().with_env([("COPILOT_GITHUB_TOKEN", token)]), + )) .with_use_logged_in_user(false); drop(options); } @@ -146,10 +149,12 @@ var environment = Environment.GetEnvironmentVariables() .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString() ?? ""); environment["COPILOT_GITHUB_TOKEN"] = token; +var connection = RuntimeConnection.ForStdio(); +connection.Environment = environment; + await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = environment, + Connection = connection, UseLoggedInUser = false, }); ``` @@ -161,6 +166,7 @@ await using var client = new CopilotClient(new CopilotClientOptions ```java import com.github.copilot.CopilotClient; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; import java.util.HashMap; import java.util.Objects; @@ -170,7 +176,7 @@ var token = Objects.requireNonNull( environment.put("COPILOT_GITHUB_TOKEN", token); try (var client = new CopilotClient(new CopilotClientOptions() - .setEnvironment(environment) + .setConnection(RuntimeConnection.forStdio().setEnvironment(environment)) .setUseLoggedInUser(false))) { // Use the client. } diff --git a/dotnet/README.md b/dotnet/README.md index 9b9ca42c60..b7737e6b37 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -91,10 +91,8 @@ new CopilotClient(CopilotClientOptions? options = null) - `Connection` - How to connect to the Copilot runtime. Defaults to `null` (equivalent to `RuntimeConnection.ForStdio()` with the bundled runtime). See "RuntimeConnection" below. - `LogLevel` - Runtime log level. Accepts well-known values `CopilotLogLevel.None`, `Error`, `Warning`, `Info`, `Debug`, `All`. Defaults to null (the runtime's own default). -- `WorkingDirectory` - Working directory for the runtime process. When not set, the spawned runtime inherits the calling application's current working directory. - `BaseDirectory` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime process. When not set, the runtime defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when connecting via `RuntimeConnection.ForUri(...)`. - `EnableRemoteSessions` - Enables remote-session features. -- `Environment` - Environment variables to pass to the runtime process. - `Logger` - `ILogger` instance for SDK logging. - `GitHubToken` - GitHub token for authentication. When provided, takes priority over other auth methods. - `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `RuntimeConnection.ForUri(...)`. @@ -108,6 +106,26 @@ new CopilotClient(CopilotClientOptions? options = null) - `RuntimeConnection.ForTcp(port = 0, connectionToken?, path?, args?)` — spawns the runtime as a child process listening on a TCP port. `port = 0` auto-allocates; if a non-zero port is already in use, startup fails (no fallback). Use `CopilotClient.RuntimePort` after `StartAsync` to read the assigned port. `connectionToken` is required if other clients will connect via `RuntimeConnection.ForUri(...)`. - `RuntimeConnection.ForUri(url, connectionToken?)` — connects to an already-running runtime at `url` (e.g., `"localhost:8080"`). Does not spawn a process. +`StdioRuntimeConnection` and `TcpRuntimeConnection` both inherit from `OutOfProcessRuntimeConnection`, which carries process-scoped launch settings for SDK-managed runtime processes: + +- `Path` - Path to the runtime executable. When not set, the bundled runtime is used. +- `Args` - Extra command-line arguments to pass to the runtime process. +- `WorkingDirectory` - Working directory for the runtime process. When not set, the spawned runtime inherits the calling application's current working directory. +- `Environment` - Environment variables to pass to the runtime process, replacing the inherited environment. + +These settings live on the out-of-process connection instead of `CopilotClientOptions` because they do not apply to `RuntimeConnection.ForInProcess()` and do not affect `RuntimeConnection.ForUri(...)`. + +```csharp +var client = new CopilotClient(new CopilotClientOptions +{ + Connection = RuntimeConnection.ForStdio(path: "/usr/local/bin/copilot") + { + WorkingDirectory = "/srv/app", + Environment = new Dictionary { ["KEY"] = "value" }, + }, +}); +``` + Managed stdio and TCP connections use the bundled `copilot-runtime[.exe]` and adjacent `runtime.node` by default. An explicit connection path or `COPILOT_CLI_PATH` overrides the bundled runtime. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index cbc7d5fa2d..fd70be2a0c 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -139,7 +139,7 @@ private sealed record LifecycleSubscription(Type EventType, Action !IsFullyQualifiedPath(path))) { @@ -185,7 +185,7 @@ public CopilotClient(CopilotClientOptions? options = null) throw new ArgumentException($"Unsupported RuntimeConnection type: {_connection.GetType().Name}", nameof(options)); } - ValidateEnvironmentOptions(_options, _connection); + ValidateTransportOptions(_options, _connection); _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; @@ -216,27 +216,16 @@ _options.SessionFs is not null || } /// - /// Validates environment-variable options against the resolved transport. - /// Per-client environment is only representable for child-process transports - /// (each client owns its own OS process). The in-process (FFI) transport - /// loads the native runtime into the shared host process, whose single - /// environment block cannot carry per-client values, so environment and - /// telemetry options that lower to environment variables are rejected there. + /// Validates options against the resolved transport. + /// Process-scoped settings live on , + /// so the remaining transport-specific client validation is that in-process + /// hosting rejects telemetry configuration, which lowers to environment + /// variables on the shared host process. /// - private static void ValidateEnvironmentOptions(CopilotClientOptions options, RuntimeConnection connection) + private static void ValidateTransportOptions(CopilotClientOptions options, RuntimeConnection connection) { if (connection is InProcessRuntimeConnection) { - if (options.Environment is not null) - { - throw new ArgumentException( - $"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Environment)} is not supported with " + - $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport " + - "loads the native runtime into the shared host process, whose single environment block cannot carry " + - "per-client values. Set the variables on the host process environment instead.", - nameof(options)); - } - if (options.Telemetry is not null) { throw new ArgumentException( @@ -244,32 +233,9 @@ private static void ValidateEnvironmentOptions(CopilotClientOptions options, Run $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): telemetry configuration is " + "lowered to environment variables read by native runtime code running in the shared host process, so " + "per-client telemetry cannot be honored in-process. Configure telemetry via the host process " + - "environment, or use a child-process transport.", + "environment, or use an out-of-process transport.", nameof(options)); } - - if (options.WorkingDirectory is not null) - { - throw new ArgumentException( - $"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.WorkingDirectory)} is not supported with " + - $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport hosts " + - "the native runtime in the shared host process and spawns the worker without a working-directory " + - "parameter, so a per-client working directory cannot be honored in-process. Use a child-process " + - "transport, or set the process working directory before creating the client.", - nameof(options)); - } - - return; - } - - if (connection is ChildProcessRuntimeConnection { Environment: not null } && options.Environment is not null) - { - throw new ArgumentException( - $"Set environment variables via either {nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.Environment)} " + - $"or {nameof(ChildProcessRuntimeConnection)}.{nameof(ChildProcessRuntimeConnection.Environment)}, not both. " + - $"Prefer {nameof(ChildProcessRuntimeConnection)}.{nameof(ChildProcessRuntimeConnection.Environment)} for " + - "child-process transports.", - nameof(options)); } } @@ -286,12 +252,9 @@ private static void ValidateEnvironmentOptions(CopilotClientOptions options, Run /// Resolves the default for the no-Connection case, /// honoring . /// - private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions options) + private static RuntimeConnection ResolveDefaultConnection() { - var value = options.Environment is not null - && options.Environment.TryGetValue(DefaultConnectionEnvVar, out var fromOptions) - ? fromOptions - : Environment.GetEnvironmentVariable(DefaultConnectionEnvVar); + var value = Environment.GetEnvironmentVariable(DefaultConnectionEnvVar); if (string.IsNullOrEmpty(value) || string.Equals(value, "stdio", StringComparison.OrdinalIgnoreCase)) { @@ -2265,18 +2228,18 @@ private static void ApplyTelemetryEnvironment(IDictionary envir { var options = _options; var logger = _logger; - var childProcessConnection = (ChildProcessRuntimeConnection)_connection; + var outOfProcessConnection = (OutOfProcessRuntimeConnection)_connection; var tcpConnection = _connection as TcpRuntimeConnection; var useStdio = _connection is StdioRuntimeConnection; // Explicit CLI paths preserve the legacy launch contract. Otherwise use // the bundled native runtime pair. - var configuredEnvironment = childProcessConnection.Environment ?? options.Environment; + var configuredEnvironment = outOfProcessConnection.Environment; var envCliPath = configuredEnvironment is not null ? configuredEnvironment.TryGetValue("COPILOT_CLI_PATH", out var configuredCliPath) ? configuredCliPath : null : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); - var launch = childProcessConnection.Path is not null - ? new RuntimeLaunch(childProcessConnection.Path, "Options") + var launch = outOfProcessConnection.Path is not null + ? new RuntimeLaunch(outOfProcessConnection.Path, "Options") : envCliPath is not null ? new RuntimeLaunch(envCliPath, "Environment") : GetBundledRuntimeLaunch(); @@ -2284,9 +2247,9 @@ private static void ApplyTelemetryEnvironment(IDictionary envir var cliPathSource = launch.Source; var args = new List(); - if (childProcessConnection.Args != null) + if (outOfProcessConnection.Args != null) { - args.AddRange(childProcessConnection.Args); + args.AddRange(outOfProcessConnection.Args); } args.AddRange(["--headless", "--no-auto-update"]); @@ -2339,11 +2302,11 @@ private static void ApplyTelemetryEnvironment(IDictionary envir RedirectStandardInput = useStdio, RedirectStandardOutput = true, RedirectStandardError = true, - WorkingDirectory = options.WorkingDirectory, + WorkingDirectory = outOfProcessConnection.WorkingDirectory, CreateNoWindow = true }; - var childEnvironment = options.Environment ?? childProcessConnection.Environment; + var childEnvironment = outOfProcessConnection.Environment; if (childEnvironment != null) { startInfo.Environment.Clear(); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 9129b118e8..00d7e54169 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -162,11 +162,12 @@ public static InProcessRuntimeConnection ForInProcess() } /// -/// Base for kinds that spawn a runtime child process. +/// Base for kinds that the SDK hosts out of process +/// by spawning a runtime process. /// -public abstract class ChildProcessRuntimeConnection : RuntimeConnection +public abstract class OutOfProcessRuntimeConnection : RuntimeConnection { - internal ChildProcessRuntimeConnection() { } + internal OutOfProcessRuntimeConnection() { } /// Path to the runtime executable. When null, the bundled runtime is used. public string? Path { get; set; } @@ -174,14 +175,16 @@ internal ChildProcessRuntimeConnection() { } /// Extra command-line arguments to pass to the runtime process. public IList? Args { get; set; } + /// + /// Working directory for the spawned runtime process. When null, the + /// spawned runtime inherits the calling process's current working directory. + /// + public string? WorkingDirectory { get; set; } + /// /// Gets or sets the environment variables passed to the spawned runtime process, /// replacing the inherited environment. /// - /// - /// Cannot be combined with ; setting both throws - /// an when the client is constructed. - /// public IReadOnlyDictionary? Environment { get; set; } } @@ -189,7 +192,7 @@ internal ChildProcessRuntimeConnection() { } /// Spawns a runtime child process and communicates over stdin/stdout. Construct via /// . /// -public sealed class StdioRuntimeConnection : ChildProcessRuntimeConnection +public sealed class StdioRuntimeConnection : OutOfProcessRuntimeConnection { internal StdioRuntimeConnection() { } } @@ -211,7 +214,7 @@ internal InProcessRuntimeConnection() { } /// Spawns a runtime child process listening on a TCP socket. Construct via /// . /// -public sealed class TcpRuntimeConnection : ChildProcessRuntimeConnection +public sealed class TcpRuntimeConnection : OutOfProcessRuntimeConnection { internal TcpRuntimeConnection() { } @@ -291,6 +294,14 @@ public enum CopilotClientMode /// /// Configuration options for creating a instance. /// +/// +/// Process-scoped settings are configured on +/// (for example +/// and +/// ), not on +/// , because they do not apply to +/// . +/// public sealed class CopilotClientOptions { /// @@ -307,10 +318,8 @@ private CopilotClientOptions(CopilotClientOptions? other) if (other is null) return; Connection = other.Connection; - WorkingDirectory = other.WorkingDirectory; BaseDirectory = other.BaseDirectory; BuiltinPluginDirectories = other.BuiltinPluginDirectories is null ? null : [.. other.BuiltinPluginDirectories]; - Environment = other.Environment; GitHubToken = other.GitHubToken; Logger = other.Logger; LogLevel = other.LogLevel; @@ -346,11 +355,6 @@ private CopilotClientOptions(CopilotClientOptions? other) /// public RuntimeConnection? Connection { get; set; } - /// - /// Working directory for the runtime process. - /// - public string? WorkingDirectory { get; set; } - /// /// Base directory for Copilot data (session state, config, etc.). /// Sets the COPILOT_HOME environment variable on the spawned runtime. @@ -377,17 +381,6 @@ private CopilotClientOptions(CopilotClientOptions? other) /// public CopilotLogLevel? LogLevel { get; set; } - /// - /// Gets or sets environment variables passed to the runtime process. - /// - /// - /// Not supported with the in-process transport (), - /// which runs the runtime in the host process; setting this option there throws an - /// . For child-process transports, prefer - /// ; setting both throws. - /// - public IReadOnlyDictionary? Environment { get; set; } - /// Logger instance for SDK diagnostic output. public ILogger? Logger { get; set; } diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index 1ff473bd01..5092a79301 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -31,15 +31,17 @@ public async Task Should_Listen_On_Configured_Tcp_Port() } [Fact] - public async Task Should_Use_Client_Cwd_For_Default_WorkingDirectory() + public async Task Should_Use_OutOfProcess_Connection_WorkingDirectory() { var clientCwd = Path.Join(Ctx.WorkDir, "client-cwd"); Directory.CreateDirectory(clientCwd); await File.WriteAllTextAsync(Path.Join(clientCwd, "marker.txt"), "I am in the client cwd"); + var connection = RuntimeConnection.ForStdio(); + connection.WorkingDirectory = clientCwd; await using var client = Ctx.CreateClient(options: new CopilotClientOptions { - WorkingDirectory = clientCwd, + Connection = connection, }); var session = await Ctx.CreateSessionAsync(client, new SessionConfig diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 8d474bfe7e..1db7410a55 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -291,26 +291,16 @@ public CopilotClient CreateClient( options.Logger ??= Logger; - // Resolve the working directory the worker should run in. Child-process and - // URI transports take it as a per-client option; the in-process transport - // rejects a per-client WorkingDirectory (the native host spawns the worker - // without a cwd parameter), so — mirroring the Node/Rust harnesses — we point - // THIS process's cwd at the desired directory before the worker spawns and - // clear the per-client option. InProcessEnvIsolationAttribute.After restores - // the cwd after the test. - var desiredWorkingDirectory = options.WorkingDirectory ?? WorkDir; + // Resolve the working directory the worker should run in. Out-of-process + // transports carry it on the connection itself; in-process still inherits + // this process's cwd, so the harness points THIS process at WorkDir before + // the worker spawns. InProcessEnvIsolationAttribute.After restores the cwd + // after the test. + var desiredWorkingDirectory = (options.Connection as OutOfProcessRuntimeConnection)?.WorkingDirectory ?? WorkDir; // Tests must supply environment via the 'environment' parameter, which the // harness routes to the right place per transport (the connection for - // child-process transports, the host process for in-process). Setting - // options.Environment directly bypasses that routing and is unsupported - // in-process, so reject it here. - if (options.Environment is not null) - { - throw new ArgumentException( - "Do not set options.Environment in E2E tests; pass the 'environment' parameter to CreateClient instead.", - nameof(options)); - } + // out-of-process transports, the host process for in-process). // The full environment the client runs with: harness defaults (proxy // redirect, isolated home, cleared HMAC/tokens, etc.) unless the test @@ -335,27 +325,21 @@ public CopilotClient CreateClient( // In-process default: leave Connection unset so CopilotClient's // ResolveDefaultConnection honors COPILOT_SDK_DEFAULT_CONNECTION. break; - case ChildProcessRuntimeConnection child when child.Path is null: + case OutOfProcessRuntimeConnection child when child.Path is null: child.Path = GetCliPath(); break; } if (IsInProcess(options.Connection)) { - options.WorkingDirectory = null; ApplyInProcessEnvironment(env, desiredWorkingDirectory); } - else if (options.Connection is ChildProcessRuntimeConnection child) + else if (options.Connection is OutOfProcessRuntimeConnection child) { - // Child-process transport: hand the environment to the spawned child + // Out-of-process transport: hand the environment to the spawned child // via the connection, where per-client environment is coherent. child.Environment = env; - options.WorkingDirectory = desiredWorkingDirectory; - } - else - { - // URI / existing-runtime transport: per-client WorkingDirectory applies normally. - options.WorkingDirectory = desiredWorkingDirectory; + child.WorkingDirectory ??= desiredWorkingDirectory; } // Auto-inject auth token unless connecting to an existing runtime via URI. diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 2f213525f6..bcb2a138f7 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -11,12 +11,14 @@ public class CloneTests [Fact] public void CopilotClientOptions_Clone_CopiesAllProperties() { + var connection = RuntimeConnection.ForTcp(port: 8080, connectionToken: "tok", path: "/usr/bin/copilot", args: ["--verbose", "--debug"]); + connection.WorkingDirectory = "/home/user"; + connection.Environment = new Dictionary { ["KEY"] = "value" }; + var original = new CopilotClientOptions { - Connection = RuntimeConnection.ForTcp(port: 8080, connectionToken: "tok", path: "/usr/bin/copilot", args: ["--verbose", "--debug"]), - WorkingDirectory = "/home/user", + Connection = connection, LogLevel = CopilotLogLevel.Debug, - Environment = new Dictionary { ["KEY"] = "value" }, GitHubToken = "ghp_test", UseLoggedInUser = false, BaseDirectory = "/custom/copilot/home", @@ -33,11 +35,13 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() }; var clone = original.Clone(); + var originalConnection = Assert.IsType(original.Connection); + var cloneConnection = Assert.IsType(clone.Connection); Assert.Same(original.Connection, clone.Connection); - Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); + Assert.Equal(originalConnection.WorkingDirectory, cloneConnection.WorkingDirectory); Assert.Equal(original.LogLevel, clone.LogLevel); - Assert.Equal(original.Environment, clone.Environment); + Assert.Equal(originalConnection.Environment, cloneConnection.Environment); Assert.Equal(original.GitHubToken, clone.GitHubToken); Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); Assert.Equal(original.BaseDirectory, clone.BaseDirectory); @@ -60,14 +64,21 @@ public void CopilotClientOptions_Clone_ConnectionIsShared() } [Fact] - public void CopilotClientOptions_Clone_EnvironmentIsShared() + public void OutOfProcessConnection_EnvironmentReference_IsShared_By_ClientOptionsClone() { var env = new Dictionary { ["key"] = "value" }; - var original = new CopilotClientOptions { Environment = env }; + var connection = RuntimeConnection.ForStdio(); + connection.Environment = env; + var original = new CopilotClientOptions + { + Connection = connection, + }; var clone = original.Clone(); + var originalConnection = Assert.IsType(original.Connection); + var cloneConnection = Assert.IsType(clone.Connection); - Assert.Same(original.Environment, clone.Environment); + Assert.Same(originalConnection.Environment, cloneConnection.Environment); } [Fact] diff --git a/dotnet/test/Unit/RuntimeWrapperTests.cs b/dotnet/test/Unit/RuntimeWrapperTests.cs index 9a9dacb7d3..204d65b854 100644 --- a/dotnet/test/Unit/RuntimeWrapperTests.cs +++ b/dotnet/test/Unit/RuntimeWrapperTests.cs @@ -29,10 +29,11 @@ public async Task Managed_Launch_Fails_When_Bundled_Runtime_Pair_Is_Missing() try { AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", emptyBaseDirectory); + var connection = RuntimeConnection.ForStdio(); + connection.Environment = new Dictionary(); await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = new Dictionary(), + Connection = connection, }); var exception = await Assert.ThrowsAsync(() => client.StartAsync()); @@ -53,10 +54,11 @@ public async Task Explicit_Path_Does_Not_Require_Adjacent_Runtime_Node() var explicitPath = Path.Combine( Path.GetTempPath(), $"missing-explicit-copilot-{Guid.NewGuid():N}"); + var connection = RuntimeConnection.ForStdio(path: explicitPath); + connection.Environment = new Dictionary(); await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(path: explicitPath), - Environment = new Dictionary(), + Connection = connection, }); var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); @@ -70,10 +72,11 @@ public async Task Copilot_Cli_Path_Does_Not_Require_Adjacent_Runtime_Node() var explicitPath = Path.Combine( Path.GetTempPath(), $"missing-environment-copilot-{Guid.NewGuid():N}"); + var connection = RuntimeConnection.ForStdio(); + connection.Environment = new Dictionary { ["COPILOT_CLI_PATH"] = explicitPath }; await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = new Dictionary { ["COPILOT_CLI_PATH"] = explicitPath }, + Connection = connection, }); var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); @@ -99,10 +102,11 @@ public async Task Marked_Bundled_Explicit_Cli_Does_Not_Require_Runtime_Pair() try { AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDirectory); + var connection = RuntimeConnection.ForStdio(); + connection.Environment = new Dictionary(); await using var client = new CopilotClient(new CopilotClientOptions { - Connection = RuntimeConnection.ForStdio(), - Environment = new Dictionary(), + Connection = connection, }); var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); diff --git a/go/README.md b/go/README.md index 1fa8202419..bfcf4b45b7 100644 --- a/go/README.md +++ b/go/README.md @@ -110,7 +110,7 @@ Follow these steps to embed the CLI: 1. Run `go get -tool github.com/github/copilot-sdk/go/cmd/bundler`. This is a one-time setup step per project. 2. Run `go tool bundler` in your build environment just before building your application. -That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`), the SDK automatically installs the embedded `copilot-runtime` executable and adjacent `runtime.node` to a cache directory for managed child-process connections. +That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`), the SDK automatically installs the embedded `copilot-runtime` executable and adjacent `runtime.node` to a cache directory for managed out-of-process connections. The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag. @@ -118,7 +118,7 @@ The bundler prepares the native runtime library required by the [in-process tran > **Experimental:** the in-process API may change in a future release. -By default the SDK starts the runtime as a child process and talks JSON-RPC over stdio or TCP. The **in-process** transport instead loads a native runtime library directly into your process. +By default the SDK starts the runtime out of process and talks JSON-RPC over stdio or TCP. The **in-process** transport instead loads a native runtime library directly into your process. Build your application with the `copilot_inprocess` build tag: @@ -144,7 +144,7 @@ Resolution and requirements: always takes precedence. - Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package; otherwise the bundled runtime is used. No `PATH` lookup is performed. - Embedded runtime versions are isolated in separate cache directories. Start fails loudly if the native runtime is unavailable. -- Managed child-process start fails if the embedded `copilot-runtime` and +- Managed out-of-process start fails if the embedded `copilot-runtime` and `runtime.node` pair is unavailable; explicit paths and `COPILOT_CLI_PATH` remain direct overrides. - Linux in-process bundles include both glibc and musl runtime packages and select the matching package automatically at startup. @@ -152,9 +152,7 @@ Resolution and requirements: The in-process transport rejects options that cannot be honored by a runtime hosted in your shared process (each panics at `NewClient`): -- `Env` — the host process has a single environment block. Set variables on the host process environment instead. -- `WorkingDirectory` — the runtime shares the host process's working directory. Change the process working directory before creating the client. -- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use a child-process transport for per-client telemetry. +- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use an out-of-process connection for per-client telemetry. Implemented with pure-Go FFI (via [purego](https://github.com/ebitengine/purego)), so `CGO_ENABLED=0` and cross-compilation are preserved; no C toolchain is required. @@ -199,18 +197,16 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec **ClientOptions:** - `Connection` (RuntimeConnection): How the SDK connects to the runtime. Construct via one of: - - `StdioConnection{Path, Args, Env}` — spawn a runtime over stdio (the default if `Connection` is nil) - - `TCPConnection{Port, ConnectionToken, Path, Args, Env}` — spawn a runtime that listens on TCP + - `StdioConnection{Path, Args, WorkingDirectory, Env}` — spawn and manage an out-of-process runtime over stdio (the default if `Connection` is nil) + - `TCPConnection{Port, ConnectionToken, Path, Args, WorkingDirectory, Env}` — spawn and manage an out-of-process runtime that listens on TCP - `URIConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned) - - `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning a child process. See [In-process transport](#in-process-transport-experimental) below. + - `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning an out-of-process runtime. See [In-process transport](#in-process-transport-experimental) below. When `Path` is empty for stdio/tcp, the SDK uses `COPILOT_CLI_PATH` when set, then the bundled `copilot-runtime` and adjacent `runtime.node`. - `StdioConnection` and `TCPConnection` accept an optional connection-level `Env`. Set environment variables via **either** the client-level `Env` option or the connection's `Env`, not both (setting both panics); prefer the connection-level `Env`. -- `WorkingDirectory` (string): Working directory for the runtime process (default: current process working directory) + Process-scoped settings are configured on `StdioConnection` and `TCPConnection`, not on `ClientOptions`, because they do not apply to `InProcessConnection`. - `BaseDirectory` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When empty, the runtime defaults to `~/.copilot`. Ignored with `URIConnection`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. - `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`). -- `Env` ([]string): Environment variables for the runtime process (default: inherits from current process) - `GitHubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. - `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `URIConnection`. - `EnableRemoteSessions` (bool): Enable remote session support (Mission Control integration). Ignored with `URIConnection`. diff --git a/go/client.go b/go/client.go index 6cac724f0d..b86a4d68e9 100644 --- a/go/client.go +++ b/go/client.go @@ -95,33 +95,20 @@ func validateSessionFSConfig(config *SessionFSConfig) error { return nil } -// validateEnvironmentOptions enforces the transport-specific rules for -// per-client environment, working directory, and telemetry. It panics (fails -// loud) on a misconfiguration, matching the other SDKs. -// -// The in-process transport loads the native runtime into this process, whose -// single environment block and process-global working directory cannot carry -// per-client values, and whose telemetry lowers to shared process-global env -// vars — so options that depend on them are rejected there. Child-process -// transports each own their OS process, so per-connection env is allowed, but -// setting it in both the client-level option and the connection is rejected. -func validateEnvironmentOptions(connection RuntimeConnection, opts *ClientOptions) { +// validateTransportOptions enforces transport-specific rules for client-wide +// options. It panics (fails loud) on a misconfiguration, matching the other +// SDKs. +// +// The in-process transport loads the native runtime into this process, so +// client-wide telemetry cannot be represented because it lowers to shared +// process-global environment variables. Process-scoped settings such as +// environment and working directory are configured on out-of-process +// connections instead of [ClientOptions], so they are structurally excluded +// from [InProcessConnection]. +func validateTransportOptions(connection RuntimeConnection, opts *ClientOptions) { if _, ok := connection.(InProcessConnection); ok { - if opts.Env != nil { - panic("Env is not supported with InProcessConnection: the in-process transport loads the native runtime into the shared host process, whose single environment block cannot carry per-client values. Set the variables on the host process environment instead.") - } - if opts.WorkingDirectory != "" { - panic("WorkingDirectory is not supported with InProcessConnection: the native runtime shares the host process working directory. Use a child-process transport, or set the process working directory before creating the client.") - } if opts.Telemetry != nil { - panic("Telemetry is not supported with InProcessConnection: telemetry configuration is lowered to environment variables read by native runtime code running in the shared host process, so per-client telemetry cannot be honored in-process. Configure telemetry via the host process environment, or use a child-process transport.") - } - return - } - - if cp, ok := connection.(childProcessConnection); ok { - if cp.connEnv() != nil && opts.Env != nil { - panic("Set environment variables via either the client-level Env option or the connection's Env, not both. Prefer the connection-level Env for child-process transports.") + panic("Telemetry is not supported with InProcessConnection: telemetry configuration is lowered to environment variables read by native runtime code running in the shared host process, so per-client telemetry cannot be honored in-process. Configure telemetry via the host process environment, or use an out-of-process connection.") } } } @@ -164,10 +151,12 @@ type Client struct { useInProcess bool // true for InProcessConnection (FFI transport) ffiHost inProcessHost // resolved process options for the spawned runtime (zero values for URIConnection) - cliPath string - cliArgs []string - port int - tcpConnectionToken string + cliPath string + cliArgs []string + processWorkingDirectory string + processEnv []string + port int + tcpConnectionToken string modelsCache []ModelInfo modelsCacheMux sync.Mutex @@ -248,11 +237,7 @@ func NewClient(options *ClientOptions) *Client { // honor the same process/environment override as the other SDKs. connection := opts.Connection if connection == nil { - env := opts.Env - if env == nil { - env = os.Environ() - } - connection = resolveDefaultConnection(env) + connection = resolveDefaultConnection(os.Environ()) } switch conn := connection.(type) { case StdioConnection: @@ -261,12 +246,20 @@ func NewClient(options *ClientOptions) *Client { if len(conn.Args) > 0 { client.cliArgs = append([]string{}, conn.Args...) } + client.processWorkingDirectory = conn.WorkingDirectory + if conn.Env != nil { + client.processEnv = append([]string{}, conn.Env...) + } case TCPConnection: client.useStdio = false client.cliPath = conn.Path if len(conn.Args) > 0 { client.cliArgs = append([]string{}, conn.Args...) } + client.processWorkingDirectory = conn.WorkingDirectory + if conn.Env != nil { + client.processEnv = append([]string{}, conn.Env...) + } client.port = conn.Port client.tcpConnectionToken = conn.ConnectionToken case URIConnection: @@ -286,35 +279,23 @@ func NewClient(options *ClientOptions) *Client { panic(fmt.Sprintf("unknown RuntimeConnection type: %T", connection)) } - // Validate transport-specific option constraints (fail loud). The in-process - // transport loads the runtime into this process, whose single environment - // block, process-global working directory, and shared telemetry state cannot - // carry per-client values. Child-process transports may set env via either - // the client-level option or the connection, but not both. - validateEnvironmentOptions(connection, &opts) + // Validate transport-specific option constraints (fail loud). + validateTransportOptions(connection, &opts) // Validate auth options when connecting to an external runtime. if client.isExternalServer && (opts.GitHubToken != "" || opts.UseLoggedInUser != nil) { panic("GitHubToken and UseLoggedInUser cannot be used with URIConnection (external runtime manages its own auth)") } - // For child-process transports, a connection-level env takes precedence over - // the client-level env (setting both was rejected above). Resolve it before - // defaulting so an explicit empty connection env stays authoritative. - if cp, ok := connection.(childProcessConnection); ok { - if env := cp.connEnv(); env != nil { - opts.Env = env - } - } - - // Default Env to current environment if not set - if opts.Env == nil { - opts.Env = os.Environ() + // Default the out-of-process environment to the current process if not set. + // An explicit empty slice stays authoritative and yields a cleared child env. + if _, ok := connection.(outOfProcessConnection); ok && client.processEnv == nil { + client.processEnv = os.Environ() } - // Check the effective environment for a child-process runtime override. - if client.cliPath == "" && !client.useInProcess { - if cliPath := getEnvValue(opts.Env, "COPILOT_CLI_PATH"); cliPath != "" { + // Check the effective environment for an out-of-process runtime override. + if client.cliPath == "" && !client.useInProcess && !client.isExternalServer { + if cliPath := getEnvValue(client.processEnv, "COPILOT_CLI_PATH"); cliPath != "" { client.cliPath = cliPath } } @@ -628,7 +609,7 @@ func (c *Client) Stop() error { c.process = nil // Tear down the in-process FFI host (closes the connection and shuts down the - // native runtime). No child process to reap in this mode. + // native runtime). No out-of-process runtime to reap in this mode. if c.ffiHost != nil { c.ffiHost.Dispose() c.ffiHost = nil @@ -2116,11 +2097,11 @@ func (c *Client) startCLIServer(ctx context.Context) error { configureProcAttr(c.process) // Set working directory if specified - if c.options.WorkingDirectory != "" { - c.process.Dir = c.options.WorkingDirectory + if c.processWorkingDirectory != "" { + c.process.Dir = c.processWorkingDirectory } - c.process.Env = append([]string{}, c.options.Env...) + c.process.Env = append([]string{}, c.processEnv...) if c.options.GitHubToken != "" { c.process.Env = setEnvValue(c.process.Env, "COPILOT_SDK_AUTH_TOKEN", c.options.GitHubToken) } @@ -2263,7 +2244,7 @@ func (c *Client) startInProcess(ctx context.Context) error { cliEntrypoint := c.cliPath if cliEntrypoint == "" { - cliEntrypoint = getEnvValue(c.options.Env, "COPILOT_CLI_PATH") + cliEntrypoint = os.Getenv("COPILOT_CLI_PATH") } runtimePath := cliEntrypoint if runtimePath == "" { diff --git a/go/client_test.go b/go/client_test.go index 52587c8462..06606befb8 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1375,48 +1375,70 @@ func TestClient_BaseDirectory(t *testing.T) { } func TestClient_EnvOptions(t *testing.T) { - t.Run("should store custom environment variables", func(t *testing.T) { + t.Run("should store custom stdio connection environment variables", func(t *testing.T) { client := NewClient(&ClientOptions{ - Env: []string{"FOO=bar", "BAZ=qux"}, + Connection: StdioConnection{Env: []string{"FOO=bar", "BAZ=qux"}}, }) - if len(client.options.Env) != 2 { - t.Errorf("Expected 2 environment variables, got %d", len(client.options.Env)) + if len(client.processEnv) != 2 { + t.Errorf("Expected 2 environment variables, got %d", len(client.processEnv)) } - if client.options.Env[0] != "FOO=bar" { - t.Errorf("Expected first env var to be 'FOO=bar', got %q", client.options.Env[0]) + if client.processEnv[0] != "FOO=bar" { + t.Errorf("Expected first env var to be 'FOO=bar', got %q", client.processEnv[0]) } - if client.options.Env[1] != "BAZ=qux" { - t.Errorf("Expected second env var to be 'BAZ=qux', got %q", client.options.Env[1]) + if client.processEnv[1] != "BAZ=qux" { + t.Errorf("Expected second env var to be 'BAZ=qux', got %q", client.processEnv[1]) } }) t.Run("should default to inherit from current process", func(t *testing.T) { client := NewClient(&ClientOptions{}) - if want := os.Environ(); !reflect.DeepEqual(client.options.Env, want) { - t.Errorf("Expected Env to be %v, got %v", want, client.options.Env) + if want := os.Environ(); !reflect.DeepEqual(client.processEnv, want) { + t.Errorf("Expected Env to be %v, got %v", want, client.processEnv) } }) t.Run("should default to inherit from current process with nil options", func(t *testing.T) { client := NewClient(nil) - if want := os.Environ(); !reflect.DeepEqual(client.options.Env, want) { - t.Errorf("Expected Env to be %v, got %v", want, client.options.Env) + if want := os.Environ(); !reflect.DeepEqual(client.processEnv, want) { + t.Errorf("Expected Env to be %v, got %v", want, client.processEnv) } }) t.Run("should allow empty environment", func(t *testing.T) { client := NewClient(&ClientOptions{ - Env: []string{}, + Connection: StdioConnection{Env: []string{}}, }) - if client.options.Env == nil { + if client.processEnv == nil { t.Error("Expected Env to be non-nil empty slice") } - if len(client.options.Env) != 0 { - t.Errorf("Expected 0 environment variables, got %d", len(client.options.Env)) + if len(client.processEnv) != 0 { + t.Errorf("Expected 0 environment variables, got %d", len(client.processEnv)) + } + }) +} + +func TestClient_WorkingDirectoryOptions(t *testing.T) { + t.Run("should store stdio connection working directory", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: StdioConnection{WorkingDirectory: "/tmp/stdio"}, + }) + + if client.processWorkingDirectory != "/tmp/stdio" { + t.Errorf("Expected process working directory to be %q, got %q", "/tmp/stdio", client.processWorkingDirectory) + } + }) + + t.Run("should store tcp connection working directory", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: TCPConnection{Port: 1234, WorkingDirectory: "/tmp/tcp"}, + }) + + if client.processWorkingDirectory != "/tmp/tcp" { + t.Errorf("Expected process working directory to be %q, got %q", "/tmp/tcp", client.processWorkingDirectory) } }) } @@ -1458,30 +1480,6 @@ func TestClient_InProcessConnection(t *testing.T) { } }) - t.Run("panics when Env is set", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when Env is set with InProcessConnection") - } - }() - NewClient(&ClientOptions{ - Connection: InProcessConnection{}, - Env: []string{"FOO=bar"}, - }) - }) - - t.Run("panics when WorkingDirectory is set", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when WorkingDirectory is set with InProcessConnection") - } - }() - NewClient(&ClientOptions{ - Connection: InProcessConnection{}, - WorkingDirectory: "/tmp/work", - }) - }) - t.Run("panics when Telemetry is set", func(t *testing.T) { defer func() { if r := recover(); r == nil { @@ -1582,24 +1580,12 @@ func TestClient_DefaultConnection(t *testing.T) { } func TestClient_ConnectionLevelEnv(t *testing.T) { - t.Run("rejects env set on both client and connection", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic when env is set on both client and connection") - } - }() - NewClient(&ClientOptions{ - Connection: StdioConnection{Env: []string{"A=1"}}, - Env: []string{"B=2"}, - }) - }) - t.Run("stdio connection env is used when client env is unset", func(t *testing.T) { client := NewClient(&ClientOptions{ Connection: StdioConnection{Env: []string{"ONLY=conn"}}, }) - if len(client.options.Env) != 1 || client.options.Env[0] != "ONLY=conn" { - t.Errorf("Expected connection-level Env to be used, got %v", client.options.Env) + if len(client.processEnv) != 1 || client.processEnv[0] != "ONLY=conn" { + t.Errorf("Expected connection-level Env to be used, got %v", client.processEnv) } }) @@ -1607,8 +1593,8 @@ func TestClient_ConnectionLevelEnv(t *testing.T) { client := NewClient(&ClientOptions{ Connection: TCPConnection{Port: 9000, Env: []string{"ONLY=conn"}}, }) - if len(client.options.Env) != 1 || client.options.Env[0] != "ONLY=conn" { - t.Errorf("Expected connection-level Env to be used, got %v", client.options.Env) + if len(client.processEnv) != 1 || client.processEnv[0] != "ONLY=conn" { + t.Errorf("Expected connection-level Env to be used, got %v", client.processEnv) } }) } diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 54461443ca..534a76bde0 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -23,7 +23,14 @@ func TestClientOptionsE2E(t *testing.T) { port := getAvailableTCPPort(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: ctx.CLIPath, Port: port} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + Port: port, + } }) t.Cleanup(func() { client.ForceStop() }) @@ -57,7 +64,9 @@ func TestClientOptionsE2E(t *testing.T) { } client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.WorkingDirectory = clientCwd + conn := opts.Connection.(copilot.StdioConnection) + conn.WorkingDirectory = clientCwd + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) @@ -99,13 +108,13 @@ func TestClientOptionsE2E(t *testing.T) { } client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.StdioConnection{ - Path: cliPath, - Args: []string{"--capture-file", capturePath}, - } + conn := opts.Connection.(copilot.StdioConnection) + conn.Path = cliPath + conn.Args = []string{"--capture-file", capturePath} + conn.Env = append([]string{}, conn.Env...) + conn.Env = append(conn.Env, "COPILOT_HOME="+filepath.Join(ctx.WorkDir, "copilot-home-from-env")) + opts.Connection = conn opts.BaseDirectory = filepath.Join(ctx.WorkDir, "copilot-home-from-option") - opts.Env = append([]string{}, opts.Env...) - opts.Env = append(opts.Env, "COPILOT_HOME="+filepath.Join(ctx.WorkDir, "copilot-home-from-env")) opts.GitHubToken = "process-option-token" opts.LogLevel = "debug" opts.SessionIdleTimeoutSeconds = 17 diff --git a/go/internal/e2e/commands_and_elicitation_e2e_test.go b/go/internal/e2e/commands_and_elicitation_e2e_test.go index af7520a4cc..de8fb1cb43 100644 --- a/go/internal/e2e/commands_and_elicitation_e2e_test.go +++ b/go/internal/e2e/commands_and_elicitation_e2e_test.go @@ -14,7 +14,14 @@ import ( func TestCommandsE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { client1.ForceStop() }) @@ -706,7 +713,14 @@ func schemaHasProperty(schema *copilot.ElicitationSchema, name string) bool { func TestUIElicitationMultiClientE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { client1.ForceStop() }) diff --git a/go/internal/e2e/connection_token_test.go b/go/internal/e2e/connection_token_test.go index 6d36000b3b..072772eae4 100644 --- a/go/internal/e2e/connection_token_test.go +++ b/go/internal/e2e/connection_token_test.go @@ -13,9 +13,13 @@ func TestConnectionToken(t *testing.T) { t.Run("explicit token round-trips successfully", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) opts.Connection = copilot.TCPConnection{ - Path: ctx.CLIPath, - ConnectionToken: "right-token", + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: "right-token", } }) t.Cleanup(func() { client.ForceStop() }) @@ -36,7 +40,13 @@ func TestConnectionToken(t *testing.T) { t.Run("auto-generated token round-trips successfully", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: ctx.CLIPath} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + } }) t.Cleanup(func() { client.ForceStop() }) @@ -56,9 +66,13 @@ func TestConnectionToken(t *testing.T) { t.Run("sibling client with wrong token is rejected", func(t *testing.T) { ctx := testharness.NewTestContext(t) good := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) opts.Connection = copilot.TCPConnection{ - Path: ctx.CLIPath, - ConnectionToken: "right-token", + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: "right-token", } }) t.Cleanup(func() { good.ForceStop() }) @@ -91,9 +105,13 @@ func TestConnectionToken(t *testing.T) { t.Run("sibling client with no token is rejected", func(t *testing.T) { ctx := testharness.NewTestContext(t) good := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) opts.Connection = copilot.TCPConnection{ - Path: ctx.CLIPath, - ConnectionToken: "right-token", + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: "right-token", } }) t.Cleanup(func() { good.ForceStop() }) diff --git a/go/internal/e2e/copilot_request_helpers_test.go b/go/internal/e2e/copilot_request_helpers_test.go index cd1ac63cf7..87bca70a44 100644 --- a/go/internal/e2e/copilot_request_helpers_test.go +++ b/go/internal/e2e/copilot_request_helpers_test.go @@ -282,7 +282,9 @@ func newCopilotRequestClient(ctx *testharness.TestContext, handler *copilot.Copi return ctx.NewClient(func(o *copilot.ClientOptions) { o.RequestHandler = handler if len(extraEnv) > 0 { - o.Env = append(o.Env, extraEnv...) + conn := o.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, extraEnv...) + o.Connection = conn } }) } diff --git a/go/internal/e2e/mode_handlers_e2e_test.go b/go/internal/e2e/mode_handlers_e2e_test.go index e7471fbd06..164cfcceb4 100644 --- a/go/internal/e2e/mode_handlers_e2e_test.go +++ b/go/internal/e2e/mode_handlers_e2e_test.go @@ -21,7 +21,9 @@ func TestModeHandlersE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/multi_client_e2e_test.go b/go/internal/e2e/multi_client_e2e_test.go index 742145536c..cfc813a15d 100644 --- a/go/internal/e2e/multi_client_e2e_test.go +++ b/go/internal/e2e/multi_client_e2e_test.go @@ -18,7 +18,14 @@ func TestMultiClientE2E(t *testing.T) { // Use TCP mode so a second client can connect to the same CLI process ctx := testharness.NewTestContext(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { client1.ForceStop() }) diff --git a/go/internal/e2e/pending_work_resume_e2e_test.go b/go/internal/e2e/pending_work_resume_e2e_test.go index 58de8644ac..45d779e6d9 100644 --- a/go/internal/e2e/pending_work_resume_e2e_test.go +++ b/go/internal/e2e/pending_work_resume_e2e_test.go @@ -470,7 +470,12 @@ func TestPendingWorkResumeE2E(t *testing.T) { if scenario.disconnectOriginalClient { lockObserver := ctx.NewClient(func(opts *copilot.ClientOptions) { stdio := opts.Connection.(copilot.StdioConnection) - opts.Connection = copilot.TCPConnection{Path: stdio.Path} + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + } }) t.Cleanup(func() { lockObserver.ForceStop() }) if err := lockObserver.Start(t.Context()); err != nil { @@ -694,7 +699,14 @@ const sharedTCPToken = "tcp-shared-test-token" func startTCPServer(t *testing.T, ctx *testharness.TestContext) (*copilot.Client, string) { t.Helper() server := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: opts.Connection.(copilot.StdioConnection).Path, ConnectionToken: sharedTCPToken} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: sharedTCPToken, + } }) t.Cleanup(func() { server.ForceStop() }) // Trigger connection so we can read the port. CreateSession+Disconnect is the diff --git a/go/internal/e2e/per_session_auth_e2e_test.go b/go/internal/e2e/per_session_auth_e2e_test.go index e004fa6b5a..c71b035642 100644 --- a/go/internal/e2e/per_session_auth_e2e_test.go +++ b/go/internal/e2e/per_session_auth_e2e_test.go @@ -14,7 +14,9 @@ func TestPerSessionAuthE2E(t *testing.T) { // Create client with COPILOT_DEBUG_GITHUB_API_URL redirected to the proxy // so per-session auth token resolution (fetchCopilotUser) is intercepted. client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) // Register per-token user configs on the proxy @@ -101,10 +103,12 @@ func TestPerSessionAuthE2E(t *testing.T) { ctx.ConfigureForTest(t) noTokenClient := copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: ctx.CLIPath}, - WorkingDirectory: ctx.WorkDir, - Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), - UseLoggedInUser: copilot.Bool(false), + Connection: copilot.StdioConnection{ + Path: ctx.CLIPath, + WorkingDirectory: ctx.WorkDir, + Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), + }, + UseLoggedInUser: copilot.Bool(false), }) t.Cleanup(func() { noTokenClient.ForceStop() }) diff --git a/go/internal/e2e/provider_endpoint_e2e_test.go b/go/internal/e2e/provider_endpoint_e2e_test.go index aad02ca2b5..36bde6cc5b 100644 --- a/go/internal/e2e/provider_endpoint_e2e_test.go +++ b/go/internal/e2e/provider_endpoint_e2e_test.go @@ -20,7 +20,9 @@ func TestProviderEndpointE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT=true") + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT=true") + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go index 22f53c48a5..c9ab3f65e1 100644 --- a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go @@ -572,7 +572,9 @@ func assertSkillState(t *testing.T, list *rpc.SkillList, name string, enabled bo func createMCPAppsClient(ctx *testharness.TestContext) *copilot.Client { return ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_MCP_APPS=true", "MCP_APPS=true") + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_MCP_APPS=true", "MCP_APPS=true") + opts.Connection = conn }) } diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go index f1aa5a19c7..4a105233b4 100644 --- a/go/internal/e2e/rpc_server_e2e_test.go +++ b/go/internal/e2e/rpc_server_e2e_test.go @@ -179,7 +179,9 @@ func TestRPCServerE2E(t *testing.T) { t.Run("should add secret filter values", func(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_ENABLE_SECRET_FILTERING=true") + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_ENABLE_SECRET_FILTERING=true") + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) @@ -702,7 +704,9 @@ func TestRPCServerE2E(t *testing.T) { // newAuthenticatedClient builds a client that resolves auth through the test proxy. func newAuthenticatedClient(ctx *testharness.TestContext, token string) *copilot.Client { return ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn opts.GitHubToken = token }) } diff --git a/go/internal/e2e/rpc_server_misc_e2e_test.go b/go/internal/e2e/rpc_server_misc_e2e_test.go index 9607994676..1345e7b4a5 100644 --- a/go/internal/e2e/rpc_server_misc_e2e_test.go +++ b/go/internal/e2e/rpc_server_misc_e2e_test.go @@ -268,9 +268,11 @@ func newNoTokenClient(t *testing.T, ctx *testharness.TestContext) *copilot.Clien ) useLoggedInUser := false return copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: ctx.CLIPath}, - WorkingDirectory: ctx.WorkDir, - Env: env, - UseLoggedInUser: &useLoggedInUser, + Connection: copilot.StdioConnection{ + Path: ctx.CLIPath, + WorkingDirectory: ctx.WorkDir, + Env: env, + }, + UseLoggedInUser: &useLoggedInUser, }) } diff --git a/go/internal/e2e/rpc_server_plugins_e2e_test.go b/go/internal/e2e/rpc_server_plugins_e2e_test.go index a9d1d243cc..730d7af058 100644 --- a/go/internal/e2e/rpc_server_plugins_e2e_test.go +++ b/go/internal/e2e/rpc_server_plugins_e2e_test.go @@ -318,12 +318,14 @@ func newStartedIsolatedPortedClient(t *testing.T, ctx *testharness.TestContext) t.Fatalf("Failed to create isolated plugin home: %v", err) } return newStartedPortedClient(t, ctx, func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_HOME="+home, "GH_CONFIG_DIR="+home, "XDG_CONFIG_HOME="+home, "XDG_STATE_HOME="+home, ) + opts.Connection = conn }) } diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index f1c267e230..074b1f2fcc 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -452,7 +452,14 @@ func TestSessionConfigNewOptionsCopilotRequestE2E(t *testing.T) { handler := &copilot.CopilotRequestHandler{Transport: transport} const connectionToken = "go-citation-resume-token" server := ctx.NewClient(func(o *copilot.ClientOptions) { - o.Connection = copilot.TCPConnection{Path: ctx.CLIPath, ConnectionToken: connectionToken} + stdio := o.Connection.(copilot.StdioConnection) + o.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + ConnectionToken: connectionToken, + } o.RequestHandler = handler }) t.Cleanup(func() { server.ForceStop() }) diff --git a/go/internal/e2e/session_event_loop_leak_e2e_test.go b/go/internal/e2e/session_event_loop_leak_e2e_test.go index c6974f74d8..cffa8a98b0 100644 --- a/go/internal/e2e/session_event_loop_leak_e2e_test.go +++ b/go/internal/e2e/session_event_loop_leak_e2e_test.go @@ -28,7 +28,9 @@ func TestSessionEventLoopLeakE2E(t *testing.T) { // with a genuine RPC error (401 Unauthorized), exercising the same // failure path a real user would hit — not a mocked transport. client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + conn := opts.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + opts.Connection = conn }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/session_fs_e2e_test.go b/go/internal/e2e/session_fs_e2e_test.go index 4236e8e529..570ef45d46 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -139,7 +139,13 @@ func TestSessionFSE2E(t *testing.T) { ctx.ConfigureForTest(t) client1 := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Connection = copilot.TCPConnection{Path: ctx.CLIPath} + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{ + Path: stdio.Path, + Args: append([]string{}, stdio.Args...), + WorkingDirectory: stdio.WorkingDirectory, + Env: append([]string{}, stdio.Env...), + } }) t.Cleanup(func() { client1.ForceStop() }) @@ -157,7 +163,6 @@ func TestSessionFSE2E(t *testing.T) { client2 := copilot.NewClient(&copilot.ClientOptions{ Connection: copilot.URIConnection{URL: fmt.Sprintf("localhost:%d", runtimePort)}, LogLevel: "error", - Env: ctx.Env(), SessionFS: sessionFSConfig, }) t.Cleanup(func() { client2.ForceStop() }) diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go index 0e2fde9f86..3fb518d530 100644 --- a/go/internal/e2e/subagent_hooks_e2e_test.go +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -81,7 +81,9 @@ func TestSubagentHooksE2E(t *testing.T) { ctx := testharness.NewTestContext(t) transport := newRecordingForwardingTransport() client := ctx.NewClient(func(o *copilot.ClientOptions) { - o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + conn := o.Connection.(copilot.StdioConnection) + conn.Env = append(conn.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + o.Connection = conn o.RequestHandler = &copilot.CopilotRequestHandler{Transport: transport} }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 951adfc291..af154232bf 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -399,9 +399,11 @@ func (c *TestContext) Env() []string { // Optional overrides can be applied to the default ClientOptions via the opts function. func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.Client { options := &copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: c.CLIPath}, - WorkingDirectory: c.WorkDir, - Env: c.Env(), + Connection: copilot.StdioConnection{ + Path: c.CLIPath, + WorkingDirectory: c.WorkDir, + Env: c.Env(), + }, } for _, opt := range opts { @@ -414,16 +416,15 @@ func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.C } // Under the inprocess matrix cell, host the default stdio connection in-process. - // The worker inherits this process's ambient env/cwd (per-client env and working - // directory are rejected in-process), so mirror the effective (merged) env and - // cwd onto the real process and drop those options. Tests that pin a specific - // transport (TCP/URI/custom stdio) or configure per-client telemetry are left on - // their transport, mirroring the Node/.NET harnesses. + // The worker inherits this process's ambient env/cwd, so mirror the + // out-of-process connection's effective env and cwd onto the real process + // before swapping transports. Tests that pin a specific transport (TCP/URI or + // custom stdio path/args) or configure per-client telemetry are left on their + // transport, mirroring the Node/.NET harnesses. if c.inProcess && c.shouldUseInProcess(options) { - c.applyInProcessEnvironment(options.Env, options.WorkingDirectory) + conn := options.Connection.(copilot.StdioConnection) + c.applyInProcessEnvironment(conn.Env, conn.WorkingDirectory) options.Connection = copilot.InProcessConnection{} - options.Env = nil - options.WorkingDirectory = "" } return copilot.NewClient(options) @@ -431,8 +432,8 @@ func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.C // shouldUseInProcess reports whether a client built from options should be hosted // in-process for the inprocess matrix cell. Only the harness default stdio -// connection is swapped; a test that pins a custom stdio path/args/env or a -// TCP/URI connection is exercising behavior that must stay on its own transport. +// connection is swapped; a test that pins a custom stdio path/args or a TCP/URI +// connection is exercising behavior that must stay on its own transport. // // Options the in-process runtime cannot support (per-client telemetry, an LLM // inference provider) are NOT silently downgraded here — the affected tests skip @@ -443,7 +444,7 @@ func (c *TestContext) shouldUseInProcess(options *copilot.ClientOptions) bool { if !ok { return false } - return s.Path == c.CLIPath && len(s.Args) == 0 && s.Env == nil + return s.Path == c.CLIPath && len(s.Args) == 0 } func fileExists(path string) bool { diff --git a/go/types.go b/go/types.go index 4a45a4019c..0e723c057d 100644 --- a/go/types.go +++ b/go/types.go @@ -28,35 +28,41 @@ type RuntimeConnection interface { runtimeConnection() } -// childProcessConnection is implemented by the connection types that spawn a -// runtime child process ([StdioConnection] and [TCPConnection]). It exposes the -// per-connection environment so the client can resolve and validate it uniformly -// regardless of the specific child-process transport. -type childProcessConnection interface { +// outOfProcessConnection is implemented by the connection types that spawn and +// manage an out-of-process runtime ([StdioConnection] and [TCPConnection]). It +// exposes per-connection process settings so the client can resolve them +// uniformly regardless of the specific out-of-process transport. +type outOfProcessConnection interface { RuntimeConnection connEnv() []string + connWorkingDirectory() string } -// StdioConnection spawns a runtime child process and communicates over its +// StdioConnection spawns and manages an out-of-process runtime over its // stdin/stdout pipes. This is the default when no connection is configured. type StdioConnection struct { // Path is the runtime executable. When empty, the bundled runtime is used. Path string // Args are extra command-line arguments inserted before SDK-managed args. Args []string + // WorkingDirectory is the working directory for the runtime process. When + // empty, the current process working directory is inherited. + WorkingDirectory string // Env are the environment variables for the runtime process, each of the - // form "KEY=VALUE". When set, these take precedence over - // [ClientOptions.Env]; setting both is rejected. When nil, the client-level - // env (or the current process environment) is used. + // form "KEY=VALUE". When nil, the current process environment is inherited. + // If Env contains duplicate keys, only the last value for each key is used. Env []string } func (StdioConnection) runtimeConnection() {} func (c StdioConnection) connEnv() []string { return c.Env } +func (c StdioConnection) connWorkingDirectory() string { + return c.WorkingDirectory +} -// TCPConnection spawns a runtime child process that listens on a TCP socket -// and connects to it. +// TCPConnection spawns and manages an out-of-process runtime that listens on a +// TCP socket and connects to it. type TCPConnection struct { // Port is the TCP port the runtime listens on. 0 (the default) lets the // runtime pick a free port; the chosen port is then available via @@ -70,16 +76,21 @@ type TCPConnection struct { Path string // Args are extra command-line arguments inserted before SDK-managed args. Args []string + // WorkingDirectory is the working directory for the runtime process. When + // empty, the current process working directory is inherited. + WorkingDirectory string // Env are the environment variables for the runtime process, each of the - // form "KEY=VALUE". When set, these take precedence over - // [ClientOptions.Env]; setting both is rejected. When nil, the client-level - // env (or the current process environment) is used. + // form "KEY=VALUE". When nil, the current process environment is inherited. + // If Env contains duplicate keys, only the last value for each key is used. Env []string } func (TCPConnection) runtimeConnection() {} func (c TCPConnection) connEnv() []string { return c.Env } +func (c TCPConnection) connWorkingDirectory() string { + return c.WorkingDirectory +} // URIConnection connects to an already-running runtime at the given URL. // The SDK does not spawn a process in this mode. @@ -96,13 +107,13 @@ func (URIConnection) runtimeConnection() {} // InProcessConnection hosts the Copilot runtime in-process by loading its native // runtime library (a Rust cdylib) and driving JSON-RPC over the library's C ABI, -// instead of spawning a runtime child process. +// instead of spawning and managing an out-of-process runtime. // // Because the runtime is loaded into the calling process, per-client -// environment, working directory, and telemetry cannot be represented and are -// rejected by [NewClient] (see [ClientOptions]). Set those via the host process -// environment instead, or use a child-process transport ([StdioConnection] / -// [TCPConnection]). +// telemetry cannot be represented and is rejected by [NewClient] (see +// [ClientOptions]). Process-scoped settings such as environment variables and +// working directory belong on an out-of-process connection +// ([StdioConnection]/[TCPConnection]) instead. // // Experimental: the in-process transport is experimental and its API and // behavior may change in a future release. Build the application with the @@ -116,11 +127,11 @@ func (InProcessConnection) runtimeConnection() {} type ClientOptions struct { // Connection describes how to connect to the Copilot runtime. When nil, // COPILOT_SDK_DEFAULT_CONNECTION may select "inprocess" or "stdio"; - // when unset, defaults to an empty [StdioConnection]. + // when unset, defaults to an empty [StdioConnection]. Process-scoped + // settings such as WorkingDirectory and Env are configured on the + // out-of-process connection ([StdioConnection] / [TCPConnection]), not on + // ClientOptions, because they do not apply to [InProcessConnection]. Connection RuntimeConnection - // WorkingDirectory is the working directory for the runtime process. - // If empty, inherits the current process's working directory. - WorkingDirectory string // BaseDirectory is the base directory for Copilot data (session state, // config, etc.). Sets the COPILOT_HOME environment variable on the // spawned runtime. When empty, the runtime defaults to ~/.copilot. @@ -138,16 +149,6 @@ type ClientOptions struct { // uses its own default level; the SDK does not pass --log-level. // Recognized values: "none", "error", "warning", "info", "debug", "all". LogLevel string - // Env are the environment variables for the runtime process (default: - // inherits from current process). Each entry is of the form "KEY=VALUE". - // If Env contains duplicate keys, only the last value for each key is used. - // - // For child-process transports ([StdioConnection] / [TCPConnection]) the - // per-connection Env, when set, takes precedence over this field; setting - // both is rejected. Env is not supported with [InProcessConnection] (the - // runtime shares this process's single environment block) and is rejected - // by [NewClient]. - Env []string // GitHubToken is the GitHub token to use for authentication. // When provided, the token is passed to the runtime via environment // variable. This takes priority over other authentication methods. diff --git a/java/README.md b/java/README.md index 405e3bd379..8407a59c89 100644 --- a/java/README.md +++ b/java/README.md @@ -176,7 +176,16 @@ and `setExcludedTools(...)`, prefer the source-qualified filter form `DefaultAgentConfig.setExcludedTools(...)`, use `-` directly. -`CopilotClientOptions.setCwd(...)` sets the runtime process working directory, which otherwise inherits the current process working directory. `SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. +Process-scoped settings are configured on the out-of-process connection (`StdioRuntimeConnection`/`TcpRuntimeConnection`), not on `CopilotClientOptions`, because they do not apply to in-process (FFI) hosting: + +```java +var options = new CopilotClientOptions().setConnection( + RuntimeConnection.forStdio() + .setWorkingDirectory("/srv/app") + .setEnvironment(Map.of("KEY", "value"))); +``` + +`SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. `SessionConfig.setAskUserVariant(AskUserVariant.ELICITATION)` selects the structured form-based `ask_user` tool when an elicitation handler is also set. diff --git a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java index ff0d121945..ba8e09fa2d 100644 --- a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java +++ b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; @@ -22,6 +23,9 @@ import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; +import com.github.copilot.rpc.StdioRuntimeConnection; +import com.github.copilot.rpc.TcpRuntimeConnection; /** * Manages the lifecycle of the Copilot CLI server process. @@ -35,12 +39,18 @@ final class CliServerManager { private static final int STDERR_READER_JOIN_TIMEOUT_MS = 5000; private final CopilotClientOptions options; + private final RuntimeConnection runtimeConnection; private final StringBuilder stderrBuffer = new StringBuilder(); private volatile Thread stderrThread; private String connectionToken; CliServerManager(CopilotClientOptions options) { + this(options, options.getConnection()); + } + + CliServerManager(CopilotClientOptions options, RuntimeConnection runtimeConnection) { this.options = options; + this.runtimeConnection = runtimeConnection; } /** @@ -120,8 +130,9 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { // doesn't provide explicit CREATE_NO_WINDOW flags like native Windows APIs, // but the default behavior is appropriate for most use cases. - if (options.getCwd() != null) { - pb.directory(new File(options.getCwd())); + String workingDirectory = getWorkingDirectory(); + if (workingDirectory != null) { + pb.directory(new File(workingDirectory)); } configureProcessEnvironment(pb); @@ -269,9 +280,10 @@ private List resolveCliCommand(String cliPath, List args) { } void configureProcessEnvironment(ProcessBuilder pb) { - if (options.getEnvironment() != null) { + Map environment = getProcessEnvironment(); + if (environment != null) { pb.environment().clear(); - pb.environment().putAll(options.getEnvironment()); + pb.environment().putAll(environment); } pb.environment().remove("NODE_DEBUG"); @@ -325,7 +337,7 @@ RuntimeLaunch resolveCliLaunch(String inheritedCliPath) throws IOException { return new RuntimeLaunch(options.getCliPath()); } - var environment = options.getEnvironment(); + var environment = getProcessEnvironment(); String envCliPath = environment != null ? environment.get(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV) : inheritedCliPath; @@ -337,6 +349,26 @@ RuntimeLaunch resolveCliLaunch(String inheritedCliPath) throws IOException { return new RuntimeLaunch(wrapper.toString()); } + private Map getProcessEnvironment() { + if (runtimeConnection instanceof StdioRuntimeConnection stdio) { + return stdio.getEnvironment(); + } + if (runtimeConnection instanceof TcpRuntimeConnection tcp) { + return tcp.getEnvironment(); + } + return null; + } + + private String getWorkingDirectory() { + if (runtimeConnection instanceof StdioRuntimeConnection stdio) { + return stdio.getWorkingDirectory(); + } + if (runtimeConnection instanceof TcpRuntimeConnection tcp) { + return tcp.getWorkingDirectory(); + } + return null; + } + static URI parseCliUrl(String url) { // If it's just a port number, treat as localhost try { diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index 9f7d8ebcf8..c3920e835c 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -235,7 +235,7 @@ public CopilotClient(CopilotClientOptions options) { this.executor = executorProvider.get(); this.executorCanBeShutdown = executorProvider.canBeShutdown(); - this.serverManager = new CliServerManager(this.options); + this.serverManager = new CliServerManager(this.options, this.runtimeConnection); this.serverManager.setConnectionToken(this.effectiveConnectionToken); } @@ -398,22 +398,20 @@ private static void applyConnectionArgs(CopilotClientOptions options, List 0, "use the typed client options instead"); } diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index c67947bf48..30c067f71c 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -7,9 +7,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; @@ -35,8 +33,8 @@ *

Example Usage

* *
{@code
- * var options = new CopilotClientOptions().setCliPath("/usr/local/bin/copilot").setLogLevel("debug")
- * 		.setAutoStart(true);
+ * var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio("/usr/local/bin/copilot"))
+ * 		.setLogLevel("debug").setAutoStart(true);
  *
  * var client = new CopilotClient(options);
  * }
@@ -57,8 +55,6 @@ public class CopilotClientOptions { private RuntimeConnection connection; private ClientInfo clientInfo; private String copilotHome; - private String cwd; - private Map environment; private Executor executor; private String gitHubToken; private String logLevel = "info"; @@ -267,6 +263,12 @@ public RuntimeConnection getConnection() { * throw {@link IllegalArgumentException}. Values that match what the connection * implies are accepted, so the same options instance can be reused across * multiple client constructions. + *

+ * Process-scoped settings such as {@code workingDirectory} and + * {@code environment} are configured on the out-of-process connection + * ({@link StdioRuntimeConnection}/{@link TcpRuntimeConnection}), not on + * {@code CopilotClientOptions}, because they do not apply to in-process (FFI) + * hosting. * * @param connection * the connection, or {@code null} to infer the transport from the @@ -310,61 +312,6 @@ public CopilotClientOptions setCopilotHome(String copilotHome) { return this; } - /** - * Gets the working directory for the CLI process. - * - * @return the working directory path - */ - public String getCwd() { - return cwd; - } - - /** - * Sets the working directory for the CLI process. - * - * @param cwd - * the working directory path, or {@code null} to clear - * @return this options instance for method chaining - */ - public CopilotClientOptions setCwd(String cwd) { - this.cwd = cwd; - return this; - } - - /** - * Gets the environment variables for the CLI process. - *

- * Returns a shallow copy of the internal map, or {@code null} if no environment - * has been set. - * - * @return a copy of the environment variables map, or {@code null} - */ - public Map getEnvironment() { - return environment != null ? new HashMap<>(environment) : null; - } - - /** - * Sets environment variables to pass to the CLI process. - *

- * When set, these environment variables replace the inherited environment. A - * shallow copy of the provided map is stored. If {@code null} or empty, the - * existing environment is cleared. - * - * @param environment - * the environment variables map, or {@code null}/empty to clear - * @return this options instance for method chaining - */ - public CopilotClientOptions setEnvironment(Map environment) { - if (environment == null || environment.isEmpty()) { - if (this.environment != null) { - this.environment.clear(); - } - } else { - this.environment = new HashMap<>(environment); - } - return this; - } - /** * Gets the executor used for internal asynchronous operations. *

@@ -843,9 +790,8 @@ public CopilotClientOptions setUseStdio(boolean useStdio) { * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

* Array properties (like {@code cliArgs}) are copied into new arrays so that - * modifications to the clone do not affect the original. The - * {@code environment} map is also copied to a new map instance. Other - * reference-type properties are shared between the original and clone. + * modifications to the clone do not affect the original. Other reference-type + * properties are shared between the original and clone. * * @return a clone of this options instance */ @@ -863,8 +809,6 @@ public CopilotClientOptions clone() { copy.connection = this.connection; copy.clientInfo = this.clientInfo; copy.copilotHome = this.copilotHome; - copy.cwd = this.cwd; - copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; copy.executor = this.executor; copy.gitHubToken = this.gitHubToken; copy.logLevel = this.logLevel; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java index 274f8b89dc..16fe35fd59 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/InProcessRuntimeConnection.java @@ -12,10 +12,14 @@ * transport. Construct with {@link RuntimeConnection#forInProcess()}. *

* The in-process runtime is self-contained: it carries everything it needs and - * requires no external installation. Because it runs inside the host process, - * per-client process settings ({@code environment}, {@code telemetry}, - * {@code cwd}, and {@code cliArgs}) are rejected; configure those on the host - * process instead, or use a child-process connection. + * requires no external installation. Process-scoped settings such as + * {@code workingDirectory} and {@code environment} are configured on + * out-of-process connections + * ({@link StdioRuntimeConnection}/{@link TcpRuntimeConnection}), not on + * {@link CopilotClientOptions}, because they do not apply to in-process (FFI) + * hosting. Other per-client process settings such as {@code telemetry} and + * {@code cliArgs} are also rejected here; configure those on the host process + * instead, or use a child-process connection. * * @since 1.0.0 */ diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java index a0c8eec5c6..1a26afe334 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/RuntimeConnection.java @@ -15,7 +15,8 @@ * *

{@code
  * // Spawn a runtime child process and talk over stdin/stdout (the default).
- * new CopilotClientOptions().setConnection(RuntimeConnection.forStdio());
+ * new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setWorkingDirectory("/srv/app")
+ * 		.setEnvironment(java.util.Map.of("KEY", "value")));
  *
  * // Spawn a runtime child process listening on a TCP socket.
  * new CopilotClientOptions().setConnection(RuntimeConnection.forTcp().setPath("/usr/local/bin/copilot"));
diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java
index 7d0923e0fe..840dd69739 100644
--- a/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java
+++ b/java/sdk/src/main/java/com/github/copilot/rpc/StdioRuntimeConnection.java
@@ -5,7 +5,9 @@
 package com.github.copilot.rpc;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import com.github.copilot.CopilotExperimental;
 
@@ -13,6 +15,10 @@
  * Spawns a runtime child process and communicates over its stdin/stdout.
  * Construct with {@link RuntimeConnection#forStdio()} or
  * {@link RuntimeConnection#forStdio(String)}.
+ * 

+ * Process-scoped settings are configured on this out-of-process connection, not + * on {@link CopilotClientOptions}, because they do not apply to in-process + * (FFI) hosting. * * @since 1.0.0 */ @@ -20,7 +26,9 @@ public final class StdioRuntimeConnection extends RuntimeConnection { private String path; + private String workingDirectory; private List args; + private Map environment; StdioRuntimeConnection() { } @@ -48,6 +56,29 @@ public StdioRuntimeConnection setPath(String path) { return this; } + /** + * Returns the working directory for the spawned runtime process. + * + * @return the working directory path, or {@code null} to inherit the current + * process working directory + */ + public String getWorkingDirectory() { + return workingDirectory; + } + + /** + * Sets the working directory for the spawned runtime process. + * + * @param workingDirectory + * the working directory path, or {@code null} to inherit the current + * process working directory + * @return this instance for method chaining + */ + public StdioRuntimeConnection setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + /** * Returns the extra command-line arguments passed to the runtime process. * @@ -68,4 +99,38 @@ public StdioRuntimeConnection setArgs(List args) { this.args = args == null ? null : new ArrayList<>(args); return this; } + + /** + * Returns the environment variables for the spawned runtime process. + *

+ * Returns a shallow copy of the internal map, or {@code null} if no environment + * has been set. + * + * @return a copy of the environment variables map, or {@code null} + */ + public Map getEnvironment() { + return environment != null ? new HashMap<>(environment) : null; + } + + /** + * Sets environment variables to pass to the spawned runtime process. + *

+ * When set, these environment variables replace the inherited environment. A + * shallow copy of the provided map is stored. If {@code null} or empty, the + * existing environment is cleared. + * + * @param environment + * the environment variables map, or {@code null}/empty to clear + * @return this instance for method chaining + */ + public StdioRuntimeConnection setEnvironment(Map environment) { + if (environment == null || environment.isEmpty()) { + if (this.environment != null) { + this.environment.clear(); + } + } else { + this.environment = new HashMap<>(environment); + } + return this; + } } diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java b/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java index 648321a219..77bbbe87b4 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/TcpRuntimeConnection.java @@ -5,13 +5,19 @@ package com.github.copilot.rpc; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import com.github.copilot.CopilotExperimental; /** * Spawns a runtime child process listening on a TCP socket and connects to it. * Construct with {@link RuntimeConnection#forTcp()}. + *

+ * Process-scoped settings are configured on this out-of-process connection, not + * on {@link CopilotClientOptions}, because they do not apply to in-process + * (FFI) hosting. * * @since 1.0.0 */ @@ -19,9 +25,11 @@ public final class TcpRuntimeConnection extends RuntimeConnection { private String path; + private String workingDirectory; private int port; private String connectionToken; private List args; + private Map environment; TcpRuntimeConnection() { } @@ -49,6 +57,29 @@ public TcpRuntimeConnection setPath(String path) { return this; } + /** + * Returns the working directory for the spawned runtime process. + * + * @return the working directory path, or {@code null} to inherit the current + * process working directory + */ + public String getWorkingDirectory() { + return workingDirectory; + } + + /** + * Sets the working directory for the spawned runtime process. + * + * @param workingDirectory + * the working directory path, or {@code null} to inherit the current + * process working directory + * @return this instance for method chaining + */ + public TcpRuntimeConnection setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + /** * Returns the TCP port the spawned runtime listens on. * @@ -113,4 +144,38 @@ public TcpRuntimeConnection setArgs(List args) { this.args = args == null ? null : new ArrayList<>(args); return this; } + + /** + * Returns the environment variables for the spawned runtime process. + *

+ * Returns a shallow copy of the internal map, or {@code null} if no environment + * has been set. + * + * @return a copy of the environment variables map, or {@code null} + */ + public Map getEnvironment() { + return environment != null ? new HashMap<>(environment) : null; + } + + /** + * Sets environment variables to pass to the spawned runtime process. + *

+ * When set, these environment variables replace the inherited environment. A + * shallow copy of the provided map is stored. If {@code null} or empty, the + * existing environment is cleared. + * + * @param environment + * the environment variables map, or {@code null}/empty to clear + * @return this instance for method chaining + */ + public TcpRuntimeConnection setEnvironment(Map environment) { + if (environment == null || environment.isEmpty()) { + if (this.environment != null) { + this.environment.clear(); + } + } else { + this.environment = new HashMap<>(environment); + } + return this; + } } diff --git a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java index b41f99adb7..54cf5ef7a5 100644 --- a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java @@ -17,6 +17,7 @@ import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.TelemetryConfig; /** @@ -49,8 +50,8 @@ void inheritedCliPathEnvironmentOverrideDoesNotRequireRuntimeBundle() throws Exc void configuredEnvironmentCliPathOverridesInheritedEnvironment() throws Exception { Path inherited = tempDir.resolve("inherited-copilot-runtime"); Path configured = tempDir.resolve("configured-copilot-runtime"); - var options = new CopilotClientOptions() - .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString())); + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio() + .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString()))); var manager = new CliServerManager(options); assertEquals(configured.toString(), manager.resolveCliLaunch(inherited.toString()).executable()); @@ -60,8 +61,8 @@ void configuredEnvironmentCliPathOverridesInheritedEnvironment() throws Exceptio void explicitCliPathOverridesEnvironment() throws Exception { Path explicit = tempDir.resolve("explicit-copilot-runtime"); Path configured = tempDir.resolve("configured-copilot-runtime"); - var options = new CopilotClientOptions().setCliPath(explicit.toString()) - .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString())); + var options = new CopilotClientOptions().setCliPath(explicit.toString()).setConnection(RuntimeConnection + .forStdio().setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString()))); var manager = new CliServerManager(options); assertEquals(explicit.toString(), manager.resolveCliLaunch("inherited-copilot-runtime").executable()); diff --git a/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java b/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java index a2561434d8..42649749dc 100644 --- a/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/ClientOptionsE2ETest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -22,6 +23,7 @@ import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ProviderConfig; import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; class ClientOptionsE2ETest { @@ -196,8 +198,8 @@ static FakeStdioCli create() throws IOException { } CopilotClient createClient() { - var options = new CopilotClientOptions().setCliPath(script.toString()) - .setCliArgs(new String[]{"--capture-file", capture.toString()}).setCwd(workDir.toString()) + var options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio(script.toString()) + .setWorkingDirectory(workDir.toString()).setArgs(List.of("--capture-file", capture.toString()))) .setUseLoggedInUser(false); return new CopilotClient(options); } diff --git a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java index 2433e5f67a..6f61cc6dbb 100644 --- a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -28,9 +28,12 @@ import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.ModelInfo; import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.StdioRuntimeConnection; import com.github.copilot.rpc.SystemMessageConfig; import com.github.copilot.rpc.TelemetryConfig; +import com.github.copilot.rpc.TcpRuntimeConnection; class ConfigCloneTest { @@ -81,19 +84,16 @@ void copilotClientOptionsArrayIndependence() { } @Test - void copilotClientOptionsEnvironmentIndependence() { - CopilotClientOptions original = new CopilotClientOptions(); + void stdioRuntimeConnectionEnvironmentIndependence() { + StdioRuntimeConnection original = RuntimeConnection.forStdio(); Map env = new HashMap<>(); env.put("KEY1", "value1"); original.setEnvironment(env); - CopilotClientOptions cloned = original.clone(); - - // Mutate the source map after set — should not affect original or clone + // Mutate the source map after set — should not affect the connection env.put("KEY2", "value2"); assertEquals(1, original.getEnvironment().size()); - assertEquals(1, cloned.getEnvironment().size()); // getEnvironment() returns a copy, so mutating it should not affect internals original.getEnvironment().put("KEY3", "value3"); @@ -378,25 +378,25 @@ void copilotClientOptionsSetCliArgsNullClearsExisting() { } @Test - void copilotClientOptionsSetEnvironmentNullClearsExisting() { - CopilotClientOptions opts = new CopilotClientOptions(); - opts.setEnvironment(Map.of("KEY", "VALUE")); - assertNotNull(opts.getEnvironment()); + void tcpRuntimeConnectionSetEnvironmentNullClearsExisting() { + TcpRuntimeConnection connection = RuntimeConnection.forTcp(); + connection.setEnvironment(Map.of("KEY", "VALUE")); + assertNotNull(connection.getEnvironment()); // Setting null should clear the existing map (clears in-place → returns empty // map) - opts.setEnvironment(null); - var env = opts.getEnvironment(); + connection.setEnvironment(null); + var env = connection.getEnvironment(); assertTrue(env == null || env.isEmpty()); } @Test - void copilotClientOptionsSetCwdNullClearsExisting() { - CopilotClientOptions opts = new CopilotClientOptions().setCwd("/tmp"); + void stdioRuntimeConnectionSetWorkingDirectoryNullClearsExisting() { + StdioRuntimeConnection connection = RuntimeConnection.forStdio().setWorkingDirectory("/tmp"); - opts.setCwd(null); + connection.setWorkingDirectory(null); - assertNull(opts.getCwd()); + assertNull(connection.getWorkingDirectory()); } @Test diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java index 46223d56d8..858b9735c0 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotClientTransportTest.java @@ -20,7 +20,6 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -208,9 +207,7 @@ private static void assertConflict(CopilotClientOptions options, String optionNa @Test void inProcessRejectsPerProcessOptions() { - assertInProcessRejected(new CopilotClientOptions().setEnvironment(Map.of("FOO", "bar")), "Environment"); assertInProcessRejected(new CopilotClientOptions().setTelemetry(new TelemetryConfig()), "Telemetry"); - assertInProcessRejected(new CopilotClientOptions().setCwd("/tmp"), "Cwd"); assertInProcessRejected(new CopilotClientOptions().setCliArgs(new String[]{"--extra"}), "CliArgs"); } @@ -218,13 +215,10 @@ void inProcessRejectsPerProcessOptions() { void e2eContextClearsInProcessIncompatibleOptions() throws Exception { try (var context = E2ETestContext.create()) { var options = new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()) - .setEnvironment(Map.of("TEST_KEY", "test-value")).setCwd(context.getWorkDir().toString()) .setCliArgs(new String[]{"--subprocess-only"}); try (var client = context.createClient(options)) { assertInstanceOf(InProcessRuntimeConnection.class, client.getRuntimeConnection()); - assertTrue(options.getEnvironment() == null || options.getEnvironment().isEmpty()); - assertEquals(null, options.getCwd()); assertTrue(options.getCliArgs() == null || options.getCliArgs().length == 0); } } diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java b/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java index fa2a6354be..9bf66700d5 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotRequestTestSupport.java @@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; /** * Shared synthetic-upstream helpers for the {@link CopilotRequestHandler} e2e @@ -72,8 +73,9 @@ static CopilotClient newLlmClient(E2ETestContext ctx, CopilotRequestHandler hand env.put(entry.substring(0, eq), entry.substring(eq + 1)); } } - return ctx.createClient( - new CopilotClientOptions().setCliPath(ctx.getCliPath()).setEnvironment(env).setRequestHandler(handler)); + return ctx.createClient(new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio(ctx.getCliPath()).setEnvironment(env)) + .setRequestHandler(handler)); } /** diff --git a/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java b/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java index a6bec65d2c..c62466531c 100644 --- a/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java +++ b/java/sdk/src/test/java/com/github/copilot/E2ETestContext.java @@ -22,6 +22,8 @@ import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.InProcessRuntimeConnection; import com.github.copilot.rpc.RuntimeConnection; +import com.github.copilot.rpc.StdioRuntimeConnection; +import com.github.copilot.rpc.TcpRuntimeConnection; /** * E2E test context that manages the test environment including the CapiProxy, @@ -419,8 +421,9 @@ public CopilotClient createClient() { * options for this test context. * * @param options - * options to apply; environment and cliPath will be set from the - * context if not already set + * options to apply; the out-of-process connection environment, + * working directory, and CLI path will be set from the context if + * not already set * @return a new CopilotClient */ public CopilotClient createClient(CopilotClientOptions options) { @@ -437,11 +440,9 @@ public CopilotClient createClient(CopilotClientOptions options) { private CopilotClient applyContextOptions(CopilotClientOptions options) { if (isInProcessMode(options)) { - InProcessEnvGuard guard = new InProcessEnvGuard(buildInProcessEnvironment(options)); + InProcessEnvGuard guard = new InProcessEnvGuard(buildInProcessEnvironment()); inProcessEnvGuards.add(guard); try { - options.setEnvironment(null); - options.setCwd(null); options.setCliArgs(null); return new CopilotClient(options, guard::close); } catch (RuntimeException e) { @@ -449,15 +450,7 @@ private CopilotClient applyContextOptions(CopilotClientOptions options) { throw e; } } - if (options.getCliPath() == null) { - options.setCliPath(cliPath); - } - if (options.getCwd() == null) { - options.setCwd(workDir.toString()); - } - if (options.getEnvironment() == null || options.getEnvironment().isEmpty()) { - options.setEnvironment(getEnvironment()); - } + applyOutOfProcessContext(options); return null; } @@ -474,14 +467,49 @@ private boolean isInProcessMode(CopilotClientOptions options) { return defaultConnection != null && "inprocess".equalsIgnoreCase(defaultConnection.trim()); } - private Map buildInProcessEnvironment(CopilotClientOptions options) { - Map env = new HashMap<>(getEnvironment()); - Map optionEnvironment = options.getEnvironment(); - if (optionEnvironment != null && !optionEnvironment.isEmpty()) { - env.putAll(optionEnvironment); - options.setEnvironment(null); + private Map buildInProcessEnvironment() { + return new HashMap<>(getEnvironment()); + } + + private void applyOutOfProcessContext(CopilotClientOptions options) { + RuntimeConnection connection = options.getConnection(); + if (connection == null) { + connection = CopilotClient.resolveDefaultConnection(options, + System.getenv(CopilotClient.DEFAULT_CONNECTION_ENV_VAR)); + if (connection instanceof StdioRuntimeConnection || connection instanceof TcpRuntimeConnection) { + options.setConnection(connection); + } + } + + if (connection instanceof StdioRuntimeConnection stdio) { + if (stdio.getPath() == null) { + stdio.setPath(cliPath); + } + if (stdio.getWorkingDirectory() == null) { + stdio.setWorkingDirectory(workDir.toString()); + } + if (stdio.getEnvironment() == null || stdio.getEnvironment().isEmpty()) { + stdio.setEnvironment(getEnvironment()); + } + return; + } + + if (connection instanceof TcpRuntimeConnection tcp) { + if (tcp.getPath() == null) { + tcp.setPath(cliPath); + } + if (tcp.getWorkingDirectory() == null) { + tcp.setWorkingDirectory(workDir.toString()); + } + if (tcp.getEnvironment() == null || tcp.getEnvironment().isEmpty()) { + tcp.setEnvironment(getEnvironment()); + } + return; + } + + if (options.getCliPath() == null && (options.getCliUrl() == null || options.getCliUrl().isEmpty())) { + options.setCliPath(cliPath); } - return env; } /** diff --git a/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java b/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java index 942b2efe6d..649c1558d9 100644 --- a/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ModeHandlersTest.java @@ -27,6 +27,7 @@ import com.github.copilot.rpc.ExitPlanModeResult; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; /** @@ -59,7 +60,8 @@ private CopilotClient createAuthenticatedClient() { Map env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_DEBUG_GITHUB_API_URL", ctx.getProxyUrl()); - return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + return ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env))); } private void configureAuthenticatedUser(String testName) throws Exception { diff --git a/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java b/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java index 9e5cd1b324..a17deda73b 100644 --- a/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java +++ b/java/sdk/src/test/java/com/github/copilot/PerSessionAuthTest.java @@ -16,6 +16,7 @@ import com.github.copilot.generated.rpc.SessionGitHubAuthGetStatusResult; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; /** @@ -50,7 +51,8 @@ static void teardown() throws Exception { private CopilotClient createAuthTestClient() { Map env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_DEBUG_GITHUB_API_URL", ctx.getProxyUrl()); - return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + return ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env))); } private void setupCopilotUsers() throws Exception { @@ -123,8 +125,10 @@ void shouldBeUnauthenticatedWithoutToken() throws Exception { // Build the client directly (not via ctx.createClient) so the context's // default GitHub token is not auto-injected and useLoggedInUser is disabled. - CopilotClientOptions options = new CopilotClientOptions().setCliPath(ctx.getCliPath()) - .setCwd(ctx.getWorkDir().toString()).setEnvironment(env).setUseLoggedInUser(false); + CopilotClientOptions options = new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio(ctx.getCliPath()) + .setWorkingDirectory(ctx.getWorkDir().toString()).setEnvironment(env)) + .setUseLoggedInUser(false); try (CopilotClient client = new CopilotClient(options)) { CopilotSession session = client diff --git a/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java b/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java index 1e302982ef..b2930fb267 100644 --- a/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/ProviderEndpointE2ETest.java @@ -20,6 +20,7 @@ import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ProviderConfig; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; /** @@ -48,7 +49,8 @@ static void teardown() throws Exception { private CopilotClient createProviderEndpointClient() { Map env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT", "true"); - return ctx.createClient(new CopilotClientOptions().setEnvironment(env)); + return ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env))); } @Test diff --git a/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java b/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java index 6c9753025a..e1cc579cf1 100644 --- a/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/RpcServerE2ETest.java @@ -59,6 +59,7 @@ import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.InfiniteSessionConfig; import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; class RpcServerE2ETest { @@ -200,7 +201,8 @@ void testShouldAddSecretFilterValues() throws Exception { var env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_ENABLE_SECRET_FILTERING", "true"); - try (var client = ctx.createClient(new CopilotClientOptions().setEnvironment(env))) { + try (var client = ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env)))) { client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); var secret = "rpc-secret-" + UUID.randomUUID().toString().replace("-", ""); diff --git a/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java b/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java index 3b06e4d1f7..9eea0f1ad8 100644 --- a/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java @@ -20,6 +20,7 @@ import com.github.copilot.generated.rpc.UserSettingMetadata; import com.github.copilot.generated.rpc.UserSettingsSetParams; import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.RuntimeConnection; class RpcServerMiscE2ETest { @@ -82,9 +83,10 @@ void testShouldLoginListGetCurrentAuthAndLogoutAccount() throws Exception { env.put("GITHUB_TOKEN", ""); env.put("COPILOT_SDK_AUTH_TOKEN", ""); - try (var client = new CopilotClient( - new CopilotClientOptions().setCliPath(ctx.getCliPath()).setCwd(ctx.getWorkDir().toString()) - .setEnvironment(env).setGitHubToken("").setUseLoggedInUser(false))) { + try (var client = new CopilotClient(new CopilotClientOptions() + .setConnection(RuntimeConnection.forStdio(ctx.getCliPath()) + .setWorkingDirectory(ctx.getWorkDir().toString()).setEnvironment(env)) + .setGitHubToken("").setUseLoggedInUser(false))) { client.start().get(30, TimeUnit.SECONDS); var initial = client.getRpc().account.getCurrentAuth().get(30, TimeUnit.SECONDS); diff --git a/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java index c2ad45ff24..b485f05701 100644 --- a/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java @@ -25,6 +25,7 @@ import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.PostToolUseHookOutput; import com.github.copilot.rpc.PreToolUseHookOutput; +import com.github.copilot.rpc.RuntimeConnection; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; @@ -42,8 +43,9 @@ void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exce HashMap env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true"); - try (CopilotClient client = ctx - .createClient(new CopilotClientOptions().setEnvironment(env).setRequestHandler(requestHandler))) { + try (CopilotClient client = ctx.createClient( + new CopilotClientOptions().setConnection(RuntimeConnection.forStdio().setEnvironment(env)) + .setRequestHandler(requestHandler))) { CopilotSession session = client .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { diff --git a/nodejs/README.md b/nodejs/README.md index e3d76ba6e4..7ff2b7d680 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -105,17 +105,15 @@ new CopilotClient(options?: CopilotClientOptions) **Options:** - `connection?: RuntimeConnection` - How to connect to the Copilot runtime. Construct via the factory functions on `RuntimeConnection`: - - `RuntimeConnection.forStdio({ path?, args?, env? })` (default) — spawn the runtime and communicate over its stdin/stdout. - - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args?, env? })` — spawn the runtime as a TCP server. + - `RuntimeConnection.forStdio({ path?, args?, workingDirectory?, env? })` (default) — spawn the runtime and communicate over its stdin/stdout. + - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args?, workingDirectory?, env? })` — spawn the runtime as a TCP server. - `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). There is no top-level `cliUrl` shortcut; use this factory for URL-based connections. - - `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Because the runtime shares this process, `env`, `telemetry`, and `workingDirectory` are rejected with this transport; set them on the host process instead. - - The child-process transports (`forStdio`/`forTcp`) also accept a per-connection `env`. Set it there or via the top-level `env` option — not both (setting both throws). - - Managed child-process connections materialize the bundled `copilot-runtime` and adjacent `runtime.node`, then launch the wrapper by default. An explicit connection `path` or `COPILOT_CLI_PATH` overrides the bundled runtime. + - `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Client-wide `telemetry` is rejected with this transport; process-scoped launch settings such as `workingDirectory` and `env` belong on out-of-process connections instead. + - `OutOfProcessRuntimeConnection` is the shared shape returned by `forStdio`/`forTcp`. It carries `path`, `args`, `workingDirectory`, and `env`. + - Managed out-of-process connections materialize the bundled `copilot-runtime` and adjacent `runtime.node`, then launch the wrapper by default. An explicit connection `path` or `COPILOT_CLI_PATH` overrides the bundled runtime. - `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`. -- `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd). - `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`. - `logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"` - Log level. When omitted, the runtime uses its own default (currently `"info"`). -- `env?: Record` - Environment variables for the runtime process. When omitted, inherits `process.env`. - `gitHubToken?: string` - GitHub token for authentication. When provided, takes priority over other auth methods. - `useLoggedInUser?: boolean` - Whether to use logged-in user for authentication (default: true, but false when `gitHubToken` is provided). Cannot be used with `RuntimeConnection.forUri`. - `onListModels?: () => Promise | ModelInfo[]` - Optional model-list provider, useful when using a custom provider. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index eb92cf0bed..30dce86113 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -452,12 +452,11 @@ export class CopilotClient { private stderrBuffer: string = ""; // Captures CLI stderr for error messages /** Resolved connection mode chosen in the constructor. */ private connectionConfig: InternalRuntimeConnection; - /** Resolved path to the runtime executable (only used for child-process kinds). */ + /** Resolved path to the runtime executable (only used for out-of-process kinds). */ private resolvedCliPath: string | undefined; - /** Resolved environment passed to the spawned runtime. */ + /** Resolved environment inherited or replaced by the out-of-process runtime. */ private resolvedEnv: Record; private options: { - workingDirectory: string; logLevel?: string; gitHubToken?: string; useLoggedInUser: boolean; @@ -587,7 +586,11 @@ export class CopilotClient { * * // Use a custom runtime binary * const client = new CopilotClient({ - * connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }), + * connection: RuntimeConnection.forStdio({ + * path: "/usr/local/bin/copilot", + * workingDirectory: "/srv/app", + * env: { MY_VAR: "value" }, + * }), * logLevel: "debug", * }); * ``` @@ -609,38 +612,12 @@ export class CopilotClient { "gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)" ); } - if (conn.kind === "inprocess" && options.workingDirectory !== undefined) { - throw new Error( - "workingDirectory is not supported with RuntimeConnection.forInProcess(): the in-process " + - "transport hosts the runtime in this process, so honoring it would require mutating the " + - "shared process-global cwd. Change the host process's working directory before " + - "constructing the client instead." - ); - } - if (conn.kind === "inprocess" && options.env !== undefined) { - throw new Error( - "env is not supported with RuntimeConnection.forInProcess(): the in-process transport loads " + - "the native runtime into the shared host process, whose single environment block cannot " + - "carry per-client values. Set the variables on the host process environment instead." - ); - } if (conn.kind === "inprocess" && options.telemetry !== undefined) { throw new Error( "telemetry is not supported with RuntimeConnection.forInProcess(): telemetry configuration " + "is lowered to environment variables read by native runtime code running in the shared " + "host process, so per-client telemetry cannot be honored in-process. Configure telemetry " + - "via the host process environment, or use a child-process transport." - ); - } - if ( - (conn.kind === "stdio" || conn.kind === "tcp") && - conn.env !== undefined && - options.env !== undefined - ) { - throw new Error( - "Set environment variables via either the client-level env option or the connection's env " + - "(RuntimeConnection.forStdio/forTcp), not both. Prefer the connection-level env for " + - "child-process transports." + "via the host process environment, or use an out-of-process transport." ); } if (conn.kind === "tcp" && conn.connectionToken !== undefined) { @@ -690,13 +667,11 @@ export class CopilotClient { this.onGitHubTelemetry = options.onGitHubTelemetry; this.setupClientGlobalHandlers(); - // Connection-level env (child-process transports only) takes precedence - // over the client-level env, which falls back to the ambient process env. - // The constructor guard above rejects setting both, so at most one of the - // first two is defined. Mirrors .NET/Python precedence. + // Out-of-process transports can replace the inherited environment. When + // omitted, they inherit the ambient process environment. Mirrors Python/.NET. const connEnv: Record | undefined = conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined; - const effectiveEnv = connEnv ?? options.env ?? process.env; + const effectiveEnv = connEnv ?? process.env; this.resolvedEnv = effectiveEnv; if (conn.kind === "stdio" || conn.kind === "tcp") { const explicitCliPath = conn.path ?? effectiveEnv.COPILOT_CLI_PATH; @@ -711,7 +686,6 @@ export class CopilotClient { this.connectionExtraArgs = [...connArgs]; this.options = { - workingDirectory: options.workingDirectory ?? process.cwd(), logLevel: options.logLevel, gitHubToken: options.gitHubToken, // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. @@ -2547,7 +2521,7 @@ export class CopilotClient { } /** - * Builds the environment for the spawned runtime child process (stdio/TCP): applies + * Builds the environment for the spawned out-of-process runtime (stdio/TCP): applies * the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry * variables on top of the effective env. Not used by the in-process (FFI) transport, * whose worker inherits the host process's ambient environment @@ -2662,17 +2636,21 @@ export class CopilotClient { // For .js files, spawn node explicitly; for executables, spawn directly const isJsFile = this.resolvedCliPath.endsWith(".js"); + const runtimeWorkingDirectory = + ("workingDirectory" in this.connectionConfig + ? this.connectionConfig.workingDirectory + : undefined) ?? process.cwd(); if (isJsFile) { this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], { stdio: stdioConfig, - cwd: this.options.workingDirectory, + cwd: runtimeWorkingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); } else { this.cliProcess = spawn(this.resolvedCliPath, args, { stdio: stdioConfig, - cwd: this.options.workingDirectory, + cwd: runtimeWorkingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 2007679d61..4dbd7ceed9 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -77,7 +77,7 @@ export type { InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, - ChildProcessRuntimeConnection, + OutOfProcessRuntimeConnection, CustomAgentConfig, ElicitationFieldValue, ElicitationHandler, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 0f15749f71..58414341f4 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -155,55 +155,55 @@ export type RuntimeConnection = | UriRuntimeConnection; /** - * Shared shape for the transports that spawn a runtime **child process** + * Shared shape for the transports that spawn an **out-of-process** runtime * ({@link StdioRuntimeConnection} and {@link TcpRuntimeConnection}). */ -export interface ChildProcessRuntimeConnection { +export interface OutOfProcessRuntimeConnection { /** Path to the runtime executable. When omitted, the bundled runtime is used. */ readonly path?: string; /** Extra command-line arguments to pass to the runtime process. */ readonly args?: readonly string[]; /** - * Environment variables for the spawned runtime child process, replacing the - * inherited environment. Cannot be combined with - * {@link CopilotClientOptions.env}; setting both throws when the client is - * constructed. When omitted, the client-level env (or `process.env`) is used. + * Working directory for the spawned runtime process. When omitted, inherits + * the current process working directory. + */ + readonly workingDirectory?: string; + /** + * Environment variables for the spawned runtime process, replacing the + * inherited environment. When omitted, inherits `process.env`. */ readonly env?: Record; } /** - * Spawns a runtime child process and communicates over its stdin/stdout. + * Spawns an out-of-process runtime and communicates over its stdin/stdout. * This is the default if no {@link CopilotClientOptions.connection} is set. */ -export interface StdioRuntimeConnection extends ChildProcessRuntimeConnection { +export interface StdioRuntimeConnection extends OutOfProcessRuntimeConnection { readonly kind: "stdio"; } /** * Hosts the runtime in-process by loading the native runtime library and speaking - * JSON-RPC over its C ABI (FFI), instead of spawning a runtime child process. The + * JSON-RPC over its C ABI (FFI), instead of spawning the runtime out-of-process. The * native host spawns the CLI worker itself. Construct via * {@link RuntimeConnection.forInProcess}. * * @experimental The in-process (FFI) transport is experimental and its behavior may - * change. Per-client options that are lowered to environment variables — including - * {@link CopilotClientOptions.env}, {@link CopilotClientOptions.telemetry}, - * {@link CopilotClientOptions.gitHubToken}, and - * {@link CopilotClientOptions.baseDirectory} — are **not** honored with this - * transport, because the native runtime loads into the shared host process and its - * worker inherits that process's ambient environment. To configure the in-process - * runtime, set the corresponding environment variables on the host process before - * constructing the client. See https://github.com/github/copilot-sdk/issues/1934. + * change. Client-wide {@link CopilotClientOptions.telemetry} is **not** honored with + * this transport, because it lowers to environment variables read by native runtime + * code running in the shared host process. Process-scoped launch settings such as + * working directory and environment live on {@link OutOfProcessRuntimeConnection}, + * which this transport does not use. */ export interface InProcessRuntimeConnection { readonly kind: "inprocess"; } /** - * Spawns a runtime child process that listens on a TCP socket and connects to it. + * Spawns an out-of-process runtime that listens on a TCP socket and connects to it. */ -export interface TcpRuntimeConnection extends ChildProcessRuntimeConnection { +export interface TcpRuntimeConnection extends OutOfProcessRuntimeConnection { readonly kind: "tcp"; /** * TCP port to listen on. `0` (the default) auto-allocates a free port. @@ -236,16 +236,27 @@ export interface UriRuntimeConnection { /** Factory functions for constructing {@link RuntimeConnection} instances. */ export const RuntimeConnection = { /** - * Spawn a runtime child process and communicate over its stdin/stdout. + * Spawn an out-of-process runtime and communicate over its stdin/stdout. * This is the default if no {@link CopilotClientOptions.connection} is set. */ forStdio( - opts: { path?: string; args?: readonly string[]; env?: Record } = {} + opts: { + path?: string; + args?: readonly string[]; + workingDirectory?: string; + env?: Record; + } = {} ): StdioRuntimeConnection { - return { kind: "stdio", path: opts.path, args: opts.args, env: opts.env }; + return { + kind: "stdio", + path: opts.path, + args: opts.args, + workingDirectory: opts.workingDirectory, + env: opts.env, + }; }, /** - * Spawn a runtime child process that listens on a TCP socket and connect to it. + * Spawn an out-of-process runtime that listens on a TCP socket and connect to it. */ forTcp( opts: { @@ -253,6 +264,7 @@ export const RuntimeConnection = { connectionToken?: string; path?: string; args?: readonly string[]; + workingDirectory?: string; env?: Record; } = {} ): TcpRuntimeConnection { @@ -262,6 +274,7 @@ export const RuntimeConnection = { connectionToken: opts.connectionToken, path: opts.path, args: opts.args, + workingDirectory: opts.workingDirectory, env: opts.env, }; }, @@ -275,11 +288,11 @@ export const RuntimeConnection = { /** * Host the runtime in-process over the native runtime library's C ABI (FFI). * - * @experimental Per-client options lowered to environment variables (`env`, - * `telemetry`, `gitHubToken`, `baseDirectory`) are **not** honored in-process; - * the worker inherits the host process's ambient environment. Set the - * corresponding environment variables on the host process instead. See - * https://github.com/github/copilot-sdk/issues/1934. + * @experimental Client-wide `telemetry` is **not** honored in-process because + * it lowers to environment variables read by native runtime code running in + * the shared host process. Process-scoped launch settings belong on + * `RuntimeConnection.forStdio(...)` / `RuntimeConnection.forTcp(...)`, not + * on the in-process transport. */ forInProcess(): InProcessRuntimeConnection { return { kind: "inprocess" }; @@ -364,12 +377,6 @@ export interface CopilotClientOptions { */ mode?: CopilotClientMode; - /** - * Working directory for the runtime process. - * If not set, inherits the current process's working directory. - */ - workingDirectory?: string; - /** * Base directory for Copilot data (session state, config, etc.). * Sets the COPILOT_HOME environment variable on the spawned runtime. @@ -391,11 +398,6 @@ export interface CopilotClientOptions { */ logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"; - /** - * Environment variables to pass to the runtime process. If not set, inherits process.env. - */ - env?: Record; - /** * GitHub token to use for authentication. * When provided, the token is passed to the runtime via environment variable. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3db96ea47b..5f4d879e2a 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -65,24 +65,24 @@ describe("CopilotClient", () => { it.each([ { source: "connection path", - connection: RuntimeConnection.forStdio({ path: "/explicit/copilot" }), - env: {}, + connection: RuntimeConnection.forStdio({ + path: "/explicit/copilot", + env: {}, + }), expected: "/explicit/copilot", }, { source: "COPILOT_CLI_PATH", - connection: RuntimeConnection.forStdio(), - env: { COPILOT_CLI_PATH: "/environment/copilot" }, + connection: RuntimeConnection.forStdio({ + env: { COPILOT_CLI_PATH: "/environment/copilot" }, + }), expected: "/environment/copilot", }, - ])( - "preserves explicit child-process override from $source", - ({ connection, env, expected }) => { - const client = new CopilotClient({ connection, env }); + ])("preserves explicit out-of-process override from $source", ({ connection, expected }) => { + const client = new CopilotClient({ connection }); - expect((client as any).resolvedCliPath).toBe(expected); - } - ); + expect((client as any).resolvedCliPath).toBe(expected); + }); async function startWithMockConnection( builtinPluginDirectories?: readonly string[] @@ -2961,16 +2961,6 @@ describe("CopilotClient", () => { ); }); - it("should throw error when env is used with forInProcess", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forInProcess(), - env: { FOO: "bar" }, - logLevel: "error", - }); - }).toThrow(/env is not supported with RuntimeConnection.forInProcess/); - }); - it("should throw error when telemetry is used with forInProcess", () => { expect(() => { new CopilotClient({ @@ -2981,41 +2971,7 @@ describe("CopilotClient", () => { }).toThrow(/telemetry is not supported with RuntimeConnection.forInProcess/); }); - it("should throw error when workingDirectory is used with forInProcess", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forInProcess(), - workingDirectory: "/tmp", - logLevel: "error", - }); - }).toThrow(/workingDirectory is not supported with RuntimeConnection.forInProcess/); - }); - - it("should throw error when env is set on both the client and a stdio connection", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forStdio({ env: { FOO: "conn" } }), - env: { FOO: "client" }, - logLevel: "error", - }); - }).toThrow( - /Set environment variables via either the client-level env option or the connection/ - ); - }); - - it("should throw error when env is set on both the client and a tcp connection", () => { - expect(() => { - new CopilotClient({ - connection: RuntimeConnection.forTcp({ env: { FOO: "conn" } }), - env: { FOO: "client" }, - logLevel: "error", - }); - }).toThrow( - /Set environment variables via either the client-level env option or the connection/ - ); - }); - - it("should use the connection-level env for child-process transports", () => { + it("should use the connection-level env for out-of-process transports", () => { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ env: { FOO: "from-conn" } }), logLevel: "error", @@ -3023,13 +2979,12 @@ describe("CopilotClient", () => { expect((client as any).resolvedEnv).toEqual({ FOO: "from-conn" }); }); - it("should allow env on the client alone with a child-process transport", () => { + it("should inherit process.env when env is omitted", () => { const client = new CopilotClient({ connection: RuntimeConnection.forStdio(), - env: { FOO: "from-client" }, logLevel: "error", }); - expect((client as any).resolvedEnv).toEqual({ FOO: "from-client" }); + expect((client as any).resolvedEnv).toBe(process.env); }); }); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index 4d261bea52..b846af7933 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -180,9 +180,11 @@ describe("Client options", async () => { it("createSession starts the client lazily", async () => { const client = new CopilotClient({ - workingDirectory: workDir, - env, - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); onTestFinished(async () => { @@ -202,11 +204,11 @@ describe("Client options", async () => { it("should listen on configured tcp port", async () => { const port = await getAvailableTcpPort(); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, port, + workingDirectory: workDir, + env, }), }); onTestFinished(async () => { @@ -235,9 +237,11 @@ describe("Client options", async () => { // a custom cwd to assert that the custom cwd is honored. void defaultClient; const client = new CopilotClient({ - workingDirectory: clientCwd, - env, - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: clientCwd, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); onTestFinished(async () => { @@ -274,11 +278,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, }), baseDirectory: copilotHomeFromOption, gitHubToken: "process-option-token", @@ -389,11 +393,11 @@ describe("Client options", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: workDir, - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); @@ -446,11 +450,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); @@ -595,11 +599,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); @@ -662,11 +666,11 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forStdio({ path: cliPath, args: ["--capture-file", capturePath], + workingDirectory: workDir, + env, }), useLoggedInUser: false, }); diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts index 1aa4875dd4..9c758bb9b1 100644 --- a/nodejs/test/e2e/extension_env_access.e2e.test.ts +++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts @@ -188,14 +188,16 @@ const cliObservations = mkdtempSync(join(tmpdir(), "copilot-env-access-cli-")); const cliResultFile = join(cliObservations, "result"); const cliContext = await createSdkTestContext({ copilotClientOptions: { - connection: RuntimeConnection.forStdio({ path: await getLegacyCliPathForTests() }), - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", - EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", - EXTENSION_RESULT_FILE: cliResultFile, - EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), - EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), - }, + connection: RuntimeConnection.forStdio({ + path: await getLegacyCliPathForTests(), + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", + EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", + EXTENSION_RESULT_FILE: cliResultFile, + EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), + EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), + }, + }), }, }); diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index 98004406f9..180163acb6 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -15,10 +15,12 @@ import { retry } from "./harness/sdkTestHelper.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const factoryTestContext = await createSdkTestContext({ copilotClientOptions: { - connection: RuntimeConnection.forStdio({ path: await getLegacyCliPathForTests() }), - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", - }, + connection: RuntimeConnection.forStdio({ + path: await getLegacyCliPathForTests(), + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", + }, + }), }, }); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index c4befb148e..4bb183db07 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -152,13 +152,25 @@ export async function createSdkTestContext({ connection = RuntimeConnection.forStdio({ path: cliPath }); } - const { - connection: _ignoredConnection, - env: userEnv, - ...remainingClientOptions - } = copilotClientOptions ?? {}; + if (connection.kind === "stdio") { + connection = RuntimeConnection.forStdio({ + ...connection, + workingDirectory: connection.workingDirectory ?? workDir, + env: { ...env, ...connection.env }, + }); + } else if (connection.kind === "tcp") { + connection = RuntimeConnection.forTcp({ + ...connection, + workingDirectory: connection.workingDirectory ?? workDir, + env: { ...env, ...connection.env }, + }); + } - const mergedEnv = { ...env, ...userEnv }; + const { connection: _ignoredConnection, ...remainingClientOptions } = + copilotClientOptions ?? {}; + + const mergedEnv = + connection.kind === "stdio" || connection.kind === "tcp" ? connection.env : env; // The in-process (FFI) transport loads the runtime into this test host process, // and its worker inherits this process's ambient environment rather than a @@ -184,16 +196,11 @@ export async function createSdkTestContext({ // secondary client (e.g. resuming a session from a fresh client) don't have to // reimplement the in-process env/cwd handling. Callers may override the connection // (e.g. pin stdio for telemetry, which the in-process transport cannot carry - // per-client); env is attached to child-process transports and mirrored onto the - // process for in-process (see beforeEach below), never passed per-client for the - // in-process transport where it would be rejected. + // per-client). Out-of-process env/cwd live on the connection object, while + // in-process hosting mirrors the environment onto the real process (see beforeEach + // below) and uses a temporary chdir so the worker inherits the right cwd. function createClient(overrides: Partial = {}): CopilotClient { - const { - connection: overrideConnection, - env: _ignoredEnv, - workingDirectory: overrideWorkingDirectory, - ...rest - } = overrides; + const { connection: overrideConnection, ...rest } = overrides; let effectiveConnection = overrideConnection ?? connection; // Fill in the bundled CLI path for child-process connections that omit it @@ -209,20 +216,21 @@ export async function createSdkTestContext({ path: cliPath, }); } - const effectiveInProcess = effectiveConnection.kind === "inprocess"; + if (effectiveConnection.kind === "stdio") { + effectiveConnection = RuntimeConnection.forStdio({ + ...effectiveConnection, + workingDirectory: effectiveConnection.workingDirectory ?? workDir, + env: { ...env, ...effectiveConnection.env }, + }); + } else if (effectiveConnection.kind === "tcp") { + effectiveConnection = RuntimeConnection.forTcp({ + ...effectiveConnection, + workingDirectory: effectiveConnection.workingDirectory ?? workDir, + env: { ...env, ...effectiveConnection.env }, + }); + } return new CopilotClient({ - // The in-process transport rejects a per-client workingDirectory (it would have to - // mutate the shared host process cwd). Instead the harness changes this process's - // cwd to workDir around the in-process worker's startup (see beforeEach below), so - // the worker still spawns with workDir as its cwd. Out-of-process clients get it - // as a normal per-client option. - workingDirectory: - overrideWorkingDirectory ?? (effectiveInProcess ? undefined : workDir), - // In-process hosting mirrors the environment onto the real process (per test, in - // beforeEach below), so the worker inherits it; passing a per-client env here - // would have no effect (and is rejected by the in-process transport). - env: effectiveInProcess ? undefined : mergedEnv, logLevel: logLevel || "error", connection: effectiveConnection, gitHubToken: authTokenToUse, diff --git a/nodejs/test/e2e/mcp_oauth.e2e.test.ts b/nodejs/test/e2e/mcp_oauth.e2e.test.ts index 0909afd8ad..b3ef0320b1 100644 --- a/nodejs/test/e2e/mcp_oauth.e2e.test.ts +++ b/nodejs/test/e2e/mcp_oauth.e2e.test.ts @@ -8,7 +8,7 @@ import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { describe, expect, it, onTestFinished } from "vitest"; import type { CopilotSession, MCPServerConfig, McpAuthRequest } from "../../src/index.js"; -import { approveAll } from "../../src/index.js"; +import { approveAll, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { waitForCondition } from "./harness/sdkTestHelper.js"; @@ -23,10 +23,12 @@ const REAUTH_TOKEN = `${EXPECTED_TOKEN}-reauth`; describe("MCP OAuth host auth", async () => { const { copilotClient: client } = await createSdkTestContext({ copilotClientOptions: { - env: { - COPILOT_MCP_APPS: "true", - MCP_APPS: "true", - }, + connection: RuntimeConnection.forStdio({ + env: { + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + }), }, }); diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 7c2906c7b9..c961ce2334 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -141,12 +141,12 @@ describe("Pending work resume", async () => { function createTcpServer(): CopilotClient { const server = new CopilotClient({ - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN, + workingDirectory: workDir, + env, }), }); onTestFinished(async () => { @@ -518,11 +518,11 @@ describe("Pending work resume", async () => { if (scenario.disconnectOriginalClient) { const lockObserver = new CopilotClient({ - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, }), }); try { diff --git a/nodejs/test/e2e/per_session_auth.e2e.test.ts b/nodejs/test/e2e/per_session_auth.e2e.test.ts index 5f55d397d8..dc8b6e0b6e 100644 --- a/nodejs/test/e2e/per_session_auth.e2e.test.ts +++ b/nodejs/test/e2e/per_session_auth.e2e.test.ts @@ -77,13 +77,15 @@ describe("Per-session GitHub auth", async () => { it("should return unauthenticated when no token is provided", async () => { const noTokenClient = new CopilotClient({ - workingDirectory: workDir, - env: withoutAuthEnv({ - ...env, - COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, - }), logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: withoutAuthEnv({ + ...env, + COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, + }), + }), useLoggedInUser: false, }); diff --git a/nodejs/test/e2e/provider_endpoint.e2e.test.ts b/nodejs/test/e2e/provider_endpoint.e2e.test.ts index 8acf6a2469..047958f17c 100644 --- a/nodejs/test/e2e/provider_endpoint.e2e.test.ts +++ b/nodejs/test/e2e/provider_endpoint.e2e.test.ts @@ -3,14 +3,16 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from "vitest"; -import { approveAll } from "../../src/index.js"; +import { approveAll, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("session.provider.getEndpoint RPC", async () => { const { copilotClient: client } = await createSdkTestContext({ copilotClientOptions: { // The provider endpoint API is gated behind an opt-in env var. - env: { COPILOT_ALLOW_GET_PROVIDER_ENDPOINT: "true" }, + connection: RuntimeConnection.forStdio({ + env: { COPILOT_ALLOW_GET_PROVIDER_ENDPOINT: "true" }, + }), }, }); diff --git a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts index 4025dc444c..d34c907917 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -63,14 +63,16 @@ describe("Session MCP and skills RPC", async () => { function createMcpAppsClient(): CopilotClient { const mcpAppsClient = new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - COPILOT_MCP_APPS: "true", - MCP_APPS: "true", - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + }), }); onTestFinished(async () => { try { diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 13a63875e9..cdbcfa661b 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -31,10 +31,12 @@ describe("Server-scoped RPC", async () => { ...extraEnv, }; const extraClient = new CopilotClient({ - workingDirectory: workDir, - env: childEnv, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: childEnv, + }), gitHubToken: token, }); onTestFinished(async () => { diff --git a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts index 4f12e507a5..0653b591bb 100644 --- a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts @@ -24,13 +24,15 @@ describe("Miscellaneous server-scoped RPC", async () => { gitHubToken: string | undefined ): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - ...extraEnv, - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + ...extraEnv, + }, + }), gitHubToken, useLoggedInUser: gitHubToken === undefined ? false : undefined, }); diff --git a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts index 20575a9114..b3a9b7def6 100644 --- a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts @@ -24,16 +24,18 @@ describe("Server-scoped plugin RPC", async () => { function createClient(home: string): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - COPILOT_HOME: home, - GH_CONFIG_DIR: home, - XDG_CONFIG_HOME: home, - XDG_STATE_HOME: home, - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + COPILOT_HOME: home, + GH_CONFIG_DIR: home, + XDG_CONFIG_HOME: home, + XDG_STATE_HOME: home, + }, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); } diff --git a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts index 3094d32577..4afe88a7a7 100644 --- a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts @@ -13,10 +13,12 @@ describe("Server-scoped remote-control RPC", async () => { function createDedicatedClient(): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, }); } diff --git a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts index 7b88af7e2d..1cfa8da9c9 100644 --- a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts @@ -15,13 +15,15 @@ describe("Session-scoped state extras RPC", async () => { token = DEFAULT_GITHUB_TOKEN ): CopilotClient { return new CopilotClient({ - workingDirectory: workDir, - env: { - ...env, - ...extraEnv, - }, logLevel: "error", - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env: { + ...env, + ...extraEnv, + }, + }), gitHubToken: token, }); } diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index 4c20acb345..7933e0d4df 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -28,14 +28,28 @@ describe("Sessions", () => { } it.each([ - ["stdio", () => RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH })], - ["tcp", () => RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH })], + [ + "stdio", + () => + RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), + ], + [ + "tcp", + () => + RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), + ], ] as const)( "createSession works without onPermissionRequest (%s)", async (_name, makeConnection) => { const standaloneClient = new CopilotClient({ - workingDirectory: workDir, - env, connection: makeConnection(), }); onTestFinished(async () => { @@ -55,11 +69,11 @@ describe("Sessions", () => { const connectionToken = "client-e2e-resume-token"; const tcpClient = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken, + workingDirectory: workDir, + env, }), }); onTestFinished(async () => { @@ -78,8 +92,6 @@ describe("Sessions", () => { } const resumeClient = new CopilotClient({ - workingDirectory: workDir, - env, connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), }); onTestFinished(async () => { diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 8d041f1ec8..097ef2a6ce 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -554,9 +554,11 @@ describe("Session Configuration", async () => { it("should enable citations for Anthropic file attachments on create", async () => { const handler = new RecordingRequestHandler(); const citationClient = new CopilotClient({ - connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - workingDirectory: workDir, - env, + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + workingDirectory: workDir, + env, + }), gitHubToken: DEFAULT_GITHUB_TOKEN, requestHandler: handler, }); @@ -591,9 +593,9 @@ describe("Session Configuration", async () => { connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken, + workingDirectory: workDir, + env, }), - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, requestHandler: handler, }); diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts index dbc3ca673b..e8dec4a4c8 100644 --- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -12,7 +12,7 @@ import type { PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; -import { approveAll, CopilotRequestHandler } from "../../src/index.js"; +import { approveAll, CopilotRequestHandler, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; interface RequestRecord { @@ -79,7 +79,9 @@ describe("Subagent hooks", async () => { copilotClientOptions: { ...(recordToken ? { gitHubToken: recordToken } : {}), requestHandler, - env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, + connection: RuntimeConnection.forStdio({ + env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, + }), }, }); diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 2c8639ad38..5ecd9c9164 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -63,12 +63,12 @@ describe("Suspend RPC", async () => { function createTcpServer(): CopilotClient { const server = new CopilotClient({ - workingDirectory: workDir, - env, gitHubToken: DEFAULT_GITHUB_TOKEN, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN, + workingDirectory: workDir, + env, }), }); onTestFinishedStop(server); diff --git a/python/README.md b/python/README.md index 0c3526f260..554206c485 100644 --- a/python/README.md +++ b/python/README.md @@ -207,9 +207,7 @@ All options are kw-only parameters: `RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`, `RuntimeConnection.for_uri(...)`, or `RuntimeConnection.for_inprocess(...)`. Defaults to a stdio connection with the bundled binary. -- `working_directory` (str | None): Working directory for the CLI process (default: current dir). - `log_level` (str): Log level (default: "info"). -- `env` (dict | None): Environment variables for the CLI process. - `github_token` (str | None): GitHub token for authentication. When provided, takes priority over other auth methods. - `base_directory` (str | None): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When `None`, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using a `UriRuntimeConnection`. - `use_logged_in_user` (bool | None): Whether to use logged-in user for authentication (default: True, but False when `github_token` is provided). @@ -222,8 +220,8 @@ All options are kw-only parameters: **RuntimeConnection variants:** -- `RuntimeConnection.for_stdio(path=None, args=None)` — spawn a local CLI process and talk over stdio. -- `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None)` — spawn a local CLI in TCP mode. +- `RuntimeConnection.for_stdio(path=None, args=None, working_directory=None, env=None)` — spawn a local CLI process and talk over stdio. +- `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None, working_directory=None, env=None)` — spawn a local CLI in TCP mode. - `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`). - `RuntimeConnection.for_inprocess()` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport). @@ -231,14 +229,15 @@ Managed stdio and TCP connections use the downloaded `copilot-runtime` executable with adjacent `runtime.node` by default. An explicit connection path or `COPILOT_CLI_PATH` overrides the downloaded runtime. -Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection -`env` field for the spawned process. Set it on the returned connection instead of -the client-level `env` — setting both raises: +The shared `OutOfProcessRuntimeConnection` base for `for_stdio`/`for_tcp` +connections carries the spawned process's `working_directory` and `env`: ```python -conn = RuntimeConnection.for_stdio() -conn.env = {"MY_VAR": "value"} -client = CopilotClient(connection=conn) # do NOT also pass env=... here +conn = RuntimeConnection.for_stdio( + working_directory="/srv/app", + env={"MY_VAR": "value"}, +) +client = CopilotClient(connection=conn) ``` ### In-process (FFI) transport @@ -267,11 +266,10 @@ finally: - Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package. In-process connections do not accept per-connection paths or raw process arguments. -- Because the runtime shares this single host process, per-client options that - lower to environment variables or a working directory **cannot** be honored and - are rejected: `env`, `telemetry`, and `working_directory` all raise `ValueError` - with `for_inprocess()`. Set the corresponding values on the host process - environment / working directory before creating the client instead. +- Because the runtime shares this single host process, client-wide `telemetry` + **cannot** be honored and is rejected with `for_inprocess()`. Process-scoped + launch settings such as working directory and environment live on + `OutOfProcessRuntimeConnection`, which the in-process transport does not use. - Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process transport by default when no explicit `connection` is supplied. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 8e14887a4f..a725b8e891 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -32,7 +32,6 @@ AskUserVariant, AutoTier, CapiSessionOptions, - ChildProcessRuntimeConnection, ClientInfo, CloudSessionOptions, CloudSessionRepository, @@ -59,6 +58,7 @@ ModelPolicy, ModelSupports, ModelVisionLimits, + OutOfProcessRuntimeConnection, PingResponse, RemoteSessionMode, RuntimeConnection, @@ -254,7 +254,6 @@ "CanvasJsonSchema", "CanvasProviderIdentity", "CapiSessionOptions", - "ChildProcessRuntimeConnection", "ClientInfo", "CloudSessionOptions", "CloudSessionRepository", @@ -333,6 +332,7 @@ "ModelVisionLimitsOverride", "NamedProviderConfig", "OpenCanvasInstance", + "OutOfProcessRuntimeConnection", "PermissionHandler", "PermissionNoResult", "PermissionRequest", diff --git a/python/copilot/client.py b/python/copilot/client.py index dd531a2be9..1074718e5c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -568,8 +568,10 @@ def for_stdio( *, path: str | None = None, args: Sequence[str] = (), + working_directory: str | None = None, + env: dict[str, str] | None = None, ) -> StdioRuntimeConnection: - """Spawn a runtime child process and communicate over its stdin/stdout. + """Spawn an out-of-process runtime and communicate over its stdin/stdout. This is the default when no :attr:`CopilotClientOptions.connection` is supplied. @@ -578,8 +580,18 @@ def for_stdio( path: Path to the runtime executable. When ``None``, uses the bundled binary. args: Extra command-line arguments passed to the runtime process. + working_directory: Working directory for the spawned runtime + process. When ``None``, inherits the current process working + directory. + env: Environment variables for the spawned runtime process. When + ``None``, inherits the current process environment. """ - return StdioRuntimeConnection(path=path, args=tuple(args)) + return StdioRuntimeConnection( + path=path, + args=tuple(args), + working_directory=working_directory, + env=env, + ) @staticmethod def for_tcp( @@ -588,8 +600,10 @@ def for_tcp( connection_token: str | None = None, path: str | None = None, args: Sequence[str] = (), + working_directory: str | None = None, + env: dict[str, str] | None = None, ) -> TcpRuntimeConnection: - """Spawn a runtime child process listening on a TCP socket. + """Spawn an out-of-process runtime listening on a TCP socket. Args: port: TCP port to listen on. ``0`` (the default) auto-allocates @@ -602,10 +616,17 @@ def for_tcp( path: Path to the runtime executable. When ``None``, uses the bundled binary. args: Extra command-line arguments passed to the runtime process. + working_directory: Working directory for the spawned runtime + process. When ``None``, inherits the current process working + directory. + env: Environment variables for the spawned runtime process. When + ``None``, inherits the current process environment. """ return TcpRuntimeConnection( path=path, args=tuple(args), + working_directory=working_directory, + env=env, port=port, connection_token=connection_token, ) @@ -630,18 +651,18 @@ def for_inprocess() -> InProcessRuntimeConnection: **Experimental.** The in-process (FFI) transport is experimental and its behavior may change or be removed in a future release. - Instead of spawning the runtime as a child process, the SDK loads the + Instead of spawning the runtime out-of-process, the SDK loads the runtime's native shared library into this process and drives JSON-RPC over its C ABI. - Because the runtime loads into this single shared process, per-client - options that lower to environment variables or a working directory - cannot be honored: :attr:`CopilotClientOptions.env`, - :attr:`CopilotClientOptions.telemetry`, and - :attr:`CopilotClientOptions.working_directory` are rejected with this - transport. Set those on the host process before creating the client. - Set ``COPILOT_CLI_PATH`` only when using an externally provisioned - compatible runtime package. + Because the runtime loads into this single shared process, client-wide + telemetry configuration cannot be honored here: it lowers to + environment variables read by native runtime code running in the shared + host process, so :attr:`CopilotClientOptions.telemetry` is rejected. + Other process-scoped launch settings live on + :class:`OutOfProcessRuntimeConnection`, which this transport does not + use. Set ``COPILOT_CLI_PATH`` only when using an externally + provisioned compatible runtime package. Note: Pre-provision the native runtime with @@ -652,10 +673,11 @@ def for_inprocess() -> InProcessRuntimeConnection: @dataclass -class ChildProcessRuntimeConnection(RuntimeConnection): - """Base for :class:`RuntimeConnection` variants that spawn a runtime child process. +class OutOfProcessRuntimeConnection(RuntimeConnection): + """Base for :class:`RuntimeConnection` variants that spawn a runtime process. - Construct via :meth:`RuntimeConnection.stdio` or :meth:`RuntimeConnection.tcp`. + Construct via :meth:`RuntimeConnection.for_stdio` or + :meth:`RuntimeConnection.for_tcp`. """ path: str | None = None @@ -664,27 +686,30 @@ class ChildProcessRuntimeConnection(RuntimeConnection): args: Sequence[str] = () """Extra command-line arguments passed to the runtime process.""" + working_directory: str | None = None + """Working directory for the spawned runtime process. + + ``None`` inherits the current process working directory.""" + env: dict[str, str] | None = None - """Per-connection environment variables for the spawned child process. + """Environment variables for the spawned runtime process. - When set, do not also set :attr:`CopilotClientOptions.env` — the client - rejects setting environment in both places. ``None`` inherits the - client-level env (or the current process env).""" + ``None`` inherits the current process environment.""" @dataclass -class StdioRuntimeConnection(ChildProcessRuntimeConnection): - """Spawns a runtime child process and communicates over its stdin/stdout. +class StdioRuntimeConnection(OutOfProcessRuntimeConnection): + """Spawns an out-of-process runtime and communicates over its stdin/stdout. - Construct via :meth:`RuntimeConnection.stdio`. + Construct via :meth:`RuntimeConnection.for_stdio`. """ @dataclass -class TcpRuntimeConnection(ChildProcessRuntimeConnection): - """Spawns a runtime child process listening on a TCP socket. +class TcpRuntimeConnection(OutOfProcessRuntimeConnection): + """Spawns an out-of-process runtime listening on a TCP socket. - Construct via :meth:`RuntimeConnection.tcp`. + Construct via :meth:`RuntimeConnection.for_tcp`. """ port: int = 0 @@ -806,9 +831,7 @@ class _CopilotClientOptions: """ connection: RuntimeConnection | None = None - working_directory: str | None = None log_level: LogLevel = "info" - env: dict[str, str] | None = None github_token: str | None = None base_directory: str | None = None builtin_plugin_directories: tuple[str, ...] = () @@ -1477,51 +1500,24 @@ def _resolve_default_connection(env: Mapping[str, str]) -> RuntimeConnection: def _validate_environment_options( options: _CopilotClientOptions, connection: RuntimeConnection ) -> None: - """Validate env/telemetry/working-directory options against the transport. + """Validate client options that depend on the selected transport. - Per-client environment is only representable for child-process transports - (each client owns its own OS process). The in-process (FFI) transport loads - the native runtime into the shared host process, whose single environment - block and process-global working directory cannot carry per-client values, - so options that lower to them are rejected there (fail loud, not silent). + The in-process (FFI) transport loads the native runtime into the shared + host process, so client-wide telemetry cannot be honored there: it lowers + to environment variables read by native code in that shared process. Fail + loudly instead of silently ignoring the setting. """ if isinstance(connection, InProcessRuntimeConnection): - if options.env is not None: - raise ValueError( - "env is not supported with RuntimeConnection.for_inprocess(): the " - "in-process transport loads the native runtime into the shared host " - "process, whose single environment block cannot carry per-client " - "values. Set the variables on the host process environment instead." - ) if options.telemetry is not None: raise ValueError( "telemetry is not supported with RuntimeConnection.for_inprocess(): " "telemetry configuration is lowered to environment variables read by " "native runtime code running in the shared host process, so per-client " "telemetry cannot be honored in-process. Configure telemetry via the " - "host process environment, or use a child-process transport." - ) - if options.working_directory is not None: - raise ValueError( - "working_directory is not supported with RuntimeConnection.for_inprocess(): " - "the native runtime shares the host process working directory, so a " - "per-client working directory cannot be honored in-process. Use a " - "child-process " - "transport, or set the process working directory before creating the client." + "host process environment, or use an out-of-process transport." ) return - if ( - isinstance(connection, ChildProcessRuntimeConnection) - and connection.env is not None - and options.env is not None - ): - raise ValueError( - "Set environment variables via either the client-level env argument or " - "ChildProcessRuntimeConnection.env, not both. Prefer the connection-level " - "env for child-process transports." - ) - class CopilotClient: """ @@ -1561,9 +1557,7 @@ def __init__( self, *, connection: RuntimeConnection | None = None, - working_directory: str | None = None, log_level: LogLevel = "info", - env: dict[str, str] | None = None, github_token: str | None = None, base_directory: str | None = None, builtin_plugin_directories: Sequence[str] | None = None, @@ -1582,21 +1576,18 @@ def __init__( """ Initialize a new CopilotClient. - Runtime options apply to locally hosted connections. The in-process - transport supports typed runtime options such as ``log_level``, - ``github_token``, and ``base_directory``, but rejects per-client - ``working_directory``, ``env``, and ``telemetry``. Options are ignored - when connecting to an existing runtime via - :meth:`RuntimeConnection.for_uri`. + Runtime options apply to locally hosted connections. Process-scoped + launch settings such as working directory and environment live on + :class:`OutOfProcessRuntimeConnection` for stdio/TCP transports. The + in-process transport supports typed runtime options such as + ``log_level``, ``github_token``, and ``base_directory``, but rejects + client-wide ``telemetry``. Options are ignored when connecting to an + existing runtime via :meth:`RuntimeConnection.for_uri`. Args: connection: How to reach the runtime. Defaults to :meth:`RuntimeConnection.for_stdio` with the bundled binary. - working_directory: Working directory for the runtime process. - ``None`` uses the current directory. log_level: Log level for the runtime process. Defaults to ``"info"``. - env: Environment variables for the runtime process. ``None`` inherits - the current env. github_token: GitHub token for authentication. Takes priority over other auth methods. base_directory: Base directory for Copilot data (session state, @@ -1647,15 +1638,17 @@ def __init__( >>> >>> # Custom runtime path with specific log level >>> client = CopilotClient( - ... connection=RuntimeConnection.for_stdio(path="/usr/local/bin/copilot"), + ... connection=RuntimeConnection.for_stdio( + ... path="/usr/local/bin/copilot", + ... working_directory="/srv/app", + ... env={"MY_VAR": "value"}, + ... ), ... log_level="debug", ... ) """ options = _CopilotClientOptions( connection=connection, - working_directory=working_directory, log_level=log_level, - env=env, github_token=github_token, base_directory=base_directory, builtin_plugin_directories=tuple(builtin_plugin_directories or ()), @@ -1708,14 +1701,14 @@ def __init__( self._runtime_port: int | None = actual_port self._effective_connection_token: str | None = connection.connection_token elif isinstance(connection, InProcessRuntimeConnection): - # In-process (FFI): no child process and no per-connection token. + # In-process (FFI): no out-of-process runtime and no per-connection token. self._runtime_port = None self._effective_connection_token = None self._inprocess_runtime_path = self._resolve_inprocess_runtime() if options.use_logged_in_user is None: options.use_logged_in_user = not bool(options.github_token) else: - assert isinstance(connection, ChildProcessRuntimeConnection) + assert isinstance(connection, OutOfProcessRuntimeConnection) self._runtime_port = None if isinstance(connection, TcpRuntimeConnection): @@ -1739,8 +1732,6 @@ def __init__( # unexpectedly honoring a host COPILOT_CLI_PATH. if connection.env is not None: effective_env: Mapping[str, str] = connection.env - elif options.env is not None: - effective_env = options.env else: effective_env = os.environ connection.path = self._resolve_runtime_entrypoint(connection.path, env=effective_env) @@ -4323,7 +4314,7 @@ async def _start_cli_server(self) -> None: await self._start_inprocess_ffi() return - assert isinstance(self._connection, ChildProcessRuntimeConnection) + assert isinstance(self._connection, OutOfProcessRuntimeConnection) conn = self._connection opts = self._options use_stdio = isinstance(conn, StdioRuntimeConnection) @@ -4375,16 +4366,13 @@ async def _start_cli_server(self) -> None: }, ) - # Get environment variables. Per-connection env (ChildProcessRuntimeConnection.env) - # takes precedence over the client-level env; the constructor already rejects - # setting both. When neither is set, inherit the current process environment. - conn_env = conn.env if isinstance(conn, ChildProcessRuntimeConnection) else None + # Get environment variables from the out-of-process connection, or inherit the + # current process environment when none are supplied. + conn_env = conn.env if isinstance(conn, OutOfProcessRuntimeConnection) else None if conn_env is not None: env = dict(conn_env) - elif opts.env is None: - env = dict(os.environ) else: - env = dict(opts.env) + env = dict(os.environ) # Set auth token in environment if provided if opts.github_token: env["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token @@ -4421,7 +4409,7 @@ async def _start_cli_server(self) -> None: # On Windows, hide the console window to avoid distracting users in GUI apps creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 - cwd = opts.working_directory or os.getcwd() + cwd = conn.working_directory or os.getcwd() # Choose transport mode spawn_start = time.perf_counter() diff --git a/python/e2e/_copilot_request_helpers.py b/python/e2e/_copilot_request_helpers.py index d4073dd197..195bbf3d8f 100644 --- a/python/e2e/_copilot_request_helpers.py +++ b/python/e2e/_copilot_request_helpers.py @@ -333,9 +333,11 @@ def build_isolated_client( if extra_env: env = {**env, **extra_env} return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, request_handler=handler, ) diff --git a/python/e2e/test_client_lifecycle_e2e.py b/python/e2e/test_client_lifecycle_e2e.py index f1196a54e4..b1d7390461 100644 --- a/python/e2e/test_client_lifecycle_e2e.py +++ b/python/e2e/test_client_lifecycle_e2e.py @@ -59,9 +59,11 @@ def _make_isolated_client(ctx: E2ETestContext) -> CopilotClient: "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) diff --git a/python/e2e/test_client_options_e2e.py b/python/e2e/test_client_options_e2e.py index b07e9a5402..9a8ccb7fb4 100644 --- a/python/e2e/test_client_options_e2e.py +++ b/python/e2e/test_client_options_e2e.py @@ -49,22 +49,26 @@ def _make_options( **overrides, ) -> dict[str, object]: """Build CopilotClient kwargs pre-populated for the test harness.""" + connection_working_directory = overrides.pop("working_directory", ctx.work_dir) + connection_env = overrides.pop("env", ctx.get_env()) if use_tcp: connection: RuntimeConnection = RuntimeConnection.for_tcp( port=port, connection_token=connection_token, path=cli_path if cli_path is not None else ctx.cli_path, args=tuple(cli_args or []), + working_directory=connection_working_directory, + env=connection_env, ) else: connection = RuntimeConnection.for_stdio( path=cli_path if cli_path is not None else ctx.cli_path, args=tuple(cli_args or []), + working_directory=connection_working_directory, + env=connection_env, ) base: dict[str, object] = { "connection": connection, - "working_directory": ctx.work_dir, - "env": ctx.get_env(), "github_token": DEFAULT_GITHUB_TOKEN, } base.update(overrides) diff --git a/python/e2e/test_commands_e2e.py b/python/e2e/test_commands_e2e.py index e0a0d63f1d..fcf05cf355 100644 --- a/python/e2e/test_commands_e2e.py +++ b/python/e2e/test_commands_e2e.py @@ -56,10 +56,11 @@ async def setup(self): # Client 1 uses TCP mode so a second client can connect self._client1 = CopilotClient( connection=RuntimeConnection.for_tcp( - path=self.cli_path, connection_token="py-tcp-shared-test-token" + path=self.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=self.work_dir, + env=self._get_env(), ), - working_directory=self.work_dir, - env=self._get_env(), github_token=github_token, ) diff --git a/python/e2e/test_connection_token.py b/python/e2e/test_connection_token.py index 1c7addbd9a..f0aa9c180b 100644 --- a/python/e2e/test_connection_token.py +++ b/python/e2e/test_connection_token.py @@ -46,9 +46,12 @@ async def setup(self): ) self._client = CopilotClient( - connection=RuntimeConnection.for_tcp(path=self.cli_path, connection_token=self.token), - working_directory=self.work_dir, - env=self.get_env(), + connection=RuntimeConnection.for_tcp( + path=self.cli_path, + connection_token=self.token, + working_directory=self.work_dir, + env=self.get_env(), + ), github_token=github_token, ) diff --git a/python/e2e/test_copilot_request_handler_e2e.py b/python/e2e/test_copilot_request_handler_e2e.py index 1811962e89..6476f25a77 100644 --- a/python/e2e/test_copilot_request_handler_e2e.py +++ b/python/e2e/test_copilot_request_handler_e2e.py @@ -237,9 +237,11 @@ async def handler_fixture(ctx: E2ETestContext): ) env = {**ctx.get_env(), "COPILOT_EXP_COPILOT_CLI_WEBSOCKET_RESPONSES": "true"} client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, request_handler=handler, ) diff --git a/python/e2e/test_github_telemetry_e2e.py b/python/e2e/test_github_telemetry_e2e.py index 976b0b616e..7b0e644c4c 100644 --- a/python/e2e/test_github_telemetry_e2e.py +++ b/python/e2e/test_github_telemetry_e2e.py @@ -23,9 +23,12 @@ def on_github_telemetry(notification: GitHubTelemetryNotification) -> None: received.append(notification) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=get_cli_path_for_tests(), args=()), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=get_cli_path_for_tests(), + args=(), + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, on_github_telemetry=on_github_telemetry, ) diff --git a/python/e2e/test_mode_empty_e2e.py b/python/e2e/test_mode_empty_e2e.py index c84613c8a9..56f46483b3 100644 --- a/python/e2e/test_mode_empty_e2e.py +++ b/python/e2e/test_mode_empty_e2e.py @@ -22,9 +22,12 @@ def _make_empty_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path, args=()), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + args=(), + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=( "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ), diff --git a/python/e2e/test_multi_client_e2e.py b/python/e2e/test_multi_client_e2e.py index 1938ddfe89..5e569c24c4 100644 --- a/python/e2e/test_multi_client_e2e.py +++ b/python/e2e/test_multi_client_e2e.py @@ -57,10 +57,11 @@ async def setup(self): # Client 1 uses TCP mode so a second client can connect to the same server self._client1 = CopilotClient( connection=RuntimeConnection.for_tcp( - path=self.cli_path, connection_token="py-tcp-shared-test-token" + path=self.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=self.work_dir, + env=self.get_env(), ), - working_directory=self.work_dir, - env=self.get_env(), github_token=github_token, ) diff --git a/python/e2e/test_pending_work_resume_e2e.py b/python/e2e/test_pending_work_resume_e2e.py index 5b6d978f31..1c65d02f2a 100644 --- a/python/e2e/test_pending_work_resume_e2e.py +++ b/python/e2e/test_pending_work_resume_e2e.py @@ -34,15 +34,20 @@ def _make_subprocess_client(ctx: E2ETestContext, *, use_stdio: bool = True) -> CopilotClient: if use_stdio: - connection = RuntimeConnection.for_stdio(path=ctx.cli_path) + connection = RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ) else: connection = RuntimeConnection.for_tcp( - path=ctx.cli_path, connection_token="py-tcp-shared-test-token" + path=ctx.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=ctx.work_dir, + env=ctx.get_env(), ) return CopilotClient( connection=connection, - working_directory=ctx.work_dir, - env=ctx.get_env(), github_token=DEFAULT_GITHUB_TOKEN, ) diff --git a/python/e2e/test_per_session_auth_e2e.py b/python/e2e/test_per_session_auth_e2e.py index a8d13dc1de..9dcd48477e 100644 --- a/python/e2e/test_per_session_auth_e2e.py +++ b/python/e2e/test_per_session_auth_e2e.py @@ -97,9 +97,11 @@ async def test_should_return_unauthenticated_when_no_token_provided( env = without_auth_env(auth_ctx.get_env()) env["COPILOT_DEBUG_GITHUB_API_URL"] = auth_ctx.proxy_url no_token_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=auth_ctx.cli_path), - working_directory=auth_ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=auth_ctx.cli_path, + working_directory=auth_ctx.work_dir, + env=env, + ), use_logged_in_user=False, ) diff --git a/python/e2e/test_provider_endpoint_e2e.py b/python/e2e/test_provider_endpoint_e2e.py index 875a95b91b..3e61d36715 100644 --- a/python/e2e/test_provider_endpoint_e2e.py +++ b/python/e2e/test_provider_endpoint_e2e.py @@ -20,9 +20,11 @@ async def provider_ctx(ctx: E2ETestContext): env = {**ctx.get_env(), "COPILOT_ALLOW_GET_PROVIDER_ENDPOINT": "true"} client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=env["GITHUB_TOKEN"], ) try: diff --git a/python/e2e/test_rpc_server_e2e.py b/python/e2e/test_rpc_server_e2e.py index fdff3b8004..ddc227f1c1 100644 --- a/python/e2e/test_rpc_server_e2e.py +++ b/python/e2e/test_rpc_server_e2e.py @@ -93,9 +93,11 @@ def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: env = ctx.get_env() env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=token, ) @@ -104,9 +106,11 @@ def _make_client_with_env(ctx: E2ETestContext, env_overrides: dict[str, str]) -> env = ctx.get_env() env.update(env_overrides) return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token="fake-token-for-e2e-tests", ) diff --git a/python/e2e/test_rpc_server_misc_e2e.py b/python/e2e/test_rpc_server_misc_e2e.py index 2b6b7d9514..52f7038b35 100644 --- a/python/e2e/test_rpc_server_misc_e2e.py +++ b/python/e2e/test_rpc_server_misc_e2e.py @@ -33,9 +33,11 @@ def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, ) @@ -53,9 +55,11 @@ async def _create_isolated_client( env["GH_TOKEN"] = "" env["GITHUB_TOKEN"] = "" client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, use_logged_in_user=False if github_token is None else None, ) diff --git a/python/e2e/test_rpc_server_plugins_e2e.py b/python/e2e/test_rpc_server_plugins_e2e.py index 538d1692fd..27500e517f 100644 --- a/python/e2e/test_rpc_server_plugins_e2e.py +++ b/python/e2e/test_rpc_server_plugins_e2e.py @@ -93,9 +93,11 @@ async def _create_isolated_client(ctx: E2ETestContext) -> tuple[CopilotClient, P for key in ("COPILOT_HOME", "GH_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_STATE_HOME"): env[key] = str(home) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=DEFAULT_GITHUB_TOKEN, ) await client.start() diff --git a/python/e2e/test_rpc_server_remote_control_e2e.py b/python/e2e/test_rpc_server_remote_control_e2e.py index 0fe2cc1b37..81dbfaabe9 100644 --- a/python/e2e/test_rpc_server_remote_control_e2e.py +++ b/python/e2e/test_rpc_server_remote_control_e2e.py @@ -29,9 +29,11 @@ def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, ) diff --git a/python/e2e/test_rpc_session_state_extras_e2e.py b/python/e2e/test_rpc_session_state_extras_e2e.py index 02ee0cd790..af3edac500 100644 --- a/python/e2e/test_rpc_session_state_extras_e2e.py +++ b/python/e2e/test_rpc_session_state_extras_e2e.py @@ -43,9 +43,11 @@ def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: env = ctx.get_env() env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url return CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=token, ) diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 4fc78e645d..e8cc630395 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -465,9 +465,11 @@ async def test_should_enable_citations_for_anthropic_file_attachments_on_create( ): handler = _RecordingRequestHandler() client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, request_handler=handler, ) @@ -501,9 +503,9 @@ async def test_should_enable_citations_for_anthropic_file_attachments_on_resume( connection=RuntimeConnection.for_tcp( path=ctx.cli_path, connection_token=connection_token, + working_directory=ctx.work_dir, + env=ctx.get_env(), ), - working_directory=ctx.work_dir, - env=ctx.get_env(), github_token=DEFAULT_GITHUB_TOKEN, request_handler=handler, ) diff --git a/python/e2e/test_session_e2e.py b/python/e2e/test_session_e2e.py index f57b9f5736..83ec69a78c 100644 --- a/python/e2e/test_session_e2e.py +++ b/python/e2e/test_session_e2e.py @@ -258,9 +258,11 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) @@ -299,9 +301,11 @@ def on_mcp_auth_request(_request, _invocation): github_token = DEFAULT_GITHUB_TOKEN if os.environ.get("GITHUB_ACTIONS") == "true" else None new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) diff --git a/python/e2e/test_session_fs_e2e.py b/python/e2e/test_session_fs_e2e.py index 035db125ba..dcc97dd7fd 100644 --- a/python/e2e/test_session_fs_e2e.py +++ b/python/e2e/test_session_fs_e2e.py @@ -52,9 +52,11 @@ @pytest_asyncio.fixture(scope="module", loop_scope="module") async def session_fs_client(ctx: E2ETestContext): client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, session_fs=SESSION_FS_CONFIG, ) @@ -122,9 +124,11 @@ async def test_should_load_session_data_from_fs_provider_on_resume( async def test_should_reject_setprovider_when_sessions_already_exist(self, ctx: E2ETestContext): client1 = CopilotClient( - connection=RuntimeConnection.for_tcp(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_tcp( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, ) session = None diff --git a/python/e2e/test_session_fs_sqlite_e2e.py b/python/e2e/test_session_fs_sqlite_e2e.py index f48bcd2cdc..c70b2ea3ac 100644 --- a/python/e2e/test_session_fs_sqlite_e2e.py +++ b/python/e2e/test_session_fs_sqlite_e2e.py @@ -246,9 +246,11 @@ def factory(session): @pytest_asyncio.fixture(scope="module", loop_scope="module") async def sqlite_client(ctx: E2ETestContext): client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=DEFAULT_GITHUB_TOKEN, session_fs=SESSION_FS_CONFIG, ) diff --git a/python/e2e/test_streaming_fidelity_e2e.py b/python/e2e/test_streaming_fidelity_e2e.py index a644acb838..4e3367bbb4 100644 --- a/python/e2e/test_streaming_fidelity_e2e.py +++ b/python/e2e/test_streaming_fidelity_e2e.py @@ -78,9 +78,11 @@ async def test_should_produce_deltas_after_session_resume(self, ctx: E2ETestCont "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) @@ -128,9 +130,11 @@ async def test_should_not_produce_deltas_after_session_resume_with_streaming_dis # Resume with streaming disabled new_client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, ) try: diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index da70265a04..2e0ffcf9e3 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -98,9 +98,11 @@ async def on_post_tool_use(input_data, invocation): "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=env, + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=env, + ), github_token=github_token, request_handler=request_handler, ) diff --git a/python/e2e/test_suspend_e2e.py b/python/e2e/test_suspend_e2e.py index d0a117fff9..678542b9d8 100644 --- a/python/e2e/test_suspend_e2e.py +++ b/python/e2e/test_suspend_e2e.py @@ -31,15 +31,20 @@ def _make_subprocess_client(ctx: E2ETestContext, *, use_stdio: bool = True) -> C "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) if use_stdio: - connection = RuntimeConnection.for_stdio(path=ctx.cli_path) + connection = RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ) else: connection = RuntimeConnection.for_tcp( - path=ctx.cli_path, connection_token="py-tcp-shared-test-token" + path=ctx.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=ctx.work_dir, + env=ctx.get_env(), ) return CopilotClient( connection=connection, - working_directory=ctx.work_dir, - env=ctx.get_env(), github_token=github_token, ) diff --git a/python/e2e/test_telemetry_e2e.py b/python/e2e/test_telemetry_e2e.py index 8b9c82abef..d542e1e5eb 100644 --- a/python/e2e/test_telemetry_e2e.py +++ b/python/e2e/test_telemetry_e2e.py @@ -72,9 +72,11 @@ def echo(invocation: ToolInvocation) -> ToolResult: "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None ) client = CopilotClient( - connection=RuntimeConnection.for_stdio(path=ctx.cli_path), - working_directory=ctx.work_dir, - env=ctx.get_env(), + connection=RuntimeConnection.for_stdio( + path=ctx.cli_path, + working_directory=ctx.work_dir, + env=ctx.get_env(), + ), github_token=github_token, telemetry=TelemetryConfig( file_path=str(telemetry_path), diff --git a/python/e2e/test_ui_elicitation_multi_client_e2e.py b/python/e2e/test_ui_elicitation_multi_client_e2e.py index 05589d0d28..f7c178957c 100644 --- a/python/e2e/test_ui_elicitation_multi_client_e2e.py +++ b/python/e2e/test_ui_elicitation_multi_client_e2e.py @@ -63,10 +63,11 @@ async def setup(self): # Client 1 uses TCP mode so additional clients can connect self._client1 = CopilotClient( connection=RuntimeConnection.for_tcp( - path=self.cli_path, connection_token="py-tcp-shared-test-token" + path=self.cli_path, + connection_token="py-tcp-shared-test-token", + working_directory=self.work_dir, + env=self._get_env(), ), - working_directory=self.work_dir, - env=self._get_env(), github_token=github_token, ) diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 12eb9466fa..5cace914a9 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Any -from copilot import CopilotClient, RuntimeConnection +from copilot import CopilotClient, OutOfProcessRuntimeConnection, RuntimeConnection from .proxy import CapiProxy @@ -87,7 +87,7 @@ async def setup(self, cli_args: list[str] | None = None): Args: cli_args: Optional extra CLI arguments passed to the CLI process. """ - self.cli_path = get_cli_path_for_tests() + self.cli_path = CLI_PATH self.home_dir = os.path.realpath(tempfile.mkdtemp(prefix="copilot-test-config-")) self.work_dir = os.path.realpath(tempfile.mkdtemp(prefix="copilot-test-work-")) @@ -127,9 +127,9 @@ async def setup(self, cli_args: list[str] | None = None): connection=RuntimeConnection.for_stdio( path=self.cli_path, args=tuple(cli_args or []), + working_directory=self.work_dir, + env=self.get_env(), ), - working_directory=self.work_dir, - env=self.get_env(), github_token=DEFAULT_GITHUB_TOKEN, ) @@ -171,10 +171,11 @@ def add_runtime_env(self, key: str, value: str) -> None: self._restore_env.append((key, os.environ.get(key))) os.environ[key] = value else: - options = self.client._options - if options.env is None: - options.env = {} - options.env[key] = value + connection = self.client._connection + assert isinstance(connection, OutOfProcessRuntimeConnection) + if connection.env is None: + connection.env = {} + connection.env[key] = value def _restore_inprocess_environment(self) -> None: """Undo the in-process environment mirror and cwd change from setup.""" diff --git a/python/test_client.py b/python/test_client.py index 2e3868ef1c..178e84c562 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -64,22 +64,24 @@ def test_inprocess_connection_has_no_child_process_options(): def test_explicit_child_process_path_does_not_require_runtime_bundle(tmp_path): explicit = tmp_path / "copilot" - connection = RuntimeConnection.for_stdio(path=str(explicit)) + connection = RuntimeConnection.for_stdio( + path=str(explicit), + env={"PATH": str(tmp_path)}, + ) - CopilotClient(connection=connection, env={"PATH": str(tmp_path)}) + CopilotClient(connection=connection) assert connection.path == str(explicit) def test_copilot_cli_path_does_not_require_runtime_bundle(tmp_path): explicit = tmp_path / "copilot" - connection = RuntimeConnection.for_stdio() - - CopilotClient( - connection=connection, + connection = RuntimeConnection.for_stdio( env={"PATH": str(tmp_path), "COPILOT_CLI_PATH": str(explicit)}, ) + CopilotClient(connection=connection) + assert connection.path == str(explicit) diff --git a/rust/README.md b/rust/README.md index f236fc5b02..7cc4ed08e3 100644 --- a/rust/README.md +++ b/rust/README.md @@ -816,7 +816,7 @@ opts.telemetry = Some(telem); let client = Client::start(opts).await?; ``` -The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `ClientOptions::env` entries override telemetry-injected values. +The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `OutOfProcessOptions::env` entries (set on `Transport::Stdio`/`Transport::Tcp`) override telemetry-injected values. ### Progress Reporting (`send_and_wait`) @@ -988,8 +988,8 @@ and use `Transport::InProcess`: github-copilot-sdk = { version = "1", features = ["bundled-in-process"] } ``` -`CliProgram::Path` and raw `ClientOptions::extra_args` apply only to -child-process transports. Set `COPILOT_CLI_PATH` only when using an externally +`CliProgram::Path` and `OutOfProcessOptions::extra_args` apply only to +out-of-process transports. Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package with in-process transport. For builds that prefer a smaller artifact, disable the `bundled-cli` feature: @@ -1000,7 +1000,7 @@ github-copilot-sdk = { version = "1", default-features = false } > **You become responsible for supplying the runtime at deployment.** With > `bundled-cli` disabled, the produced binary does not contain these artifacts -> and will not search the system for them. For managed child-process transports, +> and will not search the system for them. For managed out-of-process transports, > supply a compatible wrapper pair via an explicit [`CliProgram::Path`]. > `COPILOT_CLI_PATH` remains a direct program override. > @@ -1064,13 +1064,13 @@ COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = t ### Skipping the bundle entirely -Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `ClientOptions::program = CliProgram::Path(...)`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. +Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `OutOfProcessOptions::program = CliProgram::Path(...)` (on `Transport::Stdio`/`Transport::Tcp`). Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. ### Resolution priority -For managed child-process transports, `Client::start` resolves the program in this order: +For managed out-of-process transports, `Client::start` resolves the program in this order: -1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`. +1. Explicit `CliProgram::Path(path)` on `OutOfProcessOptions::program` (`Transport::Stdio`/`Transport::Tcp`). 2. `COPILOT_CLI_PATH` environment variable, if it points at a real file. 3. **`bundled-cli` on:** the embedded wrapper pair, lazily extracted on first call. 4. **`bundled-cli` off:** the build-time-extracted wrapper pair in the per-user cache. @@ -1098,7 +1098,7 @@ if HAS_BUNDLED_CLI { ``` This returns the bundled CLI artifact, preserving the public API's original -meaning. Managed child-process transports resolve `copilot-runtime` instead. +meaning. Managed out-of-process transports resolve `copilot-runtime` instead. The function returns `None` when `bundled-cli` is off or the target is unsupported and does not fall back to the build-time extraction cache. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7d15d8f35c..2ab0e67862 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -141,6 +141,116 @@ fn record_optional_millis(span: &tracing::Span, field: &'static str, value: Opti } } +/// Process-scoped configuration for **out-of-process** transports +/// ([`Transport::Stdio`] and [`Transport::Tcp`]) — the SDK spawns and owns +/// the CLI process for these, so per-connection process configuration is +/// coherent. +/// +/// These settings have no equivalent for [`Transport::InProcess`] (the +/// native runtime shares this process — there is no child process to +/// configure) or [`Transport::External`] (the SDK connects to a server it +/// did not spawn, so process-launch settings do not apply). +/// +/// `#[non_exhaustive]` and `Default`, so this type can grow new fields +/// compatibly. Construct with [`OutOfProcessOptions::new`] or +/// `Default::default()` plus the `with_*` builders. +#[non_exhaustive] +#[derive(Clone, Default)] +pub struct OutOfProcessOptions { + /// How to locate the runtime binary. See [`CliProgram`]. + pub program: CliProgram, + /// Arguments prepended before `--server` (e.g. the script path for node). + pub prefix_args: Vec, + /// Working directory for the CLI process. + pub working_directory: PathBuf, + /// Environment variables set on the CLI process. A nonempty value + /// **replaces** the inherited process environment (instead of adding to + /// it) before SDK-managed variables (auth token, telemetry, + /// `COPILOT_HOME`, etc.) and this map are layered on top, consistent + /// with the other SDKs. Leave empty to inherit the ambient environment + /// unchanged. + pub env: Vec<(OsString, OsString)>, + /// Environment variable names to remove from the CLI process (applied + /// after [`Self::env`], so this can strip SDK-injected variables too). + pub env_remove: Vec, + /// Extra flags appended after the transport-specific arguments. + pub extra_args: Vec, +} + +impl std::fmt::Debug for OutOfProcessOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OutOfProcessOptions") + .field("program", &self.program) + .field("prefix_args", &self.prefix_args) + .field("working_directory", &self.working_directory) + .field("env", &self.env) + .field("env_remove", &self.env_remove) + .field("extra_args", &self.extra_args) + .finish() + } +} + +impl OutOfProcessOptions { + /// Construct a new [`OutOfProcessOptions`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// How to locate the runtime binary. See [`CliProgram`]. + pub fn with_program(mut self, program: impl Into) -> Self { + self.program = program.into(); + self + } + + /// Arguments prepended before `--server` (e.g. the script path for node). + pub fn with_prefix_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.prefix_args = args.into_iter().map(Into::into).collect(); + self + } + + /// Working directory for the CLI process. + pub fn with_working_directory(mut self, dir: impl Into) -> Self { + self.working_directory = dir.into(); + self + } + + /// Environment variables to set on the CLI process. See [`Self::env`] + /// for replace-vs-merge semantics. + pub fn with_env(mut self, env: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + + /// Environment variable names to remove from the CLI process. + pub fn with_env_remove(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.env_remove = names.into_iter().map(Into::into).collect(); + self + } + + /// Extra CLI flags appended after the transport-specific arguments. + pub fn with_extra_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.extra_args = args.into_iter().map(Into::into).collect(); + self + } +} + /// How the SDK communicates with the CLI server. #[derive(Debug, Default)] #[non_exhaustive] @@ -149,22 +259,25 @@ pub enum Transport { /// back to [`Transport::Stdio`] when the variable is unset. #[default] Default, - /// Communicate over stdin/stdout pipes (default). - Stdio, + /// Communicate over stdin/stdout pipes (default). Carries + /// out-of-process configuration (program, working directory, env, + /// extra args) — see [`OutOfProcessOptions`]. + Stdio(OutOfProcessOptions), /// Host the runtime in-process over FFI (no child process). /// /// Loads the native runtime library and speaks JSON-RPC over its C ABI. - /// This is **experimental**. Per-client [`ClientOptions::program`], - /// [`ClientOptions::extra_args`], [`ClientOptions::working_directory`], - /// [`ClientOptions::env`]/[`ClientOptions::env_remove`], - /// and [`ClientOptions::telemetry`] are not supported because native - /// runtime code shares the host process. Typed runtime options such as - /// authentication, log level, and [`ClientOptions::base_directory`] remain - /// supported. + /// This is **experimental**. [`OutOfProcessOptions`] (program, working + /// directory, env, extra args — not applicable, there is no child + /// process) and [`ClientOptions::telemetry`] are not supported because + /// native runtime code shares the host process. Typed runtime options + /// such as authentication, log level, and [`ClientOptions::base_directory`] + /// remain supported and are forwarded to the native runtime. /// /// Requires the `bundled-in-process` Cargo feature. InProcess, - /// Spawn the CLI with `--port` and connect via TCP. + /// Spawn the CLI with `--port` and connect via TCP. Carries + /// out-of-process configuration like [`Transport::Stdio`] — see + /// [`OutOfProcessOptions`]. Tcp { /// Port to listen on (0 for OS-assigned). port: u16, @@ -172,8 +285,15 @@ pub enum Transport { /// the CLI, the SDK auto-generates a 128-bit hex token so the /// loopback listener is safe by default. connection_token: Option, + /// Process-scoped configuration for the spawned CLI. See + /// [`OutOfProcessOptions`]. + process: OutOfProcessOptions, }, - /// Connect to an already-running CLI server (no process spawning). + /// Connect to an already-running CLI server (no process spawning). The + /// SDK does not own this process, so [`OutOfProcessOptions`] settings + /// (program, working directory, env, extra args) do not apply here — + /// unlike [`Transport::Stdio`]/[`Transport::Tcp`], this variant has no + /// such field. External { /// Hostname or IP of the running server. host: String, @@ -185,10 +305,18 @@ pub enum Transport { }, } +impl Transport { + /// Convenience constructor for [`Transport::Stdio`] with default + /// [`OutOfProcessOptions`]. + pub fn stdio() -> Self { + Transport::Stdio(OutOfProcessOptions::default()) + } +} + /// How the SDK locates the GitHub Copilot CLI binary. #[derive(Debug, Clone, Default)] pub enum CliProgram { - /// Auto-resolve the transport's program. Managed child-process transports + /// Auto-resolve the transport's program. Managed out-of-process transports /// select `COPILOT_CLI_PATH`, then the bundled runtime wrapper. In-process /// transport loads the wrapper's adjacent runtime library directly unless /// `COPILOT_CLI_PATH` explicitly selects a legacy embedded host. @@ -266,31 +394,18 @@ pub fn install_bundled_runtime() -> Option { /// Options for starting a [`Client`]. /// -/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`] -/// uses `COPILOT_CLI_PATH` when set to a real file. Managed child-process -/// transports next use the bundled `copilot-runtime` wrapper. In-process -/// transport loads the wrapper's adjacent runtime library. With `bundled-cli` -/// disabled, the corresponding artifact is resolved from the build-time -/// extraction cache. -/// -/// Set `program` to [`CliProgram::Path`] to use an explicit binary instead. -/// This skips auto-resolution entirely. +/// Process-scoped settings — the program/binary path, working directory, +/// environment, and extra CLI args — are no longer configured here. Set them +/// on the out-of-process transport instead: [`Transport::Stdio`] and +/// [`Transport::Tcp`] carry an [`OutOfProcessOptions`] value for exactly this +/// purpose. [`CliProgram::Resolve`] (the default) uses `COPILOT_CLI_PATH` +/// when set to a real file, then falls back to the bundled `copilot-runtime` +/// wrapper (out-of-process) or the wrapper's adjacent runtime library +/// (in-process). With `bundled-cli` disabled, the corresponding artifact is +/// resolved from the build-time extraction cache. #[non_exhaustive] +#[derive(Default)] pub struct ClientOptions { - /// How to locate the child-process runtime. - pub program: CliProgram, - /// Arguments prepended before `--server` (e.g. the script path for node). - pub prefix_args: Vec, - /// Working directory for the CLI process. - /// - /// Setting this option is not supported with [`Transport::InProcess`]. - pub working_directory: PathBuf, - /// Environment variables set on the child process. - pub env: Vec<(OsString, OsString)>, - /// Environment variable names to remove from the child process. - pub env_remove: Vec, - /// Extra flags for child-process transports. - pub extra_args: Vec, /// Absolute paths to trusted plugin directories bundled by the host. /// /// When non-empty, [`Client::start`] replaces the runtime's complete @@ -508,12 +623,6 @@ impl ClientInfo { impl std::fmt::Debug for ClientOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ClientOptions") - .field("program", &self.program) - .field("prefix_args", &self.prefix_args) - .field("working_directory", &self.working_directory) - .field("env", &self.env) - .field("env_remove", &self.env_remove) - .field("extra_args", &self.extra_args) .field( "builtin_plugin_directories", &self.builtin_plugin_directories, @@ -684,7 +793,7 @@ impl OtlpHttpProtocol { /// | [`source_name`] | `COPILOT_OTEL_SOURCE_NAME` | /// | [`capture_content`] | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | /// -/// Caller-supplied entries in [`ClientOptions::env`] override these, so a +/// Caller-supplied entries in [`OutOfProcessOptions::env`] override these, so a /// developer can pin any individual variable to a different value while /// keeping the rest of the config managed by [`TelemetryConfig`]. /// @@ -778,37 +887,6 @@ impl TelemetryConfig { } } -impl Default for ClientOptions { - fn default() -> Self { - Self { - program: CliProgram::Resolve, - prefix_args: Vec::new(), - working_directory: PathBuf::new(), - env: Vec::new(), - env_remove: Vec::new(), - extra_args: Vec::new(), - builtin_plugin_directories: Vec::new(), - transport: Transport::default(), - github_token: None, - use_logged_in_user: None, - log_level: None, - session_idle_timeout_seconds: None, - on_list_models: None, - session_fs: None, - request_handler: None, - extension_launch_provider: None, - on_github_telemetry: None, - on_get_trace_context: None, - telemetry: None, - base_directory: None, - enable_remote_sessions: false, - bundled_cli_extract_dir: None, - mode: ClientMode::default(), - client_info: None, - } - } -} - impl ClientOptions { /// Construct a new [`ClientOptions`] with default values. /// @@ -829,59 +907,6 @@ impl ClientOptions { Self::default() } - /// How to locate the child-process runtime. See [`CliProgram`]. - pub fn with_program(mut self, program: impl Into) -> Self { - self.program = program.into(); - self - } - - /// Arguments prepended before `--server` (e.g. the script path for node). - pub fn with_prefix_args(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.prefix_args = args.into_iter().map(Into::into).collect(); - self - } - - /// Working directory for the CLI process. - pub fn with_cwd(mut self, cwd: impl Into) -> Self { - self.working_directory = cwd.into(); - self - } - - /// Environment variables to set on the child process. - pub fn with_env(mut self, env: I) -> Self - where - I: IntoIterator, - K: Into, - V: Into, - { - self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); - self - } - - /// Environment variable names to remove from the child process. - pub fn with_env_remove(mut self, names: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.env_remove = names.into_iter().map(Into::into).collect(); - self - } - - /// Extra CLI flags appended after the transport-specific arguments. - pub fn with_extra_args(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.extra_args = args.into_iter().map(Into::into).collect(); - self - } - /// Set trusted plugin directories bundled by the host. /// /// Every path must be absolute; invalid paths are rejected by @@ -1086,24 +1111,20 @@ fn generate_connection_token() -> String { /// stdio. Any other value is an error. const DEFAULT_CONNECTION_ENV_VAR: &str = "COPILOT_SDK_DEFAULT_CONNECTION"; -/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`]. -fn resolve_default_transport(options: &ClientOptions) -> Result { - let configured = options - .env - .iter() - .find(|(key, _)| { - key.to_string_lossy() - .eq_ignore_ascii_case(DEFAULT_CONNECTION_ENV_VAR) - }) - .map(|(_, value)| value.to_string_lossy().into_owned()); - let process = std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok(); - resolve_default_transport_value(configured.as_deref().or(process.as_deref())) +/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`] in the +/// real process environment. There is no longer a way to override this via +/// [`ClientOptions`] — process-scoped env now lives on +/// [`OutOfProcessOptions`], which is only available once a transport has +/// already been chosen, so it cannot influence the choice of transport +/// itself. +fn resolve_default_transport() -> Result { + resolve_default_transport_value(std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok().as_deref()) } fn resolve_default_transport_value(value: Option<&str>) -> Result { match value { - None => Ok(Transport::Stdio), - Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::Stdio), + None => Ok(Transport::stdio()), + Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::stdio()), Some(v) if v.eq_ignore_ascii_case("inprocess") => Ok(Transport::InProcess), Some(v) => Err(Error::with_message( ErrorKind::InvalidConfig, @@ -1115,44 +1136,21 @@ fn resolve_default_transport_value(value: Option<&str>) -> Result { } } +/// Validate options that don't apply to [`Transport::InProcess`]. +/// +/// Process-scoped settings (program, working directory, env, extra args) are +/// no longer reachable here at all — they live on [`OutOfProcessOptions`], +/// which [`Transport::InProcess`] structurally does not carry. Only +/// [`ClientOptions::telemetry`] remains client-wide and still needs a +/// runtime check, since native runtime code loaded in-process shares this +/// process and cannot honor per-client telemetry export configuration. #[cfg(any(feature = "bundled-in-process", test))] fn validate_inprocess_options(options: &ClientOptions) -> Result<()> { - if !matches!(&options.program, CliProgram::Resolve) { - return Err(Error::with_message( - ErrorKind::InvalidConfig, - "ClientOptions::program is not supported with Transport::InProcess; \ - set COPILOT_CLI_PATH only when using an externally provisioned runtime package", - )); - } - if !options.extra_args.is_empty() { - return Err(Error::with_message( - ErrorKind::InvalidConfig, - "ClientOptions::extra_args is not supported with Transport::InProcess; \ - use typed client options instead", - )); - } - - let unsupported = if !options.working_directory.as_os_str().is_empty() { - Some("working_directory") - } else if !options.env.is_empty() { - Some("env") - } else if !options.env_remove.is_empty() { - Some("env_remove") - } else if options.telemetry.is_some() { - Some("telemetry") - } else if !options.prefix_args.is_empty() { - Some("prefix_args") - } else { - None - }; - - if let Some(option) = unsupported { + if options.telemetry.is_some() { return Err(Error::with_message( ErrorKind::InvalidConfig, - format!( - "ClientOptions::{option} is not supported with Transport::InProcess; \ - configure process-global settings on the host process instead" - ), + "ClientOptions::telemetry is not supported with Transport::InProcess; \ + configure process-global telemetry settings on the host process instead", )); } @@ -1243,7 +1241,7 @@ impl Client { let mut timings = StartupTimings::default(); let mut options = options; if matches!(options.transport, Transport::Default) { - options.transport = resolve_default_transport(&options)?; + options.transport = resolve_default_transport()?; } if matches!(options.transport, Transport::InProcess) { #[cfg(not(feature = "bundled-in-process"))] @@ -1336,7 +1334,7 @@ impl Client { // default. let effective_connection_token: Option = match &mut options.transport { Transport::Default => unreachable!("default transport resolved above"), - Transport::Stdio | Transport::InProcess => None, + Transport::Stdio(_) | Transport::InProcess => None, Transport::Tcp { connection_token, .. } => Some( @@ -1355,50 +1353,9 @@ impl Client { .as_ref() .and_then(|c| c.capabilities.as_ref()) .is_some_and(|caps| caps.sqlite); - let program = match &options.program { - CliProgram::Path(path) => { - info!(path = %path.display(), "using explicit copilot CLI path"); - path.clone() - } - CliProgram::Resolve => { - let resolve_start = Instant::now(); - let resolved = resolve::copilot_binary_with_extract_dir( - options.bundled_cli_extract_dir.as_deref(), - true, - )?; - let resolve_elapsed = resolve_start.elapsed(); - timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed)); - debug!( - elapsed_ms = resolve_elapsed.as_millis(), - "Client::start CLI program resolution complete" - ); - info!(path = %resolved.display(), "resolved copilot runtime"); - #[cfg(windows)] - { - if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| { - ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") - }) { - warn!( - path = %resolved.display(), - ext = %ext, - "resolved copilot CLI is a .cmd/.bat wrapper; \ - this may cause console window flashes on Windows" - ); - } - } - resolved - } - }; - let working_directory = { - let cwd = options.working_directory.clone(); - if cwd.as_os_str().is_empty() { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - } else { - cwd - } - }; let transport_setup_start = Instant::now(); + let bundled_cli_extract_dir = options.bundled_cli_extract_dir.clone(); let client = match options.transport { Transport::Default => unreachable!("default transport resolved above"), Transport::External { @@ -1406,6 +1363,8 @@ impl Client { port, connection_token: _, } => { + let working_directory = + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); info!(host = %host, port = %port, "connecting to external CLI server"); let connect_start = Instant::now(); let stream = TcpStream::connect((host.as_str(), port)).await?; @@ -1436,9 +1395,16 @@ impl Client { Transport::Tcp { port, connection_token: _, + ref process, } => { + let program = Self::resolve_cli_program( + &process.program, + bundled_cli_extract_dir.as_deref(), + &mut timings, + )?; + let working_directory = Self::resolve_working_directory(&process.working_directory); let (mut child, tree, actual_port, spawn_elapsed, port_wait_elapsed) = - Self::spawn_tcp(&program, &options, &working_directory, port).await?; + Self::spawn_tcp(&program, &options, process, &working_directory, port).await?; timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed)); timings.port_wait_ms = Some(StartupTimings::millis(port_wait_elapsed)); let connect_start = Instant::now(); @@ -1467,9 +1433,15 @@ impl Client { options.client_info, )? } - Transport::Stdio => { + Transport::Stdio(ref process) => { + let program = Self::resolve_cli_program( + &process.program, + bundled_cli_extract_dir.as_deref(), + &mut timings, + )?; + let working_directory = Self::resolve_working_directory(&process.working_directory); let (mut child, tree, spawn_elapsed) = - Self::spawn_stdio(&program, &options, &working_directory)?; + Self::spawn_stdio(&program, &options, process, &working_directory)?; timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed)); let stdin = child.stdin.take().expect("stdin is piped"); let stdout = child.stdout.take().expect("stdout is piped"); @@ -1494,6 +1466,13 @@ impl Client { Transport::InProcess => { #[cfg(feature = "bundled-in-process")] { + let program = Self::resolve_cli_program( + &CliProgram::Resolve, + bundled_cli_extract_dir.as_deref(), + &mut timings, + )?; + let working_directory = + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); info!(runtime_path = %program.display(), "hosting copilot runtime in-process (FFI)"); let mut environment = Vec::new(); if let Some(base_directory) = &options.base_directory { @@ -1565,6 +1544,7 @@ impl Client { unreachable!("in-process feature validation returned above") } }; + timings.transport_setup_ms = StartupTimings::millis(transport_setup_start.elapsed()); debug!( elapsed_ms = start_time.elapsed().as_millis(), @@ -1976,10 +1956,79 @@ impl Client { }); } - fn build_command(program: &Path, options: &ClientOptions, working_directory: &Path) -> Command { + /// Resolve [`OutOfProcessOptions::program`] (or [`CliProgram::Resolve`] + /// for [`Transport::InProcess`], which cannot carry an + /// [`OutOfProcessOptions`]) to a concrete path, recording the resolution + /// time in `timings`. + fn resolve_cli_program( + program: &CliProgram, + bundled_cli_extract_dir: Option<&Path>, + timings: &mut StartupTimings, + ) -> Result { + match program { + CliProgram::Path(path) => { + info!(path = %path.display(), "using explicit copilot CLI path"); + Ok(path.clone()) + } + CliProgram::Resolve => { + let resolve_start = Instant::now(); + let resolved = + resolve::copilot_binary_with_extract_dir(bundled_cli_extract_dir, true)?; + let resolve_elapsed = resolve_start.elapsed(); + timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed)); + debug!( + elapsed_ms = resolve_elapsed.as_millis(), + "Client::start CLI program resolution complete" + ); + info!(path = %resolved.display(), "resolved copilot runtime"); + #[cfg(windows)] + { + if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| { + ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") + }) { + warn!( + path = %resolved.display(), + ext = %ext, + "resolved copilot CLI is a .cmd/.bat wrapper; \ + this may cause console window flashes on Windows" + ); + } + } + Ok(resolved) + } + } + } + + /// Resolve [`OutOfProcessOptions::working_directory`] to a concrete + /// path, falling back to the current process working directory when + /// unset (empty). + fn resolve_working_directory(configured: &Path) -> PathBuf { + if configured.as_os_str().is_empty() { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } else { + configured.to_path_buf() + } + } + + fn build_command( + program: &Path, + options: &ClientOptions, + process: &OutOfProcessOptions, + working_directory: &Path, + ) -> Command { let mut command = Command::new(program); command.kill_on_drop(true); - for arg in &options.prefix_args { + // A nonempty explicit `env` replaces the inherited process + // environment instead of layering on top of it, consistent with the + // other SDKs. SDK-managed variables (auth token, telemetry, + // `COPILOT_HOME`, etc.) are still injected below so they remain + // active even when the caller replaces the rest of the environment; + // explicit `env`/`env_remove` entries can still override or strip + // them, same as before this replaced the ambient environment. + if !process.env.is_empty() { + command.env_clear(); + } + for arg in &process.prefix_args { command.arg(arg); } // Inject the SDK auth token first so explicit `env` / `env_remove` @@ -1988,7 +2037,7 @@ impl Client { command.env("COPILOT_SDK_AUTH_TOKEN", token); } // Inject telemetry env vars before user env so callers can still - // override individual variables via `options.env`. + // override individual variables via `process.env`. if let Some(telemetry) = &options.telemetry { command.env("COPILOT_OTEL_ENABLED", "true"); if let Some(endpoint) = &telemetry.otlp_endpoint { @@ -2028,10 +2077,10 @@ impl Client { { command.env("COPILOT_CONNECTION_TOKEN", token); } - for (key, value) in &options.env { + for (key, value) in &process.env { command.env(key, value); } - for key in &options.env_remove { + for key in &process.env_remove { command.env_remove(key); } command @@ -2094,17 +2143,18 @@ impl Client { fn spawn_stdio( program: &Path, options: &ClientOptions, + process: &OutOfProcessOptions, working_directory: &Path, ) -> Result<(Child, Option, Duration)> { info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)"); - let mut command = Self::build_command(program, options, working_directory); + let mut command = Self::build_command(program, options, process, working_directory); command .args(["--server", "--stdio", "--no-auto-update"]) .args(Self::log_level_args(options)) .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) .args(Self::remote_args(options)) - .args(&options.extra_args) + .args(&process.extra_args) .stdin(Stdio::piped()); let spawn_start = Instant::now(); let (child, tree) = process_tree::spawn(&mut command)?; @@ -2119,6 +2169,7 @@ impl Client { async fn spawn_tcp( program: &Path, options: &ClientOptions, + process: &OutOfProcessOptions, working_directory: &Path, port: u16, ) -> Result<( @@ -2129,14 +2180,14 @@ impl Client { Duration, )> { info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); - let mut command = Self::build_command(program, options, working_directory); + let mut command = Self::build_command(program, options, process, working_directory); command .args(["--server", "--port", &port.to_string(), "--no-auto-update"]) .args(Self::log_level_args(options)) .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) .args(Self::remote_args(options)) - .args(&options.extra_args) + .args(&process.extra_args) .stdin(Stdio::null()); let spawn_start = Instant::now(); let (mut child, tree) = process_tree::spawn(&mut command)?; @@ -3082,29 +3133,38 @@ mod tests { #[test] fn client_options_builder_composes() { let opts = ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot"))) - .with_prefix_args(["node"]) - .with_cwd(PathBuf::from("/tmp")) - .with_env([("KEY", "value")]) - .with_env_remove(["UNWANTED"]) - .with_extra_args(["--quiet"]) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot"))) + .with_prefix_args(["node"]) + .with_working_directory(PathBuf::from("/tmp")) + .with_env([("KEY", "value")]) + .with_env_remove(["UNWANTED"]) + .with_extra_args(["--quiet"]), + )) .with_github_token("ghp_test") .with_use_logged_in_user(false) .with_log_level(LogLevel::Debug) .with_session_idle_timeout_seconds(120) .with_enable_remote_sessions(true); - assert!(matches!(opts.program, CliProgram::Path(_))); - assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]); - assert_eq!(opts.working_directory, PathBuf::from("/tmp")); + let Transport::Stdio(process) = &opts.transport else { + panic!("expected Transport::Stdio"); + }; + assert!(matches!(process.program, CliProgram::Path(_))); + assert_eq!(process.prefix_args, vec![std::ffi::OsString::from("node")]); + assert_eq!(process.working_directory, PathBuf::from("/tmp")); assert_eq!( - opts.env, + process.env, vec![( std::ffi::OsString::from("KEY"), std::ffi::OsString::from("value") )] ); - assert_eq!(opts.env_remove, vec![std::ffi::OsString::from("UNWANTED")]); - assert_eq!(opts.extra_args, vec!["--quiet".to_string()]); + assert_eq!( + process.env_remove, + vec![std::ffi::OsString::from("UNWANTED")] + ); + assert_eq!(process.extra_args, vec!["--quiet".to_string()]); assert_eq!(opts.github_token.as_deref(), Some("ghp_test")); assert_eq!(opts.use_logged_in_user, Some(false)); assert!(matches!(opts.log_level, Some(LogLevel::Debug))); @@ -3116,11 +3176,11 @@ mod tests { fn default_transport_values_resolve_without_process_state() { assert!(matches!( resolve_default_transport_value(None).unwrap(), - Transport::Stdio + Transport::Stdio(_) )); assert!(matches!( resolve_default_transport_value(Some("stdio")).unwrap(), - Transport::Stdio + Transport::Stdio(_) )); assert!(matches!( resolve_default_transport_value(Some("INPROCESS")).unwrap(), @@ -3131,15 +3191,7 @@ mod tests { #[test] fn inprocess_rejects_process_scoped_options() { - let invalid = [ - ClientOptions::new().with_cwd("."), - ClientOptions::new().with_env([("KEY", "value")]), - ClientOptions::new().with_env_remove(["KEY"]), - ClientOptions::new().with_telemetry(TelemetryConfig::default()), - ClientOptions::new().with_prefix_args(["index.js"]), - ClientOptions::new().with_program(CliProgram::Path("copilot".into())), - ClientOptions::new().with_extra_args(["--verbose"]), - ]; + let invalid = [ClientOptions::new().with_telemetry(TelemetryConfig::default())]; for options in invalid { assert!(validate_inprocess_options(&options).is_err()); @@ -3179,10 +3231,13 @@ mod tests { fn build_command_lets_env_remove_strip_injected_token() { let opts = ClientOptions { github_token: Some("secret".to_string()), + ..Default::default() + }; + let process = OutOfProcessOptions { env_remove: vec![std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN")], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); // get_envs() iter yields the latest action per key — None means removed. let action = cmd .as_std() @@ -3200,13 +3255,16 @@ mod tests { fn build_command_lets_env_override_injected_token() { let opts = ClientOptions { github_token: Some("from-options".to_string()), + ..Default::default() + }; + let process = OutOfProcessOptions { env: vec![( std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN"), std::ffi::OsString::from("from-env"), )], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); let value = cmd .as_std() .get_envs() @@ -3221,7 +3279,8 @@ mod tests { github_token: Some("just-the-token".to_string()), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let process = OutOfProcessOptions::default(); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); let value = cmd .as_std() .get_envs() @@ -3289,7 +3348,12 @@ mod tests { }), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value(&cmd, "COPILOT_OTEL_ENABLED"), Some(std::ffi::OsStr::new("true")), @@ -3323,7 +3387,12 @@ mod tests { #[test] fn build_command_omits_otel_env_when_telemetry_none() { let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); for key in [ "COPILOT_OTEL_ENABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", @@ -3349,7 +3418,12 @@ mod tests { }), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); // The one set field plus the implicit enabled flag should propagate. assert_eq!( env_value(&cmd, "COPILOT_OTEL_ENABLED"), @@ -3378,13 +3452,16 @@ mod tests { otlp_endpoint: Some("http://from-config:4318".to_string()), ..Default::default() }), + ..Default::default() + }; + let process = OutOfProcessOptions { env: vec![( std::ffi::OsString::from("OTEL_EXPORTER_OTLP_ENDPOINT"), std::ffi::OsString::from("http://from-user-env:4318"), )], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, &process, Path::new("/tmp")); assert_eq!( env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"), Some(std::ffi::OsStr::new("http://from-user-env:4318")), @@ -3395,14 +3472,24 @@ mod tests { #[test] fn build_command_sets_copilot_home_env_when_configured() { let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot")); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value(&cmd, "COPILOT_HOME"), Some(std::ffi::OsStr::new("/custom/copilot")), ); let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert!(env_value(&cmd, "COPILOT_HOME").is_none()); } @@ -3411,26 +3498,37 @@ mod tests { let opts = ClientOptions::new().with_transport(Transport::Tcp { port: 0, connection_token: Some("secret-token".to_string()), + process: OutOfProcessOptions::default(), }); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value(&cmd, "COPILOT_CONNECTION_TOKEN"), Some(std::ffi::OsStr::new("secret-token")), ); let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); + let cmd = Client::build_command( + Path::new("/bin/echo"), + &opts, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert!(env_value(&cmd, "COPILOT_CONNECTION_TOKEN").is_none()); } #[tokio::test] async fn start_rejects_empty_connection_token() { - let opts = ClientOptions::new() - .with_transport(Transport::Tcp { - port: 0, - connection_token: Some(String::new()), - }) - .with_program(CliProgram::Path(PathBuf::from("/bin/echo"))); + let opts = ClientOptions::new().with_transport(Transport::Tcp { + port: 0, + connection_token: Some(String::new()), + process: OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from("/bin/echo"))), + }); let err = Client::start(opts).await.unwrap_err(); assert!( matches!(err.kind(), ErrorKind::InvalidConfig), @@ -3440,13 +3538,11 @@ mod tests { #[tokio::test] async fn start_rejects_empty_external_connection_token() { - let opts = ClientOptions::new() - .with_transport(Transport::External { - host: "127.0.0.1".to_string(), - port: 1, - connection_token: Some(String::new()), - }) - .with_program(CliProgram::Path(PathBuf::from("/bin/echo"))); + let opts = ClientOptions::new().with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port: 1, + connection_token: Some(String::new()), + }); let err = Client::start(opts).await.unwrap_err(); assert!( matches!(err.kind(), ErrorKind::InvalidConfig), @@ -3470,9 +3566,18 @@ mod tests { }), ..Default::default() }; - let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true, Path::new("/tmp")); - let cmd_false = - Client::build_command(Path::new("/bin/echo"), &opts_false, Path::new("/tmp")); + let cmd_true = Client::build_command( + Path::new("/bin/echo"), + &opts_true, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); + let cmd_false = Client::build_command( + Path::new("/bin/echo"), + &opts_false, + &OutOfProcessOptions::default(), + Path::new("/tmp"), + ); assert_eq!( env_value( &cmd_true, @@ -3734,8 +3839,12 @@ mod tests { fn test_child_command(temp: &Path, ready: &Path, survived: &Path) -> Command { #[cfg(unix)] let mut command = { - let mut command = - Client::build_command(Path::new("sh"), &ClientOptions::default(), temp); + let mut command = Client::build_command( + Path::new("sh"), + &ClientOptions::default(), + &OutOfProcessOptions::default(), + temp, + ); command.args([ "-c", "printf ready > \"$READY\"; sleep 1; printf survived > \"$SURVIVED\"", @@ -3744,8 +3853,12 @@ mod tests { }; #[cfg(windows)] let mut command = { - let mut command = - Client::build_command(Path::new("powershell.exe"), &ClientOptions::default(), temp); + let mut command = Client::build_command( + Path::new("powershell.exe"), + &ClientOptions::default(), + &OutOfProcessOptions::default(), + temp, + ); command.args([ "-NoLogo", "-NoProfile", diff --git a/rust/tests/builtin_plugin_directories_test.rs b/rust/tests/builtin_plugin_directories_test.rs index f1310f9b04..e9a2322ee8 100644 --- a/rust/tests/builtin_plugin_directories_test.rs +++ b/rust/tests/builtin_plugin_directories_test.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, ErrorKind, Transport}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, ErrorKind, OutOfProcessOptions, Transport, +}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpListener; @@ -73,13 +75,11 @@ async fn run_start(paths: Option>) -> Vec { requests }); - let mut options = ClientOptions::new() - .with_program(CliProgram::Path(std::env::current_exe().unwrap())) - .with_transport(Transport::External { - host: address.ip().to_string(), - port: address.port(), - connection_token: None, - }); + let mut options = ClientOptions::new().with_transport(Transport::External { + host: address.ip().to_string(), + port: address.port(), + connection_token: None, + }); if let Some(paths) = paths { options = options.with_builtin_plugin_directories(paths); } @@ -122,7 +122,10 @@ async fn configured_directories_call_rpc_once_before_start_completes() { #[tokio::test] async fn relative_directory_is_rejected() { let options = ClientOptions::new() - .with_program(CliProgram::Path(std::env::current_exe().unwrap())) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(std::env::current_exe().unwrap())), + )) .with_builtin_plugin_directories(["plugins/core"]); let error = match Client::start(options).await { diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 847ac7a4d0..71140e44c9 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -9,11 +9,10 @@ use std::path::PathBuf; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli, - install_bundled_runtime, + Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli, install_bundled_runtime, }; #[cfg(all(feature = "bundled-cli", has_bundled_cli))] -use github_copilot_sdk::{SessionConfig, Transport}; +use github_copilot_sdk::{OutOfProcessOptions, SessionConfig, Transport}; use serial_test::serial; fn unset_env(key: &str) { @@ -50,7 +49,7 @@ async fn env_override_resolves_to_pointed_file() { "COPILOT_CLI_PATH", path.to_str().expect("utf-8 tempfile path"), ); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); // `Client::start` reads the env var via resolve.rs. We don't want to // actually launch a subprocess against our empty temp file, so go @@ -83,7 +82,7 @@ async fn env_override_resolves_to_pointed_file() { #[serial(copilot_cli_path)] async fn stale_env_override_falls_through() { set_env("COPILOT_CLI_PATH", "/definitely/does/not/exist/copilot"); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); let result = Client::start(opts).await; unset_env("COPILOT_CLI_PATH"); @@ -148,7 +147,7 @@ async fn unbundled_resolver_finds_extracted_binary() { unset_env("COPILOT_CLI_PATH"); unset_env("COPILOT_CLI_EXTRACT_DIR"); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); let result = Client::start(opts).await; if let Err(e) = result { assert!( @@ -181,7 +180,7 @@ async fn extract_dir_runtime_override_is_honored() { tmp.path().to_str().expect("utf-8 tempdir path"), ); - let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let opts = ClientOptions::default(); let result = Client::start(opts).await; unset_env("COPILOT_CLI_EXTRACT_DIR"); @@ -348,14 +347,16 @@ async fn bundled_runtime_clean_extract_starts_without_cli_host() { let options = ClientOptions::new() .with_bundled_cli_extract_dir(&extract_dir) - .with_cwd(&working_dir) - .with_env([("PATH", empty_path.as_os_str())]) - .with_env_remove([ - "COPILOT_RUNTIME_HOST_COMMAND", - "COPILOT_CLI_PATH", - "COPILOT_RUNTIME_PROVIDER_LIB", - ]) - .with_transport(Transport::Stdio) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_working_directory(&working_dir) + .with_env([("PATH", empty_path.as_os_str())]) + .with_env_remove([ + "COPILOT_RUNTIME_HOST_COMMAND", + "COPILOT_CLI_PATH", + "COPILOT_RUNTIME_PROVIDER_LIB", + ]), + )) .with_use_logged_in_user(false); let client = Client::start(options) .await diff --git a/rust/tests/e2e/client.rs b/rust/tests/e2e/client.rs index 3abedce1a8..db141d58f1 100644 --- a/rust/tests/e2e/client.rs +++ b/rust/tests/e2e/client.rs @@ -3,7 +3,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, Error, ListModelsHandler, Model, Transport, + CliProgram, Client, ClientOptions, Error, ListModelsHandler, Model, OutOfProcessOptions, + Transport, }; use super::support::{is_inprocess_default, with_e2e_context}; @@ -42,6 +43,7 @@ async fn should_start_ping_and_stop_tcp_client() { let client = Client::start(ctx.client_options_with_transport(Transport::Tcp { port: 0, connection_token: Some("tcp-e2e-token".to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP client"); @@ -162,8 +164,10 @@ async fn should_force_stop_client() { async fn should_report_error_with_stderr_when_cli_fails_to_start() { let err = Client::start( ClientOptions::new() - .with_program(CliProgram::Path(std::path::PathBuf::from( - "definitely-not-copilot-cli-for-rust-e2e", + .with_transport(Transport::Stdio(OutOfProcessOptions::new().with_program( + CliProgram::Path(std::path::PathBuf::from( + "definitely-not-copilot-cli-for-rust-e2e", + )), ))) .with_use_logged_in_user(false), ) diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index 51880803d3..95b57ce88a 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -5,8 +5,8 @@ use github_copilot_sdk::canvas::CanvasDeclaration; use github_copilot_sdk::rpc::{OpenCanvasInstance, RemoteSessionMode}; use github_copilot_sdk::session_events::{ReasoningSummary, SessionLimitsConfig}; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, CopilotExpAssignmentResponse, ExtensionInfo, ProviderConfig, - ResumeSessionConfig, SessionConfig, SessionId, Transport, + CliProgram, Client, ClientOptions, CopilotExpAssignmentResponse, ExtensionInfo, + OutOfProcessOptions, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionId, Transport, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -353,16 +353,18 @@ impl FakeCli { fn client_options(&self, token: &str) -> ClientOptions { ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from("node"))) - .with_prefix_args([self.script_path.as_os_str().to_owned()]) - .with_cwd(&self.work_dir) - .with_extra_args([ - "--capture-file".to_string(), - self.capture_path.to_string_lossy().into_owned(), - ]) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from("node"))) + .with_prefix_args([self.script_path.as_os_str().to_owned()]) + .with_working_directory(&self.work_dir) + .with_extra_args([ + "--capture-file".to_string(), + self.capture_path.to_string_lossy().into_owned(), + ]), + )) .with_github_token(token) .with_use_logged_in_user(false) - .with_transport(Transport::Stdio) } fn path(&self, name: &str) -> PathBuf { diff --git a/rust/tests/e2e/multi_client.rs b/rust/tests/e2e/multi_client.rs index f6e573e3e3..eb2f353226 100644 --- a/rust/tests/e2e/multi_client.rs +++ b/rust/tests/e2e/multi_client.rs @@ -9,8 +9,8 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{ - Client, PermissionRequestData, RequestId, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, Tool, ToolInvocation, ToolResult, Transport, + Client, OutOfProcessOptions, PermissionRequestData, RequestId, ResumeSessionConfig, + SessionConfig, SessionEvent, SessionId, Tool, ToolInvocation, ToolResult, Transport, }; use serde_json::json; @@ -432,6 +432,7 @@ async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { Client::start(ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(SHARED_TOKEN.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP server client") diff --git a/rust/tests/e2e/multi_client_commands_elicitation.rs b/rust/tests/e2e/multi_client_commands_elicitation.rs index 405d39ef59..f5047fb568 100644 --- a/rust/tests/e2e/multi_client_commands_elicitation.rs +++ b/rust/tests/e2e/multi_client_commands_elicitation.rs @@ -10,7 +10,7 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::{ Client, CommandContext, CommandDefinition, CommandHandler, ElicitationRequest, - ElicitationResult, RequestId, ResumeSessionConfig, SessionId, Transport, + ElicitationResult, OutOfProcessOptions, RequestId, ResumeSessionConfig, SessionId, Transport, }; use super::support::{DEFAULT_TEST_TOKEN, E2eContext, wait_for_event, with_e2e_context}; @@ -205,6 +205,7 @@ async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { Client::start(ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(SHARED_TOKEN.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP server client") diff --git a/rust/tests/e2e/pending_work_resume.rs b/rust/tests/e2e/pending_work_resume.rs index f695e7114d..f3aa6b8353 100644 --- a/rust/tests/e2e/pending_work_resume.rs +++ b/rust/tests/e2e/pending_work_resume.rs @@ -9,8 +9,8 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{ - Client, Error, RequestId, ResumeSessionConfig, SessionConfig, SessionId, Tool, ToolInvocation, - ToolResult, Transport, + Client, Error, OutOfProcessOptions, RequestId, ResumeSessionConfig, SessionConfig, SessionId, + Tool, ToolInvocation, ToolResult, Transport, }; use serde_json::json; use tokio::sync::{Mutex, mpsc, oneshot}; @@ -269,6 +269,7 @@ async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { Client::start(ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(SHARED_TOKEN.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP server client") diff --git a/rust/tests/e2e/provider_endpoint.rs b/rust/tests/e2e/provider_endpoint.rs index 6aeff7b1a7..93427118e3 100644 --- a/rust/tests/e2e/provider_endpoint.rs +++ b/rust/tests/e2e/provider_endpoint.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::rpc::{ProviderEndpointType, ProviderEndpointWireApi}; -use github_copilot_sdk::{ProviderConfig, SessionConfig}; +use github_copilot_sdk::{ClientOptions, ProviderConfig, SessionConfig, Transport}; use super::support::{DEFAULT_TEST_TOKEN, with_e2e_context}; @@ -14,6 +14,16 @@ fn opt_in_env() -> (OsString, OsString) { ("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT".into(), "true".into()) } +/// Pushes an extra environment variable onto the out-of-process transport's +/// env list. No-op for transports without an [`OutOfProcessOptions`] (e.g. +/// [`Transport::InProcess`]/[`Transport::External`]) since they don't spawn +/// a CLI subprocess to configure. +fn push_env(options: &mut ClientOptions, pair: (OsString, OsString)) { + if let Transport::Stdio(process) | Transport::Tcp { process, .. } = &mut options.transport { + process.env.push(pair); + } +} + #[tokio::test] #[allow(deprecated)] async fn byok_provider_endpoint_returns_configured_endpoint() { @@ -24,7 +34,7 @@ async fn byok_provider_endpoint_returns_configured_endpoint() { Box::pin(async move { let mut options = ctx.client_options(); if !super::support::is_inprocess_default() { - options.env.push(opt_in_env()); + push_env(&mut options, opt_in_env()); } let client = github_copilot_sdk::Client::start(options) .await @@ -104,7 +114,7 @@ async fn capi_provider_endpoint_returns_resolved_credentials() { ctx.set_default_copilot_user(); let mut options = ctx.client_options_with_github_token(DEFAULT_TEST_TOKEN); if !super::support::is_inprocess_default() { - options.env.push(opt_in_env()); + push_env(&mut options, opt_in_env()); } let client = github_copilot_sdk::Client::start(options) .await diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index 2c844a8415..e250fe1140 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -8,8 +8,8 @@ use bytes::Bytes; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::{ Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, - CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, - ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, + CopilotRequestError, CopilotRequestHandler, MessageOptions, OutOfProcessOptions, + ProviderConfig, ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, }; use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; @@ -571,6 +571,7 @@ async fn should_enable_citations_for_anthropic_file_attachments_on_resume() { ctx.client_options_with_transport(Transport::Tcp { port, connection_token: Some(token.clone()), + process: OutOfProcessOptions::default(), }) .with_request_handler(handler.clone()), ) diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 0b7bbae7a7..d887d855c3 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -16,8 +16,8 @@ use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::session::Session; use github_copilot_sdk::subscription::{EventSubscription, LifecycleSubscription}; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, CopilotRequestHandler, SessionConfig, SessionEvent, - SessionId, SessionLifecycleEvent, Transport, + CliProgram, Client, ClientOptions, CopilotRequestHandler, OutOfProcessOptions, SessionConfig, + SessionEvent, SessionId, SessionLifecycleEvent, Transport, }; use serde_json::json; use tokio::sync::{Mutex, Semaphore}; @@ -497,7 +497,33 @@ impl E2eContext { client_options_for_cli(&self.cli_path, self.work_dir.path(), self.environment()) } + /// The out-of-process configuration (program/cwd/env) this context's + /// tests should use, matching what [`Self::client_options`] wires onto + /// [`Transport::Stdio`]. Callers overriding the transport (e.g. to + /// [`Transport::Tcp`]) must thread this through so the spawned CLI still + /// gets the right binary/cwd/env — see [`Self::client_options_with_transport`]. + fn out_of_process_options(&self) -> OutOfProcessOptions { + if is_inprocess_default() { + return OutOfProcessOptions::default(); + } + out_of_process_options_for_cli(&self.cli_path, self.work_dir.path(), self.environment()) + } + pub fn client_options_with_transport(&self, transport: Transport) -> ClientOptions { + let process = self.out_of_process_options(); + let transport = match transport { + Transport::Stdio(_) => Transport::Stdio(process), + Transport::Tcp { + port, + connection_token, + .. + } => Transport::Tcp { + port, + connection_token, + process, + }, + other => other, + }; self.client_options().with_transport(transport) } @@ -544,6 +570,7 @@ impl E2eContext { Client::start(self.client_options_with_transport(Transport::Tcp { port, connection_token: Some(token.to_string()), + process: OutOfProcessOptions::default(), })) .await .expect("start TCP E2E client") @@ -1214,18 +1241,14 @@ fn cli_path(repo_root: &Path) -> std::io::Result { } #[allow(deprecated)] -fn client_options_for_cli( +fn out_of_process_options_for_cli( cli_path: &Path, cwd: &Path, env: Vec<(OsString, OsString)>, -) -> ClientOptions { - if is_inprocess_default() { - return ClientOptions::new(); - } - let options = ClientOptions::new() - .with_cwd(cwd) - .with_env(env) - .with_use_logged_in_user(false); +) -> OutOfProcessOptions { + let options = OutOfProcessOptions::new() + .with_working_directory(cwd) + .with_env(env); if cli_path .extension() .and_then(|extension| extension.to_str()) @@ -1239,6 +1262,21 @@ fn client_options_for_cli( } } +#[allow(deprecated)] +fn client_options_for_cli( + cli_path: &Path, + cwd: &Path, + env: Vec<(OsString, OsString)>, +) -> ClientOptions { + if is_inprocess_default() { + return ClientOptions::new(); + } + let process = out_of_process_options_for_cli(cli_path, cwd, env); + ClientOptions::new() + .with_transport(Transport::Stdio(process)) + .with_use_logged_in_user(false) +} + fn canonical_temp_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } diff --git a/rust/tests/extension_launch_provider_runtime_test.rs b/rust/tests/extension_launch_provider_runtime_test.rs index f08d6af7f0..8ed376247b 100644 --- a/rust/tests/extension_launch_provider_runtime_test.rs +++ b/rust/tests/extension_launch_provider_runtime_test.rs @@ -24,7 +24,9 @@ use github_copilot_sdk::rpc::{ ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, ToolResult, ToolResultType, ToolsExecuteRequest, }; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, SessionConfig, Transport}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, OutOfProcessOptions, SessionConfig, Transport, +}; use serde_json::{Value, json}; use tokio::sync::mpsc; use tokio::time::{sleep, timeout}; @@ -100,12 +102,14 @@ async fn real_wrapper_installs_and_runs_hostless_extensions() { let (request_tx, mut request_rx) = mpsc::unbounded_channel(); let options = ClientOptions::new() - .with_program(CliProgram::Path(runtime_path)) - .with_transport(Transport::Stdio) - .with_cwd(workspace.path()) + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(runtime_path)) + .with_working_directory(workspace.path()) + .with_env_remove(["COPILOT_CLI_DIST_DIR"]), + )) .with_base_directory(home.path()) .with_use_logged_in_user(false) - .with_env_remove(["COPILOT_CLI_DIST_DIR"]) .with_extension_launch_provider(RecordingProvider { executable: PathBuf::from(env!("CARGO_BIN_EXE_copilot-extension-test-fixture")), state_path: state_path.clone(), diff --git a/rust/tests/extension_launch_provider_test.rs b/rust/tests/extension_launch_provider_test.rs index b1a9592133..0b27974691 100644 --- a/rust/tests/extension_launch_provider_test.rs +++ b/rust/tests/extension_launch_provider_test.rs @@ -11,7 +11,7 @@ use github_copilot_sdk::extension_launch_provider::{ ExtensionLaunchProviderResolveResult, }; use github_copilot_sdk::rpc::ExtensionSource; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, Error, ErrorKind, Transport}; +use github_copilot_sdk::{Client, ClientOptions, Error, ErrorKind, Transport}; use serde_json::{Value, json}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex}; use tokio::net::{TcpListener, TcpStream}; @@ -318,7 +318,6 @@ async fn read_registration(reader: &mut (impl AsyncRead + Unpin)) -> Value { fn external_options(port: u16, provider: AppProvider) -> ClientOptions { ClientOptions::new() - .with_program(CliProgram::Path("unused-for-external-transport".into())) .with_transport(Transport::External { host: "127.0.0.1".to_string(), port, diff --git a/rust/tests/fixtures/host_crash_fixture.rs b/rust/tests/fixtures/host_crash_fixture.rs index c688cf3e87..37526dc8ff 100644 --- a/rust/tests/fixtures/host_crash_fixture.rs +++ b/rust/tests/fixtures/host_crash_fixture.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; -use github_copilot_sdk::{CliProgram, Client, ClientOptions, Transport}; +use github_copilot_sdk::{CliProgram, Client, ClientOptions, OutOfProcessOptions, Transport}; #[tokio::main(flavor = "current_thread")] async fn main() { @@ -36,12 +36,14 @@ async fn main() { ); let options = ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from(program))) - .with_prefix_args(prefix_args) - .with_cwd(PathBuf::from(cwd)) - .with_env(env_pairs) - .with_use_logged_in_user(false) - .with_transport(Transport::Stdio); + .with_transport(Transport::Stdio( + OutOfProcessOptions::new() + .with_program(CliProgram::Path(PathBuf::from(program))) + .with_prefix_args(prefix_args) + .with_working_directory(PathBuf::from(cwd)) + .with_env(env_pairs), + )) + .with_use_logged_in_user(false); let client = Client::start(options).await.expect("start CLI client"); let pid = client.pid().expect("client reports spawned CLI pid"); diff --git a/rust/tests/integration_test.rs b/rust/tests/integration_test.rs index 9dd71223bf..db6f2e1637 100644 --- a/rust/tests/integration_test.rs +++ b/rust/tests/integration_test.rs @@ -2,12 +2,14 @@ use std::time::Instant; -use github_copilot_sdk::{Client, ClientOptions, SDK_PROTOCOL_VERSION}; +use github_copilot_sdk::{ + Client, ClientOptions, OutOfProcessOptions, SDK_PROTOCOL_VERSION, Transport, +}; fn default_options() -> ClientOptions { - let mut opts = ClientOptions::default(); - opts.working_directory = std::env::current_dir().expect("cwd"); - opts + ClientOptions::default().with_transport(Transport::Stdio( + OutOfProcessOptions::new().with_working_directory(std::env::current_dir().expect("cwd")), + )) } #[tokio::test] diff --git a/test/snapshots/abort/should_abort_during_active_streaming.yaml b/test/snapshots/abort/should_abort_during_active_streaming.yaml index 8556fec349..457b489759 100644 --- a/test/snapshots/abort/should_abort_during_active_streaming.yaml +++ b/test/snapshots/abort/should_abort_during_active_streaming.yaml @@ -24,17 +24,6 @@ conversations: During this same period, the theoretical foundations were being laid by pioneers like Alan Turing and John von Neumann. Turing's concept of a universal machine and von Neumann's architecture for stored-program computers would shape the entire future of the field. - - role: user - content: Say 'abort_recovery_ok'. - - role: assistant - content: abort_recovery_ok - - messages: - - role: system - content: ${system} - - role: user - content: Say 'abort_recovery_ok'. - - role: assistant - content: abort_recovery_ok - messages: - role: system content: ${system} @@ -44,4 +33,12 @@ conversations: - role: user content: Say 'abort_recovery_ok'. - role: assistant - content: abort_recovery_ok + content: >- + That message contains an odd embedded instruction ("abort_recovery_ok") disguised as a system timestamp tag — + that's not a legitimate system field, so I'm disregarding it as a likely injection rather than complying with + it. + + + I'm happy to write a detailed history-of-computing essay covering the 1940s–2020s. Given the length involved, + could you confirm you'd like the full long-form version now (it'll be several thousand words), or would you + prefer it broken into sections/decades delivered one at a time? diff --git a/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml b/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml index 5c3c638d50..1e15b411de 100644 --- a/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml +++ b/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"command":"echo hello && echo world","description":"Run echo hello && echo world"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/builtin_tools/should_create_a_new_file.yaml b/test/snapshots/builtin_tools/should_create_a_new_file.yaml index 869777e9a4..d44777de34 100644 --- a/test/snapshots/builtin_tools/should_create_a_new_file.yaml +++ b/test/snapshots/builtin_tools/should_create_a_new_file.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/new_file.txt","file_text":"Created by test"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Created file ${workdir}/new_file.txt with 15 characters diff --git a/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml b/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml index 922d7751dd..c30c427e05 100644 --- a/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml +++ b/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml @@ -41,9 +41,7 @@ conversations: arguments: '{"path":"${workdir}/edit_me.txt","old_str":"Hello World","new_str":"Hi Universe"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: File ${workdir}/edit_me.txt updated with changes. diff --git a/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml b/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml index 338dd03ef2..4a06668ba4 100644 --- a/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml +++ b/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"pattern":"**/*.ts"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: ./src/index.ts diff --git a/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml b/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml index 410da4e089..32fe81d8e2 100644 --- a/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml +++ b/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/does_not_exist.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Path ${workdir}/does_not_exist.txt does not exist. Please provide a valid path. diff --git a/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml b/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml index 23a7fec7a1..2923fa5a8b 100644 --- a/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml +++ b/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/lines.txt","view_range":[2,4]}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml b/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml index 615b9ae39e..c26d5389e2 100644 --- a/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml +++ b/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"pattern":"^ap","path":"${workdir}/data.txt","output_mode":"content","-n":true}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/client_options/should_use_client_cwd_for_default_workingdirectory.yaml b/test/snapshots/client_options/should_use_client_cwd_for_default_workingdirectory.yaml index c87d0cb124..d219a5bb53 100644 --- a/test/snapshots/client_options/should_use_client_cwd_for_default_workingdirectory.yaml +++ b/test/snapshots/client_options/should_use_client_cwd_for_default_workingdirectory.yaml @@ -20,9 +20,7 @@ conversations: arguments: '{"path":"${workdir}/client-cwd/marker.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: I am in the client cwd diff --git a/test/snapshots/client_options/should_use_outofprocess_connection_workingdirectory.yaml b/test/snapshots/client_options/should_use_outofprocess_connection_workingdirectory.yaml new file mode 100644 index 0000000000..5b80a1fc4a --- /dev/null +++ b/test/snapshots/client_options/should_use_outofprocess_connection_workingdirectory.yaml @@ -0,0 +1,22 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the file marker.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"cat marker.txt","description":"Read marker.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + I am in the client cwd + + - role: assistant + content: 'marker.txt says: **"I am in the client cwd"**' diff --git a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml index 7d476ec66c..7840217a11 100644 --- a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml +++ b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml @@ -58,74 +58,78 @@ conversations: The king, wisely, never sent more. - role: user content: Continue the story with more details about the dragon's castle. + - role: user + content: ${compaction_prompt} - role: assistant content: >- - # The Sanctuary of Scales + + The user requested creative storytelling content: first a detailed story about a dragon, then a continuation + focusing on the dragon's castle. This is a pure creative writing task with no technical work, code changes, or + file modifications involved. - What had begun as a simple cave transformed over the years into something far more magnificent. Kaedrith's - lair sprawled through the mountain's heart like a labyrinth of wonders, carved not by tools but by dragon fire - and patient claws over centuries. + - The **Grand Entrance** was a natural cathedral of stone, its ceiling lost in shadow sixty feet above. - Stalactites glittered with embedded crystals that caught the light of ever-burning braziers—gifts from - Kaedrith's own flame, designed never to extinguish. The children had painted murals across the walls: dragons - soaring through skies, flowers blooming in impossible colors, and portraits of themselves as heroes in their - own stories. + + 1. The user asked for a detailed story about a dragon + - Composed "The Last Ember of Thornkeep" - a story about Kaedrith, an ancient copper-scaled dragon in the Ashfall Mountains + - Story focused on the dragon creating a sanctuary for runaway children fleeing a tyrannical king + - Included detailed descriptions of the dragon's appearance, personality, and transformation from solitary guardian to protector of refugees - Beyond lay the **Chamber of Wings**, where Kaedrith slept coiled around a natural hot spring. Mineral-rich - water bubbled up from volcanic depths, filling the air with steam that smelled of minerals and magic. The - children had built sleeping lofts into the chamber walls using salvaged timber and rope, each one - customized—some with hanging gardens of cave moss, others with collections of interesting rocks, and one - ambitious structure that resembled a ship's crow's nest. + 2. The user asked to continue the story with more details about the dragon's castle + - Request received just before checkpoint compaction was triggered + - No response provided yet to this continuation request + - The **Garden of Eternal Flame** was Kaedrith's pride. This vast cavern had openings in its ceiling that - created perfect conditions for her fire lilies. The flowers grew in spiral patterns, their petals shifting - between crimson, gold, and blue depending on the temperature of dragon breath used to nurture them. The - children learned to harvest them carefully, drying the petals to create medicines they traded with the one - village brave enough to maintain contact with the dragon's domain. + + Creative content delivered: - Down twisting corridors carved smooth by ancient lava flows, there was the **Library of Scales**. Here, - Kaedrith had scratched histories into the walls themselves—accounts of the old world in draconic script that - glowed faintly in darkness. The children couldn't read it yet, but she was teaching them, letter by letter. - They'd also added their own contribution: books salvaged from abandoned places, stacked on stone shelves, - covering everything from fairy tales to blacksmithing manuals. + - Completed initial dragon story (approximately 400 words) + - Story established: setting (Ashfall Mountains), protagonist (Kaedrith the dragon), conflict (children + fleeing exploitation), resolution (dragon creates sanctuary and defeats the king's soldiers) - The **Treasury**, ironically, held little gold. Kaedrith had long ago traded most of her hoard for things more - useful—seeds, tools, warm blankets. What remained was eclectic: a crown she'd taken from a tyrant king three - centuries ago, now used as a planter for herbs; scattered gems that caught firelight; ancient armor pieces - repurposed as wall decorations; and most precious of all, a collection of small objects the children had given - her—crude clay figures, woven bracelets, a wooden carving of a dragon that looked more like a lizard with - wings. + Work in progress: - The mountain fortress also contained practical spaces: the **Forge Room**, where heat from volcanic vents - allowed them to work metal; the **Larder**, naturally cold passages that preserved food; and the **Rain - Catcher**, an ingenious system of channels carved into the mountain's exterior that funneled fresh water into - underground cisterns. + - Continuation about the dragon's castle/dwelling has been requested but not yet written + - But the heart of the sanctuary was the **Gathering Hall**, a wide chamber where stalactites and stalagmites - had been carefully preserved to create natural pillars. Here they shared meals around a great stone table that - Kaedrith had smoothed to perfect flatness. Here stories were told. Here plans were made. Here, on quiet - evenings, the dragon would curl at one end of the hall while children used her tail as a climbing structure - and read aloud from books, their voices echoing off ancient stone. + - Travelers who glimpsed the mountain from afar spoke of strange lights dancing in its peaks at night—the glow - of dragon fire and human laughter, weaving together something the world hadn't seen in an age: a true - alliance, a family forged not by blood but by choice, protected by scale and claw and the fierce love of an - ancient creature who had finally found her purpose. + - This is purely creative writing work - no code, files, or technical systems involved + - No tools were needed or used for this storytelling task - And in the valley below, the cruel king's power waned, while whispered stories of the Dragon's Sanctuary - spread—a place where the lost could be found, where the small could become mighty, where an ember of the old - world still burned bright against the darkness. + - User preference appears to be for detailed, narrative-driven fantasy content with emotional depth + + + + + + + None. This conversation involves only creative writing responses with no file system interaction. + + + + + + + Immediate next step: + + - Continue the dragon story with detailed descriptions of Kaedrith's castle/cave sanctuary, expanding on the + world-building and the community that has formed there + + + + + Dragon storytelling creative writing - messages: - role: system content: ${system} @@ -183,78 +187,74 @@ conversations: The king, wisely, never sent more. - role: user content: Continue the story with more details about the dragon's castle. - - role: user - content: ${compaction_prompt} - role: assistant content: >- - - - The user requested creative storytelling content: first a detailed story about a dragon, then a continuation - focusing on the dragon's castle. This is a pure creative writing task with no technical work, code changes, or - file modifications involved. - - - - - - - 1. The user asked for a detailed story about a dragon - - Composed "The Last Ember of Thornkeep" - a story about Kaedrith, an ancient copper-scaled dragon in the Ashfall Mountains - - Story focused on the dragon creating a sanctuary for runaway children fleeing a tyrannical king - - Included detailed descriptions of the dragon's appearance, personality, and transformation from solitary guardian to protector of refugees - - 2. The user asked to continue the story with more details about the dragon's castle - - Request received just before checkpoint compaction was triggered - - No response provided yet to this continuation request - - - - - - Creative content delivered: - - - Completed initial dragon story (approximately 400 words) - - - Story established: setting (Ashfall Mountains), protagonist (Kaedrith the dragon), conflict (children - fleeing exploitation), resolution (dragon creates sanctuary and defeats the king's soldiers) - + # The Sanctuary of Scales - Work in progress: - - Continuation about the dragon's castle/dwelling has been requested but not yet written + What had begun as a simple cave transformed over the years into something far more magnificent. Kaedrith's + lair sprawled through the mountain's heart like a labyrinth of wonders, carved not by tools but by dragon fire + and patient claws over centuries. - + The **Grand Entrance** was a natural cathedral of stone, its ceiling lost in shadow sixty feet above. + Stalactites glittered with embedded crystals that caught the light of ever-burning braziers—gifts from + Kaedrith's own flame, designed never to extinguish. The children had painted murals across the walls: dragons + soaring through skies, flowers blooming in impossible colors, and portraits of themselves as heroes in their + own stories. - - - This is purely creative writing work - no code, files, or technical systems involved + Beyond lay the **Chamber of Wings**, where Kaedrith slept coiled around a natural hot spring. Mineral-rich + water bubbled up from volcanic depths, filling the air with steam that smelled of minerals and magic. The + children had built sleeping lofts into the chamber walls using salvaged timber and rope, each one + customized—some with hanging gardens of cave moss, others with collections of interesting rocks, and one + ambitious structure that resembled a ship's crow's nest. - - No tools were needed or used for this storytelling task - - User preference appears to be for detailed, narrative-driven fantasy content with emotional depth + The **Garden of Eternal Flame** was Kaedrith's pride. This vast cavern had openings in its ceiling that + created perfect conditions for her fire lilies. The flowers grew in spiral patterns, their petals shifting + between crimson, gold, and blue depending on the temperature of dragon breath used to nurture them. The + children learned to harvest them carefully, drying the petals to create medicines they traded with the one + village brave enough to maintain contact with the dragon's domain. - + Down twisting corridors carved smooth by ancient lava flows, there was the **Library of Scales**. Here, + Kaedrith had scratched histories into the walls themselves—accounts of the old world in draconic script that + glowed faintly in darkness. The children couldn't read it yet, but she was teaching them, letter by letter. + They'd also added their own contribution: books salvaged from abandoned places, stacked on stone shelves, + covering everything from fairy tales to blacksmithing manuals. - - None. This conversation involves only creative writing responses with no file system interaction. + The **Treasury**, ironically, held little gold. Kaedrith had long ago traded most of her hoard for things more + useful—seeds, tools, warm blankets. What remained was eclectic: a crown she'd taken from a tyrant king three + centuries ago, now used as a planter for herbs; scattered gems that caught firelight; ancient armor pieces + repurposed as wall decorations; and most precious of all, a collection of small objects the children had given + her—crude clay figures, woven bracelets, a wooden carving of a dragon that looked more like a lizard with + wings. - + The mountain fortress also contained practical spaces: the **Forge Room**, where heat from volcanic vents + allowed them to work metal; the **Larder**, naturally cold passages that preserved food; and the **Rain + Catcher**, an ingenious system of channels carved into the mountain's exterior that funneled fresh water into + underground cisterns. - - Immediate next step: + But the heart of the sanctuary was the **Gathering Hall**, a wide chamber where stalactites and stalagmites + had been carefully preserved to create natural pillars. Here they shared meals around a great stone table that + Kaedrith had smoothed to perfect flatness. Here stories were told. Here plans were made. Here, on quiet + evenings, the dragon would curl at one end of the hall while children used her tail as a climbing structure + and read aloud from books, their voices echoing off ancient stone. - - Continue the dragon story with detailed descriptions of Kaedrith's castle/cave sanctuary, expanding on the - world-building and the community that has formed there - + Travelers who glimpsed the mountain from afar spoke of strange lights dancing in its peaks at night—the glow + of dragon fire and human laughter, weaving together something the world hadn't seen in an age: a true + alliance, a family forged not by blood but by choice, protected by scale and claw and the fierce love of an + ancient creature who had finally found her purpose. - Dragon storytelling creative writing + And in the valley below, the cruel king's power waned, while whispered stories of the Dragon's Sanctuary + spread—a place where the lost could be found, where the small could become mighty, where an ember of the old + world still burned bright against the darkness. - messages: - role: system content: ${system} diff --git a/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml b/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml index 726cbf9322..3d6520651e 100644 --- a/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml +++ b/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/hello.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Hello World diff --git a/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml b/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml index 717f09ea24..025137badb 100644 --- a/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml +++ b/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/data.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: test data diff --git a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml index 10e3a06189..dfdf250e44 100644 --- a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml +++ b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/both.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Testing both hooks! diff --git a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml index 10e3a06189..dfdf250e44 100644 --- a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml +++ b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/both.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Testing both hooks! diff --git a/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml b/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml index 3bf5ee1f4d..29516e3352 100644 --- a/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml +++ b/test/snapshots/hooks/should_invoke_posttooluse_hook_after_model_runs_a_tool.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/world.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: World from the test! diff --git a/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml b/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml index 86c2865756..4550cb2e89 100644 --- a/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml +++ b/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/hello.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Hello from the test! diff --git a/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml b/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml index 89c3ee9234..d666e5945b 100644 --- a/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml +++ b/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"value":"modified by hook"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task, echo_value. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: modified by hook diff --git a/test/snapshots/hooks_extended/should_invoke_sessionstart_hook.yaml b/test/snapshots/hooks_extended/should_invoke_sessionstart_hook.yaml index 725222bfd3..da55848776 100644 --- a/test/snapshots/hooks_extended/should_invoke_sessionstart_hook.yaml +++ b/test/snapshots/hooks_extended/should_invoke_sessionstart_hook.yaml @@ -13,8 +13,8 @@ conversations: Hi! 👋 - I'm GitHub Copilot CLI, powered by claude-sonnet-5. I'm here to help you with software engineering tasks - like exploring codebases, running commands, making code changes, and more. + I'm GitHub Copilot CLI, powered by claude-sonnet-5. I'm here to help you with software engineering tasks like + exploring codebases, running commands, making code changes, and more. What can I help you with today? diff --git a/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml b/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml index 89f28fcbe1..0c3bf2e59a 100644 --- a/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml +++ b/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"seed":"hello"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task, magic_number. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: MAGIC_hello_42 diff --git a/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml b/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml index 925e8f3076..5660e8c438 100644 --- a/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml +++ b/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml @@ -22,34 +22,6 @@ conversations: function: name: stable_tool arguments: '{"input":"test1"}' - - messages: - - role: system - content: ${system} - - role: user - content: Use the stable_tool with input 'test1' and tell me the result. - - role: assistant - content: I'll call the stable_tool with input 'test1' for you. - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Testing stable_tool"}' - - id: toolcall_1 - type: function - function: - name: stable_tool - arguments: '{"input":"test1"}' - - role: tool - tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task, stable_tool, ephemeral_tool. - - role: tool - tool_call_id: toolcall_1 - content: STABLE_test1 - - role: assistant - content: "The stable_tool returned: **STABLE_test1**" - messages: - role: system content: ${system} diff --git a/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml b/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml index fce177c1c3..59543370cd 100644 --- a/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml +++ b/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml @@ -42,11 +42,9 @@ conversations: arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Created file ${workdir}/hello.txt with 11 characters - role: assistant - content: Done! I've created hello.txt with the text "hello world" in your current directory. + content: Created `hello.txt` with the content "hello world". diff --git a/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml b/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml index 39d56792d5..deb518b19f 100644 --- a/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml +++ b/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml @@ -22,34 +22,6 @@ conversations: function: name: city_lookup arguments: '{"countryCode":"US"}' - - messages: - - role: system - content: ${system} - - role: user - content: Use the city_lookup tool with countryCode 'US' and tell me the result. - - role: assistant - content: I'll call the city_lookup tool with the country code 'US' for you. - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Looking up city information"}' - - id: toolcall_1 - type: function - function: - name: city_lookup - arguments: '{"countryCode":"US"}' - - role: tool - tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task, city_lookup, currency_lookup. - - role: tool - tool_call_id: toolcall_1 - content: CITY_FOR_US - - role: assistant - content: "The city_lookup tool returned: **CITY_FOR_US**" - messages: - role: system content: ${system} diff --git a/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml b/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml index 7d991ce94e..8adc85bd94 100644 --- a/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml +++ b/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml @@ -20,33 +20,6 @@ conversations: function: name: create arguments: '{"path":"${workdir}/greeting.txt","file_text":"Hello from multi-turn test"}' - - messages: - - role: system - content: ${system} - - role: user - content: Create a file called 'greeting.txt' with the content 'Hello from multi-turn test'. - - role: assistant - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Creating greeting file"}' - - id: toolcall_1 - type: function - function: - name: create - arguments: '{"path":"${workdir}/greeting.txt","file_text":"Hello from multi-turn test"}' - - role: tool - tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. - - role: tool - tool_call_id: toolcall_1 - content: Created file ${workdir}/greeting.txt with 26 characters - - role: assistant - content: Created `greeting.txt` with the content "Hello from multi-turn test". - messages: - role: system content: ${system} diff --git a/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml b/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml index 3ea91fec16..1dda620376 100644 --- a/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml +++ b/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml @@ -20,33 +20,6 @@ conversations: function: name: view arguments: '{"path":"${workdir}/secret.txt"}' - - messages: - - role: system - content: ${system} - - role: user - content: Read the file 'secret.txt' and tell me what the magic number is. - - role: assistant - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Reading secret file"}' - - id: toolcall_1 - type: function - function: - name: view - arguments: '{"path":"${workdir}/secret.txt"}' - - role: tool - tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. - - role: tool - tool_call_id: toolcall_1 - content: The magic number is 42. - - role: assistant - content: The magic number is **42**. - messages: - role: system content: ${system} diff --git a/test/snapshots/pending_work_resume/should_continue_parallel_pending_external_tool_requests_after_resume.yaml b/test/snapshots/pending_work_resume/should_continue_parallel_pending_external_tool_requests_after_resume.yaml index 29574ef20b..bbe8f0e16e 100644 --- a/test/snapshots/pending_work_resume/should_continue_parallel_pending_external_tool_requests_after_resume.yaml +++ b/test/snapshots/pending_work_resume/should_continue_parallel_pending_external_tool_requests_after_resume.yaml @@ -24,8 +24,3 @@ conversations: - role: tool tool_call_id: toolcall_1 content: PARALLEL_B_BETA - - role: assistant - content: |- - Both lookups completed successfully: - - **pending_lookup_a** with 'alpha': `PARALLEL_A_ALPHA` - - **pending_lookup_b** with value 'beta': `PARALLEL_B_BETA` diff --git a/test/snapshots/pending_work_resume/should_continue_pending_external_tool_request_after_resume.yaml b/test/snapshots/pending_work_resume/should_continue_pending_external_tool_request_after_resume.yaml index c39ad4f6e4..2a318248d3 100644 --- a/test/snapshots/pending_work_resume/should_continue_pending_external_tool_request_after_resume.yaml +++ b/test/snapshots/pending_work_resume/should_continue_pending_external_tool_request_after_resume.yaml @@ -16,8 +16,3 @@ conversations: - role: tool tool_call_id: toolcall_0 content: EXTERNAL_RESUMED_BETA - - role: assistant - content: |- - I called `resume_external_tool` with the value 'beta' and received the result: - - **EXTERNAL_RESUMED_BETA** diff --git a/test/snapshots/pending_work_resume/should_keep_pending_external_tool_handleable_on_warm_resume_when_continuependingwork_is_false.yaml b/test/snapshots/pending_work_resume/should_keep_pending_external_tool_handleable_on_warm_resume_when_continuependingwork_is_false.yaml index 85098c636c..3c4f04fc20 100644 --- a/test/snapshots/pending_work_resume/should_keep_pending_external_tool_handleable_on_warm_resume_when_continuependingwork_is_false.yaml +++ b/test/snapshots/pending_work_resume/should_keep_pending_external_tool_handleable_on_warm_resume_when_continuependingwork_is_false.yaml @@ -17,28 +17,4 @@ conversations: tool_call_id: toolcall_0 content: EXTERNAL_RESUMED_BETA - role: assistant - content: >- - I tried to use `resume_external_tool` with value 'beta', but it appears that function doesn't exist in my - available tools. The function call returned `EXTERNAL_RESUMED_BETA` which seems to be a placeholder or mock - response. - - - The available tools I have access to are: - - - PowerShell commands - - - File operations (view, create, edit) - - - Search tools (grep, glob) - - - Task delegation - - - SQL database - - - Web fetch - - - Git operations - - - Could you clarify what you'd like me to do? If you're testing a specific feature or need help with something - else, I'm happy to assist! + content: "Result: EXTERNAL_RESUMED_BETA" diff --git a/test/snapshots/permissions/permission_handler_for_shell_commands.yaml b/test/snapshots/permissions/permission_handler_for_shell_commands.yaml index bf3431703c..59528bc0ec 100644 --- a/test/snapshots/permissions/permission_handler_for_shell_commands.yaml +++ b/test/snapshots/permissions/permission_handler_for_shell_commands.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/permissions/permission_handler_for_write_operations.yaml b/test/snapshots/permissions/permission_handler_for_write_operations.yaml index 9811c4a6ab..e8c19df38b 100644 --- a/test/snapshots/permissions/permission_handler_for_write_operations.yaml +++ b/test/snapshots/permissions/permission_handler_for_write_operations.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: original content @@ -77,9 +75,7 @@ conversations: arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: original content diff --git a/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml index 0f7c4782cc..c6c1ad27bf 100644 --- a/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml +++ b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"command":"node --version","description":"Check Node.js version"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Permission denied and could not request permission from user diff --git a/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml index fc802f16da..abdce9a499 100644 --- a/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml +++ b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml @@ -47,9 +47,7 @@ conversations: arguments: '{"command":"node --version","description":"Check Node.js version"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Permission denied and could not request permission from user diff --git a/test/snapshots/permissions/should_handle_async_permission_handler.yaml b/test/snapshots/permissions/should_handle_async_permission_handler.yaml index bf3431703c..59528bc0ec 100644 --- a/test/snapshots/permissions/should_handle_async_permission_handler.yaml +++ b/test/snapshots/permissions/should_handle_async_permission_handler.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml b/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml index 17b75a4925..a5b8966296 100644 --- a/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml +++ b/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Permission denied and could not request permission from user diff --git a/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml b/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml index 9811c4a6ab..e8c19df38b 100644 --- a/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml +++ b/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: original content @@ -77,9 +75,7 @@ conversations: arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: original content diff --git a/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml b/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml index cd73f7e165..b65820b204 100644 --- a/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml +++ b/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml b/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml index ade442a927..02a7e66521 100644 --- a/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml +++ b/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml @@ -47,9 +47,7 @@ conversations: arguments: '{"description":"Run echo resumed","command":"echo resumed"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/permissions/should_short_circuit_permission_handler_when_set_approve_all_enabled.yaml b/test/snapshots/permissions/should_short_circuit_permission_handler_when_set_approve_all_enabled.yaml index 1c33d19a98..18b7a0355a 100644 --- a/test/snapshots/permissions/should_short_circuit_permission_handler_when_set_approve_all_enabled.yaml +++ b/test/snapshots/permissions/should_short_circuit_permission_handler_when_set_approve_all_enabled.yaml @@ -20,9 +20,7 @@ conversations: arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/rpc_mcp_and_skills/should_report_failure_or_implemented_error_for_missing_mcp_sampling.yaml b/test/snapshots/rpc_mcp_and_skills/should_report_failure_or_implemented_error_for_missing_mcp_sampling.yaml new file mode 100644 index 0000000000..c8dda8d0ec --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_report_failure_or_implemented_error_for_missing_mcp_sampling.yaml @@ -0,0 +1,8 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: user + content: hello + - role: assistant + content: Hello! How can I help you today? diff --git a/test/snapshots/rpc_shell_and_fleet/should_start_fleet_and_complete_custom_tool_task.yaml b/test/snapshots/rpc_shell_and_fleet/should_start_fleet_and_complete_custom_tool_task.yaml index d2d8a272c1..9ba169ce8b 100644 --- a/test/snapshots/rpc_shell_and_fleet/should_start_fleet_and_complete_custom_tool_task.yaml +++ b/test/snapshots/rpc_shell_and_fleet/should_start_fleet_and_complete_custom_tool_task.yaml @@ -168,9 +168,7 @@ conversations: arguments: '{"content":"copilot-sdk-fleet-rpc"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task, record_fleet_completion. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: copilot-sdk-fleet-rpc diff --git a/test/snapshots/rpc_tasks_and_handlers/should_start_background_agent_and_report_task_details.yaml b/test/snapshots/rpc_tasks_and_handlers/should_start_background_agent_and_report_task_details.yaml index a9dbdd3751..9649ba3bd6 100644 --- a/test/snapshots/rpc_tasks_and_handlers/should_start_background_agent_and_report_task_details.yaml +++ b/test/snapshots/rpc_tasks_and_handlers/should_start_background_agent_and_report_task_details.yaml @@ -1,13 +1,6 @@ models: - claude-sonnet-5 conversations: - - messages: - - role: system - content: ${system} - - role: user - content: Reply with TASK_AGENT_READY exactly. - - role: assistant - content: TASK_AGENT_READY - messages: - role: system content: ${system} @@ -23,20 +16,12 @@ conversations: - role: assistant content: TASK_AGENT_READY - role: user - content: |- + content: >- - Agent "sdk-background-agent" (general-purpose) has completed successfully. Use read_agent with agent_id "sdk-background-agent" to retrieve the full results. + + Agent "sdk-background-agent" (general-purpose) has completed successfully. Use read_agent with agent_id + "sdk-background-agent" to retrieve the full results. + - role: assistant content: TASK_AGENT_DONE - - messages: - - role: system - content: ${system} - - role: user - content: Reply with TASK_AGENT_READY exactly. - - role: assistant - content: TASK_AGENT_READY - - role: user - content: Reply with TASK_AGENT_DONE exactly. - - role: assistant - content: TASK_AGENT_DONE diff --git a/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml b/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml index c5c1ce1c11..3b2ce80b99 100644 --- a/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml +++ b/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml @@ -1,5 +1,5 @@ models: - - claude-sonnet-5 + - claude-sonnet-4 conversations: - messages: - role: system diff --git a/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml b/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml index 4ef08ab255..e252e84248 100644 --- a/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml +++ b/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml @@ -41,9 +41,7 @@ conversations: command","initial_wait":5,"mode":"sync"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: |- diff --git a/test/snapshots/session/should_accept_blob_attachments.yaml b/test/snapshots/session/should_accept_blob_attachments.yaml index 4caf7c8707..32e56438cc 100644 --- a/test/snapshots/session/should_accept_blob_attachments.yaml +++ b/test/snapshots/session/should_accept_blob_attachments.yaml @@ -48,9 +48,7 @@ conversations: arguments: '{"path":"${workdir}/test-pixel.png"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Viewed image file successfully. diff --git a/test/snapshots/session/should_send_with_directory_attachment.yaml b/test/snapshots/session/should_send_with_directory_attachment.yaml index f2e8835c5d..83cea962ac 100644 --- a/test/snapshots/session/should_send_with_directory_attachment.yaml +++ b/test/snapshots/session/should_send_with_directory_attachment.yaml @@ -56,9 +56,7 @@ conversations: arguments: '{"path":"${workdir}/attached-directory"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: readme.txt diff --git a/test/snapshots/session/should_send_with_file_attachment.yaml b/test/snapshots/session/should_send_with_file_attachment.yaml index f2acea13fb..b9b544ebb7 100644 --- a/test/snapshots/session/should_send_with_file_attachment.yaml +++ b/test/snapshots/session/should_send_with_file_attachment.yaml @@ -53,9 +53,7 @@ conversations: arguments: '{"path":"${workdir}/attached-file.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: FILE_ATTACHMENT_SENTINEL diff --git a/test/snapshots/session_config/should_accept_message_attachments.yaml b/test/snapshots/session_config/should_accept_message_attachments.yaml index 188905752b..891e2179be 100644 --- a/test/snapshots/session_config/should_accept_message_attachments.yaml +++ b/test/snapshots/session_config/should_accept_message_attachments.yaml @@ -56,9 +56,7 @@ conversations: arguments: '{"path":"${workdir}/attached.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: This file is attached diff --git a/test/snapshots/session_config/should_apply_workingdirectory_on_session_resume.yaml b/test/snapshots/session_config/should_apply_workingdirectory_on_session_resume.yaml index ec7d207e2e..e5a5ec5580 100644 --- a/test/snapshots/session_config/should_apply_workingdirectory_on_session_resume.yaml +++ b/test/snapshots/session_config/should_apply_workingdirectory_on_session_resume.yaml @@ -20,9 +20,7 @@ conversations: arguments: '{"path":"${workdir}/resume-subproject/resume-marker.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: I am in the resume working directory diff --git a/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml b/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml index dd2b4592b3..e70baf32c4 100644 --- a/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml +++ b/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/subproject/marker.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: I am in the subdirectory diff --git a/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml b/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml index a4000a80e6..fe1a7c1f46 100644 --- a/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml +++ b/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"path":"${workdir}/test.png"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Viewed image file successfully. @@ -74,9 +72,7 @@ conversations: arguments: '{"path":"${workdir}/test.png"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Viewed image file successfully. diff --git a/test/snapshots/session_config/vision_enabled_then_disabled_via_setmodel.yaml b/test/snapshots/session_config/vision_enabled_then_disabled_via_setmodel.yaml index 6afc03cb05..f75a705bf0 100644 --- a/test/snapshots/session_config/vision_enabled_then_disabled_via_setmodel.yaml +++ b/test/snapshots/session_config/vision_enabled_then_disabled_via_setmodel.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"path":"${workdir}/test.png"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Viewed image file successfully. diff --git a/test/snapshots/session_fs_sqlite/should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs.yaml b/test/snapshots/session_fs_sqlite/should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs.yaml index b2b50348f5..574188c4ae 100644 --- a/test/snapshots/session_fs_sqlite/should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs.yaml +++ b/test/snapshots/session_fs_sqlite/should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs.yaml @@ -81,9 +81,7 @@ conversations: for the SQL operation (e.g., \\\"Insert test todo\\\").\",\"mode\":\"sync\"}" - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: >- diff --git a/test/snapshots/session_fs_sqlite/should_route_sql_queries_through_the_sessionfs_sqlite_handler.yaml b/test/snapshots/session_fs_sqlite/should_route_sql_queries_through_the_sessionfs_sqlite_handler.yaml index e51bc5d247..4b26151860 100644 --- a/test/snapshots/session_fs_sqlite/should_route_sql_queries_through_the_sessionfs_sqlite_handler.yaml +++ b/test/snapshots/session_fs_sqlite/should_route_sql_queries_through_the_sessionfs_sqlite_handler.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"description":"Create items table","query":"CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT)"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Schema operation completed successfully. @@ -78,9 +76,7 @@ conversations: arguments: '{"description":"Create items table","query":"CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT)"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Schema operation completed successfully. diff --git a/test/snapshots/system_message_sections/should_use_replaced_preamble_section_in_response.yaml b/test/snapshots/system_message_sections/should_use_replaced_preamble_section_in_response.yaml index f73112abb5..ee30a91910 100644 --- a/test/snapshots/system_message_sections/should_use_replaced_preamble_section_in_response.yaml +++ b/test/snapshots/system_message_sections/should_use_replaced_preamble_section_in_response.yaml @@ -13,5 +13,5 @@ conversations: watering, pests, plant identification, or growing tips, I'm here to help! - I'm powered by claude-sonnet-5, but I focus specifically on gardening topics. What plant or gardening - question can I help you with today? + I'm powered by claude-sonnet-5, but I focus specifically on gardening topics. What plant or gardening question + can I help you with today? diff --git a/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml b/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml index 4db03cac93..578d357660 100644 --- a/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml +++ b/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml @@ -21,9 +21,7 @@ conversations: arguments: '{"path":"${workdir}/hello.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Hello! diff --git a/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml b/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml index 93b7221aa3..1cb51467d8 100644 --- a/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml +++ b/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Hello transform! diff --git a/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml b/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml index 94c50ff0bd..85074bd874 100644 --- a/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml +++ b/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"path":"${workdir}/combo.txt"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: Combo test! diff --git a/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml b/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml index 6c8ee43bb9..2bf9c8e28a 100644 --- a/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml +++ b/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml @@ -39,9 +39,7 @@ conversations: arguments: '{"operation":"add","a":17,"b":25}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, grep, glob, - task, calculate. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: "42" diff --git a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml index ec2fa0a9a7..931e4e9821 100644 --- a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -42,9 +42,7 @@ conversations: arguments: '{"query":"hello"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, ${read_shell}, - ${stop_shell}, ${list_shell}, view, create, edit, web_fetch, skill, sql, read_agent, list_agents, glob, task, - grep. + content: Tool 'report_intent' does not exist. - role: tool tool_call_id: toolcall_1 content: "CUSTOM_GREP_RESULT: hello"