Skip to main content

github_copilot_sdk/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4#![cfg_attr(test, allow(clippy::unwrap_used))]
5
6/// Canvas declarations, provider callbacks, and host-side canvas RPC types.
7pub mod canvas;
8mod canvas_dispatch;
9/// Bundled CLI binary extraction and caching.
10#[cfg(feature = "bundled-cli")]
11pub(crate) mod embeddedcli;
12mod errors;
13/// Connection-level extension launch profile provider.
14pub mod extension_launch_provider;
15/// In-process FFI transport hosting the runtime cdylib (`Transport::InProcess`).
16#[cfg(feature = "bundled-in-process")]
17pub(crate) mod ffi;
18pub use errors::*;
19/// Connection-level Copilot request handler — intercept and replace the
20/// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and
21/// BYOK sessions.
22pub mod copilot_request_handler;
23/// GitHub telemetry forwarding callback surface (experimental). Public but
24/// `#[doc(hidden)]` — re-exports the generated telemetry payload types.
25#[doc(hidden)]
26pub mod github_telemetry;
27/// Session-scoped GitHub token provider callbacks.
28pub mod github_token;
29/// Event handler traits for session lifecycle.
30pub mod handler;
31/// Lifecycle hook callbacks (pre/post tool use, prompt submission, session start/end).
32pub mod hooks;
33mod jsonrpc;
34/// Permission-policy helpers that produce a [`handler::PermissionHandler`].
35pub mod permission;
36mod process_tree;
37/// BYOK bearer-token provider callbacks.
38pub mod provider_token;
39mod provider_token_dispatch;
40/// GitHub Copilot CLI binary resolution (env var, embedded, dev cache).
41pub(crate) mod resolve;
42mod router;
43/// Session management — create, resume, send messages, and interact with the agent.
44pub mod session;
45/// Custom session filesystem provider (virtualizable filesystem layer).
46pub mod session_fs;
47mod session_fs_dispatch;
48/// Per-phase timing breakdown for [`Client::start`].
49pub mod startup_timings;
50/// Event subscription handles returned by `subscribe()` methods.
51pub mod subscription;
52/// Typed tool definition framework and dispatch router.
53pub mod tool;
54/// W3C Trace Context propagation for distributed tracing.
55pub mod trace_context;
56/// System message transform callbacks for customizing agent prompts.
57pub mod transforms;
58/// Protocol types shared between the SDK and the GitHub Copilot CLI.
59pub mod types;
60mod wire;
61
62/// Session event payload types — auto-generated from the protocol schema.
63pub mod session_events;
64
65/// JSON-RPC request/response types and typed namespace builders for
66/// [`Client::rpc`] and [`session::Session::rpc`](crate::session::Session::rpc).
67pub mod rpc;
68
69// Auto-generated protocol-type modules. Crate-private so the only public
70// access path is via the `session_events` and `rpc` facade modules above —
71// callers can never depend on the implementation-detail layout under
72// `generated::*`.
73pub(crate) mod generated;
74
75/// Client-level mode ([`ClientMode`]) and the [`ToolSet`] builder for
76/// source-qualified tool filter patterns.
77pub mod mode;
78
79use std::ffi::OsString;
80use std::path::{Path, PathBuf};
81use std::process::Stdio;
82use std::sync::{Arc, OnceLock};
83use std::time::{Duration, Instant};
84
85use async_trait::async_trait;
86pub use github_token::{
87    GitHubToken, GitHubTokenProvider, GitHubTokenProviderArgs, GitHubTokenProviderResult,
88    GitHubTokenRequestReason,
89};
90/// Re-export of [`indexmap::IndexMap`], used for order-preserving maps in the
91/// public API (e.g. [`Tool::parameters`](types::Tool::parameters) and
92/// `SessionConfig::mcp_servers`) so serialized key order stays deterministic.
93pub use indexmap::IndexMap;
94// JSON-RPC wire types are internal transport details.
95// External callers interact via Client/Session methods, not raw RPC.
96pub(crate) use jsonrpc::{
97    JsonRpcClient, JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, error_codes,
98};
99pub use mode::{BUILTIN_TOOLS_ISOLATED, ClientMode, ToolSet};
100pub use provider_token::{BearerTokenError, BearerTokenProvider, ProviderTokenArgs};
101
102/// Re-exported JSON-RPC internals for integration tests (requires `test-support` feature).
103#[cfg(feature = "test-support")]
104pub mod test_support {
105    pub use crate::jsonrpc::{
106        JsonRpcClient, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
107        error_codes,
108    };
109}
110use serde::{Deserialize, Serialize};
111use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
112use tokio::net::TcpStream;
113use tokio::process::{Child, Command};
114use tokio::sync::{broadcast, mpsc, oneshot};
115use tracing::{Instrument, debug, error, info, warn};
116pub use types::*;
117
118mod sdk_protocol_version;
119pub use sdk_protocol_version::{SDK_PROTOCOL_VERSION, get_sdk_protocol_version};
120pub use startup_timings::StartupTimings;
121pub use subscription::{EventSubscription, LifecycleSubscription};
122
123/// Minimum protocol version this SDK can communicate with.
124const MIN_PROTOCOL_VERSION: u32 = 3;
125const RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
126
127fn record_optional_millis(span: &tracing::Span, field: &'static str, value: Option<u64>) {
128    match value {
129        Some(value) => {
130            span.record(field, value);
131        }
132        None => {
133            span.record(field, "None");
134        }
135    }
136}
137
138/// How the SDK communicates with the CLI server.
139#[derive(Debug, Default)]
140#[non_exhaustive]
141pub enum Transport {
142    /// Resolve the transport from `COPILOT_SDK_DEFAULT_CONNECTION`, falling
143    /// back to [`Transport::Stdio`] when the variable is unset.
144    #[default]
145    Default,
146    /// Communicate over stdin/stdout pipes (default).
147    Stdio,
148    /// Host the runtime in-process over FFI (no child process).
149    ///
150    /// Loads the native runtime library and speaks JSON-RPC over its C ABI.
151    /// This is **experimental**. Per-client [`ClientOptions::program`],
152    /// [`ClientOptions::extra_args`], [`ClientOptions::working_directory`],
153    /// [`ClientOptions::env`]/[`ClientOptions::env_remove`],
154    /// and [`ClientOptions::telemetry`] are not supported because native
155    /// runtime code shares the host process. Typed runtime options such as
156    /// authentication, log level, and [`ClientOptions::base_directory`] remain
157    /// supported.
158    ///
159    /// Requires the `bundled-in-process` Cargo feature.
160    InProcess,
161    /// Spawn the CLI with `--port` and connect via TCP.
162    Tcp {
163        /// Port to listen on (0 for OS-assigned).
164        port: u16,
165        /// Optional connection token. When `None` and the SDK is spawning
166        /// the CLI, the SDK auto-generates a 128-bit hex token so the
167        /// loopback listener is safe by default.
168        connection_token: Option<String>,
169    },
170    /// Connect to an already-running CLI server (no process spawning).
171    External {
172        /// Hostname or IP of the running server.
173        host: String,
174        /// Port of the running server.
175        port: u16,
176        /// Optional connection token. Required when the external server
177        /// was started with a token, ignored otherwise.
178        connection_token: Option<String>,
179    },
180}
181
182/// How the SDK locates the GitHub Copilot CLI binary.
183#[derive(Debug, Clone, Default)]
184pub enum CliProgram {
185    /// Auto-resolve the transport's program. Managed child-process transports
186    /// select `COPILOT_CLI_PATH`, then the bundled runtime wrapper. In-process
187    /// transport loads the wrapper's adjacent runtime library directly unless
188    /// `COPILOT_CLI_PATH` explicitly selects a legacy embedded host.
189    #[default]
190    Resolve,
191    /// Use an explicit binary path (skips resolution).
192    Path(PathBuf),
193}
194
195impl From<PathBuf> for CliProgram {
196    fn from(path: PathBuf) -> Self {
197        Self::Path(path)
198    }
199}
200
201/// `true` when this build of the SDK has the Copilot CLI embedded in
202/// its binary — i.e. the `bundled-cli` cargo feature is on **and** the
203/// target platform is one for which `build.rs` shipped an archive.
204///
205/// Useful for branching on bundling presence without forcing the lazy
206/// extraction triggered by [`install_bundled_cli`].
207pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli);
208
209/// Returns the path to the bundled Copilot CLI, extracting it from the
210/// embedded archive on first call.
211///
212/// This exposes the CLI artifact directly for callers such as health checks,
213/// diagnostics, version probes, and in-process hosting. Managed child-process
214/// transports resolve the bundled `copilot-runtime` wrapper instead.
215///
216/// Subsequent calls return the cached result. Extraction is skipped when
217/// an already-published binary passes a cheap integrity re-check; a
218/// truncated, empty, or antivirus-quarantined binary is re-extracted and
219/// re-verified rather than returned.
220///
221/// Returns `None` when the `bundled-cli` feature is off, the target
222/// platform isn't supported by `build.rs`, or extraction failed (the
223/// failure is logged via `tracing::warn!`). When `None` is returned for
224/// the "feature off" reason, [`HAS_BUNDLED_CLI`] is also `false`.
225///
226/// This deliberately does not fall back to the build-time-extracted
227/// dev-cache path used when `bundled-cli` is off — callers that want
228/// that resolution should continue to use [`CliProgram::Resolve`].
229pub fn install_bundled_cli() -> Option<PathBuf> {
230    #[cfg(feature = "bundled-cli")]
231    {
232        embeddedcli::path()
233    }
234    #[cfg(not(feature = "bundled-cli"))]
235    {
236        None
237    }
238}
239
240/// Returns the path to the bundled `copilot-runtime` executable, extracting it
241/// with adjacent `runtime.node` on first call.
242///
243/// This is intended for health checks and intermediate launchers that need the
244/// concrete managed runtime path before [`Client::start`]. Subsequent calls
245/// return the cached result.
246///
247/// Returns `None` when the `bundled-cli` feature is off, the target platform
248/// isn't supported, or extraction failed. It does not fall back to the
249/// build-time extraction cache.
250pub fn install_bundled_runtime() -> Option<PathBuf> {
251    #[cfg(feature = "bundled-cli")]
252    {
253        embeddedcli::runtime_path()
254    }
255    #[cfg(not(feature = "bundled-cli"))]
256    {
257        None
258    }
259}
260
261/// Options for starting a [`Client`].
262///
263/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`]
264/// uses `COPILOT_CLI_PATH` when set to a real file. Managed child-process
265/// transports next use the bundled `copilot-runtime` wrapper. In-process
266/// transport loads the wrapper's adjacent runtime library. With `bundled-cli`
267/// disabled, the corresponding artifact is resolved from the build-time
268/// extraction cache.
269///
270/// Set `program` to [`CliProgram::Path`] to use an explicit binary instead.
271/// This skips auto-resolution entirely.
272#[non_exhaustive]
273pub struct ClientOptions {
274    /// How to locate the child-process runtime.
275    pub program: CliProgram,
276    /// Arguments prepended before `--server` (e.g. the script path for node).
277    pub prefix_args: Vec<OsString>,
278    /// Working directory for the CLI process.
279    ///
280    /// Setting this option is not supported with [`Transport::InProcess`].
281    pub working_directory: PathBuf,
282    /// Environment variables set on the child process.
283    pub env: Vec<(OsString, OsString)>,
284    /// Environment variable names to remove from the child process.
285    pub env_remove: Vec<OsString>,
286    /// Extra flags for child-process transports.
287    pub extra_args: Vec<String>,
288    /// Absolute paths to trusted plugin directories bundled by the host.
289    ///
290    /// When non-empty, [`Client::start`] replaces the runtime's complete
291    /// trusted built-in plugin directory set before sessions can be created.
292    pub builtin_plugin_directories: Vec<PathBuf>,
293    /// Transport mode used to communicate with the CLI server.
294    pub transport: Transport,
295    /// GitHub token for authentication. When set, the SDK passes the token
296    /// to the CLI via `--auth-token-env COPILOT_SDK_AUTH_TOKEN` and exports
297    /// the token in that env var. When set, the CLI defaults to *not*
298    /// using the logged-in user (override with [`Self::use_logged_in_user`]).
299    pub github_token: Option<String>,
300    /// Whether the CLI should fall back to the logged-in `gh` user when no
301    /// token is provided. `None` means use the runtime default (true unless
302    /// [`Self::github_token`] is set, in which case false).
303    pub use_logged_in_user: Option<bool>,
304    /// Log level passed to the CLI server via `--log-level`. When `None`,
305    /// the SDK does not pass `--log-level` to the runtime at all and the
306    /// CLI uses its built-in default.
307    pub log_level: Option<LogLevel>,
308    /// Server-wide idle timeout for sessions, in seconds. When set to a
309    /// positive value, the SDK passes `--session-idle-timeout <secs>` to
310    /// the CLI; sessions without activity for this duration are
311    /// automatically cleaned up. `None` or `Some(0)` leaves sessions
312    /// running indefinitely (the CLI default).
313    pub session_idle_timeout_seconds: Option<u64>,
314    /// Optional override for [`Client::list_models`].
315    ///
316    /// When set, [`Client::list_models`] returns the handler's result
317    /// without making a `models.list` RPC. This is the BYOK escape hatch
318    /// for environments where the model catalog is provisioned separately
319    /// from the GitHub Copilot CLI (e.g. external inference servers selected via
320    /// [`Transport::External`]).
321    pub on_list_models: Option<Arc<dyn ListModelsHandler>>,
322    /// Custom session filesystem provider configuration.
323    ///
324    /// When set, the SDK calls `sessionFs.setProvider` during
325    /// [`Client::start`] to register a virtualizable filesystem layer with
326    /// the CLI. Each session created on this client must supply its own
327    /// [`SessionFsProvider`] via
328    /// [`SessionConfig::with_session_fs_provider`](crate::SessionConfig::with_session_fs_provider).
329    pub session_fs: Option<SessionFsConfig>,
330    /// Connection-level Copilot request handler configuration.
331    ///
332    /// When set, the SDK registers itself as the runtime's request handler
333    /// during [`Client::start`], so the runtime routes its model-layer HTTP and
334    /// WebSocket traffic — for both CAPI and BYOK sessions — through the
335    /// configured
336    /// [`CopilotRequestHandler`]
337    /// instead of issuing the calls itself.
338    pub request_handler: Option<Arc<dyn crate::copilot_request_handler::CopilotRequestHandler>>,
339    /// Connection-level extension launch profile provider.
340    ///
341    /// When set, the SDK registers itself with the runtime during
342    /// [`Client::start`] before any session can be created. Incoming
343    /// `extensionLaunchProvider.resolve` requests are dispatched independently
344    /// of sessions.
345    pub extension_launch_provider:
346        Option<Arc<dyn crate::extension_launch_provider::ExtensionLaunchProvider>>,
347    /// Connection-level GitHub telemetry forwarding callback (experimental).
348    ///
349    /// When set, every session created or resumed on this client opts into
350    /// telemetry forwarding (`enableGitHubTelemetryForwarding`) and the
351    /// callback is invoked for each `gitHubTelemetry.event` notification the
352    /// runtime forwards. `#[doc(hidden)]`, consistent with the experimental
353    /// telemetry payload types.
354    #[doc(hidden)]
355    pub on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
356    /// Optional [`TraceContextProvider`] used to inject W3C Trace Context
357    /// headers (`traceparent` / `tracestate`) on outbound `session.create`,
358    /// `session.resume`, and `session.send` requests.
359    ///
360    /// When [`MessageOptions`] carries a per-turn override (set via
361    /// [`MessageOptions::with_trace_context`](crate::types::MessageOptions::with_trace_context)
362    /// or the underlying fields), it takes precedence over this provider.
363    ///
364    /// [`MessageOptions`]: crate::types::MessageOptions
365    pub on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
366    /// OpenTelemetry config forwarded to the spawned CLI process. See
367    /// [`TelemetryConfig`] for the env-var mapping. The SDK takes no
368    /// OpenTelemetry dependency — this is pure spawn-time env injection.
369    pub telemetry: Option<TelemetryConfig>,
370    /// Override the directory where the CLI persists its state (sessions,
371    /// auth, telemetry buffers). When set, exported as `COPILOT_HOME` to
372    /// the spawned CLI process. Useful for sandboxing test runs or
373    /// running multiple isolated SDK instances side-by-side.
374    pub base_directory: Option<PathBuf>,
375    /// Enable remote session support (Mission Control integration).
376    /// When `true`, the SDK passes `--remote` to the spawned CLI process so
377    /// sessions in a GitHub repository working directory are accessible from
378    /// GitHub web and mobile. Ignored when connecting to an external server
379    /// via [`Transport::External`].
380    pub enable_remote_sessions: bool,
381    /// Override the directory where the bundled CLI binary is extracted on
382    /// first use.
383    ///
384    /// When `None` (the default), the SDK extracts the embedded CLI to
385    /// `<platform cache dir>/github-copilot-sdk/cli/<version>/copilot[.exe]`,
386    /// where the cache dir is [`dirs::cache_dir()`] —
387    /// `%LOCALAPPDATA%` on Windows, `~/Library/Caches/` on macOS,
388    /// `$XDG_CACHE_HOME` (or `~/.cache/`) on Linux. Use this knob to
389    /// redirect the extraction (e.g. to a session-scoped temp directory in
390    /// CI runners) without changing the global cache layout.
391    ///
392    /// Only applies when the `bundled-cli` cargo feature is on (the
393    /// default). With `bundled-cli` disabled (`default-features = false`)
394    /// there is no archive to re-extract at runtime — the binary lives
395    /// at a build-time-known conventional path. To relocate that
396    /// extraction, set `COPILOT_CLI_EXTRACT_DIR` (honored symmetrically
397    /// at build and runtime); to point the runtime at a different
398    /// binary altogether, use [`CliProgram::Path`] or `COPILOT_CLI_PATH`.
399    pub bundled_cli_extract_dir: Option<PathBuf>,
400    /// SDK-level mode controlling whether sessions get CLI-style defaults
401    /// (the default) or are stripped to a minimal/safe baseline. See
402    /// [`ClientMode`] for the contract and trade-offs.
403    pub mode: ClientMode,
404    /// Declares the integrating application's identity, forwarded to the runtime on
405    /// the `server.connect` handshake. Declaring it lets the telemetry the
406    /// runtime emits on this connection be attributed to a consistent surface
407    /// (the application and its Copilot integration) instead of the runtime's own
408    /// build. All fields are optional; leave it `None` to keep the runtime's
409    /// default attribution.
410    pub client_info: Option<ClientInfo>,
411}
412
413/// Identity of the integrating application, declared on the `server.connect`
414/// handshake.
415///
416/// Declaring it lets the telemetry the runtime emits on the connection be
417/// attributed to a single, consistent surface instead of the runtime's own
418/// build. All fields are optional; an empty field is omitted from the
419/// handshake.
420///
421/// The struct is `#[non_exhaustive]`, so construct it with [`ClientInfo::new`]
422/// and the `with_*` builder methods rather than a struct literal. This lets the
423/// SDK add identity fields in future releases without a breaking change.
424#[derive(Debug, Clone, Default, PartialEq, Eq)]
425#[non_exhaustive]
426pub struct ClientInfo {
427    /// Name of the application using the SDK.
428    pub application_name: Option<String>,
429    /// Version of the application using the SDK.
430    pub application_version: Option<String>,
431    /// Optional name of a specific integration within the application, such as an
432    /// extension or plugin.
433    pub integration_name: Option<String>,
434    /// Optional version of the integration identified by [`Self::integration_name`].
435    pub integration_version: Option<String>,
436}
437
438impl ClientInfo {
439    /// Create an empty `ClientInfo`. Populate fields with the `with_*` builder
440    /// methods; every field is optional.
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// Set the name of the application using the SDK.
446    pub fn with_application_name(mut self, application_name: impl Into<String>) -> Self {
447        self.application_name = Some(application_name.into());
448        self
449    }
450
451    /// Set the version of the application using the SDK.
452    pub fn with_application_version(mut self, application_version: impl Into<String>) -> Self {
453        self.application_version = Some(application_version.into());
454        self
455    }
456
457    /// Set the name of a specific integration within the application, such as an
458    /// extension or plugin.
459    pub fn with_integration_name(mut self, integration_name: impl Into<String>) -> Self {
460        self.integration_name = Some(integration_name.into());
461        self
462    }
463
464    /// Set the version of the integration identified by
465    /// [`Self::with_integration_name`].
466    pub fn with_integration_version(mut self, integration_version: impl Into<String>) -> Self {
467        self.integration_version = Some(integration_version.into());
468        self
469    }
470
471    /// Returns `true` when no field carries a non-empty value, in which case the
472    /// SDK omits `clientInfo` from the handshake and the runtime keeps its
473    /// default attribution.
474    fn is_empty(&self) -> bool {
475        Self::non_empty(&self.application_name).is_none()
476            && Self::non_empty(&self.application_version).is_none()
477            && Self::non_empty(&self.integration_name).is_none()
478            && Self::non_empty(&self.integration_version).is_none()
479    }
480
481    /// Clone the field only when it holds a non-empty string, so empty fields are
482    /// dropped from the handshake.
483    fn non_empty(value: &Option<String>) -> Option<String> {
484        value.as_ref().filter(|s| !s.is_empty()).cloned()
485    }
486
487    /// Map onto the generated connect wire shape, dropping empty fields. Returns
488    /// `None` when no field carries a non-empty value.
489    fn to_wire(&self) -> Option<crate::generated::api_types::ConnectClientInfo> {
490        if self.is_empty() {
491            return None;
492        }
493        Some(crate::generated::api_types::ConnectClientInfo {
494            editor_name: Self::non_empty(&self.application_name),
495            editor_version: Self::non_empty(&self.application_version),
496            extension_name: Self::non_empty(&self.integration_name),
497            extension_version: Self::non_empty(&self.integration_version),
498        })
499    }
500}
501
502impl std::fmt::Debug for ClientOptions {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        f.debug_struct("ClientOptions")
505            .field("program", &self.program)
506            .field("prefix_args", &self.prefix_args)
507            .field("working_directory", &self.working_directory)
508            .field("env", &self.env)
509            .field("env_remove", &self.env_remove)
510            .field("extra_args", &self.extra_args)
511            .field(
512                "builtin_plugin_directories",
513                &self.builtin_plugin_directories,
514            )
515            .field("transport", &self.transport)
516            .field(
517                "github_token",
518                &self.github_token.as_ref().map(|_| "<redacted>"),
519            )
520            .field("use_logged_in_user", &self.use_logged_in_user)
521            .field("log_level", &self.log_level)
522            .field(
523                "session_idle_timeout_seconds",
524                &self.session_idle_timeout_seconds,
525            )
526            .field(
527                "on_list_models",
528                &self.on_list_models.as_ref().map(|_| "<set>"),
529            )
530            .field("session_fs", &self.session_fs)
531            .field(
532                "request_handler",
533                &self.request_handler.as_ref().map(|_| "<set>"),
534            )
535            .field(
536                "extension_launch_provider",
537                &self.extension_launch_provider.as_ref().map(|_| "<set>"),
538            )
539            .field(
540                "on_github_telemetry",
541                &self.on_github_telemetry.as_ref().map(|_| "<set>"),
542            )
543            .field(
544                "on_get_trace_context",
545                &self.on_get_trace_context.as_ref().map(|_| "<set>"),
546            )
547            .field("telemetry", &self.telemetry)
548            .field("base_directory", &self.base_directory)
549            .field("enable_remote_sessions", &self.enable_remote_sessions)
550            .field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir)
551            .field("client_info", &self.client_info)
552            .finish()
553    }
554}
555
556/// Custom handler for [`Client::list_models`].
557///
558/// Implementations override the default `models.list` RPC, returning a
559/// caller-supplied catalog of models. Set via [`ClientOptions::on_list_models`].
560///
561/// Implementations must be `Send + Sync` because [`Client`] is shared across
562/// tasks. Errors returned by [`list_models`](Self::list_models) are propagated
563/// from [`Client::list_models`] unchanged.
564#[async_trait]
565pub trait ListModelsHandler: Send + Sync + 'static {
566    /// Return the list of available models.
567    async fn list_models(&self) -> Result<Vec<Model>>;
568}
569
570/// Log verbosity for the CLI server (passed via `--log-level`).
571#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
572#[serde(rename_all = "lowercase")]
573pub enum LogLevel {
574    /// Suppress all CLI logs.
575    None,
576    /// Errors only.
577    Error,
578    /// Warnings and errors.
579    Warning,
580    /// Info and above.
581    Info,
582    /// Debug, info, warnings, errors.
583    Debug,
584    /// Everything, including trace output.
585    All,
586}
587
588impl LogLevel {
589    /// CLI argument value (e.g. `"info"`, `"debug"`).
590    pub fn as_str(self) -> &'static str {
591        match self {
592            Self::None => "none",
593            Self::Error => "error",
594            Self::Warning => "warning",
595            Self::Info => "info",
596            Self::Debug => "debug",
597            Self::All => "all",
598        }
599    }
600}
601
602impl std::fmt::Display for LogLevel {
603    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604        f.write_str(self.as_str())
605    }
606}
607
608/// Backend exporter for the CLI's OpenTelemetry pipeline.
609///
610/// Maps to the `COPILOT_OTEL_EXPORTER_TYPE` environment variable on the
611/// spawned CLI process. Wire values are `"otlp-http"` and `"file"`.
612#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
613#[serde(rename_all = "kebab-case")]
614#[non_exhaustive]
615pub enum OtelExporterType {
616    /// Export via OTLP HTTP to the endpoint configured by
617    /// [`TelemetryConfig::otlp_endpoint`].
618    OtlpHttp,
619    /// Export to a JSON-lines file at the path configured by
620    /// [`TelemetryConfig::file_path`].
621    File,
622}
623
624impl OtelExporterType {
625    /// Environment-variable value (`"otlp-http"` or `"file"`).
626    pub fn as_str(self) -> &'static str {
627        match self {
628            Self::OtlpHttp => "otlp-http",
629            Self::File => "file",
630        }
631    }
632}
633
634/// OTLP HTTP protocol used by the CLI's OpenTelemetry OTLP exporter.
635///
636/// Maps to the standard `OTEL_EXPORTER_OTLP_PROTOCOL` environment variable on
637/// the spawned CLI process. Wire values are `"http/json"` and
638/// `"http/protobuf"`.
639#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
640#[non_exhaustive]
641pub enum OtlpHttpProtocol {
642    /// Export using OTLP/HTTP JSON.
643    #[serde(rename = "http/json")]
644    HttpJson,
645    /// Export using OTLP/HTTP protobuf.
646    #[serde(rename = "http/protobuf")]
647    HttpProtobuf,
648}
649
650impl OtlpHttpProtocol {
651    /// Environment-variable value (`"http/json"` or `"http/protobuf"`).
652    pub fn as_str(self) -> &'static str {
653        match self {
654            Self::HttpJson => "http/json",
655            Self::HttpProtobuf => "http/protobuf",
656        }
657    }
658}
659
660/// OpenTelemetry configuration forwarded to the spawned GitHub Copilot CLI
661/// process.
662///
663/// When [`ClientOptions::telemetry`] is `Some(...)`, the SDK sets
664/// `COPILOT_OTEL_ENABLED=true` plus any populated fields below as the
665/// corresponding `OTEL_*` / `COPILOT_OTEL_*` environment variables. The
666/// CLI's built-in OpenTelemetry exporter consumes these at startup. The
667/// SDK itself takes no OpenTelemetry dependency.
668///
669/// Environment-variable mapping:
670///
671/// | Field                | Variable                                              |
672/// |----------------------|-------------------------------------------------------|
673/// | (any field set)      | `COPILOT_OTEL_ENABLED=true`                           |
674/// | [`otlp_endpoint`]    | `OTEL_EXPORTER_OTLP_ENDPOINT`                         |
675/// | [`otlp_protocol`]    | `OTEL_EXPORTER_OTLP_PROTOCOL`                         |
676/// | [`file_path`]        | `COPILOT_OTEL_FILE_EXPORTER_PATH`                     |
677/// | [`exporter_type`]    | `COPILOT_OTEL_EXPORTER_TYPE`                          |
678/// | [`source_name`]      | `COPILOT_OTEL_SOURCE_NAME`                            |
679/// | [`capture_content`]  | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`  |
680///
681/// Caller-supplied entries in [`ClientOptions::env`] override these, so a
682/// developer can pin any individual variable to a different value while
683/// keeping the rest of the config managed by [`TelemetryConfig`].
684///
685/// Marked `#[non_exhaustive]` so future CLI-side telemetry knobs can be
686/// added without breaking callers.
687///
688/// [`otlp_endpoint`]: Self::otlp_endpoint
689/// [`otlp_protocol`]: Self::otlp_protocol
690/// [`file_path`]: Self::file_path
691/// [`exporter_type`]: Self::exporter_type
692/// [`source_name`]: Self::source_name
693/// [`capture_content`]: Self::capture_content
694#[derive(Debug, Clone, Default)]
695#[non_exhaustive]
696pub struct TelemetryConfig {
697    /// OTLP HTTP endpoint URL for trace/metric export.
698    pub otlp_endpoint: Option<String>,
699    /// OTLP HTTP protocol for all signals.
700    pub otlp_protocol: Option<OtlpHttpProtocol>,
701    /// File path for JSON-lines trace output.
702    pub file_path: Option<PathBuf>,
703    /// Exporter backend type. Typically [`OtelExporterType::OtlpHttp`] or
704    /// [`OtelExporterType::File`].
705    pub exporter_type: Option<OtelExporterType>,
706    /// Instrumentation scope name. Useful for distinguishing this
707    /// embedder's traces from other Copilot-CLI consumers exporting to the
708    /// same backend.
709    pub source_name: Option<String>,
710    /// Whether the CLI captures GenAI message content (prompts and
711    /// responses) on emitted spans. `Some(true)` opts in; `Some(false)`
712    /// opts out; `None` leaves the CLI default (typically off).
713    pub capture_content: Option<bool>,
714}
715
716impl TelemetryConfig {
717    /// Construct an empty [`TelemetryConfig`]; all fields default to
718    /// unset (`is_empty()` returns `true`).
719    pub fn new() -> Self {
720        Self::default()
721    }
722
723    /// Set the OTLP HTTP endpoint URL for trace/metric export.
724    pub fn with_otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
725        self.otlp_endpoint = Some(endpoint.into());
726        self
727    }
728
729    /// Set the OTLP HTTP protocol for all signals.
730    pub fn with_otlp_protocol(mut self, protocol: OtlpHttpProtocol) -> Self {
731        self.otlp_protocol = Some(protocol);
732        self
733    }
734
735    /// Set the file path for JSON-lines trace output.
736    pub fn with_file_path(mut self, path: impl Into<PathBuf>) -> Self {
737        self.file_path = Some(path.into());
738        self
739    }
740
741    /// Set the exporter backend type.
742    pub fn with_exporter_type(mut self, exporter_type: OtelExporterType) -> Self {
743        self.exporter_type = Some(exporter_type);
744        self
745    }
746
747    /// Set the instrumentation scope name. Useful for distinguishing
748    /// this embedder's traces from other Copilot-CLI consumers
749    /// exporting to the same backend.
750    pub fn with_source_name(mut self, source_name: impl Into<String>) -> Self {
751        self.source_name = Some(source_name.into());
752        self
753    }
754
755    /// Opt in or out of GenAI message content capture on emitted spans.
756    /// `true` opts in; `false` opts out. Leaving this unset preserves
757    /// the CLI default (typically off).
758    pub fn with_capture_content(mut self, capture: bool) -> Self {
759        self.capture_content = Some(capture);
760        self
761    }
762
763    /// Returns `true` if all fields are unset. Used by [`Client::start`]
764    /// to decide whether to set `COPILOT_OTEL_ENABLED`.
765    pub fn is_empty(&self) -> bool {
766        self.otlp_endpoint.is_none()
767            && self.otlp_protocol.is_none()
768            && self.file_path.is_none()
769            && self.exporter_type.is_none()
770            && self.source_name.is_none()
771            && self.capture_content.is_none()
772    }
773}
774
775impl Default for ClientOptions {
776    fn default() -> Self {
777        Self {
778            program: CliProgram::Resolve,
779            prefix_args: Vec::new(),
780            working_directory: PathBuf::new(),
781            env: Vec::new(),
782            env_remove: Vec::new(),
783            extra_args: Vec::new(),
784            builtin_plugin_directories: Vec::new(),
785            transport: Transport::default(),
786            github_token: None,
787            use_logged_in_user: None,
788            log_level: None,
789            session_idle_timeout_seconds: None,
790            on_list_models: None,
791            session_fs: None,
792            request_handler: None,
793            extension_launch_provider: None,
794            on_github_telemetry: None,
795            on_get_trace_context: None,
796            telemetry: None,
797            base_directory: None,
798            enable_remote_sessions: false,
799            bundled_cli_extract_dir: None,
800            mode: ClientMode::default(),
801            client_info: None,
802        }
803    }
804}
805
806impl ClientOptions {
807    /// Construct a new [`ClientOptions`] with default values.
808    ///
809    /// Equivalent to [`ClientOptions::default`]; provided as a documented
810    /// construction entry point for the builder chain. The struct is
811    /// `#[non_exhaustive]`, so external callers cannot use struct-literal
812    /// syntax — use this builder or [`Default::default`] plus mut-let.
813    ///
814    /// # Example
815    ///
816    /// ```
817    /// # use github_copilot_sdk::{ClientOptions, LogLevel};
818    /// let opts = ClientOptions::new()
819    ///     .with_log_level(LogLevel::Debug)
820    ///     .with_github_token("ghp_…");
821    /// ```
822    pub fn new() -> Self {
823        Self::default()
824    }
825
826    /// How to locate the child-process runtime. See [`CliProgram`].
827    pub fn with_program(mut self, program: impl Into<CliProgram>) -> Self {
828        self.program = program.into();
829        self
830    }
831
832    /// Arguments prepended before `--server` (e.g. the script path for node).
833    pub fn with_prefix_args<I, S>(mut self, args: I) -> Self
834    where
835        I: IntoIterator<Item = S>,
836        S: Into<OsString>,
837    {
838        self.prefix_args = args.into_iter().map(Into::into).collect();
839        self
840    }
841
842    /// Working directory for the CLI process.
843    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
844        self.working_directory = cwd.into();
845        self
846    }
847
848    /// Environment variables to set on the child process.
849    pub fn with_env<I, K, V>(mut self, env: I) -> Self
850    where
851        I: IntoIterator<Item = (K, V)>,
852        K: Into<OsString>,
853        V: Into<OsString>,
854    {
855        self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
856        self
857    }
858
859    /// Environment variable names to remove from the child process.
860    pub fn with_env_remove<I, S>(mut self, names: I) -> Self
861    where
862        I: IntoIterator<Item = S>,
863        S: Into<OsString>,
864    {
865        self.env_remove = names.into_iter().map(Into::into).collect();
866        self
867    }
868
869    /// Extra CLI flags appended after the transport-specific arguments.
870    pub fn with_extra_args<I, S>(mut self, args: I) -> Self
871    where
872        I: IntoIterator<Item = S>,
873        S: Into<String>,
874    {
875        self.extra_args = args.into_iter().map(Into::into).collect();
876        self
877    }
878
879    /// Set trusted plugin directories bundled by the host.
880    ///
881    /// Every path must be absolute; invalid paths are rejected by
882    /// [`Client::start`].
883    pub fn with_builtin_plugin_directories<I, P>(mut self, paths: I) -> Self
884    where
885        I: IntoIterator<Item = P>,
886        P: Into<PathBuf>,
887    {
888        self.builtin_plugin_directories = paths.into_iter().map(Into::into).collect();
889        self
890    }
891
892    /// Transport mode used to communicate with the CLI server. See [`Transport`].
893    pub fn with_transport(mut self, transport: Transport) -> Self {
894        self.transport = transport;
895        self
896    }
897
898    /// GitHub token for authentication. The SDK passes the token to the
899    /// CLI via `--auth-token-env COPILOT_SDK_AUTH_TOKEN`.
900    pub fn with_github_token(mut self, token: impl Into<String>) -> Self {
901        self.github_token = Some(token.into());
902        self
903    }
904
905    /// Whether the CLI should fall back to the logged-in `gh` user when
906    /// no token is provided. See the field docs for default semantics.
907    pub fn with_use_logged_in_user(mut self, use_logged_in: bool) -> Self {
908        self.use_logged_in_user = Some(use_logged_in);
909        self
910    }
911
912    /// Log level passed to the CLI server via `--log-level`.
913    pub fn with_log_level(mut self, level: LogLevel) -> Self {
914        self.log_level = Some(level);
915        self
916    }
917
918    /// Server-wide idle timeout for sessions (seconds). Pass `0` to leave
919    /// sessions running indefinitely (the CLI default).
920    pub fn with_session_idle_timeout_seconds(mut self, seconds: u64) -> Self {
921        self.session_idle_timeout_seconds = Some(seconds);
922        self
923    }
924
925    /// Override [`Client::list_models`] with a caller-supplied handler.
926    /// The handler is wrapped in `Arc` internally.
927    pub fn with_list_models_handler<H>(mut self, handler: H) -> Self
928    where
929        H: ListModelsHandler + 'static,
930    {
931        self.on_list_models = Some(Arc::new(handler));
932        self
933    }
934
935    /// Custom session filesystem provider configuration.
936    pub fn with_session_fs(mut self, config: SessionFsConfig) -> Self {
937        self.session_fs = Some(config);
938        self
939    }
940
941    /// Register a connection-level Copilot request handler. The runtime will
942    /// route its model-layer HTTP and WebSocket traffic through the handler
943    /// configured here instead of issuing the calls itself. The handler is
944    /// wrapped in `Arc` internally.
945    pub fn with_request_handler<H>(mut self, handler: H) -> Self
946    where
947        H: crate::copilot_request_handler::CopilotRequestHandler,
948    {
949        self.request_handler = Some(Arc::new(handler));
950        self
951    }
952
953    /// Register a connection-level extension launch profile provider.
954    ///
955    /// The provider is wrapped in [`Arc`] internally and registered with the
956    /// runtime before [`Client::start`] returns.
957    pub fn with_extension_launch_provider<P>(mut self, provider: P) -> Self
958    where
959        P: crate::extension_launch_provider::ExtensionLaunchProvider,
960    {
961        self.extension_launch_provider = Some(Arc::new(provider));
962        self
963    }
964
965    /// Register a connection-level GitHub telemetry forwarding callback
966    /// (internal/experimental). Registering a callback auto-enables telemetry
967    /// forwarding on every session created or resumed on this client; the
968    /// callback fires for each forwarded `gitHubTelemetry.event` notification.
969    /// The callback is wrapped in `Arc` internally.
970    #[doc(hidden)]
971    pub fn with_on_github_telemetry<F>(mut self, callback: F) -> Self
972    where
973        F: Fn(crate::github_telemetry::GitHubTelemetryNotification) + Send + Sync + 'static,
974    {
975        self.on_github_telemetry = Some(Arc::new(callback));
976        self
977    }
978
979    /// Set the [`TraceContextProvider`] used to inject W3C Trace Context
980    /// headers on outbound `session.create` / `session.resume` /
981    /// `session.send` requests. The provider is wrapped in `Arc` internally.
982    pub fn with_trace_context_provider<P>(mut self, provider: P) -> Self
983    where
984        P: TraceContextProvider + 'static,
985    {
986        self.on_get_trace_context = Some(Arc::new(provider));
987        self
988    }
989
990    /// OpenTelemetry config forwarded to the spawned CLI process.
991    pub fn with_telemetry(mut self, config: TelemetryConfig) -> Self {
992        self.telemetry = Some(config);
993        self
994    }
995
996    /// Override the directory where the CLI persists its state. Set as
997    /// `COPILOT_HOME` on the spawned CLI process.
998    pub fn with_base_directory(mut self, dir: impl Into<PathBuf>) -> Self {
999        self.base_directory = Some(dir.into());
1000        self
1001    }
1002
1003    /// Enable remote session support (Mission Control). Passes `--remote`
1004    /// to the spawned CLI process.
1005    pub fn with_enable_remote_sessions(mut self, enabled: bool) -> Self {
1006        self.enable_remote_sessions = enabled;
1007        self
1008    }
1009
1010    /// Override the directory where bundled CLI and runtime artifacts are
1011    /// extracted on first use. See [`Self::bundled_cli_extract_dir`].
1012    ///
1013    /// Only applies when the `bundled-cli` cargo feature is on. With
1014    /// `bundled-cli` disabled (`default-features = false`), set
1015    /// `COPILOT_CLI_EXTRACT_DIR` to relocate the build-time extraction
1016    /// (honored symmetrically at build and runtime), or use
1017    /// [`CliProgram::Path`] / `COPILOT_CLI_PATH` to point at a different
1018    /// binary at runtime.
1019    pub fn with_bundled_cli_extract_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1020        self.bundled_cli_extract_dir = Some(dir.into());
1021        self
1022    }
1023
1024    /// Set the SDK [`ClientMode`]. Use [`ClientMode::Empty`] for any
1025    /// scenario where CLI-like ambient behavior is unsafe (e.g. multi-user
1026    /// servers). Empty mode additionally requires [`Self::base_directory`]
1027    /// or [`Self::session_fs`] to be set, validated at [`Client::start`].
1028    pub fn with_mode(mut self, mode: ClientMode) -> Self {
1029        self.mode = mode;
1030        self
1031    }
1032
1033    /// Declare the integrating application's identity, forwarded to the runtime on
1034    /// the `server.connect` handshake so its telemetry is attributed to a
1035    /// consistent surface. See [`Self::client_info`].
1036    pub fn with_client_info(mut self, client_info: ClientInfo) -> Self {
1037        self.client_info = Some(client_info);
1038        self
1039    }
1040}
1041
1042/// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`.
1043fn validate_session_fs_config(cfg: &SessionFsConfig) -> Result<()> {
1044    if cfg.initial_cwd.trim().is_empty() {
1045        return Err(Error::with_message(
1046            ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
1047            "invalid SessionFsConfig: initial_cwd must not be empty",
1048        ));
1049    }
1050    if cfg.session_state_path.trim().is_empty() {
1051        return Err(Error::with_message(
1052            ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
1053            "invalid SessionFsConfig: session_state_path must not be empty",
1054        ));
1055    }
1056    Ok(())
1057}
1058
1059/// Generate a fresh CSPRNG-backed token for authenticating an SDK-spawned
1060/// loopback CLI server. 128 bits of entropy, lowercase-hex encoded — not
1061/// a UUID (the schema-shaped IDs in this crate stay `String` per the
1062/// pre-1.0 review consensus, so adopting a `Uuid` type just for SDK-
1063/// generated secrets would be inconsistent and semantically misleading;
1064/// this is opaque random data, not an identifier).
1065fn generate_connection_token() -> String {
1066    let mut bytes = [0u8; 16];
1067    getrandom::getrandom(&mut bytes)
1068        .expect("OS CSPRNG (getrandom) is unavailable; cannot generate connection token");
1069    let mut hex = String::with_capacity(32);
1070    for byte in bytes {
1071        use std::fmt::Write;
1072        let _ = write!(hex, "{byte:02x}");
1073    }
1074    hex
1075}
1076
1077/// Environment variable that overrides the transport used when the caller
1078/// leaves [`ClientOptions::transport`] at [`Transport::Default`].
1079/// Accepts `"inprocess"` or `"stdio"` (case-insensitive); unset preserves
1080/// stdio. Any other value is an error.
1081const DEFAULT_CONNECTION_ENV_VAR: &str = "COPILOT_SDK_DEFAULT_CONNECTION";
1082
1083/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`].
1084fn resolve_default_transport(options: &ClientOptions) -> Result<Transport> {
1085    let configured = options
1086        .env
1087        .iter()
1088        .find(|(key, _)| {
1089            key.to_string_lossy()
1090                .eq_ignore_ascii_case(DEFAULT_CONNECTION_ENV_VAR)
1091        })
1092        .map(|(_, value)| value.to_string_lossy().into_owned());
1093    let process = std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok();
1094    resolve_default_transport_value(configured.as_deref().or(process.as_deref()))
1095}
1096
1097fn resolve_default_transport_value(value: Option<&str>) -> Result<Transport> {
1098    match value {
1099        None => Ok(Transport::Stdio),
1100        Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::Stdio),
1101        Some(v) if v.eq_ignore_ascii_case("inprocess") => Ok(Transport::InProcess),
1102        Some(v) => Err(Error::with_message(
1103            ErrorKind::InvalidConfig,
1104            format!(
1105                "invalid {DEFAULT_CONNECTION_ENV_VAR} value '{v}'. \
1106                 Expected 'inprocess', 'stdio', or unset."
1107            ),
1108        )),
1109    }
1110}
1111
1112#[cfg(any(feature = "bundled-in-process", test))]
1113fn validate_inprocess_options(options: &ClientOptions) -> Result<()> {
1114    if !matches!(&options.program, CliProgram::Resolve) {
1115        return Err(Error::with_message(
1116            ErrorKind::InvalidConfig,
1117            "ClientOptions::program is not supported with Transport::InProcess; \
1118             set COPILOT_CLI_PATH only when using an externally provisioned runtime package",
1119        ));
1120    }
1121    if !options.extra_args.is_empty() {
1122        return Err(Error::with_message(
1123            ErrorKind::InvalidConfig,
1124            "ClientOptions::extra_args is not supported with Transport::InProcess; \
1125             use typed client options instead",
1126        ));
1127    }
1128
1129    let unsupported = if !options.working_directory.as_os_str().is_empty() {
1130        Some("working_directory")
1131    } else if !options.env.is_empty() {
1132        Some("env")
1133    } else if !options.env_remove.is_empty() {
1134        Some("env_remove")
1135    } else if options.telemetry.is_some() {
1136        Some("telemetry")
1137    } else if !options.prefix_args.is_empty() {
1138        Some("prefix_args")
1139    } else {
1140        None
1141    };
1142
1143    if let Some(option) = unsupported {
1144        return Err(Error::with_message(
1145            ErrorKind::InvalidConfig,
1146            format!(
1147                "ClientOptions::{option} is not supported with Transport::InProcess; \
1148                 configure process-global settings on the host process instead"
1149            ),
1150        ));
1151    }
1152
1153    Ok(())
1154}
1155
1156/// Connection to a GitHub Copilot CLI server (stdio, TCP, or external).
1157///
1158/// Cheaply cloneable — cloning shares the underlying connection.
1159/// The child process (if any) is killed when the last clone drops.
1160#[derive(Clone)]
1161pub struct Client {
1162    inner: Arc<ClientInner>,
1163}
1164
1165impl std::fmt::Debug for Client {
1166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1167        f.debug_struct("Client")
1168            .field("working_directory", &self.inner.cwd)
1169            .field("pid", &self.pid())
1170            .finish()
1171    }
1172}
1173
1174struct ClientInner {
1175    child: parking_lot::Mutex<Option<Child>>,
1176    process_tree: parking_lot::Mutex<Option<process_tree::ProcessTree>>,
1177    #[cfg(feature = "bundled-in-process")]
1178    /// In-process FFI runtime host, set only for [`Transport::InProcess`].
1179    /// Closing it tears down the native runtime connection.
1180    ffi_host: parking_lot::Mutex<Option<Arc<crate::ffi::FfiShared>>>,
1181    rpc: JsonRpcClient,
1182    cwd: PathBuf,
1183    request_rx: parking_lot::Mutex<Option<mpsc::UnboundedReceiver<JsonRpcRequest>>>,
1184    notification_tx: broadcast::Sender<JsonRpcNotification>,
1185    router: router::SessionRouter,
1186    github_token_registry: Arc<github_token::GitHubTokenRegistry>,
1187    negotiated_protocol_version: OnceLock<u32>,
1188    state: parking_lot::Mutex<ConnectionState>,
1189    lifecycle_tx: broadcast::Sender<SessionLifecycleEvent>,
1190    on_list_models: Option<Arc<dyn ListModelsHandler>>,
1191    models_cache: parking_lot::Mutex<Arc<tokio::sync::OnceCell<Vec<Model>>>>,
1192    session_fs_configured: bool,
1193    session_fs_sqlite_declared: bool,
1194    /// Inbound `llmInference.*` dispatcher, installed when
1195    /// [`ClientOptions::request_handler`] is set.
1196    llm_inference: OnceLock<Arc<copilot_request_handler::CopilotRequestDispatcher>>,
1197    extension_launch_provider: Arc<extension_launch_provider::ExtensionLaunchProviderDispatcher>,
1198    /// Connection-level GitHub telemetry forwarding callback, set from
1199    /// [`ClientOptions::on_github_telemetry`]. Drives the
1200    /// `enableGitHubTelemetryForwarding` wire flag and the
1201    /// `gitHubTelemetry.event` notification dispatch.
1202    on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
1203    on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
1204    /// Token sent in the `connect` handshake. Auto-generated when the
1205    /// SDK spawns its own CLI in TCP mode and no explicit token is set;
1206    /// `None` for stdio and for external-server transport without an
1207    /// explicit token.
1208    effective_connection_token: Option<String>,
1209    /// Application identity forwarded on the `connect` handshake, set from
1210    /// [`ClientOptions::client_info`]. `None` keeps the runtime's default
1211    /// telemetry attribution.
1212    client_info: Option<ClientInfo>,
1213    /// SDK [`ClientMode`] captured at start time. Drives empty-mode safe
1214    /// defaults inside `create_session` / `resume_session`.
1215    pub(crate) mode: ClientMode,
1216    /// Per-phase startup timing breakdown, populated once at the end of
1217    /// [`Client::start`]. Empty for clients built via [`Client::from_streams`]
1218    /// or [`Client::from_transport`] directly.
1219    startup_timings: OnceLock<StartupTimings>,
1220}
1221
1222impl Client {
1223    /// Start a CLI server process with the given options.
1224    ///
1225    /// For [`Transport::Stdio`], spawns the CLI with `--stdio` and communicates
1226    /// over stdin/stdout pipes. For [`Transport::Tcp`], spawns with `--port`
1227    /// and connects via TCP once the server reports it is listening. For
1228    /// [`Transport::External`], connects to an already-running server.
1229    ///
1230    /// After establishing the connection, calls [`verify_protocol_version`](Self::verify_protocol_version)
1231    /// to ensure the CLI server speaks a compatible protocol version.
1232    /// When [`ClientOptions::session_fs`] is set, also calls
1233    /// `sessionFs.setProvider` to register the SDK as the filesystem
1234    /// backend.
1235    pub async fn start(options: ClientOptions) -> Result<Self> {
1236        let start_time = Instant::now();
1237        let mut timings = StartupTimings::default();
1238        let mut options = options;
1239        if matches!(options.transport, Transport::Default) {
1240            options.transport = resolve_default_transport(&options)?;
1241        }
1242        if matches!(options.transport, Transport::InProcess) {
1243            #[cfg(not(feature = "bundled-in-process"))]
1244            {
1245                return Err(Error::with_message(
1246                    ErrorKind::InvalidConfig,
1247                    "Transport::InProcess requires the `bundled-in-process` Cargo feature",
1248                ));
1249            }
1250            #[cfg(feature = "bundled-in-process")]
1251            validate_inprocess_options(&options)?;
1252        }
1253        if options.mode == ClientMode::Empty
1254            && options.base_directory.is_none()
1255            && options.session_fs.is_none()
1256        {
1257            return Err(Error::with_message(
1258                ErrorKind::InvalidConfig,
1259                "ClientMode::Empty requires either `base_directory` or \
1260                 `session_fs` to be set (no implicit ~/.copilot fallback).",
1261            ));
1262        }
1263        if let Some(cfg) = &options.session_fs {
1264            validate_session_fs_config(cfg)?;
1265        }
1266        let builtin_plugin_directories = options
1267            .builtin_plugin_directories
1268            .iter()
1269            .map(|path| {
1270                if !path.is_absolute() {
1271                    return Err(Error::with_message(
1272                        ErrorKind::InvalidConfig,
1273                        format!(
1274                            "builtin_plugin_directories must contain only absolute paths: {}",
1275                            path.display()
1276                        ),
1277                    ));
1278                }
1279                path.to_str().map(str::to_owned).ok_or_else(|| {
1280                    Error::with_message(
1281                        ErrorKind::InvalidConfig,
1282                        format!(
1283                            "builtin_plugin_directories must contain valid UTF-8 paths: {}",
1284                            path.display()
1285                        ),
1286                    )
1287                })
1288            })
1289            .collect::<Result<Vec<_>>>()?;
1290        // Auth options only make sense when the SDK spawns the CLI; with an
1291        // external server, the server manages its own auth.
1292        if matches!(options.transport, Transport::External { .. }) {
1293            if options.github_token.is_some() {
1294                return Err(Error::with_message(
1295                    ErrorKind::InvalidConfig,
1296                    "invalid client configuration: github_token cannot be used with \
1297                     Transport::External (external server manages its own auth)",
1298                ));
1299            }
1300            if options.use_logged_in_user == Some(true) {
1301                return Err(Error::with_message(
1302                    ErrorKind::InvalidConfig,
1303                    "invalid client configuration: use_logged_in_user cannot be used with \
1304                     Transport::External (external server manages its own auth)",
1305                ));
1306            }
1307        }
1308        // Validate token shape. Stdio variants no longer carry a token
1309        // (enforced by the type). For Tcp/External, empty-string is
1310        // rejected eagerly.
1311        match &options.transport {
1312            Transport::Tcp {
1313                connection_token: Some(t),
1314                ..
1315            }
1316            | Transport::External {
1317                connection_token: Some(t),
1318                ..
1319            } if t.is_empty() => {
1320                return Err(Error::with_message(
1321                    ErrorKind::InvalidConfig,
1322                    "invalid client configuration: connection_token must be a non-empty string",
1323                ));
1324            }
1325            _ => {}
1326        }
1327        // Capture (and where needed, auto-generate) the token actually sent
1328        // to the server. For Tcp, the SDK auto-generates one when the
1329        // caller leaves it unset so the loopback listener is safe by
1330        // default.
1331        let effective_connection_token: Option<String> = match &mut options.transport {
1332            Transport::Default => unreachable!("default transport resolved above"),
1333            Transport::Stdio | Transport::InProcess => None,
1334            Transport::Tcp {
1335                connection_token, ..
1336            } => Some(
1337                connection_token
1338                    .get_or_insert_with(generate_connection_token)
1339                    .clone(),
1340            ),
1341            Transport::External {
1342                connection_token, ..
1343            } => connection_token.clone(),
1344        };
1345        let session_fs_config = options.session_fs.clone();
1346        let request_handler = options.request_handler.clone();
1347        let extension_launch_provider = options.extension_launch_provider.clone();
1348        let session_fs_sqlite_declared = session_fs_config
1349            .as_ref()
1350            .and_then(|c| c.capabilities.as_ref())
1351            .is_some_and(|caps| caps.sqlite);
1352        let program = match &options.program {
1353            CliProgram::Path(path) => {
1354                info!(path = %path.display(), "using explicit copilot CLI path");
1355                path.clone()
1356            }
1357            CliProgram::Resolve => {
1358                let resolve_start = Instant::now();
1359                let resolved = resolve::copilot_binary_with_extract_dir(
1360                    options.bundled_cli_extract_dir.as_deref(),
1361                    true,
1362                )?;
1363                let resolve_elapsed = resolve_start.elapsed();
1364                timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed));
1365                debug!(
1366                    elapsed_ms = resolve_elapsed.as_millis(),
1367                    "Client::start CLI program resolution complete"
1368                );
1369                info!(path = %resolved.display(), "resolved copilot runtime");
1370                #[cfg(windows)]
1371                {
1372                    if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| {
1373                        ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")
1374                    }) {
1375                        warn!(
1376                            path = %resolved.display(),
1377                            ext = %ext,
1378                            "resolved copilot CLI is a .cmd/.bat wrapper; \
1379                             this may cause console window flashes on Windows"
1380                        );
1381                    }
1382                }
1383                resolved
1384            }
1385        };
1386        let working_directory = {
1387            let cwd = options.working_directory.clone();
1388            if cwd.as_os_str().is_empty() {
1389                std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1390            } else {
1391                cwd
1392            }
1393        };
1394
1395        let transport_setup_start = Instant::now();
1396        let client = match options.transport {
1397            Transport::Default => unreachable!("default transport resolved above"),
1398            Transport::External {
1399                ref host,
1400                port,
1401                connection_token: _,
1402            } => {
1403                info!(host = %host, port = %port, "connecting to external CLI server");
1404                let connect_start = Instant::now();
1405                let stream = TcpStream::connect((host.as_str(), port)).await?;
1406                debug!(
1407                    elapsed_ms = connect_start.elapsed().as_millis(),
1408                    host = %host,
1409                    port,
1410                    "Client::start TCP connect complete"
1411                );
1412                let (reader, writer) = tokio::io::split(stream);
1413                Self::from_transport(
1414                    reader,
1415                    writer,
1416                    None,
1417                    None,
1418                    working_directory,
1419                    options.on_list_models,
1420                    extension_launch_provider.clone(),
1421                    session_fs_config.is_some(),
1422                    session_fs_sqlite_declared,
1423                    options.on_get_trace_context,
1424                    options.on_github_telemetry,
1425                    effective_connection_token.clone(),
1426                    options.mode,
1427                    options.client_info,
1428                )?
1429            }
1430            Transport::Tcp {
1431                port,
1432                connection_token: _,
1433            } => {
1434                let (mut child, tree, actual_port, spawn_elapsed, port_wait_elapsed) =
1435                    Self::spawn_tcp(&program, &options, &working_directory, port).await?;
1436                timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed));
1437                timings.port_wait_ms = Some(StartupTimings::millis(port_wait_elapsed));
1438                let connect_start = Instant::now();
1439                let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?;
1440                debug!(
1441                    elapsed_ms = connect_start.elapsed().as_millis(),
1442                    port = actual_port,
1443                    "Client::start TCP connect complete"
1444                );
1445                let (reader, writer) = tokio::io::split(stream);
1446                Self::drain_stderr(&mut child);
1447                Self::from_transport(
1448                    reader,
1449                    writer,
1450                    Some(child),
1451                    tree,
1452                    working_directory,
1453                    options.on_list_models,
1454                    extension_launch_provider.clone(),
1455                    session_fs_config.is_some(),
1456                    session_fs_sqlite_declared,
1457                    options.on_get_trace_context,
1458                    options.on_github_telemetry,
1459                    effective_connection_token.clone(),
1460                    options.mode,
1461                    options.client_info,
1462                )?
1463            }
1464            Transport::Stdio => {
1465                let (mut child, tree, spawn_elapsed) =
1466                    Self::spawn_stdio(&program, &options, &working_directory)?;
1467                timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed));
1468                let stdin = child.stdin.take().expect("stdin is piped");
1469                let stdout = child.stdout.take().expect("stdout is piped");
1470                Self::drain_stderr(&mut child);
1471                Self::from_transport(
1472                    stdout,
1473                    stdin,
1474                    Some(child),
1475                    tree,
1476                    working_directory,
1477                    options.on_list_models,
1478                    extension_launch_provider.clone(),
1479                    session_fs_config.is_some(),
1480                    session_fs_sqlite_declared,
1481                    options.on_get_trace_context,
1482                    options.on_github_telemetry,
1483                    effective_connection_token.clone(),
1484                    options.mode,
1485                    options.client_info,
1486                )?
1487            }
1488            Transport::InProcess => {
1489                #[cfg(feature = "bundled-in-process")]
1490                {
1491                    info!(runtime_path = %program.display(), "hosting copilot runtime in-process (FFI)");
1492                    let mut environment = Vec::new();
1493                    if let Some(base_directory) = &options.base_directory {
1494                        let value = base_directory.to_str().ok_or_else(|| {
1495                            Error::with_message(
1496                                ErrorKind::InvalidConfig,
1497                                "base_directory must be valid UTF-8 for Transport::InProcess",
1498                            )
1499                        })?;
1500                        environment.push(("COPILOT_HOME".to_string(), value.to_string()));
1501                    }
1502                    if options.mode == ClientMode::Empty {
1503                        environment.push(("COPILOT_DISABLE_KEYTAR".to_string(), "1".to_string()));
1504                    }
1505                    if let Some(github_token) = &options.github_token {
1506                        environment
1507                            .push(("COPILOT_SDK_AUTH_TOKEN".to_string(), github_token.clone()));
1508                    }
1509                    let mut args = Vec::new();
1510                    args.extend(
1511                        Self::log_level_args(&options)
1512                            .into_iter()
1513                            .map(str::to_string),
1514                    );
1515                    args.extend(Self::session_idle_timeout_args(&options));
1516                    args.extend(Self::remote_args(&options));
1517                    if options.github_token.is_some() {
1518                        args.extend([
1519                            "--auth-token-env".to_string(),
1520                            "COPILOT_SDK_AUTH_TOKEN".to_string(),
1521                        ]);
1522                    }
1523                    let use_logged_in_user = options
1524                        .use_logged_in_user
1525                        .unwrap_or(options.github_token.is_none());
1526                    if !use_logged_in_user {
1527                        args.push("--no-auto-login".to_string());
1528                    }
1529                    let explicit_cli = std::env::var_os("COPILOT_CLI_PATH")
1530                        .map(PathBuf::from)
1531                        .filter(|path| path.is_file());
1532                    let host = crate::ffi::FfiHost::create(
1533                        &program,
1534                        explicit_cli.as_deref(),
1535                        environment,
1536                        args,
1537                    )?;
1538                    let (reader, writer, shared) = host.start().await?;
1539                    let client = Self::from_transport(
1540                        reader,
1541                        writer,
1542                        None,
1543                        None,
1544                        working_directory,
1545                        options.on_list_models,
1546                        extension_launch_provider.clone(),
1547                        session_fs_config.is_some(),
1548                        session_fs_sqlite_declared,
1549                        options.on_get_trace_context,
1550                        options.on_github_telemetry,
1551                        effective_connection_token.clone(),
1552                        options.mode,
1553                        options.client_info,
1554                    )?;
1555                    *client.inner.ffi_host.lock() = Some(shared);
1556                    client
1557                }
1558                #[cfg(not(feature = "bundled-in-process"))]
1559                unreachable!("in-process feature validation returned above")
1560            }
1561        };
1562        timings.transport_setup_ms = StartupTimings::millis(transport_setup_start.elapsed());
1563        debug!(
1564            elapsed_ms = start_time.elapsed().as_millis(),
1565            "Client::start transport setup complete"
1566        );
1567        let handshake_start = Instant::now();
1568        client.verify_protocol_version().await?;
1569        timings.handshake_ms = StartupTimings::millis(handshake_start.elapsed());
1570        debug!(
1571            elapsed_ms = start_time.elapsed().as_millis(),
1572            "Client::start protocol verification complete"
1573        );
1574        let request_dispatcher = request_handler.map(|handler| {
1575            let dispatcher = Arc::new(copilot_request_handler::CopilotRequestDispatcher::new(
1576                handler,
1577            ));
1578            dispatcher.set_client(Arc::downgrade(&client.inner));
1579            let _ = client.inner.llm_inference.set(dispatcher.clone());
1580            dispatcher
1581        });
1582        if client.inner.extension_launch_provider.is_configured() {
1583            client.inner.router.ensure_started(
1584                &client.inner.notification_tx,
1585                &client.inner.request_rx,
1586                client.inner.extension_launch_provider.clone(),
1587                request_dispatcher.clone(),
1588                client.inner.on_github_telemetry.clone(),
1589                client.inner.github_token_registry.clone(),
1590            );
1591            client.rpc().register_extension_launch_provider().await?;
1592        }
1593        if !builtin_plugin_directories.is_empty() {
1594            client
1595                .call(
1596                    "plugins.builtin.set",
1597                    Some(serde_json::json!({ "paths": builtin_plugin_directories })),
1598                )
1599                .await?;
1600        }
1601        if let Some(cfg) = session_fs_config {
1602            let session_fs_start = Instant::now();
1603            let capabilities = cfg.capabilities.as_ref().map(|c| {
1604                crate::generated::api_types::SessionFsSetProviderCapabilities {
1605                    sqlite: Some(c.sqlite),
1606                }
1607            });
1608            let request = crate::generated::api_types::SessionFsSetProviderRequest {
1609                capabilities,
1610                conventions: cfg.conventions.into_wire(),
1611                initial_cwd: cfg.initial_cwd,
1612                session_state_path: cfg.session_state_path,
1613            };
1614            client.rpc().session_fs().set_provider(request).await?;
1615            let session_fs_elapsed = session_fs_start.elapsed();
1616            timings.session_fs_ms = Some(StartupTimings::millis(session_fs_elapsed));
1617            debug!(
1618                elapsed_ms = session_fs_elapsed.as_millis(),
1619                "Client::start session filesystem setup complete"
1620            );
1621        }
1622        if let Some(dispatcher) = request_dispatcher {
1623            let llm_inference_start = Instant::now();
1624            // Start the router early (before any session is registered) so the
1625            // startup model catalog request is dispatched to the handler.
1626            client.inner.router.ensure_started(
1627                &client.inner.notification_tx,
1628                &client.inner.request_rx,
1629                client.inner.extension_launch_provider.clone(),
1630                Some(dispatcher.clone()),
1631                client.inner.on_github_telemetry.clone(),
1632                client.inner.github_token_registry.clone(),
1633            );
1634            client.rpc().llm_inference().set_provider().await?;
1635            let llm_inference_elapsed = llm_inference_start.elapsed();
1636            timings.llm_handler_ms = Some(StartupTimings::millis(llm_inference_elapsed));
1637            debug!(
1638                elapsed_ms = llm_inference_elapsed.as_millis(),
1639                "Client::start Copilot request handler registration complete"
1640            );
1641        }
1642        timings.total_ms = StartupTimings::millis(start_time.elapsed());
1643        // A span allows optional fields to retain their numeric type when
1644        // present while recording an explicit "None" when a phase did not run.
1645        let timings_span = tracing::debug_span!(
1646            "Client::start timings",
1647            program_resolve_ms = tracing::field::Empty,
1648            process_spawn_ms = tracing::field::Empty,
1649            port_wait_ms = tracing::field::Empty,
1650            transport_setup_ms = timings.transport_setup_ms,
1651            handshake_ms = timings.handshake_ms,
1652            session_fs_ms = tracing::field::Empty,
1653            llm_handler_ms = tracing::field::Empty,
1654            total_ms = timings.total_ms,
1655        );
1656        record_optional_millis(
1657            &timings_span,
1658            "program_resolve_ms",
1659            timings.program_resolve_ms,
1660        );
1661        record_optional_millis(&timings_span, "process_spawn_ms", timings.process_spawn_ms);
1662        record_optional_millis(&timings_span, "port_wait_ms", timings.port_wait_ms);
1663        record_optional_millis(&timings_span, "session_fs_ms", timings.session_fs_ms);
1664        record_optional_millis(&timings_span, "llm_handler_ms", timings.llm_handler_ms);
1665        timings_span.in_scope(|| debug!("Client::start timings"));
1666        let _ = client.inner.startup_timings.set(timings);
1667        debug!(
1668            elapsed_ms = start_time.elapsed().as_millis(),
1669            "Client::start complete"
1670        );
1671        Ok(client)
1672    }
1673
1674    /// Create a Client from raw async streams (no child process).
1675    ///
1676    /// Useful for testing or connecting to a server over a custom transport.
1677    pub fn from_streams(
1678        reader: impl AsyncRead + Unpin + Send + 'static,
1679        writer: impl AsyncWrite + Unpin + Send + 'static,
1680        cwd: PathBuf,
1681    ) -> Result<Self> {
1682        Self::from_transport(
1683            reader,
1684            writer,
1685            None,
1686            None,
1687            cwd,
1688            None,
1689            None,
1690            false,
1691            false,
1692            None,
1693            None,
1694            None,
1695            ClientMode::default(),
1696            None,
1697        )
1698    }
1699
1700    /// Construct a [`Client`] from raw streams with a preset extension launch
1701    /// provider, for integration testing connection-global reverse requests.
1702    #[doc(hidden)]
1703    #[cfg(any(test, feature = "test-support"))]
1704    pub fn from_streams_with_extension_launch_provider(
1705        reader: impl AsyncRead + Unpin + Send + 'static,
1706        writer: impl AsyncWrite + Unpin + Send + 'static,
1707        cwd: PathBuf,
1708        provider: Arc<dyn crate::extension_launch_provider::ExtensionLaunchProvider>,
1709    ) -> Result<Self> {
1710        Self::from_transport(
1711            reader,
1712            writer,
1713            None,
1714            None,
1715            cwd,
1716            None,
1717            Some(provider),
1718            false,
1719            false,
1720            None,
1721            None,
1722            None,
1723            ClientMode::default(),
1724            None,
1725        )
1726    }
1727
1728    /// Construct a [`Client`] from raw streams with a
1729    /// [`TraceContextProvider`] preset, for integration testing.
1730    ///
1731    /// Mirrors [`from_streams`](Self::from_streams) but exposes the
1732    /// `on_get_trace_context` plumbing so tests can verify outbound
1733    /// `traceparent` / `tracestate` injection on `session.create`,
1734    /// `session.resume`, and `session.send`.
1735    #[cfg(any(test, feature = "test-support"))]
1736    pub fn from_streams_with_trace_provider(
1737        reader: impl AsyncRead + Unpin + Send + 'static,
1738        writer: impl AsyncWrite + Unpin + Send + 'static,
1739        cwd: PathBuf,
1740        provider: Arc<dyn TraceContextProvider>,
1741    ) -> Result<Self> {
1742        Self::from_transport(
1743            reader,
1744            writer,
1745            None,
1746            None,
1747            cwd,
1748            None,
1749            None,
1750            false,
1751            false,
1752            Some(provider),
1753            None,
1754            None,
1755            ClientMode::default(),
1756            None,
1757        )
1758    }
1759
1760    /// Construct a [`Client`] from raw streams with a preset
1761    /// `effective_connection_token`, for integration testing the
1762    /// `connect` handshake's token-forwarding path.
1763    #[cfg(any(test, feature = "test-support"))]
1764    pub fn from_streams_with_connection_token(
1765        reader: impl AsyncRead + Unpin + Send + 'static,
1766        writer: impl AsyncWrite + Unpin + Send + 'static,
1767        cwd: PathBuf,
1768        token: Option<String>,
1769    ) -> Result<Self> {
1770        Self::from_transport(
1771            reader,
1772            writer,
1773            None,
1774            None,
1775            cwd,
1776            None,
1777            None,
1778            false,
1779            false,
1780            None,
1781            None,
1782            token,
1783            ClientMode::default(),
1784            None,
1785        )
1786    }
1787
1788    /// Construct a [`Client`] from raw streams with a preset GitHub telemetry
1789    /// callback, for integration testing telemetry forwarding.
1790    #[doc(hidden)]
1791    #[cfg(any(test, feature = "test-support"))]
1792    pub fn from_streams_with_github_telemetry(
1793        reader: impl AsyncRead + Unpin + Send + 'static,
1794        writer: impl AsyncWrite + Unpin + Send + 'static,
1795        cwd: PathBuf,
1796        on_github_telemetry: crate::github_telemetry::GitHubTelemetryCallback,
1797    ) -> Result<Self> {
1798        Self::from_transport(
1799            reader,
1800            writer,
1801            None,
1802            None,
1803            cwd,
1804            None,
1805            None,
1806            false,
1807            false,
1808            None,
1809            Some(on_github_telemetry),
1810            None,
1811            ClientMode::default(),
1812            None,
1813        )
1814    }
1815
1816    /// Public test-only wrapper around the random connection-token
1817    /// generator used by [`Client::start`] when the SDK spawns a TCP
1818    /// server without an explicit token. Lets integration tests
1819    /// validate the token shape (32-char lowercase hex, 128 bits of
1820    /// entropy) without re-implementing the helper.
1821    #[cfg(any(test, feature = "test-support"))]
1822    pub fn generate_connection_token_for_test() -> String {
1823        generate_connection_token()
1824    }
1825
1826    /// Construct a [`Client`] from raw streams with a preset
1827    /// [`ClientInfo`], for integration testing the `connect` handshake's
1828    /// application-identity forwarding path.
1829    #[doc(hidden)]
1830    #[cfg(any(test, feature = "test-support"))]
1831    pub fn from_streams_with_client_info(
1832        reader: impl AsyncRead + Unpin + Send + 'static,
1833        writer: impl AsyncWrite + Unpin + Send + 'static,
1834        cwd: PathBuf,
1835        client_info: Option<ClientInfo>,
1836    ) -> Result<Self> {
1837        Self::from_transport(
1838            reader,
1839            writer,
1840            None,
1841            None,
1842            cwd,
1843            None,
1844            None,
1845            false,
1846            false,
1847            None,
1848            None,
1849            None,
1850            ClientMode::default(),
1851            client_info,
1852        )
1853    }
1854
1855    #[allow(clippy::too_many_arguments)]
1856    fn from_transport(
1857        reader: impl AsyncRead + Unpin + Send + 'static,
1858        writer: impl AsyncWrite + Unpin + Send + 'static,
1859        child: Option<Child>,
1860        process_tree: Option<process_tree::ProcessTree>,
1861        cwd: PathBuf,
1862        on_list_models: Option<Arc<dyn ListModelsHandler>>,
1863        extension_launch_provider: Option<
1864            Arc<dyn crate::extension_launch_provider::ExtensionLaunchProvider>,
1865        >,
1866        session_fs_configured: bool,
1867        session_fs_sqlite_declared: bool,
1868        on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
1869        on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
1870        effective_connection_token: Option<String>,
1871        mode: ClientMode,
1872        client_info: Option<ClientInfo>,
1873    ) -> Result<Self> {
1874        let setup_start = Instant::now();
1875        let (request_tx, request_rx) = mpsc::unbounded_channel::<JsonRpcRequest>();
1876        let (notification_broadcast_tx, _) = broadcast::channel::<JsonRpcNotification>(1024);
1877        let rpc = JsonRpcClient::new(
1878            writer,
1879            reader,
1880            notification_broadcast_tx.clone(),
1881            request_tx,
1882        );
1883
1884        let pid = child.as_ref().and_then(|c| c.id());
1885        info!(pid = ?pid, "copilot CLI client ready");
1886
1887        let github_token_registry = Arc::new(github_token::GitHubTokenRegistry::new());
1888        let extension_launch_provider = Arc::new(
1889            extension_launch_provider::ExtensionLaunchProviderDispatcher::new(
1890                extension_launch_provider,
1891            ),
1892        );
1893        let client = Self {
1894            inner: Arc::new(ClientInner {
1895                child: parking_lot::Mutex::new(child),
1896                process_tree: parking_lot::Mutex::new(process_tree),
1897                #[cfg(feature = "bundled-in-process")]
1898                ffi_host: parking_lot::Mutex::new(None),
1899                rpc,
1900                cwd,
1901                request_rx: parking_lot::Mutex::new(Some(request_rx)),
1902                notification_tx: notification_broadcast_tx,
1903                router: router::SessionRouter::new(),
1904                github_token_registry: github_token_registry.clone(),
1905                negotiated_protocol_version: OnceLock::new(),
1906                state: parking_lot::Mutex::new(ConnectionState::Connected),
1907                lifecycle_tx: broadcast::channel(256).0,
1908                on_list_models,
1909                models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())),
1910                session_fs_configured,
1911                session_fs_sqlite_declared,
1912                llm_inference: OnceLock::new(),
1913                extension_launch_provider: extension_launch_provider.clone(),
1914                on_github_telemetry,
1915                on_get_trace_context,
1916                effective_connection_token,
1917                mode,
1918                client_info,
1919                startup_timings: OnceLock::new(),
1920            }),
1921        };
1922        github_token_registry.set_client(Arc::downgrade(&client.inner));
1923        extension_launch_provider.set_client(Arc::downgrade(&client.inner));
1924        client.spawn_lifecycle_dispatcher();
1925        debug!(
1926            elapsed_ms = setup_start.elapsed().as_millis(),
1927            pid = ?pid,
1928            "Client::from_transport setup complete"
1929        );
1930        Ok(client)
1931    }
1932
1933    /// Spawn the background task that re-broadcasts `session.lifecycle`
1934    /// notifications via [`ClientInner::lifecycle_tx`] to subscribers
1935    /// returned by [`Self::subscribe_lifecycle`].
1936    fn spawn_lifecycle_dispatcher(&self) {
1937        let mut notif_rx = self.inner.notification_tx.subscribe();
1938        let lifecycle_tx = self.inner.lifecycle_tx.clone();
1939        tokio::spawn(async move {
1940            loop {
1941                match notif_rx.recv().await {
1942                    Ok(notification) => {
1943                        if notification.method != "session.lifecycle" {
1944                            continue;
1945                        }
1946                        let Some(params) = notification.params.as_ref() else {
1947                            continue;
1948                        };
1949                        let event: SessionLifecycleEvent =
1950                            match serde_json::from_value(params.clone()) {
1951                                Ok(e) => e,
1952                                Err(e) => {
1953                                    warn!(
1954                                        error = %e,
1955                                        "failed to deserialize session.lifecycle notification"
1956                                    );
1957                                    continue;
1958                                }
1959                            };
1960                        // `send` only errors when there are no subscribers — that's
1961                        // the normal case before any consumer calls subscribe_lifecycle.
1962                        let _ = lifecycle_tx.send(event);
1963                    }
1964                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1965                        warn!(missed = n, "lifecycle dispatcher lagged");
1966                    }
1967                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1968                }
1969            }
1970        });
1971    }
1972
1973    fn build_command(program: &Path, options: &ClientOptions, working_directory: &Path) -> Command {
1974        let mut command = Command::new(program);
1975        command.kill_on_drop(true);
1976        for arg in &options.prefix_args {
1977            command.arg(arg);
1978        }
1979        // Inject the SDK auth token first so explicit `env` / `env_remove`
1980        // entries can override or strip it.
1981        if let Some(token) = &options.github_token {
1982            command.env("COPILOT_SDK_AUTH_TOKEN", token);
1983        }
1984        // Inject telemetry env vars before user env so callers can still
1985        // override individual variables via `options.env`.
1986        if let Some(telemetry) = &options.telemetry {
1987            command.env("COPILOT_OTEL_ENABLED", "true");
1988            if let Some(endpoint) = &telemetry.otlp_endpoint {
1989                command.env("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint);
1990            }
1991            if let Some(protocol) = telemetry.otlp_protocol {
1992                command.env("OTEL_EXPORTER_OTLP_PROTOCOL", protocol.as_str());
1993            }
1994            if let Some(path) = &telemetry.file_path {
1995                command.env("COPILOT_OTEL_FILE_EXPORTER_PATH", path);
1996            }
1997            if let Some(exporter) = telemetry.exporter_type {
1998                command.env("COPILOT_OTEL_EXPORTER_TYPE", exporter.as_str());
1999            }
2000            if let Some(source) = &telemetry.source_name {
2001                command.env("COPILOT_OTEL_SOURCE_NAME", source);
2002            }
2003            if let Some(capture) = telemetry.capture_content {
2004                command.env(
2005                    "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
2006                    if capture { "true" } else { "false" },
2007                );
2008            }
2009        }
2010        if let Some(dir) = &options.base_directory {
2011            command.env("COPILOT_HOME", dir);
2012        }
2013        // Empty mode disables the process-wide system keychain so the CLI
2014        // falls back to file-based credentials scoped to COPILOT_HOME.
2015        if options.mode == ClientMode::Empty {
2016            command.env("COPILOT_DISABLE_KEYTAR", "1");
2017        }
2018        if let Transport::Tcp {
2019            connection_token: Some(token),
2020            ..
2021        } = &options.transport
2022        {
2023            command.env("COPILOT_CONNECTION_TOKEN", token);
2024        }
2025        for (key, value) in &options.env {
2026            command.env(key, value);
2027        }
2028        for key in &options.env_remove {
2029            command.env_remove(key);
2030        }
2031        command
2032            .current_dir(working_directory)
2033            .stdout(Stdio::piped())
2034            .stderr(Stdio::piped());
2035
2036        command
2037    }
2038
2039    /// Returns the CLI auth flags derived from [`ClientOptions::github_token`]
2040    /// and [`ClientOptions::use_logged_in_user`].
2041    ///
2042    /// When a token is set, adds `--auth-token-env COPILOT_SDK_AUTH_TOKEN`.
2043    /// When the effective `use_logged_in_user` is `false` (either explicitly
2044    /// or because a token was provided without an override), adds
2045    /// `--no-auto-login`.
2046    fn auth_args(options: &ClientOptions) -> Vec<&'static str> {
2047        let mut args: Vec<&'static str> = Vec::new();
2048        if options.github_token.is_some() {
2049            args.push("--auth-token-env");
2050            args.push("COPILOT_SDK_AUTH_TOKEN");
2051        }
2052        let use_logged_in = options
2053            .use_logged_in_user
2054            .unwrap_or(options.github_token.is_none());
2055        if !use_logged_in {
2056            args.push("--no-auto-login");
2057        }
2058        args
2059    }
2060
2061    /// Returns `--session-idle-timeout <secs>` when
2062    /// [`ClientOptions::session_idle_timeout_seconds`] is `Some(n)` with
2063    /// `n > 0`. Otherwise returns an empty vector.
2064    fn session_idle_timeout_args(options: &ClientOptions) -> Vec<String> {
2065        match options.session_idle_timeout_seconds {
2066            Some(secs) if secs > 0 => {
2067                vec!["--session-idle-timeout".to_string(), secs.to_string()]
2068            }
2069            _ => Vec::new(),
2070        }
2071    }
2072
2073    fn remote_args(options: &ClientOptions) -> Vec<String> {
2074        if options.enable_remote_sessions {
2075            vec!["--remote".to_string()]
2076        } else {
2077            Vec::new()
2078        }
2079    }
2080
2081    fn log_level_args(options: &ClientOptions) -> Vec<&'static str> {
2082        match options.log_level {
2083            Some(level) => vec!["--log-level", level.as_str()],
2084            None => Vec::new(),
2085        }
2086    }
2087
2088    fn spawn_stdio(
2089        program: &Path,
2090        options: &ClientOptions,
2091        working_directory: &Path,
2092    ) -> Result<(Child, Option<process_tree::ProcessTree>, Duration)> {
2093        info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)");
2094        let mut command = Self::build_command(program, options, working_directory);
2095        command
2096            .args(["--server", "--stdio", "--no-auto-update"])
2097            .args(Self::log_level_args(options))
2098            .args(Self::auth_args(options))
2099            .args(Self::session_idle_timeout_args(options))
2100            .args(Self::remote_args(options))
2101            .args(&options.extra_args)
2102            .stdin(Stdio::piped());
2103        let spawn_start = Instant::now();
2104        let (child, tree) = process_tree::spawn(&mut command)?;
2105        let spawn_elapsed = spawn_start.elapsed();
2106        debug!(
2107            elapsed_ms = spawn_elapsed.as_millis(),
2108            "Client::spawn_stdio subprocess spawned"
2109        );
2110        Ok((child, tree, spawn_elapsed))
2111    }
2112
2113    async fn spawn_tcp(
2114        program: &Path,
2115        options: &ClientOptions,
2116        working_directory: &Path,
2117        port: u16,
2118    ) -> Result<(
2119        Child,
2120        Option<process_tree::ProcessTree>,
2121        u16,
2122        Duration,
2123        Duration,
2124    )> {
2125        info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)");
2126        let mut command = Self::build_command(program, options, working_directory);
2127        command
2128            .args(["--server", "--port", &port.to_string(), "--no-auto-update"])
2129            .args(Self::log_level_args(options))
2130            .args(Self::auth_args(options))
2131            .args(Self::session_idle_timeout_args(options))
2132            .args(Self::remote_args(options))
2133            .args(&options.extra_args)
2134            .stdin(Stdio::null());
2135        let spawn_start = Instant::now();
2136        let (mut child, tree) = process_tree::spawn(&mut command)?;
2137        let spawn_elapsed = spawn_start.elapsed();
2138        debug!(
2139            elapsed_ms = spawn_elapsed.as_millis(),
2140            "Client::spawn_tcp subprocess spawned"
2141        );
2142        let stdout = child.stdout.take().expect("stdout is piped");
2143
2144        let (port_tx, port_rx) = oneshot::channel::<u16>();
2145        let span = tracing::error_span!("copilot_cli_port_scan");
2146        tokio::spawn(
2147            async move {
2148                // Scan stdout for the port announcement.
2149                let port_re = regex::Regex::new(r"listening on port (\d+)").expect("valid regex");
2150                let mut lines = BufReader::new(stdout).lines();
2151                let mut port_tx = Some(port_tx);
2152                while let Ok(Some(line)) = lines.next_line().await {
2153                    debug!(line = %line, "CLI stdout");
2154                    if let Some(tx) = port_tx.take() {
2155                        if let Some(caps) = port_re.captures(&line)
2156                            && let Some(p) =
2157                                caps.get(1).and_then(|m| m.as_str().parse::<u16>().ok())
2158                        {
2159                            let _ = tx.send(p);
2160                            continue;
2161                        }
2162                        // Not the port line — put tx back
2163                        port_tx = Some(tx);
2164                    }
2165                }
2166            }
2167            .instrument(span),
2168        );
2169
2170        let port_wait_start = Instant::now();
2171        let actual_port = tokio::time::timeout(std::time::Duration::from_secs(10), port_rx)
2172            .await
2173            .map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout)))?
2174            .map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupFailed)))?;
2175
2176        let port_wait_elapsed = port_wait_start.elapsed();
2177        debug!(
2178            elapsed_ms = port_wait_elapsed.as_millis(),
2179            port = actual_port,
2180            "Client::spawn_tcp TCP port wait complete"
2181        );
2182        info!(port = %actual_port, "CLI server listening");
2183        Ok((child, tree, actual_port, spawn_elapsed, port_wait_elapsed))
2184    }
2185
2186    fn drain_stderr(child: &mut Child) {
2187        if let Some(stderr) = child.stderr.take() {
2188            let span = tracing::error_span!("copilot_cli");
2189            tokio::spawn(
2190                async move {
2191                    let mut reader = BufReader::new(stderr).lines();
2192                    while let Ok(Some(line)) = reader.next_line().await {
2193                        warn!(line = %line, "CLI stderr");
2194                    }
2195                }
2196                .instrument(span),
2197            );
2198        }
2199    }
2200
2201    /// Returns the working directory of the CLI process.
2202    pub fn cwd(&self) -> &PathBuf {
2203        &self.inner.cwd
2204    }
2205
2206    /// Returns the SDK [`ClientMode`] this client was started with.
2207    pub fn mode(&self) -> ClientMode {
2208        self.inner.mode
2209    }
2210
2211    /// Typed RPC namespace for server-level methods.
2212    ///
2213    /// Every protocol method lives here under its schema-aligned path —
2214    /// e.g. `client.rpc().models().list()`. Wire method names and request/
2215    /// response types are generated from the protocol schema, so the typed
2216    /// namespace can't drift from the wire contract.
2217    ///
2218    /// The hand-authored helpers on [`Client`] delegate to this namespace
2219    /// and remain the recommended entry point for everyday use; reach for
2220    /// `rpc()` when you want a method without a hand-written wrapper.
2221    pub fn rpc(&self) -> crate::generated::rpc::ClientRpc<'_> {
2222        crate::generated::rpc::ClientRpc { client: self }
2223    }
2224
2225    /// Send a JSON-RPC request and wait for the response.
2226    #[allow(dead_code, reason = "convenience for future internal use")]
2227    pub(crate) async fn send_request(
2228        &self,
2229        method: &str,
2230        params: Option<serde_json::Value>,
2231    ) -> Result<JsonRpcResponse> {
2232        self.inner.rpc.send_request(method, params).await
2233    }
2234
2235    /// Send a JSON-RPC request, check for errors, and return the result value.
2236    ///
2237    /// This is the primary method for session-level RPC calls. It wraps
2238    /// the internal send/receive cycle with error checking so callers
2239    /// don't need to inspect the response manually.
2240    ///
2241    /// # Cancel safety
2242    ///
2243    /// **Cancel-safe.** The frame is committed to the wire via the
2244    /// writer-actor task before the future yields; cancelling the await
2245    /// (via `tokio::time::timeout`, `select!`, or dropped JoinHandle)
2246    /// drops the response oneshot but does not desync the transport.
2247    /// The pending-requests entry is cleaned up by an RAII guard.
2248    /// However, the call's *side effect* on the CLI may still occur —
2249    /// the CLI receives the request and processes it; the caller just
2250    /// won't see the response. For idempotent methods this is fine; for
2251    /// non-idempotent methods (e.g. `session.create`) the caller should
2252    /// avoid wrapping the call in a timeout shorter than the expected
2253    /// CLI processing window.
2254    pub async fn call(
2255        &self,
2256        method: &str,
2257        params: Option<serde_json::Value>,
2258    ) -> Result<serde_json::Value> {
2259        self.call_with_inline_callback(method, params, None).await
2260    }
2261
2262    /// Same as [`call`](Self::call), but installs an `inline_callback`
2263    /// that runs synchronously on the JSON-RPC read task the instant the
2264    /// successful response is parsed, before it is delivered to this
2265    /// awaiter and before the read loop dispatches the next message.
2266    ///
2267    /// This is the only way to perform client-side bookkeeping (for
2268    /// example, registering a server-assigned session id with the
2269    /// router) that must be visible to any notification or request the
2270    /// server may emit on the same connection immediately after the
2271    /// response.
2272    ///
2273    /// If the callback returns an error, that error is propagated to
2274    /// this awaiter in place of the response. The callback never causes
2275    /// the read loop to crash.
2276    pub(crate) async fn call_with_inline_callback(
2277        &self,
2278        method: &str,
2279        params: Option<serde_json::Value>,
2280        inline_callback: Option<crate::jsonrpc::InlineResponseCallback>,
2281    ) -> Result<serde_json::Value> {
2282        let session_id: Option<SessionId> = params
2283            .as_ref()
2284            .and_then(|p| p.get("sessionId"))
2285            .and_then(|v| v.as_str())
2286            .map(SessionId::from);
2287        let response = self
2288            .inner
2289            .rpc
2290            .send_request_with_inline_callback(method, params, inline_callback)
2291            .await?;
2292        if let Some(err) = response.error {
2293            if err.message.contains("Session not found") {
2294                return Err(ErrorKind::Session(SessionErrorKind::NotFound(
2295                    session_id.unwrap_or_else(|| "unknown".into()),
2296                ))
2297                .into());
2298            }
2299            return Err(Error::with_message(
2300                ErrorKind::Rpc { code: err.code },
2301                err.message,
2302            ));
2303        }
2304        Ok(response.result.unwrap_or(serde_json::Value::Null))
2305    }
2306
2307    /// Send a JSON-RPC response back to the CLI (e.g. for permission or tool call requests).
2308    pub(crate) async fn send_response(&self, response: &JsonRpcResponse) -> Result<()> {
2309        self.inner.rpc.write(response).await
2310    }
2311
2312    /// Reconstruct a [`Client`] handle from a shared inner pointer.
2313    pub(crate) fn from_inner(inner: Arc<ClientInner>) -> Self {
2314        Self { inner }
2315    }
2316
2317    /// Take the receiver for incoming JSON-RPC requests from the CLI.
2318    ///
2319    /// Can only be called once — subsequent calls return `None`.
2320    #[expect(dead_code, reason = "reserved for future pub(crate) use")]
2321    pub(crate) fn take_request_rx(&self) -> Option<mpsc::UnboundedReceiver<JsonRpcRequest>> {
2322        self.inner.request_rx.lock().take()
2323    }
2324
2325    /// Register a session to receive filtered events and requests.
2326    ///
2327    /// Returns per-session channels for notifications and requests, routed
2328    /// by `sessionId`. Starts the internal router on first call.
2329    ///
2330    /// When done, call [`unregister_session`](Self::unregister_session) to
2331    /// clean up (typically on session destroy).
2332    pub(crate) fn register_session(
2333        &self,
2334        session_id: &SessionId,
2335    ) -> crate::router::SessionChannels {
2336        self.inner.router.ensure_started(
2337            &self.inner.notification_tx,
2338            &self.inner.request_rx,
2339            self.inner.extension_launch_provider.clone(),
2340            self.inner.llm_inference.get().cloned(),
2341            self.inner.on_github_telemetry.clone(),
2342            self.inner.github_token_registry.clone(),
2343        );
2344        self.inner.router.register(session_id)
2345    }
2346
2347    /// Unregister a session, dropping its per-session channels.
2348    pub(crate) fn unregister_session(&self, session_id: &SessionId) {
2349        self.inner.router.unregister(session_id);
2350    }
2351
2352    pub(crate) fn register_github_token_provider(
2353        &self,
2354        provider: Arc<dyn GitHubTokenProvider>,
2355    ) -> github_token::GitHubTokenRegistration {
2356        self.inner.router.ensure_started(
2357            &self.inner.notification_tx,
2358            &self.inner.request_rx,
2359            self.inner.extension_launch_provider.clone(),
2360            self.inner.llm_inference.get().cloned(),
2361            self.inner.on_github_telemetry.clone(),
2362            self.inner.github_token_registry.clone(),
2363        );
2364        let id = self.inner.github_token_registry.register(provider);
2365        github_token::GitHubTokenRegistration::new(self.inner.github_token_registry.clone(), id)
2366    }
2367
2368    pub(crate) fn retire_github_token_provider(&self, session_id: &SessionId) {
2369        self.inner.github_token_registry.retire_session(session_id);
2370    }
2371
2372    /// Returns the protocol version negotiated with the CLI server, if any.
2373    ///
2374    /// Set during [`start`](Self::start). Returns `None` if the server didn't
2375    /// report a version, or if the client was created via
2376    /// [`from_streams`](Self::from_streams) without calling
2377    /// [`verify_protocol_version`](Self::verify_protocol_version).
2378    pub fn protocol_version(&self) -> Option<u32> {
2379        self.inner.negotiated_protocol_version.get().copied()
2380    }
2381
2382    /// Returns the per-phase [`StartupTimings`] breakdown captured during
2383    /// [`start`](Self::start), if available.
2384    ///
2385    /// Returns `None` for clients created via
2386    /// [`from_streams`](Self::from_streams), which bypasses the timed startup
2387    /// sequence.
2388    pub fn startup_timings(&self) -> Option<StartupTimings> {
2389        self.inner.startup_timings.get().cloned()
2390    }
2391
2392    /// Verify the CLI server's protocol version is within the supported range.
2393    ///
2394    /// Called automatically by [`start`](Self::start). Call manually after
2395    /// [`from_streams`](Self::from_streams) if you need version verification
2396    /// on a custom transport.
2397    ///
2398    /// # Handshake sequence
2399    ///
2400    /// 1. Sends the `connect` JSON-RPC method, forwarding the
2401    ///    [`Transport`]'s `connection_token` (or the auto-generated
2402    ///    token for SDK-spawned TCP servers) as the `token` param. This
2403    ///    is the canonical handshake used by all SDK languages and is
2404    ///    what the CLI uses to enforce loopback authentication when
2405    ///    started with `COPILOT_CONNECTION_TOKEN`.
2406    /// 2. If the server returns `-32601` (`MethodNotFound`), falls back
2407    ///    to the legacy `ping` RPC. This preserves compatibility with
2408    ///    older CLI versions that predate `connect`.
2409    ///
2410    /// # Result
2411    ///
2412    /// Returns an error if the negotiated `protocolVersion` is outside
2413    /// `MIN_PROTOCOL_VERSION`..=[`SDK_PROTOCOL_VERSION`]. If the server
2414    /// doesn't report a version, logs a warning and succeeds.
2415    pub async fn verify_protocol_version(&self) -> Result<()> {
2416        let handshake_start = Instant::now();
2417        let mut used_fallback_ping = false;
2418        // Try the new `connect` handshake first (sends the connection
2419        // token, if any). Fall back to `ping` for legacy CLI servers
2420        // that don't expose `connect` (-32601 MethodNotFound).
2421        let server_version = match self.connect_handshake().await {
2422            Ok(v) => v,
2423            Err(ref e) if e.rpc_code() == Some(error_codes::METHOD_NOT_FOUND) => {
2424                used_fallback_ping = true;
2425                self.ping(None).await?.protocol_version
2426            }
2427            Err(e) => return Err(e),
2428        };
2429
2430        match server_version {
2431            None => {
2432                warn!("CLI server did not report protocolVersion; skipping version check");
2433            }
2434            Some(v) if !(MIN_PROTOCOL_VERSION..=SDK_PROTOCOL_VERSION).contains(&v) => {
2435                return Err(ErrorKind::Protocol(ProtocolErrorKind::VersionMismatch {
2436                    server: v,
2437                    min: MIN_PROTOCOL_VERSION,
2438                    max: SDK_PROTOCOL_VERSION,
2439                })
2440                .into());
2441            }
2442            Some(v) => {
2443                if let Some(&existing) = self.inner.negotiated_protocol_version.get() {
2444                    if existing != v {
2445                        return Err(ErrorKind::Protocol(ProtocolErrorKind::VersionChanged {
2446                            previous: existing,
2447                            current: v,
2448                        })
2449                        .into());
2450                    }
2451                } else {
2452                    let _ = self.inner.negotiated_protocol_version.set(v);
2453                }
2454            }
2455        }
2456
2457        debug!(
2458            elapsed_ms = handshake_start.elapsed().as_millis(),
2459            protocol_version = ?server_version,
2460            used_fallback_ping,
2461            "Client::verify_protocol_version protocol handshake complete"
2462        );
2463        Ok(())
2464    }
2465
2466    /// Send the `connect` JSON-RPC handshake. Returns the server's
2467    /// reported protocol version, or `None` if the server omits it.
2468    /// Forwards the [`Transport`]'s `connection_token` (or the
2469    /// auto-generated token for SDK-spawned TCP servers) as the `token`
2470    /// param. Server-side, the token is required when the server was
2471    /// started with `COPILOT_CONNECTION_TOKEN`.
2472    async fn connect_handshake(&self) -> Result<Option<u32>> {
2473        let params = crate::generated::api_types::ConnectRequest {
2474            token: self.inner.effective_connection_token.clone(),
2475            enable_git_hub_telemetry_forwarding: self
2476                .inner
2477                .on_github_telemetry
2478                .is_some()
2479                .then_some(true),
2480            // Declare the integrating application's identity so the runtime attributes
2481            // the telemetry it emits on this connection to a consistent surface
2482            // instead of its own build. `None` when the app didn't supply it, and
2483            // empty fields are dropped.
2484            client_info: self
2485                .inner
2486                .client_info
2487                .as_ref()
2488                .and_then(ClientInfo::to_wire),
2489        };
2490        let value = self
2491            .call(
2492                crate::generated::api_types::rpc_methods::CONNECT,
2493                Some(serde_json::to_value(params)?),
2494            )
2495            .await?;
2496        let result: crate::generated::api_types::ConnectResult = serde_json::from_value(value)?;
2497        Ok(Some(u32::try_from(result.protocol_version).map_err(
2498            |_| ProtocolErrorKind::InvalidProtocolVersion {
2499                server: result.protocol_version,
2500            },
2501        )?))
2502    }
2503
2504    /// Send a `ping` RPC and return the typed [`PingResponse`].
2505    ///
2506    /// Pass `Some(message)` to have the server echo it back; pass `None` for
2507    /// a bare health check. The response includes a `protocolVersion` when
2508    /// the CLI reports one.
2509    ///
2510    /// [`PingResponse`]: crate::types::PingResponse
2511    pub async fn ping(&self, message: Option<&str>) -> Result<crate::types::PingResponse> {
2512        let params = match message {
2513            Some(m) => serde_json::json!({ "message": m }),
2514            None => serde_json::json!({}),
2515        };
2516        let value = self
2517            .call(generated::api_types::rpc_methods::PING, Some(params))
2518            .await?;
2519        Ok(serde_json::from_value(value)?)
2520    }
2521
2522    /// List persisted sessions, optionally filtered by working directory,
2523    /// repository, or git context.
2524    pub async fn list_sessions(
2525        &self,
2526        filter: Option<SessionListFilter>,
2527    ) -> Result<Vec<SessionMetadata>> {
2528        let params = match filter {
2529            Some(f) => serde_json::json!({ "filter": f }),
2530            None => serde_json::json!({}),
2531        };
2532        let result = self.call("session.list", Some(params)).await?;
2533        let response: ListSessionsResponse = serde_json::from_value(result)?;
2534        Ok(response.sessions)
2535    }
2536
2537    /// Fetch metadata for a specific persisted session by ID.
2538    ///
2539    /// Returns `Ok(None)` if no session with the given ID exists. More
2540    /// efficient than calling [`list_sessions`](Self::list_sessions) and
2541    /// filtering when you only need data for a single session.
2542    ///
2543    /// # Example
2544    ///
2545    /// ```no_run
2546    /// # async fn example(client: &github_copilot_sdk::Client) -> Result<(), github_copilot_sdk::Error> {
2547    /// use github_copilot_sdk::types::SessionId;
2548    /// if let Some(metadata) = client.get_session_metadata(&SessionId::new("session-123")).await? {
2549    ///     println!("Session started at: {}", metadata.start_time);
2550    /// }
2551    /// # Ok(())
2552    /// # }
2553    /// ```
2554    pub async fn get_session_metadata(
2555        &self,
2556        session_id: &SessionId,
2557    ) -> Result<Option<SessionMetadata>> {
2558        let result = self
2559            .call(
2560                "session.getMetadata",
2561                Some(serde_json::json!({ "sessionId": session_id })),
2562            )
2563            .await?;
2564        let response: GetSessionMetadataResponse = serde_json::from_value(result)?;
2565        Ok(response.session)
2566    }
2567
2568    /// Delete a persisted session by ID.
2569    pub async fn delete_session(&self, session_id: &SessionId) -> Result<()> {
2570        self.call(
2571            "session.delete",
2572            Some(serde_json::json!({ "sessionId": session_id })),
2573        )
2574        .await?;
2575        self.retire_github_token_provider(session_id);
2576        Ok(())
2577    }
2578
2579    /// Start this client's notification and request router on the current runtime.
2580    /// This is test-harness plumbing, not part of the supported SDK API.
2581    #[cfg(feature = "test-support")]
2582    #[doc(hidden)]
2583    pub fn start_router_for_test(&self) {
2584        self.inner.router.ensure_started(
2585            &self.inner.notification_tx,
2586            &self.inner.request_rx,
2587            self.inner.extension_launch_provider.clone(),
2588            self.inner.llm_inference.get().cloned(),
2589            self.inner.on_github_telemetry.clone(),
2590            self.inner.github_token_registry.clone(),
2591        );
2592    }
2593
2594    #[cfg(feature = "test-support")]
2595    #[doc(hidden)]
2596    /// Disconnect and delete every session owned by this test client's isolated
2597    /// runtime. This is test-harness plumbing, not part of the supported SDK API.
2598    pub async fn cleanup_sessions_for_test(&self) -> Result<()> {
2599        let mut first_error = None;
2600
2601        for session_id in self.inner.router.session_ids() {
2602            if let Err(error) = self
2603                .call(
2604                    "session.destroy",
2605                    Some(serde_json::json!({ "sessionId": session_id })),
2606                )
2607                .await
2608                && first_error.is_none()
2609            {
2610                first_error = Some(error);
2611            }
2612            self.inner.router.unregister(&session_id);
2613        }
2614        self.inner.github_token_registry.clear();
2615
2616        match self.list_sessions(None).await {
2617            Ok(sessions) => {
2618                for session in sessions {
2619                    if let Err(error) = self.delete_session(&session.session_id).await
2620                        && first_error.is_none()
2621                    {
2622                        first_error = Some(error);
2623                    }
2624                }
2625            }
2626            Err(error) if first_error.is_none() => first_error = Some(error),
2627            Err(_) => {}
2628        }
2629
2630        match first_error {
2631            Some(error) => Err(error),
2632            None => Ok(()),
2633        }
2634    }
2635
2636    /// Return the ID of the most recently updated session, if any.
2637    ///
2638    /// Useful for resuming the last conversation when the session ID was
2639    /// not stored. Returns `Ok(None)` if no sessions exist.
2640    ///
2641    /// # Example
2642    ///
2643    /// ```no_run
2644    /// # async fn example(client: &github_copilot_sdk::Client) -> Result<(), github_copilot_sdk::Error> {
2645    /// if let Some(last_id) = client.get_last_session_id().await? {
2646    ///     println!("Last session: {last_id}");
2647    /// }
2648    /// # Ok(())
2649    /// # }
2650    /// ```
2651    pub async fn get_last_session_id(&self) -> Result<Option<SessionId>> {
2652        let result = self
2653            .call("session.getLastId", Some(serde_json::json!({})))
2654            .await?;
2655        let response: GetLastSessionIdResponse = serde_json::from_value(result)?;
2656        Ok(response.session_id)
2657    }
2658
2659    /// Return the ID of the session currently displayed in the TUI, if any.
2660    ///
2661    /// Only meaningful when connected to a server running in TUI+server mode
2662    /// (`--ui-server`). Returns `Ok(None)` if no foreground session is set.
2663    pub async fn get_foreground_session_id(&self) -> Result<Option<SessionId>> {
2664        let result = self
2665            .call("session.getForeground", Some(serde_json::json!({})))
2666            .await?;
2667        let response: GetForegroundSessionResponse = serde_json::from_value(result)?;
2668        Ok(response.session_id)
2669    }
2670
2671    /// Request that the TUI switch to displaying the specified session.
2672    ///
2673    /// Only meaningful when connected to a server running in TUI+server mode
2674    /// (`--ui-server`).
2675    pub async fn set_foreground_session_id(&self, session_id: &SessionId) -> Result<()> {
2676        self.call(
2677            "session.setForeground",
2678            Some(serde_json::json!({ "sessionId": session_id })),
2679        )
2680        .await?;
2681        Ok(())
2682    }
2683
2684    /// Get the CLI server status.
2685    pub async fn get_status(&self) -> Result<GetStatusResponse> {
2686        let result = self.call("status.get", Some(serde_json::json!({}))).await?;
2687        Ok(serde_json::from_value(result)?)
2688    }
2689
2690    /// Get authentication status.
2691    pub async fn get_auth_status(&self) -> Result<GetAuthStatusResponse> {
2692        let result = self
2693            .call("auth.getStatus", Some(serde_json::json!({})))
2694            .await?;
2695        Ok(serde_json::from_value(result)?)
2696    }
2697
2698    /// List available models.
2699    ///
2700    /// When [`ClientOptions::on_list_models`] is set, returns the handler's
2701    /// result without making a `models.list` RPC. Otherwise queries the CLI.
2702    pub async fn list_models(&self) -> Result<Vec<Model>> {
2703        let cache = self.inner.models_cache.lock().clone();
2704        let models = cache
2705            .get_or_try_init(|| async {
2706                if let Some(handler) = &self.inner.on_list_models {
2707                    handler.list_models().await
2708                } else {
2709                    Ok(self.rpc().models().list().await?.models)
2710                }
2711            })
2712            .await?;
2713        Ok(models.clone())
2714    }
2715
2716    /// Invoke [`ClientOptions::on_get_trace_context`] when configured,
2717    /// otherwise return [`TraceContext::default()`].
2718    pub(crate) async fn resolve_trace_context(&self) -> TraceContext {
2719        if let Some(provider) = &self.inner.on_get_trace_context {
2720            provider.get_trace_context().await
2721        } else {
2722            TraceContext::default()
2723        }
2724    }
2725
2726    /// Return the OS process ID of the CLI child process, if one was spawned.
2727    pub fn pid(&self) -> Option<u32> {
2728        self.inner.child.lock().as_ref().and_then(|c| c.id())
2729    }
2730
2731    /// Cooperatively shut down the client and the CLI child process.
2732    ///
2733    /// Walks every still-registered session and sends `session.destroy`
2734    /// for each one, asks SDK-owned runtimes to shut down, terminates the
2735    /// Windows-owned CLI Job Object when present, and reaps the root process.
2736    /// Errors from per-session destroys, runtime shutdown, and final process
2737    /// termination are collected into [`StopErrors`] rather than
2738    /// short-circuiting on the first failure — so callers see the full picture
2739    /// of teardown.
2740    ///
2741    /// If you have already called [`Session::disconnect`] on every
2742    /// session this client created, the per-session destroy step is a
2743    /// no-op (the router map is empty); only the child-kill remains.
2744    ///
2745    /// [`Session::disconnect`]: crate::session::Session::disconnect
2746    ///
2747    /// # Cancel safety
2748    ///
2749    /// **Cancel-unsafe but recoverable.** The body sequentially destroys
2750    /// every registered session (each via [`Client::call`](Self::call),
2751    /// individually cancel-safe) before killing the child. Cancelling
2752    /// `stop()` mid-loop leaves some sessions still in the router map
2753    /// and the child still running. Recovery: call [`force_stop`](Self::force_stop)
2754    /// (sync, kills the child unconditionally and clears router state)
2755    /// or call `stop()` again with a fresh future. The documented
2756    /// `tokio::time::timeout(..., client.stop())` pattern in the example
2757    /// below uses `force_stop` as the fallback for exactly this case.
2758    pub async fn stop(&self) -> std::result::Result<(), StopErrors> {
2759        let pid = self.pid();
2760        info!(pid = ?pid, "stopping CLI process");
2761        let mut errors: Vec<Error> = Vec::new();
2762        self.inner.extension_launch_provider.clear();
2763
2764        // Snapshot the registered session IDs without holding the router
2765        // lock across the destroy RPCs.
2766        for session_id in self.inner.router.session_ids() {
2767            match self
2768                .call(
2769                    "session.destroy",
2770                    Some(serde_json::json!({ "sessionId": session_id })),
2771                )
2772                .await
2773            {
2774                Ok(_) => {}
2775                Err(e) => {
2776                    warn!(
2777                        session_id = %session_id,
2778                        error = %e,
2779                        "session.destroy failed during Client::stop",
2780                    );
2781                    errors.push(e);
2782                }
2783            }
2784            self.inner.router.unregister(&session_id);
2785        }
2786        self.inner.github_token_registry.clear();
2787
2788        let should_shutdown_runtime = self.inner.child.lock().is_some();
2789        #[cfg(feature = "bundled-in-process")]
2790        let should_shutdown_runtime =
2791            should_shutdown_runtime || self.inner.ffi_host.lock().is_some();
2792        if should_shutdown_runtime {
2793            let runtime_shutdown_start = Instant::now();
2794            match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, self.rpc().runtime().shutdown())
2795                .await
2796            {
2797                Ok(Ok(())) => {
2798                    debug!(
2799                        elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2800                        "Client::stop runtime shutdown complete"
2801                    );
2802                }
2803                Ok(Err(e)) => {
2804                    warn!(
2805                        elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2806                        error = %e,
2807                        "runtime.shutdown failed during Client::stop",
2808                    );
2809                    errors.push(e);
2810                }
2811                Err(_) => {
2812                    let e = std::io::Error::new(
2813                        std::io::ErrorKind::TimedOut,
2814                        "runtime.shutdown timed out during Client::stop",
2815                    );
2816                    warn!(
2817                        elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2818                        timeout = ?RUNTIME_SHUTDOWN_TIMEOUT,
2819                        error = %e,
2820                        "runtime.shutdown timed out during Client::stop",
2821                    );
2822                    errors.push(e.into());
2823                }
2824            }
2825        }
2826
2827        let child = self.inner.child.lock().take();
2828        let process_tree = self.inner.process_tree.lock().take();
2829        *self.inner.state.lock() = ConnectionState::Disconnected;
2830        *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
2831        if let Some(process_tree) = process_tree
2832            && let Err(error) = process_tree.terminate()
2833        {
2834            errors.push(error.into());
2835        }
2836        if let Some(mut child) = child {
2837            match child.try_wait() {
2838                Ok(Some(_status)) => {}
2839                Ok(None) => {
2840                    // The runtime completes all cleanup before responding to
2841                    // runtime.shutdown and then leaves termination to us; it
2842                    // deliberately keeps its JSON-RPC server alive to send the
2843                    // response and never self-exits. Waiting for a self-exit
2844                    // that will never come just wastes time, so terminate the
2845                    // child immediately.
2846                    if let Err(e) = child.kill().await {
2847                        errors.push(e.into());
2848                    }
2849                }
2850                Err(e) => errors.push(e.into()),
2851            }
2852        }
2853
2854        // Provider registration is scoped to the connection. Closing the
2855        // transport unregisters it and prevents stale callbacks after stop.
2856        self.inner.rpc.force_close();
2857        #[cfg(feature = "bundled-in-process")]
2858        {
2859            if let Some(host) = self.inner.ffi_host.lock().take() {
2860                host.close();
2861            }
2862        }
2863
2864        info!(pid = ?pid, errors = errors.len(), "CLI process stopped");
2865        if errors.is_empty() {
2866            Ok(())
2867        } else {
2868            Err(StopErrors(errors))
2869        }
2870    }
2871
2872    /// Forcibly stop the CLI process without waiting for it to exit.
2873    ///
2874    /// Synchronous fallback when [`stop`](Self::stop) is unsuitable — for
2875    /// example when the awaiting tokio runtime is shutting down or the
2876    /// process is wedged on I/O. Terminates the Windows-owned CLI Job Object
2877    /// when present and immediately drops all per-session router state so
2878    /// dependent tasks observe a closed channel rather than a hang.
2879    ///
2880    /// # Cancel safety
2881    ///
2882    /// **Synchronous and infallible by construction.** Not async; cannot
2883    /// be cancelled. Designed as the recovery path when [`stop`](Self::stop)
2884    /// is wrapped in a timeout that elapses.
2885    ///
2886    /// # Example
2887    ///
2888    /// ```no_run
2889    /// # async fn example(client: github_copilot_sdk::Client) {
2890    /// // Try graceful shutdown first; fall back to force_stop if hung.
2891    /// match tokio::time::timeout(
2892    ///     std::time::Duration::from_secs(5),
2893    ///     client.stop(),
2894    /// ).await {
2895    ///     Ok(_) => {}
2896    ///     Err(_) => client.force_stop(),
2897    /// }
2898    /// # }
2899    /// ```
2900    pub fn force_stop(&self) {
2901        let pid = self.pid();
2902        info!(pid = ?pid, "force-stopping CLI process");
2903        self.inner.extension_launch_provider.clear();
2904        if let Some(process_tree) = self.inner.process_tree.lock().take()
2905            && let Err(error) = process_tree.terminate()
2906        {
2907            error!(pid = ?pid, %error, "failed to terminate CLI process tree");
2908        }
2909        if let Some(mut child) = self.inner.child.lock().take()
2910            && let Err(e) = child.start_kill()
2911        {
2912            error!(pid = ?pid, error = %e, "failed to send kill signal");
2913        }
2914        self.inner.rpc.force_close();
2915        #[cfg(feature = "bundled-in-process")]
2916        {
2917            if let Some(host) = self.inner.ffi_host.lock().take() {
2918                host.close();
2919            }
2920        }
2921        // Drop all session channels so any awaiters see a closed channel
2922        // instead of waiting for responses that will never arrive.
2923        self.inner.router.clear();
2924        self.inner.github_token_registry.clear();
2925        *self.inner.state.lock() = ConnectionState::Disconnected;
2926        *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
2927    }
2928
2929    /// Subscribe to lifecycle events.
2930    ///
2931    /// Returns a [`LifecycleSubscription`] that yields every
2932    /// [`SessionLifecycleEvent`] sent by the CLI. Drop the value to
2933    /// unsubscribe; there is no separate cancel handle.
2934    ///
2935    /// The returned handle implements both an inherent
2936    /// [`recv`](LifecycleSubscription::recv) method and [`Stream`](tokio_stream::Stream),
2937    /// so callers can use a `while let` loop or any combinator from
2938    /// `tokio_stream::StreamExt` / `futures::StreamExt`.
2939    ///
2940    /// Each subscriber maintains its own queue. If a consumer cannot keep
2941    /// up, the oldest events are dropped and `recv` returns
2942    /// [`RecvErrorKind::Lagged`](crate::subscription::RecvErrorKind::Lagged)
2943    /// with the count of skipped events; consumers
2944    /// should match on it and continue. Slow consumers do not block the
2945    /// producer.
2946    ///
2947    /// To filter by event type, match on `event.event_type` in the
2948    /// consumer task. There is no built-in typed filter — `match` is more
2949    /// flexible and keeps the API surface small.
2950    ///
2951    /// # Example
2952    ///
2953    /// ```no_run
2954    /// # async fn example(client: github_copilot_sdk::Client) {
2955    /// let mut events = client.subscribe_lifecycle();
2956    /// tokio::spawn(async move {
2957    ///     while let Ok(event) = events.recv().await {
2958    ///         println!("session {} -> {:?}", event.session_id, event.event_type);
2959    ///     }
2960    /// });
2961    /// # }
2962    /// ```
2963    pub fn subscribe_lifecycle(&self) -> LifecycleSubscription {
2964        LifecycleSubscription::new(self.inner.lifecycle_tx.subscribe())
2965    }
2966}
2967
2968impl Drop for ClientInner {
2969    fn drop(&mut self) {
2970        let pid = self.child.lock().as_ref().and_then(Child::id);
2971        if let Some(process_tree) = self.process_tree.lock().take()
2972            && let Err(error) = process_tree.terminate()
2973        {
2974            error!(pid = ?pid, %error, "failed to terminate CLI process tree on drop");
2975        }
2976        if let Some(ref mut child) = *self.child.lock() {
2977            if let Err(e) = child.start_kill() {
2978                error!(pid = ?pid, error = %e, "failed to kill CLI process on drop");
2979            } else {
2980                info!(pid = ?pid, "kill signal sent for CLI process on drop");
2981            }
2982        }
2983        #[cfg(feature = "bundled-in-process")]
2984        {
2985            if let Some(host) = self.ffi_host.lock().take() {
2986                self.rpc.force_close();
2987                host.close();
2988            }
2989        }
2990    }
2991}
2992
2993#[cfg(test)]
2994mod tests {
2995    use super::*;
2996
2997    #[test]
2998    fn is_transport_failure_matches_request_cancelled() {
2999        let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::RequestCancelled));
3000        assert!(err.is_transport_failure());
3001    }
3002
3003    #[test]
3004    fn is_transport_failure_matches_io_error() {
3005        let err = Error::from(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "gone"));
3006        assert!(err.is_transport_failure());
3007    }
3008
3009    #[test]
3010    fn is_transport_failure_rejects_rpc_error() {
3011        let err = Error::with_message(ErrorKind::Rpc { code: -1 }, "bad");
3012        assert!(!err.is_transport_failure());
3013    }
3014
3015    #[test]
3016    fn is_transport_failure_rejects_session_error() {
3017        let err = Error::from(ErrorKind::Session(SessionErrorKind::NotFound("s1".into())));
3018        assert!(!err.is_transport_failure());
3019    }
3020
3021    #[test]
3022    fn client_options_builder_composes() {
3023        let opts = ClientOptions::new()
3024            .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot")))
3025            .with_prefix_args(["node"])
3026            .with_cwd(PathBuf::from("/tmp"))
3027            .with_env([("KEY", "value")])
3028            .with_env_remove(["UNWANTED"])
3029            .with_extra_args(["--quiet"])
3030            .with_github_token("ghp_test")
3031            .with_use_logged_in_user(false)
3032            .with_log_level(LogLevel::Debug)
3033            .with_session_idle_timeout_seconds(120)
3034            .with_enable_remote_sessions(true);
3035        assert!(matches!(opts.program, CliProgram::Path(_)));
3036        assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]);
3037        assert_eq!(opts.working_directory, PathBuf::from("/tmp"));
3038        assert_eq!(
3039            opts.env,
3040            vec![(
3041                std::ffi::OsString::from("KEY"),
3042                std::ffi::OsString::from("value")
3043            )]
3044        );
3045        assert_eq!(opts.env_remove, vec![std::ffi::OsString::from("UNWANTED")]);
3046        assert_eq!(opts.extra_args, vec!["--quiet".to_string()]);
3047        assert_eq!(opts.github_token.as_deref(), Some("ghp_test"));
3048        assert_eq!(opts.use_logged_in_user, Some(false));
3049        assert!(matches!(opts.log_level, Some(LogLevel::Debug)));
3050        assert_eq!(opts.session_idle_timeout_seconds, Some(120));
3051        assert!(opts.enable_remote_sessions);
3052    }
3053
3054    #[test]
3055    fn default_transport_values_resolve_without_process_state() {
3056        assert!(matches!(
3057            resolve_default_transport_value(None).unwrap(),
3058            Transport::Stdio
3059        ));
3060        assert!(matches!(
3061            resolve_default_transport_value(Some("stdio")).unwrap(),
3062            Transport::Stdio
3063        ));
3064        assert!(matches!(
3065            resolve_default_transport_value(Some("INPROCESS")).unwrap(),
3066            Transport::InProcess
3067        ));
3068        assert!(resolve_default_transport_value(Some("tcp")).is_err());
3069    }
3070
3071    #[test]
3072    fn inprocess_rejects_process_scoped_options() {
3073        let invalid = [
3074            ClientOptions::new().with_cwd("."),
3075            ClientOptions::new().with_env([("KEY", "value")]),
3076            ClientOptions::new().with_env_remove(["KEY"]),
3077            ClientOptions::new().with_telemetry(TelemetryConfig::default()),
3078            ClientOptions::new().with_prefix_args(["index.js"]),
3079            ClientOptions::new().with_program(CliProgram::Path("copilot".into())),
3080            ClientOptions::new().with_extra_args(["--verbose"]),
3081        ];
3082
3083        for options in invalid {
3084            assert!(validate_inprocess_options(&options).is_err());
3085        }
3086    }
3087
3088    #[test]
3089    fn inprocess_allows_typed_runtime_options() {
3090        let options = ClientOptions::new()
3091            .with_base_directory("state")
3092            .with_log_level(LogLevel::Debug)
3093            .with_session_idle_timeout_seconds(10)
3094            .with_github_token("token")
3095            .with_use_logged_in_user(false)
3096            .with_enable_remote_sessions(true);
3097
3098        assert!(validate_inprocess_options(&options).is_ok());
3099    }
3100
3101    #[cfg(not(feature = "bundled-in-process"))]
3102    #[tokio::test]
3103    async fn inprocess_requires_cargo_feature() {
3104        let error = Client::start(ClientOptions::new().with_transport(Transport::InProcess))
3105            .await
3106            .unwrap_err();
3107
3108        assert!(error.to_string().contains("bundled-in-process"));
3109    }
3110
3111    #[test]
3112    fn is_transport_failure_rejects_other_protocol_errors() {
3113        let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout));
3114        assert!(!err.is_transport_failure());
3115    }
3116
3117    #[test]
3118    fn build_command_lets_env_remove_strip_injected_token() {
3119        let opts = ClientOptions {
3120            github_token: Some("secret".to_string()),
3121            env_remove: vec![std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN")],
3122            ..Default::default()
3123        };
3124        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3125        // get_envs() iter yields the latest action per key — None means removed.
3126        let action = cmd
3127            .as_std()
3128            .get_envs()
3129            .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
3130            .map(|(_, v)| v);
3131        assert_eq!(
3132            action,
3133            Some(None),
3134            "env_remove should win over github_token"
3135        );
3136    }
3137
3138    #[test]
3139    fn build_command_lets_env_override_injected_token() {
3140        let opts = ClientOptions {
3141            github_token: Some("from-options".to_string()),
3142            env: vec![(
3143                std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN"),
3144                std::ffi::OsString::from("from-env"),
3145            )],
3146            ..Default::default()
3147        };
3148        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3149        let value = cmd
3150            .as_std()
3151            .get_envs()
3152            .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
3153            .and_then(|(_, v)| v);
3154        assert_eq!(value, Some(std::ffi::OsStr::new("from-env")));
3155    }
3156
3157    #[test]
3158    fn build_command_injects_github_token_by_default() {
3159        let opts = ClientOptions {
3160            github_token: Some("just-the-token".to_string()),
3161            ..Default::default()
3162        };
3163        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3164        let value = cmd
3165            .as_std()
3166            .get_envs()
3167            .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
3168            .and_then(|(_, v)| v);
3169        assert_eq!(value, Some(std::ffi::OsStr::new("just-the-token")));
3170    }
3171
3172    fn env_value<'a>(cmd: &'a tokio::process::Command, key: &str) -> Option<&'a std::ffi::OsStr> {
3173        cmd.as_std()
3174            .get_envs()
3175            .find(|(k, _)| *k == std::ffi::OsStr::new(key))
3176            .and_then(|(_, v)| v)
3177    }
3178
3179    #[test]
3180    fn telemetry_config_builder_composes() {
3181        let cfg = TelemetryConfig::new()
3182            .with_otlp_endpoint("http://collector:4318")
3183            .with_otlp_protocol(OtlpHttpProtocol::HttpProtobuf)
3184            .with_file_path(PathBuf::from("/var/log/copilot.jsonl"))
3185            .with_exporter_type(OtelExporterType::OtlpHttp)
3186            .with_source_name("my-app")
3187            .with_capture_content(true);
3188
3189        assert_eq!(cfg.otlp_endpoint.as_deref(), Some("http://collector:4318"));
3190        assert_eq!(cfg.otlp_protocol, Some(OtlpHttpProtocol::HttpProtobuf));
3191        assert_eq!(
3192            cfg.file_path.as_deref(),
3193            Some(Path::new("/var/log/copilot.jsonl")),
3194        );
3195        assert_eq!(cfg.exporter_type, Some(OtelExporterType::OtlpHttp));
3196        assert_eq!(cfg.source_name.as_deref(), Some("my-app"));
3197        assert_eq!(cfg.capture_content, Some(true));
3198        assert!(!cfg.is_empty());
3199        assert!(TelemetryConfig::new().is_empty());
3200    }
3201
3202    #[test]
3203    fn otlp_http_protocol_serde_matches_env_value() {
3204        for (protocol, wire) in [
3205            (OtlpHttpProtocol::HttpJson, "http/json"),
3206            (OtlpHttpProtocol::HttpProtobuf, "http/protobuf"),
3207        ] {
3208            assert_eq!(protocol.as_str(), wire);
3209
3210            let serialized = serde_json::to_string(&protocol).unwrap();
3211            assert_eq!(serialized, format!("\"{wire}\""));
3212
3213            let deserialized: OtlpHttpProtocol = serde_json::from_str(&serialized).unwrap();
3214            assert_eq!(deserialized, protocol);
3215        }
3216    }
3217
3218    #[test]
3219    fn build_command_sets_otel_env_when_telemetry_enabled() {
3220        let opts = ClientOptions {
3221            telemetry: Some(TelemetryConfig {
3222                otlp_endpoint: Some("http://collector:4318".to_string()),
3223                otlp_protocol: Some(OtlpHttpProtocol::HttpProtobuf),
3224                file_path: Some(PathBuf::from("/var/log/copilot.jsonl")),
3225                exporter_type: Some(OtelExporterType::OtlpHttp),
3226                source_name: Some("my-app".to_string()),
3227                capture_content: Some(true),
3228            }),
3229            ..Default::default()
3230        };
3231        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3232        assert_eq!(
3233            env_value(&cmd, "COPILOT_OTEL_ENABLED"),
3234            Some(std::ffi::OsStr::new("true")),
3235        );
3236        assert_eq!(
3237            env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
3238            Some(std::ffi::OsStr::new("http://collector:4318")),
3239        );
3240        assert_eq!(
3241            env_value(&cmd, "OTEL_EXPORTER_OTLP_PROTOCOL"),
3242            Some(std::ffi::OsStr::new("http/protobuf")),
3243        );
3244        assert_eq!(
3245            env_value(&cmd, "COPILOT_OTEL_FILE_EXPORTER_PATH"),
3246            Some(std::ffi::OsStr::new("/var/log/copilot.jsonl")),
3247        );
3248        assert_eq!(
3249            env_value(&cmd, "COPILOT_OTEL_EXPORTER_TYPE"),
3250            Some(std::ffi::OsStr::new("otlp-http")),
3251        );
3252        assert_eq!(
3253            env_value(&cmd, "COPILOT_OTEL_SOURCE_NAME"),
3254            Some(std::ffi::OsStr::new("my-app")),
3255        );
3256        assert_eq!(
3257            env_value(&cmd, "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"),
3258            Some(std::ffi::OsStr::new("true")),
3259        );
3260    }
3261
3262    #[test]
3263    fn build_command_omits_otel_env_when_telemetry_none() {
3264        let opts = ClientOptions::default();
3265        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3266        for key in [
3267            "COPILOT_OTEL_ENABLED",
3268            "OTEL_EXPORTER_OTLP_ENDPOINT",
3269            "OTEL_EXPORTER_OTLP_PROTOCOL",
3270            "COPILOT_OTEL_FILE_EXPORTER_PATH",
3271            "COPILOT_OTEL_EXPORTER_TYPE",
3272            "COPILOT_OTEL_SOURCE_NAME",
3273            "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
3274        ] {
3275            assert!(
3276                env_value(&cmd, key).is_none(),
3277                "expected {key} to be unset when telemetry is None",
3278            );
3279        }
3280    }
3281
3282    #[test]
3283    fn build_command_omits_unset_telemetry_fields() {
3284        let opts = ClientOptions {
3285            telemetry: Some(TelemetryConfig {
3286                otlp_endpoint: Some("http://collector:4318".to_string()),
3287                ..Default::default()
3288            }),
3289            ..Default::default()
3290        };
3291        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3292        // The one set field plus the implicit enabled flag should propagate.
3293        assert_eq!(
3294            env_value(&cmd, "COPILOT_OTEL_ENABLED"),
3295            Some(std::ffi::OsStr::new("true")),
3296        );
3297        assert_eq!(
3298            env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
3299            Some(std::ffi::OsStr::new("http://collector:4318")),
3300        );
3301        // None of the other fields should leak as env vars.
3302        for key in [
3303            "OTEL_EXPORTER_OTLP_PROTOCOL",
3304            "COPILOT_OTEL_FILE_EXPORTER_PATH",
3305            "COPILOT_OTEL_EXPORTER_TYPE",
3306            "COPILOT_OTEL_SOURCE_NAME",
3307            "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
3308        ] {
3309            assert!(env_value(&cmd, key).is_none(), "{key} should be unset");
3310        }
3311    }
3312
3313    #[test]
3314    fn build_command_lets_user_env_override_telemetry() {
3315        let opts = ClientOptions {
3316            telemetry: Some(TelemetryConfig {
3317                otlp_endpoint: Some("http://from-config:4318".to_string()),
3318                ..Default::default()
3319            }),
3320            env: vec![(
3321                std::ffi::OsString::from("OTEL_EXPORTER_OTLP_ENDPOINT"),
3322                std::ffi::OsString::from("http://from-user-env:4318"),
3323            )],
3324            ..Default::default()
3325        };
3326        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3327        assert_eq!(
3328            env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
3329            Some(std::ffi::OsStr::new("http://from-user-env:4318")),
3330            "user-supplied options.env should override telemetry config",
3331        );
3332    }
3333
3334    #[test]
3335    fn build_command_sets_copilot_home_env_when_configured() {
3336        let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot"));
3337        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3338        assert_eq!(
3339            env_value(&cmd, "COPILOT_HOME"),
3340            Some(std::ffi::OsStr::new("/custom/copilot")),
3341        );
3342
3343        let opts = ClientOptions::default();
3344        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3345        assert!(env_value(&cmd, "COPILOT_HOME").is_none());
3346    }
3347
3348    #[test]
3349    fn build_command_sets_connection_token_env_when_configured() {
3350        let opts = ClientOptions::new().with_transport(Transport::Tcp {
3351            port: 0,
3352            connection_token: Some("secret-token".to_string()),
3353        });
3354        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3355        assert_eq!(
3356            env_value(&cmd, "COPILOT_CONNECTION_TOKEN"),
3357            Some(std::ffi::OsStr::new("secret-token")),
3358        );
3359
3360        let opts = ClientOptions::default();
3361        let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp"));
3362        assert!(env_value(&cmd, "COPILOT_CONNECTION_TOKEN").is_none());
3363    }
3364
3365    #[tokio::test]
3366    async fn start_rejects_empty_connection_token() {
3367        let opts = ClientOptions::new()
3368            .with_transport(Transport::Tcp {
3369                port: 0,
3370                connection_token: Some(String::new()),
3371            })
3372            .with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
3373        let err = Client::start(opts).await.unwrap_err();
3374        assert!(
3375            matches!(err.kind(), ErrorKind::InvalidConfig),
3376            "got {err:?}"
3377        );
3378    }
3379
3380    #[tokio::test]
3381    async fn start_rejects_empty_external_connection_token() {
3382        let opts = ClientOptions::new()
3383            .with_transport(Transport::External {
3384                host: "127.0.0.1".to_string(),
3385                port: 1,
3386                connection_token: Some(String::new()),
3387            })
3388            .with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
3389        let err = Client::start(opts).await.unwrap_err();
3390        assert!(
3391            matches!(err.kind(), ErrorKind::InvalidConfig),
3392            "got {err:?}"
3393        );
3394    }
3395
3396    #[test]
3397    fn telemetry_config_capture_content_serializes_as_lowercase_bool() {
3398        let opts_true = ClientOptions {
3399            telemetry: Some(TelemetryConfig {
3400                capture_content: Some(true),
3401                ..Default::default()
3402            }),
3403            ..Default::default()
3404        };
3405        let opts_false = ClientOptions {
3406            telemetry: Some(TelemetryConfig {
3407                capture_content: Some(false),
3408                ..Default::default()
3409            }),
3410            ..Default::default()
3411        };
3412        let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true, Path::new("/tmp"));
3413        let cmd_false =
3414            Client::build_command(Path::new("/bin/echo"), &opts_false, Path::new("/tmp"));
3415        assert_eq!(
3416            env_value(
3417                &cmd_true,
3418                "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
3419            ),
3420            Some(std::ffi::OsStr::new("true")),
3421        );
3422        assert_eq!(
3423            env_value(
3424                &cmd_false,
3425                "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
3426            ),
3427            Some(std::ffi::OsStr::new("false")),
3428        );
3429    }
3430
3431    #[test]
3432    fn session_idle_timeout_args_are_omitted_by_default() {
3433        let opts = ClientOptions::default();
3434        assert!(Client::session_idle_timeout_args(&opts).is_empty());
3435    }
3436
3437    #[test]
3438    fn session_idle_timeout_args_omitted_for_zero() {
3439        let opts = ClientOptions {
3440            session_idle_timeout_seconds: Some(0),
3441            ..Default::default()
3442        };
3443        assert!(Client::session_idle_timeout_args(&opts).is_empty());
3444    }
3445
3446    #[test]
3447    fn session_idle_timeout_args_emit_flag_for_positive_value() {
3448        let opts = ClientOptions {
3449            session_idle_timeout_seconds: Some(300),
3450            ..Default::default()
3451        };
3452        assert_eq!(
3453            Client::session_idle_timeout_args(&opts),
3454            vec!["--session-idle-timeout".to_string(), "300".to_string()]
3455        );
3456    }
3457
3458    #[test]
3459    fn remote_args_omitted_by_default() {
3460        let opts = ClientOptions::default();
3461        assert!(Client::remote_args(&opts).is_empty());
3462    }
3463
3464    #[test]
3465    fn remote_args_emit_flag_when_enabled() {
3466        let opts = ClientOptions {
3467            enable_remote_sessions: true,
3468            ..Default::default()
3469        };
3470        assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]);
3471    }
3472
3473    #[test]
3474    fn log_level_args_omitted_when_unset() {
3475        let opts = ClientOptions::default();
3476        assert!(opts.log_level.is_none());
3477        assert!(
3478            Client::log_level_args(&opts).is_empty(),
3479            "with no caller-supplied log_level the SDK must not pass --log-level"
3480        );
3481    }
3482
3483    #[test]
3484    fn log_level_args_emit_flag_when_set() {
3485        let opts = ClientOptions::default().with_log_level(LogLevel::Debug);
3486        assert_eq!(Client::log_level_args(&opts), vec!["--log-level", "debug"]);
3487    }
3488
3489    #[test]
3490    fn log_level_str_round_trips() {
3491        for level in [
3492            LogLevel::None,
3493            LogLevel::Error,
3494            LogLevel::Warning,
3495            LogLevel::Info,
3496            LogLevel::Debug,
3497            LogLevel::All,
3498        ] {
3499            let s = level.as_str();
3500            let json = serde_json::to_string(&level).unwrap();
3501            assert_eq!(json, format!("\"{s}\""));
3502            let parsed: LogLevel = serde_json::from_str(&json).unwrap();
3503            assert_eq!(parsed, level);
3504        }
3505    }
3506
3507    #[test]
3508    fn client_options_debug_redacts_handler() {
3509        struct StubHandler;
3510        #[async_trait]
3511        impl ListModelsHandler for StubHandler {
3512            async fn list_models(&self) -> Result<Vec<Model>> {
3513                Ok(vec![])
3514            }
3515        }
3516        let opts = ClientOptions {
3517            on_list_models: Some(Arc::new(StubHandler)),
3518            github_token: Some("secret-token".into()),
3519            ..Default::default()
3520        };
3521        let debug = format!("{opts:?}");
3522        assert!(debug.contains("on_list_models: Some(\"<set>\")"));
3523        assert!(debug.contains("github_token: Some(\"<redacted>\")"));
3524        assert!(!debug.contains("secret-token"));
3525    }
3526
3527    #[tokio::test]
3528    async fn list_models_uses_on_list_models_handler_when_set() {
3529        use std::sync::atomic::{AtomicUsize, Ordering};
3530
3531        struct CountingHandler {
3532            calls: Arc<AtomicUsize>,
3533            models: Vec<Model>,
3534        }
3535        #[async_trait]
3536        impl ListModelsHandler for CountingHandler {
3537            async fn list_models(&self) -> Result<Vec<Model>> {
3538                self.calls.fetch_add(1, Ordering::SeqCst);
3539                Ok(self.models.clone())
3540            }
3541        }
3542
3543        let calls = Arc::new(AtomicUsize::new(0));
3544        let model = Model {
3545            id: "byok-gpt-4".into(),
3546            name: "BYOK GPT-4".into(),
3547            ..Default::default()
3548        };
3549        let handler: Arc<dyn ListModelsHandler> = Arc::new(CountingHandler {
3550            calls: Arc::clone(&calls),
3551            models: vec![model.clone()],
3552        });
3553
3554        let client = client_with_list_models_handler(handler);
3555
3556        let result = client.list_models().await.unwrap();
3557        assert_eq!(result.len(), 1);
3558        assert_eq!(result[0].id, "byok-gpt-4");
3559        assert_eq!(calls.load(Ordering::SeqCst), 1);
3560    }
3561
3562    #[tokio::test]
3563    async fn list_models_serializes_concurrent_cache_misses() {
3564        use std::sync::atomic::{AtomicUsize, Ordering};
3565
3566        struct SlowCountingHandler {
3567            calls: Arc<AtomicUsize>,
3568            models: Vec<Model>,
3569        }
3570        #[async_trait]
3571        impl ListModelsHandler for SlowCountingHandler {
3572            async fn list_models(&self) -> Result<Vec<Model>> {
3573                self.calls.fetch_add(1, Ordering::SeqCst);
3574                tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3575                Ok(self.models.clone())
3576            }
3577        }
3578
3579        let calls = Arc::new(AtomicUsize::new(0));
3580        let model = Model {
3581            id: "single-flight-model".into(),
3582            name: "Single Flight Model".into(),
3583            ..Default::default()
3584        };
3585        let handler: Arc<dyn ListModelsHandler> = Arc::new(SlowCountingHandler {
3586            calls: Arc::clone(&calls),
3587            models: vec![model],
3588        });
3589        let client = client_with_list_models_handler(handler);
3590
3591        let (first, second) = tokio::join!(client.list_models(), client.list_models());
3592        assert_eq!(first.unwrap()[0].id, "single-flight-model");
3593        assert_eq!(second.unwrap()[0].id, "single-flight-model");
3594        assert_eq!(calls.load(Ordering::SeqCst), 1);
3595    }
3596
3597    #[tokio::test]
3598    async fn cancelled_resume_session_unregisters_pending_session() {
3599        let (client_write, _server_read) = tokio::io::duplex(8192);
3600        let (_server_write, client_read) = tokio::io::duplex(8192);
3601        let client = Client::from_streams(client_read, client_write, std::env::temp_dir()).unwrap();
3602        assert!(client.startup_timings().is_none());
3603        let session_id = SessionId::new("resume-cancel-test");
3604        let handle = tokio::spawn({
3605            let client = client.clone();
3606            async move {
3607                client
3608                    .resume_session(ResumeSessionConfig::new(session_id))
3609                    .await
3610            }
3611        });
3612
3613        wait_for_pending_session_registration(&client).await;
3614        handle.abort();
3615        let _ = handle.await;
3616
3617        assert!(client.inner.router.session_ids().is_empty());
3618        client.force_stop();
3619    }
3620
3621    #[cfg(any(unix, windows))]
3622    #[tokio::test]
3623    async fn dropping_last_client_kills_spawned_cli() {
3624        let temp = tempfile::tempdir().unwrap();
3625        let ready = temp.path().join("ready");
3626        let survived = temp.path().join("survived");
3627        let child = test_child_command(temp.path(), &ready, &survived)
3628            .spawn()
3629            .unwrap();
3630        let (client_write, _server_read) = tokio::io::duplex(64);
3631        let (_server_write, client_read) = tokio::io::duplex(64);
3632        let client = Client::from_transport(
3633            client_read,
3634            client_write,
3635            Some(child),
3636            None,
3637            temp.path().to_path_buf(),
3638            None,
3639            None,
3640            false,
3641            false,
3642            None,
3643            None,
3644            None,
3645            ClientMode::default(),
3646            None,
3647        )
3648        .unwrap();
3649
3650        wait_for_test_child(&ready).await;
3651        drop(client);
3652
3653        assert_test_child_killed(&survived).await;
3654    }
3655
3656    #[cfg(any(unix, windows))]
3657    #[tokio::test]
3658    async fn spawned_child_is_killed_when_dropped() {
3659        let temp = tempfile::tempdir().unwrap();
3660        let ready = temp.path().join("ready");
3661        let survived = temp.path().join("survived");
3662        let child = test_child_command(temp.path(), &ready, &survived)
3663            .spawn()
3664            .unwrap();
3665
3666        wait_for_test_child(&ready).await;
3667        drop(child);
3668
3669        assert_test_child_killed(&survived).await;
3670    }
3671
3672    #[cfg(any(unix, windows))]
3673    fn test_child_command(temp: &Path, ready: &Path, survived: &Path) -> Command {
3674        #[cfg(unix)]
3675        let mut command = {
3676            let mut command =
3677                Client::build_command(Path::new("sh"), &ClientOptions::default(), temp);
3678            command.args([
3679                "-c",
3680                "printf ready > \"$READY\"; sleep 1; printf survived > \"$SURVIVED\"",
3681            ]);
3682            command
3683        };
3684        #[cfg(windows)]
3685        let mut command = {
3686            let mut command =
3687                Client::build_command(Path::new("powershell.exe"), &ClientOptions::default(), temp);
3688            command.args([
3689                "-NoLogo",
3690                "-NoProfile",
3691                "-NonInteractive",
3692                "-Command",
3693                "Set-Content -LiteralPath $env:READY ready; Start-Sleep -Seconds 1; Set-Content -LiteralPath $env:SURVIVED survived",
3694            ]);
3695            command
3696        };
3697        command.env("READY", ready).env("SURVIVED", survived);
3698        command
3699    }
3700
3701    #[cfg(any(unix, windows))]
3702    async fn wait_for_test_child(ready: &Path) {
3703        let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
3704        while !ready.exists() {
3705            assert!(
3706                tokio::time::Instant::now() < deadline,
3707                "child did not report readiness"
3708            );
3709            tokio::time::sleep(Duration::from_millis(10)).await;
3710        }
3711    }
3712
3713    #[cfg(any(unix, windows))]
3714    async fn assert_test_child_killed(survived: &Path) {
3715        tokio::time::sleep(Duration::from_millis(1500)).await;
3716
3717        assert!(
3718            !survived.exists(),
3719            "child survived after its owner was dropped"
3720        );
3721    }
3722
3723    fn client_with_list_models_handler(handler: Arc<dyn ListModelsHandler>) -> Client {
3724        Client {
3725            inner: Arc::new(ClientInner {
3726                child: parking_lot::Mutex::new(None),
3727                process_tree: parking_lot::Mutex::new(None),
3728                #[cfg(feature = "bundled-in-process")]
3729                ffi_host: parking_lot::Mutex::new(None),
3730                rpc: {
3731                    let (req_tx, _req_rx) = mpsc::unbounded_channel();
3732                    let (notif_tx, _notif_rx) = broadcast::channel(16);
3733                    let (read_pipe, _write_pipe) = tokio::io::duplex(64);
3734                    let (_unused_read, write_pipe) = tokio::io::duplex(64);
3735                    JsonRpcClient::new(write_pipe, read_pipe, notif_tx, req_tx)
3736                },
3737                cwd: PathBuf::from("."),
3738                request_rx: parking_lot::Mutex::new(None),
3739                notification_tx: broadcast::channel(16).0,
3740                router: router::SessionRouter::new(),
3741                github_token_registry: Arc::new(github_token::GitHubTokenRegistry::new()),
3742                negotiated_protocol_version: OnceLock::new(),
3743                state: parking_lot::Mutex::new(ConnectionState::Connected),
3744                lifecycle_tx: broadcast::channel(16).0,
3745                on_list_models: Some(handler),
3746                models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())),
3747                session_fs_configured: false,
3748                session_fs_sqlite_declared: false,
3749                llm_inference: OnceLock::new(),
3750                extension_launch_provider: Arc::new(
3751                    extension_launch_provider::ExtensionLaunchProviderDispatcher::new(None),
3752                ),
3753                on_github_telemetry: None,
3754                on_get_trace_context: None,
3755                effective_connection_token: None,
3756                mode: ClientMode::default(),
3757                client_info: None,
3758                startup_timings: OnceLock::new(),
3759            }),
3760        }
3761    }
3762
3763    async fn wait_for_pending_session_registration(client: &Client) {
3764        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
3765        while client.inner.router.session_ids().is_empty() {
3766            assert!(
3767                tokio::time::Instant::now() < deadline,
3768                "session was not registered"
3769            );
3770            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3771        }
3772    }
3773}