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